first commit
This commit is contained in:
250
src/pages/FileUploadTest.vue
Normal file
250
src/pages/FileUploadTest.vue
Normal file
@@ -0,0 +1,250 @@
|
||||
<template>
|
||||
<div class="file-upload-test">
|
||||
<el-card class="test-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<h2>文件上传组件测试</h2>
|
||||
<p>测试通用文件上传组件的各种功能</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="test-content">
|
||||
<!-- 图片上传测试 -->
|
||||
<div class="test-section">
|
||||
<h3>图片上传</h3>
|
||||
<FileUpload
|
||||
v-model="imageFile"
|
||||
accept="image/jpeg,image/jpg,image/png,image/gif,image/webp"
|
||||
:max-size="5"
|
||||
title="上传图片"
|
||||
description="支持 JPG/PNG/GIF/WEBP 格式,文件大小不超过 5MB"
|
||||
@change="handleImageChange"
|
||||
@remove="handleImageRemove"
|
||||
/>
|
||||
<div class="file-info" v-if="imageFile">
|
||||
<p><strong>文件名:</strong> {{ imageFile.name }}</p>
|
||||
<p><strong>文件大小:</strong> {{ (imageFile.size / 1024 / 1024).toFixed(2) }} MB</p>
|
||||
<p><strong>文件类型:</strong> {{ imageFile.type }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 文档上传测试 -->
|
||||
<div class="test-section">
|
||||
<h3>文档上传</h3>
|
||||
<FileUpload
|
||||
v-model="documentFile"
|
||||
accept="application/pdf,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
||||
:max-size="20"
|
||||
title="上传文档"
|
||||
description="支持 PDF/DOC/DOCX 格式,文件大小不超过 20MB"
|
||||
@change="handleDocumentChange"
|
||||
@remove="handleDocumentRemove"
|
||||
/>
|
||||
<div class="file-info" v-if="documentFile">
|
||||
<p><strong>文件名:</strong> {{ documentFile.name }}</p>
|
||||
<p><strong>文件大小:</strong> {{ (documentFile.size / 1024 / 1024).toFixed(2) }} MB</p>
|
||||
<p><strong>文件类型:</strong> {{ documentFile.type }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 任意文件上传测试 -->
|
||||
<div class="test-section">
|
||||
<h3>任意文件上传</h3>
|
||||
<FileUpload
|
||||
v-model="anyFile"
|
||||
accept="*/*"
|
||||
:max-size="50"
|
||||
title="上传任意文件"
|
||||
description="支持所有文件类型,文件大小不超过 50MB"
|
||||
@change="handleAnyFileChange"
|
||||
@remove="handleAnyFileRemove"
|
||||
/>
|
||||
<div class="file-info" v-if="anyFile">
|
||||
<p><strong>文件名:</strong> {{ anyFile.name }}</p>
|
||||
<p><strong>文件大小:</strong> {{ (anyFile.size / 1024 / 1024).toFixed(2) }} MB</p>
|
||||
<p><strong>文件类型:</strong> {{ anyFile.type }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 禁用状态测试 -->
|
||||
<div class="test-section">
|
||||
<h3>禁用状态</h3>
|
||||
<FileUpload
|
||||
v-model="disabledFile"
|
||||
accept="image/jpeg,image/jpg,image/png"
|
||||
:max-size="4"
|
||||
title="禁用状态"
|
||||
description="此上传组件已被禁用"
|
||||
:disabled="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="test-actions">
|
||||
<el-button type="primary" @click="clearAllFiles">清空所有文件</el-button>
|
||||
<el-button type="success" @click="showFileInfo">显示文件信息</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import FileUpload from '@/components/common/FileUpload.vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
// 文件状态
|
||||
const imageFile = ref(null)
|
||||
const documentFile = ref(null)
|
||||
const anyFile = ref(null)
|
||||
const disabledFile = ref(null)
|
||||
|
||||
// 处理图片文件变化
|
||||
const handleImageChange = (file) => {
|
||||
ElMessage.success('图片文件已选择')
|
||||
console.log('图片文件:', file)
|
||||
}
|
||||
|
||||
// 处理图片文件删除
|
||||
const handleImageRemove = () => {
|
||||
ElMessage.info('图片文件已删除')
|
||||
}
|
||||
|
||||
// 处理文档文件变化
|
||||
const handleDocumentChange = (file) => {
|
||||
ElMessage.success('文档文件已选择')
|
||||
console.log('文档文件:', file)
|
||||
}
|
||||
|
||||
// 处理文档文件删除
|
||||
const handleDocumentRemove = () => {
|
||||
ElMessage.info('文档文件已删除')
|
||||
}
|
||||
|
||||
// 处理任意文件变化
|
||||
const handleAnyFileChange = (file) => {
|
||||
ElMessage.success('文件已选择')
|
||||
console.log('任意文件:', file)
|
||||
}
|
||||
|
||||
// 处理任意文件删除
|
||||
const handleAnyFileRemove = () => {
|
||||
ElMessage.info('文件已删除')
|
||||
}
|
||||
|
||||
// 清空所有文件
|
||||
const clearAllFiles = () => {
|
||||
imageFile.value = null
|
||||
documentFile.value = null
|
||||
anyFile.value = null
|
||||
disabledFile.value = null
|
||||
ElMessage.success('所有文件已清空')
|
||||
}
|
||||
|
||||
// 显示文件信息
|
||||
const showFileInfo = () => {
|
||||
const files = []
|
||||
if (imageFile.value) files.push(`图片: ${imageFile.value.name}`)
|
||||
if (documentFile.value) files.push(`文档: ${documentFile.value.name}`)
|
||||
if (anyFile.value) files.push(`文件: ${anyFile.value.name}`)
|
||||
|
||||
if (files.length > 0) {
|
||||
ElMessage.success(`已选择的文件: ${files.join(', ')}`)
|
||||
} else {
|
||||
ElMessage.info('没有选择任何文件')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.file-upload-test {
|
||||
padding: 20px;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.test-card {
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.card-header h2 {
|
||||
margin: 0 0 8px 0;
|
||||
color: #1e293b;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.card-header p {
|
||||
margin: 0;
|
||||
color: #64748b;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.test-content {
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.test-section {
|
||||
margin-bottom: 40px;
|
||||
padding: 24px;
|
||||
background: #f8fafc;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.test-section h3 {
|
||||
margin: 0 0 16px 0;
|
||||
color: #1e293b;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.file-info {
|
||||
margin-top: 16px;
|
||||
padding: 16px;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.file-info p {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 14px;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.file-info p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.test-actions {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
justify-content: center;
|
||||
margin-top: 32px;
|
||||
padding-top: 24px;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.file-upload-test {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.test-section {
|
||||
padding: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.test-actions {
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
341
src/pages/StatisticsTest.vue
Normal file
341
src/pages/StatisticsTest.vue
Normal file
@@ -0,0 +1,341 @@
|
||||
<template>
|
||||
<div class="statistics-test">
|
||||
<div class="test-header">
|
||||
<h2>统计功能测试</h2>
|
||||
<p>测试前端统计功能是否正常工作</p>
|
||||
</div>
|
||||
|
||||
<div class="test-content">
|
||||
<!-- API测试 -->
|
||||
<el-card class="test-card">
|
||||
<div slot="header">
|
||||
<span>API接口测试</span>
|
||||
</div>
|
||||
|
||||
<div class="test-buttons">
|
||||
<el-button @click="testPublicStatistics" :loading="loading.public">测试公开统计</el-button>
|
||||
<el-button @click="testUserStatistics" :loading="loading.user">测试用户统计</el-button>
|
||||
<el-button @click="testMetrics" :loading="loading.metrics">测试指标列表</el-button>
|
||||
<el-button @click="testDashboards" :loading="loading.dashboards">测试仪表板</el-button>
|
||||
<el-button @click="testReports" :loading="loading.reports">测试报告列表</el-button>
|
||||
</div>
|
||||
|
||||
<div class="test-results" v-if="testResults.length > 0">
|
||||
<h4>测试结果:</h4>
|
||||
<div v-for="(result, index) in testResults" :key="index" class="result-item">
|
||||
<el-tag :type="result.success ? 'success' : 'danger'">
|
||||
{{ result.success ? '成功' : '失败' }}
|
||||
</el-tag>
|
||||
<span class="result-text">{{ result.message }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 组件测试 -->
|
||||
<el-card class="test-card">
|
||||
<div slot="header">
|
||||
<span>组件测试</span>
|
||||
</div>
|
||||
|
||||
<div class="component-tests">
|
||||
<!-- StatCard测试 -->
|
||||
<div class="component-test">
|
||||
<h4>StatCard组件</h4>
|
||||
<StatCard
|
||||
title="测试指标"
|
||||
value="1234"
|
||||
unit="次"
|
||||
trend="12.5"
|
||||
icon="el-icon-data-line"
|
||||
color="#409EFF"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- ChartCard测试 -->
|
||||
<div class="component-test">
|
||||
<h4>ChartCard组件</h4>
|
||||
<ChartCard
|
||||
title="测试图表"
|
||||
subtitle="这是一个测试图表"
|
||||
type="line"
|
||||
:data="testChartData"
|
||||
height="200"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 页面测试 -->
|
||||
<el-card class="test-card">
|
||||
<div slot="header">
|
||||
<span>页面测试</span>
|
||||
</div>
|
||||
|
||||
<div class="page-tests">
|
||||
<el-button @click="goToStatistics">前往统计页面</el-button>
|
||||
<el-button @click="goToReports">前往报告页面</el-button>
|
||||
<el-button @click="goToAnalysis">前往分析页面</el-button>
|
||||
<el-button @click="goToAdminMetrics" v-if="isAdmin">前往指标管理</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {
|
||||
getDashboards,
|
||||
getMetrics,
|
||||
getPublicStatistics,
|
||||
getReports,
|
||||
getUserStatistics
|
||||
} from '@/api/statistics'
|
||||
import ChartCard from '@/components/statistics/ChartCard.vue'
|
||||
import StatCard from '@/components/statistics/StatCard.vue'
|
||||
|
||||
export default {
|
||||
name: 'StatisticsTest',
|
||||
components: {
|
||||
StatCard,
|
||||
ChartCard
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
loading: {
|
||||
public: false,
|
||||
user: false,
|
||||
metrics: false,
|
||||
dashboards: false,
|
||||
reports: false
|
||||
},
|
||||
testResults: [],
|
||||
testChartData: [
|
||||
{ time: '2024-01-01', value: 100 },
|
||||
{ time: '2024-01-02', value: 150 },
|
||||
{ time: '2024-01-03', value: 200 },
|
||||
{ time: '2024-01-04', value: 180 },
|
||||
{ time: '2024-01-05', value: 250 }
|
||||
]
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
isAdmin() {
|
||||
return this.$store.getters.userRole === 'admin'
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
// 测试公开统计
|
||||
async testPublicStatistics() {
|
||||
this.loading.public = true
|
||||
try {
|
||||
const response = await getPublicStatistics()
|
||||
this.addTestResult(true, '公开统计API调用成功', response)
|
||||
} catch (error) {
|
||||
this.addTestResult(false, '公开统计API调用失败: ' + error.message)
|
||||
} finally {
|
||||
this.loading.public = false
|
||||
}
|
||||
},
|
||||
|
||||
// 测试用户统计
|
||||
async testUserStatistics() {
|
||||
this.loading.user = true
|
||||
try {
|
||||
const response = await getUserStatistics()
|
||||
this.addTestResult(true, '用户统计API调用成功', response)
|
||||
} catch (error) {
|
||||
this.addTestResult(false, '用户统计API调用失败: ' + error.message)
|
||||
} finally {
|
||||
this.loading.user = false
|
||||
}
|
||||
},
|
||||
|
||||
// 测试指标列表
|
||||
async testMetrics() {
|
||||
this.loading.metrics = true
|
||||
try {
|
||||
const response = await getMetrics({ limit: 10 })
|
||||
this.addTestResult(true, '指标列表API调用成功', response)
|
||||
} catch (error) {
|
||||
this.addTestResult(false, '指标列表API调用失败: ' + error.message)
|
||||
} finally {
|
||||
this.loading.metrics = false
|
||||
}
|
||||
},
|
||||
|
||||
// 测试仪表板
|
||||
async testDashboards() {
|
||||
this.loading.dashboards = true
|
||||
try {
|
||||
const response = await getDashboards({ limit: 10 })
|
||||
this.addTestResult(true, '仪表板API调用成功', response)
|
||||
} catch (error) {
|
||||
this.addTestResult(false, '仪表板API调用失败: ' + error.message)
|
||||
} finally {
|
||||
this.loading.dashboards = false
|
||||
}
|
||||
},
|
||||
|
||||
// 测试报告列表
|
||||
async testReports() {
|
||||
this.loading.reports = true
|
||||
try {
|
||||
const response = await getReports({ limit: 10 })
|
||||
this.addTestResult(true, '报告列表API调用成功', response)
|
||||
} catch (error) {
|
||||
this.addTestResult(false, '报告列表API调用失败: ' + error.message)
|
||||
} finally {
|
||||
this.loading.reports = false
|
||||
}
|
||||
},
|
||||
|
||||
// 添加测试结果
|
||||
addTestResult(success, message, data = null) {
|
||||
this.testResults.unshift({
|
||||
success,
|
||||
message,
|
||||
data,
|
||||
timestamp: new Date().toLocaleTimeString()
|
||||
})
|
||||
|
||||
// 只保留最近10条结果
|
||||
if (this.testResults.length > 10) {
|
||||
this.testResults = this.testResults.slice(0, 10)
|
||||
}
|
||||
},
|
||||
|
||||
// 前往统计页面
|
||||
goToStatistics() {
|
||||
this.$router.push('/statistics')
|
||||
},
|
||||
|
||||
// 前往报告页面
|
||||
goToReports() {
|
||||
this.$router.push('/statistics/reports')
|
||||
},
|
||||
|
||||
// 前往分析页面
|
||||
goToAnalysis() {
|
||||
this.$router.push('/statistics/analysis')
|
||||
},
|
||||
|
||||
// 前往指标管理
|
||||
goToAdminMetrics() {
|
||||
this.$router.push('/admin/statistics/metrics')
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.statistics-test {
|
||||
padding: 20px;
|
||||
background-color: #f5f5f5;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.test-header {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.test-header h2 {
|
||||
margin: 0 0 8px 0;
|
||||
color: #303133;
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.test-header p {
|
||||
margin: 0;
|
||||
color: #606266;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.test-content {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.test-card {
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.test-buttons {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.test-results {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.test-results h4 {
|
||||
margin: 0 0 12px 0;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.result-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 8px;
|
||||
padding: 8px 12px;
|
||||
background: #fafafa;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.result-text {
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
.component-tests {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.component-test {
|
||||
padding: 16px;
|
||||
background: #fafafa;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.component-test h4 {
|
||||
margin: 0 0 12px 0;
|
||||
color: #303133;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.page-tests {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.statistics-test {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.test-buttons {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.component-tests {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.page-tests {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
919
src/pages/admin/api-calls/index.vue
Normal file
919
src/pages/admin/api-calls/index.vue
Normal 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>
|
||||
175
src/pages/admin/articles/categories.vue
Normal file
175
src/pages/admin/articles/categories.vue
Normal 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>
|
||||
224
src/pages/admin/articles/components/ArticleDetailDialog.vue
Normal file
224
src/pages/admin/articles/components/ArticleDetailDialog.vue
Normal 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>
|
||||
339
src/pages/admin/articles/components/ArticleEditDialog.vue
Normal file
339
src/pages/admin/articles/components/ArticleEditDialog.vue
Normal 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 包含 section,section 包含段落等
|
||||
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>
|
||||
84
src/pages/admin/articles/components/ArticleStats.vue
Normal file
84
src/pages/admin/articles/components/ArticleStats.vue
Normal 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>
|
||||
160
src/pages/admin/articles/components/CategoryEditDialog.vue
Normal file
160
src/pages/admin/articles/components/CategoryEditDialog.vue
Normal 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>
|
||||
206
src/pages/admin/articles/components/SchedulePublishDialog.vue
Normal file
206
src/pages/admin/articles/components/SchedulePublishDialog.vue
Normal 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>
|
||||
178
src/pages/admin/articles/components/TagEditDialog.vue
Normal file
178
src/pages/admin/articles/components/TagEditDialog.vue
Normal 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>
|
||||
654
src/pages/admin/articles/index.vue
Normal file
654
src/pages/admin/articles/index.vue
Normal 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>
|
||||
187
src/pages/admin/articles/tags.vue
Normal file
187
src/pages/admin/articles/tags.vue
Normal 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>
|
||||
307
src/pages/admin/categories/index.vue
Normal file
307
src/pages/admin/categories/index.vue
Normal 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>
|
||||
|
||||
524
src/pages/admin/consumption/index.vue
Normal file
524
src/pages/admin/consumption/index.vue
Normal 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>
|
||||
944
src/pages/admin/invoices/index.vue
Normal file
944
src/pages/admin/invoices/index.vue
Normal 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">
|
||||
支持 PDF、JPG、PNG 格式,文件大小不超过 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>
|
||||
472
src/pages/admin/products/index.vue
Normal file
472
src/pages/admin/products/index.vue
Normal 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>
|
||||
732
src/pages/admin/recharge-records/index.vue
Normal file
732
src/pages/admin/recharge-records/index.vue
Normal 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>
|
||||
551
src/pages/admin/statistics/AdminStatisticsPage.vue
Normal file
551
src/pages/admin/statistics/AdminStatisticsPage.vue
Normal 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>
|
||||
591
src/pages/admin/statistics/DashboardManagement.vue
Normal file
591
src/pages/admin/statistics/DashboardManagement.vue
Normal 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>
|
||||
558
src/pages/admin/statistics/MetricsManagement.vue
Normal file
558
src/pages/admin/statistics/MetricsManagement.vue
Normal 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>
|
||||
660
src/pages/admin/statistics/ReportsManagement.vue
Normal file
660
src/pages/admin/statistics/ReportsManagement.vue
Normal 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>
|
||||
413
src/pages/admin/statistics/StatisticsSettings.vue
Normal file
413
src/pages/admin/statistics/StatisticsSettings.vue
Normal 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>
|
||||
1325
src/pages/admin/statistics/SystemStatisticsPage.vue
Normal file
1325
src/pages/admin/statistics/SystemStatisticsPage.vue
Normal file
File diff suppressed because it is too large
Load Diff
1141
src/pages/admin/subscriptions/index.vue
Normal file
1141
src/pages/admin/subscriptions/index.vue
Normal file
File diff suppressed because it is too large
Load Diff
754
src/pages/admin/transactions/index.vue
Normal file
754
src/pages/admin/transactions/index.vue
Normal 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>
|
||||
746
src/pages/admin/usage/index.vue
Normal file
746
src/pages/admin/usage/index.vue
Normal 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>
|
||||
1083
src/pages/admin/users/index.vue
Normal file
1083
src/pages/admin/users/index.vue
Normal file
File diff suppressed because it is too large
Load Diff
1312
src/pages/api/ApiDebugger.vue
Normal file
1312
src/pages/api/ApiDebugger.vue
Normal file
File diff suppressed because it is too large
Load Diff
10
src/pages/api/ApiManagement.vue
Normal file
10
src/pages/api/ApiManagement.vue
Normal file
@@ -0,0 +1,10 @@
|
||||
<template>
|
||||
<div class="api-management-page">
|
||||
<h1 class="text-3xl font-bold mb-6">API管理</h1>
|
||||
<p class="text-gray-600 mb-8">API管理页面 - 待开发</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
// API管理页面 - 待开发
|
||||
</script>
|
||||
687
src/pages/api/Usage.vue
Normal file
687
src/pages/api/Usage.vue
Normal file
@@ -0,0 +1,687 @@
|
||||
<template>
|
||||
<ListPageLayout
|
||||
title="调用记录"
|
||||
subtitle="查看您的API调用历史记录"
|
||||
>
|
||||
<!-- 统计信息 -->
|
||||
<template #actions>
|
||||
<div class="flex gap-4">
|
||||
<div class="stat-item">
|
||||
<div class="stat-value">{{ stats.total_calls || 0 }}</div>
|
||||
<div class="stat-label">总调用次数</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #filters>
|
||||
<FilterSection>
|
||||
<FilterItem label="交易ID">
|
||||
<el-input
|
||||
v-model="filters.transaction_id"
|
||||
placeholder="输入交易ID"
|
||||
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="调用状态">
|
||||
<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="时间范围">
|
||||
<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="handleDateRangeChange"
|
||||
class="w-full"
|
||||
/>
|
||||
</FilterItem>
|
||||
|
||||
<template #stats>
|
||||
共找到 {{ total }} 条调用记录
|
||||
</template>
|
||||
|
||||
<template #buttons>
|
||||
<el-button @click="resetFilters">重置筛选</el-button>
|
||||
<el-button type="primary" @click="loadApiCalls">应用筛选</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="apiCalls.length === 0" class="text-center py-12">
|
||||
<el-empty description="暂无调用记录">
|
||||
<el-button type="primary" @click="$router.push('/api')">
|
||||
前往开发者中心
|
||||
</el-button>
|
||||
</el-empty>
|
||||
</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="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>
|
||||
<!-- 调用详情弹窗 -->
|
||||
<el-dialog
|
||||
v-model="detailDialogVisible"
|
||||
title="调用详情"
|
||||
width="800px"
|
||||
class="detail-dialog"
|
||||
>
|
||||
<div v-if="selectedApiCall" class="space-y-6">
|
||||
<div class="grid grid-cols-2 gap-6">
|
||||
<div class="detail-item">
|
||||
<label class="detail-label">交易ID</label>
|
||||
<span class="detail-value">{{ selectedApiCall.transaction_id }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<label class="detail-label">状态</label>
|
||||
<span class="detail-value">
|
||||
<el-tag :type="getStatusType(selectedApiCall.status)" size="small">
|
||||
{{ getStatusText(selectedApiCall.status) }}
|
||||
</el-tag>
|
||||
</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<label class="detail-label">接口名称</label>
|
||||
<span class="detail-value">{{ selectedApiCall.product_name || '未知接口' }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<label class="detail-label">费用</label>
|
||||
<span class="detail-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="detail-item">
|
||||
<label class="detail-label">客户端IP</label>
|
||||
<span class="detail-value">{{ selectedApiCall.client_ip }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<label class="detail-label">调用时间</label>
|
||||
<span class="detail-value">{{ formatDateTime(selectedApiCall.start_at) }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<label class="detail-label">完成时间</label>
|
||||
<span class="detail-value">
|
||||
<span v-if="selectedApiCall.end_at">{{ formatDateTime(selectedApiCall.end_at) }}</span>
|
||||
<span v-else class="text-gray-400">-</span>
|
||||
</span>
|
||||
</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 v-if="selectedApiCall.translated_error_msg" class="translated-error">
|
||||
{{ selectedApiCall.translated_error_msg }}
|
||||
</div>
|
||||
<div v-else class="original-error">
|
||||
{{ selectedApiCall.error_msg }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</template>
|
||||
</ListPageLayout>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { apiApi } from '@/api'
|
||||
import FilterItem from '@/components/common/FilterItem.vue'
|
||||
import FilterSection from '@/components/common/FilterSection.vue'
|
||||
import ListPageLayout from '@/components/common/ListPageLayout.vue'
|
||||
import { useCertification } from '@/composables/useCertification'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
// 认证相关
|
||||
const {
|
||||
isCertified,
|
||||
certificationLoading,
|
||||
requiresCertification,
|
||||
callProtectedAPI,
|
||||
canCallAPI
|
||||
} = useCertification()
|
||||
|
||||
// 响应式数据
|
||||
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 stats = ref({
|
||||
total_calls: 0,
|
||||
success_rate: '0%'
|
||||
})
|
||||
|
||||
// 筛选条件
|
||||
const filters = reactive({
|
||||
transaction_id: '',
|
||||
product_name: '',
|
||||
status: '',
|
||||
start_time: '',
|
||||
end_time: ''
|
||||
})
|
||||
|
||||
// 初始化
|
||||
onMounted(() => {
|
||||
loadApiCalls()
|
||||
loadStats()
|
||||
})
|
||||
|
||||
// 加载API调用记录
|
||||
const loadApiCalls = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const params = {
|
||||
page: currentPage.value,
|
||||
page_size: pageSize.value,
|
||||
...filters
|
||||
}
|
||||
|
||||
const response = await callProtectedAPI(apiApi.getUserApiCalls, params)
|
||||
if (response) {
|
||||
apiCalls.value = response.data?.items || []
|
||||
total.value = response.data?.total || 0
|
||||
} else {
|
||||
// 如果API调用被阻止,显示空数据
|
||||
apiCalls.value = []
|
||||
total.value = 0
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载API调用记录失败:', error)
|
||||
if (canCallAPI.value) {
|
||||
ElMessage.error('加载API调用记录失败')
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 加载统计数据
|
||||
const loadStats = async () => {
|
||||
try {
|
||||
// 计算统计数据
|
||||
stats.value = {
|
||||
total_calls: total.value
|
||||
}
|
||||
} 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 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 handleDateRangeChange = (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 handleViewDetail = (apiCall) => {
|
||||
selectedApiCall.value = apiCall
|
||||
detailDialogVisible.value = true
|
||||
}
|
||||
</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;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
/* 详情弹窗样式 */
|
||||
.detail-dialog :deep(.el-dialog) {
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.detail-dialog :deep(.el-dialog__title) {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.detail-dialog :deep(.el-dialog__body) {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.detail-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;
|
||||
}
|
||||
|
||||
/* 详情项样式 */
|
||||
.detail-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
font-size: 16px;
|
||||
color: #1e293b;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 错误信息样式 */
|
||||
.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-type {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #dc2626;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
font-size: 14px;
|
||||
color: #7f1d1d;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.translated-error {
|
||||
font-weight: 500;
|
||||
color: #dc2626;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.original-error {
|
||||
font-size: 13px;
|
||||
color: #991b1b;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.error-info-cell .original-error {
|
||||
font-size: 12px;
|
||||
color: #991b1b;
|
||||
font-style: italic;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* 信息区域样式 */
|
||||
.request-info,
|
||||
.response-info {
|
||||
background: rgba(248, 250, 252, 0.8);
|
||||
border: 1px solid rgba(226, 232, 240, 0.6);
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.info-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.info-content {
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
border: 1px solid rgba(226, 232, 240, 0.4);
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.json-content {
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 12px;
|
||||
color: #475569;
|
||||
line-height: 1.4;
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* 表格样式优化 */
|
||||
: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;
|
||||
}
|
||||
|
||||
.detail-item {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
437
src/pages/api/WhiteList.vue
Normal file
437
src/pages/api/WhiteList.vue
Normal file
@@ -0,0 +1,437 @@
|
||||
<template>
|
||||
<ListPageLayout
|
||||
title="白名单管理"
|
||||
subtitle="管理您的API访问IP白名单,最多可添加10个IP地址"
|
||||
>
|
||||
<template #actions>
|
||||
<el-button type="primary" @click="showAddForm = true">
|
||||
<PlusIcon class="w-4 h-4 mr-1" />
|
||||
添加IP地址
|
||||
</el-button>
|
||||
</template>
|
||||
|
||||
<template #filters>
|
||||
<FilterSection>
|
||||
<FilterItem label="IP地址搜索">
|
||||
<el-input
|
||||
v-model="filters.keyword"
|
||||
placeholder="输入IP地址进行搜索"
|
||||
clearable
|
||||
@input="handleSearch"
|
||||
class="w-full"
|
||||
>
|
||||
<template #prefix>
|
||||
<ComputerDesktopIcon class="w-4 h-4 text-gray-400" />
|
||||
</template>
|
||||
</el-input>
|
||||
</FilterItem>
|
||||
|
||||
<FilterItem label="状态筛选">
|
||||
<el-select
|
||||
v-model="filters.status"
|
||||
placeholder="选择状态"
|
||||
clearable
|
||||
@change="handleFilterChange"
|
||||
class="w-full"
|
||||
>
|
||||
<el-option label="全部" value="" />
|
||||
<el-option label="已添加" value="active" />
|
||||
<el-option label="待添加" value="pending" />
|
||||
</el-select>
|
||||
</FilterItem>
|
||||
|
||||
<template #stats>
|
||||
共找到 {{ whiteListData.length }}/10 个IP地址
|
||||
</template>
|
||||
|
||||
<template #buttons>
|
||||
<el-button @click="resetFilters">重置筛选</el-button>
|
||||
<el-button type="primary" @click="loadWhiteList">应用筛选</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="whiteListData.length === 0" class="text-center py-12">
|
||||
<el-empty description="暂无白名单IP地址">
|
||||
<template #image>
|
||||
<ShieldCheckIcon class="w-16 h-16 text-gray-400" />
|
||||
</template>
|
||||
<p class="text-gray-500 mt-2">添加IP地址到白名单以允许API访问</p>
|
||||
<el-button type="primary" @click="showAddForm = true" class="mt-4">
|
||||
<PlusIcon class="w-4 h-4 mr-1" />
|
||||
添加第一个IP地址
|
||||
</el-button>
|
||||
</el-empty>
|
||||
</div>
|
||||
|
||||
<div v-else class="white-list-table">
|
||||
<el-table :data="whiteListData" stripe>
|
||||
<el-table-column prop="ip_address" label="IP地址" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<div class="flex items-center">
|
||||
<ComputerDesktopIcon class="w-4 h-4 mr-2 text-blue-500" />
|
||||
<span class="font-mono text-sm">{{ row.ip_address }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column prop="created_at" label="添加时间" min-width="180">
|
||||
<template #default="{ row }">
|
||||
<div class="flex items-center">
|
||||
<CalendarIcon class="w-4 h-4 mr-2 text-gray-400" />
|
||||
<span class="text-sm">{{ formatDate(row.created_at) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="状态" width="120">
|
||||
<template #default>
|
||||
<el-tag type="success" size="small">
|
||||
<span class="flex items-center"><ShieldCheckIcon class="w-3 h-3 mr-1" />已添加</span>
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="操作" width="120" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
type="danger"
|
||||
size="small"
|
||||
@click="handleDeleteIP(row.ip_address)"
|
||||
:loading="deleteLoading === row.ip_address"
|
||||
>
|
||||
<TrashIcon class="w-3 h-3 mr-1" />
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #extra>
|
||||
<!-- 添加IP地址弹窗 -->
|
||||
<el-dialog
|
||||
v-model="showAddForm"
|
||||
title="添加IP地址"
|
||||
width="500px"
|
||||
:close-on-click-modal="false"
|
||||
>
|
||||
<el-form
|
||||
ref="addFormRef"
|
||||
:model="addForm"
|
||||
:rules="addFormRules"
|
||||
label-width="100px"
|
||||
>
|
||||
<el-form-item label="IP地址" prop="ipAddress">
|
||||
<el-input
|
||||
v-model="addForm.ipAddress"
|
||||
placeholder="请输入IP地址,如:192.168.1.1"
|
||||
:disabled="whiteListData.length >= 10"
|
||||
>
|
||||
<template #prefix>
|
||||
<ComputerDesktopIcon class="w-4 h-4 text-gray-400" />
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item v-if="whiteListData.length >= 10">
|
||||
<el-alert
|
||||
title="白名单已满"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
>
|
||||
<template #default>
|
||||
<p>您已达到白名单数量上限(10个),请先删除不需要的IP地址后再添加新的。</p>
|
||||
</template>
|
||||
</el-alert>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-2">
|
||||
<el-button @click="showAddForm = false">取消</el-button>
|
||||
<el-button
|
||||
type="primary"
|
||||
@click="handleAddIP"
|
||||
:loading="addLoading"
|
||||
:disabled="whiteListData.length >= 10"
|
||||
>
|
||||
添加
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 使用说明卡片 -->
|
||||
<div class="mt-6">
|
||||
<el-card class="help-card">
|
||||
<template #header>
|
||||
<div class="flex items-center">
|
||||
<QuestionMarkCircleIcon class="w-5 h-5 mr-2 text-orange-600" />
|
||||
<span class="text-lg font-semibold">使用说明</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="help-content">
|
||||
<div class="help-item">
|
||||
<h4 class="help-title">什么是白名单?</h4>
|
||||
<p class="help-text">
|
||||
白名单是API访问的安全机制,只有添加到白名单中的IP地址才能调用您的API接口。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="help-item">
|
||||
<h4 class="help-title">如何添加IP地址?</h4>
|
||||
<p class="help-text">
|
||||
点击"添加IP地址"按钮,在弹窗中输入有效的IP地址(支持IPv4格式),点击"添加"即可。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="help-item">
|
||||
<h4 class="help-title">数量限制</h4>
|
||||
<p class="help-text">
|
||||
每个用户最多可添加10个IP地址到白名单中,超出限制需要先删除不需要的IP地址。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="help-item">
|
||||
<h4 class="help-title">安全提醒</h4>
|
||||
<p class="help-text">
|
||||
请确保只添加您信任的IP地址,避免将API访问权限泄露给未授权的服务器。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
</ListPageLayout>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { whiteListApi } from '@/api'
|
||||
import FilterItem from '@/components/common/FilterItem.vue'
|
||||
import FilterSection from '@/components/common/FilterSection.vue'
|
||||
import ListPageLayout from '@/components/common/ListPageLayout.vue'
|
||||
import { useCertification } from '@/composables/useCertification'
|
||||
import {
|
||||
CalendarIcon,
|
||||
ComputerDesktopIcon,
|
||||
PlusIcon,
|
||||
QuestionMarkCircleIcon,
|
||||
ShieldCheckIcon,
|
||||
TrashIcon
|
||||
} from '@heroicons/vue/24/outline'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
|
||||
// 认证相关
|
||||
const {
|
||||
isCertified,
|
||||
certificationLoading,
|
||||
requiresCertification,
|
||||
callProtectedAPI,
|
||||
canCallAPI
|
||||
} = useCertification()
|
||||
|
||||
// 响应式数据
|
||||
const loading = ref(false)
|
||||
const addLoading = ref(false)
|
||||
const deleteLoading = ref('')
|
||||
const whiteListData = ref([])
|
||||
const addFormRef = ref()
|
||||
const showAddForm = ref(false)
|
||||
|
||||
// 表单数据
|
||||
const addForm = reactive({
|
||||
ipAddress: ''
|
||||
})
|
||||
|
||||
// 筛选条件
|
||||
const filters = reactive({
|
||||
keyword: '',
|
||||
status: ''
|
||||
})
|
||||
|
||||
// 表单验证规则
|
||||
const addFormRules = {
|
||||
ipAddress: [
|
||||
{ required: true, message: '请输入IP地址', trigger: 'blur' },
|
||||
{
|
||||
pattern: /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/,
|
||||
message: '请输入有效的IPv4地址格式',
|
||||
trigger: 'blur'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
// 搜索防抖
|
||||
let searchTimer = null
|
||||
|
||||
// 页面加载时获取数据
|
||||
onMounted(() => {
|
||||
loadWhiteList()
|
||||
})
|
||||
|
||||
// 获取白名单列表
|
||||
const loadWhiteList = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await callProtectedAPI(whiteListApi.getWhiteList)
|
||||
if (response) {
|
||||
whiteListData.value = response.data.items || []
|
||||
} else {
|
||||
// 如果API调用被阻止,显示空数据
|
||||
whiteListData.value = []
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取白名单失败:', error)
|
||||
if (canCallAPI.value) {
|
||||
ElMessage.error('获取白名单失败')
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 添加IP地址
|
||||
const handleAddIP = async () => {
|
||||
if (!addFormRef.value) return
|
||||
|
||||
try {
|
||||
await addFormRef.value.validate()
|
||||
} catch (error) {
|
||||
return
|
||||
}
|
||||
|
||||
if (whiteListData.value.length >= 10) {
|
||||
ElMessage.warning('白名单已满,请先删除不需要的IP地址')
|
||||
return
|
||||
}
|
||||
|
||||
addLoading.value = true
|
||||
try {
|
||||
const response = await callProtectedAPI(whiteListApi.addWhiteListIP, addForm.ipAddress)
|
||||
if (response) {
|
||||
ElMessage.success('添加IP地址成功')
|
||||
addForm.ipAddress = ''
|
||||
showAddForm.value = false
|
||||
await loadWhiteList()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('添加IP地址失败:', error)
|
||||
if (canCallAPI.value) {
|
||||
ElMessage.error(error.response?.data?.message || '添加IP地址失败')
|
||||
}
|
||||
} finally {
|
||||
addLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 删除IP地址
|
||||
const handleDeleteIP = async (ipAddress) => {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定要删除IP地址 "${ipAddress}" 吗?`,
|
||||
'确认删除',
|
||||
{
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
}
|
||||
)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
deleteLoading.value = ipAddress
|
||||
try {
|
||||
const response = await callProtectedAPI(whiteListApi.deleteWhiteListIP, ipAddress)
|
||||
if (response) {
|
||||
ElMessage.success('删除IP地址成功')
|
||||
await loadWhiteList()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('删除IP地址失败:', error)
|
||||
if (canCallAPI.value) {
|
||||
ElMessage.error(error.response?.data?.message || '删除IP地址失败')
|
||||
}
|
||||
} finally {
|
||||
deleteLoading.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
// 处理筛选变化
|
||||
const handleFilterChange = () => {
|
||||
loadWhiteList()
|
||||
}
|
||||
|
||||
// 处理搜索
|
||||
const handleSearch = () => {
|
||||
if (searchTimer) {
|
||||
clearTimeout(searchTimer)
|
||||
}
|
||||
searchTimer = setTimeout(() => {
|
||||
loadWhiteList()
|
||||
}, 500)
|
||||
}
|
||||
|
||||
// 重置筛选
|
||||
const resetFilters = () => {
|
||||
filters.keyword = ''
|
||||
filters.status = ''
|
||||
loadWhiteList()
|
||||
}
|
||||
|
||||
// 格式化日期
|
||||
const formatDate = (dateString) => {
|
||||
if (!dateString) return '-'
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.help-content {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.help-item {
|
||||
padding: 16px;
|
||||
border-left: 4px solid #409eff;
|
||||
background-color: #f8f9fa;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.help-title {
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.help-text {
|
||||
color: #606266;
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.help-content {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
193
src/pages/auth/AuthIntegrationTest.vue
Normal file
193
src/pages/auth/AuthIntegrationTest.vue
Normal file
@@ -0,0 +1,193 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-gray-50 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div class="max-w-2xl w-full space-y-8">
|
||||
<div>
|
||||
<h2 class="auth-title text-center">
|
||||
认证集成测试
|
||||
</h2>
|
||||
<p class="auth-subtitle text-center">
|
||||
测试用户store与请求拦截器的集成
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="auth-card py-8 px-6">
|
||||
<div class="space-y-4">
|
||||
<!-- 当前状态 -->
|
||||
<div class="p-4 bg-blue-50 rounded-lg">
|
||||
<h3 class="text-lg font-medium text-blue-900 mb-2">当前状态</h3>
|
||||
<div class="space-y-2 text-sm text-blue-800">
|
||||
<p>登录状态: {{ userStore.isLoggedIn ? '已登录' : '未登录' }}</p>
|
||||
<p>用户信息: {{ userStore.userInfo ? JSON.stringify(userStore.userInfo) : '无' }}</p>
|
||||
<p>Token: {{ userStore.accessToken ? '已设置' : '未设置' }}</p>
|
||||
<p>加载状态: {{ userStore.loading ? '加载中' : '空闲' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 测试按钮 -->
|
||||
<div class="space-y-2">
|
||||
<el-button
|
||||
type="primary"
|
||||
@click="testLogin"
|
||||
:loading="userStore.loading"
|
||||
class="auth-button w-full"
|
||||
>
|
||||
测试登录
|
||||
</el-button>
|
||||
|
||||
<el-button
|
||||
type="success"
|
||||
@click="testAuthError"
|
||||
class="auth-button w-full"
|
||||
>
|
||||
模拟认证错误
|
||||
</el-button>
|
||||
|
||||
<el-button
|
||||
type="warning"
|
||||
@click="testLogout"
|
||||
class="auth-button w-full"
|
||||
>
|
||||
测试登出
|
||||
</el-button>
|
||||
|
||||
<el-button
|
||||
type="info"
|
||||
@click="testCheckAuth"
|
||||
:loading="checkingAuth"
|
||||
class="auth-button w-full"
|
||||
>
|
||||
检查认证状态
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 测试结果 -->
|
||||
<div v-if="testResult" class="p-4 bg-green-50 rounded-lg">
|
||||
<h3 class="text-lg font-medium text-green-900 mb-2">测试结果</h3>
|
||||
<pre class="text-sm text-green-800 whitespace-pre-wrap">{{ testResult }}</pre>
|
||||
</div>
|
||||
|
||||
<!-- 事件日志 -->
|
||||
<div v-if="eventLog.length > 0" class="p-4 bg-gray-50 rounded-lg">
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-2">事件日志</h3>
|
||||
<div class="space-y-1">
|
||||
<div
|
||||
v-for="(log, index) in eventLog"
|
||||
:key="index"
|
||||
class="text-sm text-gray-700 p-2 bg-white rounded border"
|
||||
>
|
||||
<span class="font-medium">{{ log.timestamp }}</span>: {{ log.message }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup name="AuthIntegrationTest">
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { authEventBus } from '@/utils/request'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const userStore = useUserStore()
|
||||
const testResult = ref('')
|
||||
const eventLog = ref([])
|
||||
const checkingAuth = ref(false)
|
||||
|
||||
// 添加事件日志
|
||||
const addLog = (message) => {
|
||||
const timestamp = new Date().toLocaleTimeString()
|
||||
eventLog.value.unshift({ timestamp, message })
|
||||
if (eventLog.value.length > 10) {
|
||||
eventLog.value = eventLog.value.slice(0, 10)
|
||||
}
|
||||
}
|
||||
|
||||
// 监听认证错误事件
|
||||
const handleAuthError = (message) => {
|
||||
addLog(`认证错误: ${message}`)
|
||||
}
|
||||
|
||||
// 测试登录
|
||||
const testLogin = async () => {
|
||||
testResult.value = ''
|
||||
addLog('开始测试登录')
|
||||
|
||||
try {
|
||||
const loginData = {
|
||||
method: 'sms',
|
||||
phone: '13800138000',
|
||||
code: '123456'
|
||||
}
|
||||
|
||||
const result = await userStore.login(loginData)
|
||||
testResult.value = JSON.stringify(result, null, 2)
|
||||
|
||||
if (result.success) {
|
||||
addLog('登录测试成功')
|
||||
ElMessage.success('登录测试成功')
|
||||
} else {
|
||||
addLog('登录测试失败')
|
||||
ElMessage.error('登录测试失败')
|
||||
}
|
||||
} catch (error) {
|
||||
testResult.value = `错误: ${error.message}`
|
||||
addLog(`登录测试异常: ${error.message}`)
|
||||
ElMessage.error('登录测试异常')
|
||||
}
|
||||
}
|
||||
|
||||
// 模拟认证错误
|
||||
const testAuthError = () => {
|
||||
addLog('模拟认证错误')
|
||||
// 直接触发认证错误事件
|
||||
authEventBus.emitAuthError('模拟的认证错误')
|
||||
testResult.value = '已触发认证错误事件'
|
||||
}
|
||||
|
||||
// 测试登出
|
||||
const testLogout = () => {
|
||||
addLog('测试登出')
|
||||
userStore.logout()
|
||||
testResult.value = '已执行登出操作'
|
||||
ElMessage.success('登出成功')
|
||||
}
|
||||
|
||||
// 检查认证状态
|
||||
const testCheckAuth = async () => {
|
||||
checkingAuth.value = true
|
||||
testResult.value = ''
|
||||
addLog('检查认证状态')
|
||||
|
||||
try {
|
||||
const isAuth = await userStore.checkAuth()
|
||||
testResult.value = `认证检查结果: ${isAuth}`
|
||||
|
||||
if (isAuth) {
|
||||
addLog('认证检查通过')
|
||||
ElMessage.success('认证检查通过')
|
||||
} else {
|
||||
addLog('认证检查失败')
|
||||
ElMessage.warning('认证检查失败')
|
||||
}
|
||||
} catch (error) {
|
||||
testResult.value = `认证检查异常: ${error.message}`
|
||||
addLog(`认证检查异常: ${error.message}`)
|
||||
ElMessage.error('认证检查异常')
|
||||
} finally {
|
||||
checkingAuth.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// 监听认证错误事件
|
||||
authEventBus.onAuthError(handleAuthError)
|
||||
addLog('页面已加载,开始监听认证错误事件')
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
// 清理事件监听(如果需要的话)
|
||||
addLog('页面即将卸载')
|
||||
})
|
||||
</script>
|
||||
285
src/pages/auth/Login.vue
Normal file
285
src/pages/auth/Login.vue
Normal file
@@ -0,0 +1,285 @@
|
||||
<template>
|
||||
<div class="w-full auth-fade-in">
|
||||
<!-- 标题 -->
|
||||
<div class="text-center mb-6">
|
||||
<h2 class="auth-title">欢迎回来</h2>
|
||||
<p class="auth-subtitle">请选择登录方式</p>
|
||||
</div>
|
||||
|
||||
<!-- 登录方式切换 -->
|
||||
<div class="mb-6">
|
||||
<el-radio-group v-model="loginMethod" class="w-full flex bg-gray-100 rounded-lg p-1 auth-radio">
|
||||
<el-radio-button value="sms" class="flex-1 !border-0 !rounded-md">
|
||||
<div class="flex items-center justify-center gap-2">
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M2.003 5.884L10 9.882l7.997-3.998A2 2 0 0016 4H4a2 2 0 00-1.997 1.884z" />
|
||||
<path d="M18 8.118l-8 4-8-4V14a2 2 0 002 2h12a2 2 0 002-2V8.118z" />
|
||||
</svg>
|
||||
验证码登录
|
||||
</div>
|
||||
</el-radio-button>
|
||||
<el-radio-button value="password" class="flex-1 !border-0 !rounded-md">
|
||||
<div class="flex items-center justify-center gap-2">
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd"
|
||||
d="M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z"
|
||||
clip-rule="evenodd" />
|
||||
</svg>
|
||||
密码登录
|
||||
</div>
|
||||
</el-radio-button>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
|
||||
<form class="space-y-4" @submit.prevent="onLogin">
|
||||
<!-- 手机号输入 -->
|
||||
<div>
|
||||
<label class="auth-label">手机号</label>
|
||||
<el-input v-model="form.phone" name="login-phone" placeholder="请输入手机号" size="large" clearable maxlength="11"
|
||||
:disabled="loading" class="auth-input">
|
||||
<template #prefix>
|
||||
<svg class="w-4 h-4 text-gray-400" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
d="M2 3a1 1 0 011-1h2.153a1 1 0 01.986.836l.74 4.435a1 1 0 01-.54 1.06l-1.548.773a11.037 11.037 0 006.105 6.105l.774-1.548a1 1 0 011.059-.54l4.435.74a1 1 0 01.836.986V17a1 1 0 01-1 1h-2C7.82 18 2 12.18 2 5V3z" />
|
||||
</svg>
|
||||
</template>
|
||||
</el-input>
|
||||
</div>
|
||||
|
||||
<!-- 验证码登录 -->
|
||||
<div v-if="loginMethod === 'sms'">
|
||||
<label class="auth-label">验证码</label>
|
||||
<div class="flex gap-3">
|
||||
<el-input v-model="form.code" name="login-sms-code" placeholder="请输入验证码" size="large" maxlength="6"
|
||||
:disabled="loading" class="auth-input">
|
||||
<template #prefix>
|
||||
<svg class="w-4 h-4 text-gray-400" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd"
|
||||
d="M18 8A6 6 0 006 8c0 3.314-4.03 6-6 6s6 2.686 6 6a6 6 0 0012 0c0-3.314 4.03-6 6-6s-6-2.686-6-6z"
|
||||
clip-rule="evenodd" />
|
||||
</svg>
|
||||
</template>
|
||||
</el-input>
|
||||
<el-button type="primary" size="large" :disabled="!canSendCode || loading" @click="sendCode"
|
||||
:loading="sendingCode" class="auth-button !px-6 !min-w-[120px]">
|
||||
{{ countdown > 0 ? `${countdown}s` : '获取验证码' }}
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 密码登录 -->
|
||||
<div v-if="loginMethod === 'password'">
|
||||
<label class="auth-label">密码</label>
|
||||
<el-input v-model="form.password" name="login-password" type="password" placeholder="请输入密码" size="large"
|
||||
show-password :disabled="loading" class="auth-input">
|
||||
<template #prefix>
|
||||
<svg class="w-4 h-4 text-gray-400" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd"
|
||||
d="M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z"
|
||||
clip-rule="evenodd" />
|
||||
</svg>
|
||||
</template>
|
||||
</el-input>
|
||||
</div>
|
||||
|
||||
<!-- 操作链接 -->
|
||||
<div class="flex items-center justify-end text-sm ">
|
||||
<router-link to="/auth/reset" class="auth-link">
|
||||
忘记密码?
|
||||
</router-link>
|
||||
<router-link to="/auth/register" class="auth-link">
|
||||
注册新账号
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<!-- 登录按钮 -->
|
||||
<el-button type="primary" size="large" class="auth-button w-full !h-12 !text-base !font-medium"
|
||||
native-type="submit" :loading="loading" :disabled="!canSubmit">
|
||||
{{ loading ? '登录中...' : '登录' }}
|
||||
</el-button>
|
||||
</form>
|
||||
|
||||
<!-- 隐私政策和用户协议 -->
|
||||
<div class="mt-6 text-center text-xs text-gray-500">
|
||||
<span>登录即表示您同意</span>
|
||||
<a href="/yhxy.pdf" target="_blank" class="text-blue-500 hover:text-blue-600 transition-colors duration-200">
|
||||
用户协议
|
||||
</a>
|
||||
<span>和</span>
|
||||
<a href="/yszc.pdf" target="_blank" class="text-blue-500 hover:text-blue-600 transition-colors duration-200">
|
||||
隐私政策
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup name="UserLogin">
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
|
||||
// 登录方式
|
||||
const loginMethod = ref('sms')
|
||||
|
||||
// 表单数据
|
||||
const form = ref({
|
||||
phone: '',
|
||||
password: '',
|
||||
code: ''
|
||||
})
|
||||
|
||||
// 状态
|
||||
const loading = ref(false)
|
||||
const sendingCode = ref(false)
|
||||
const countdown = ref(0)
|
||||
let countdownTimer = null
|
||||
|
||||
// 计算属性
|
||||
const canSendCode = computed(() => {
|
||||
return form.value.phone && form.value.phone.length === 11 && countdown.value === 0
|
||||
})
|
||||
|
||||
const canSubmit = computed(() => {
|
||||
if (loginMethod.value === 'sms') {
|
||||
return form.value.phone && form.value.phone.length === 11 &&
|
||||
form.value.code && form.value.code.length === 6
|
||||
} else {
|
||||
return form.value.phone && form.value.phone.length === 11 &&
|
||||
form.value.password && form.value.password.length > 0
|
||||
}
|
||||
})
|
||||
|
||||
// 发送验证码
|
||||
const sendCode = async () => {
|
||||
if (!canSendCode.value) return
|
||||
|
||||
sendingCode.value = true
|
||||
try {
|
||||
const result = await userStore.sendCode(form.value.phone, 'login')
|
||||
if (result.success) {
|
||||
ElMessage.success('验证码发送成功')
|
||||
startCountdown()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('验证码发送失败:', error)
|
||||
} finally {
|
||||
sendingCode.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 开始倒计时
|
||||
const startCountdown = () => {
|
||||
countdown.value = 60
|
||||
countdownTimer = setInterval(() => {
|
||||
countdown.value--
|
||||
if (countdown.value <= 0) {
|
||||
clearInterval(countdownTimer)
|
||||
}
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
// 登录
|
||||
const onLogin = async () => {
|
||||
if (!canSubmit.value) return
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const loginData = {
|
||||
method: loginMethod.value,
|
||||
phone: form.value.phone,
|
||||
code: form.value.code,
|
||||
password: form.value.password
|
||||
}
|
||||
|
||||
// 执行登录
|
||||
const result = await userStore.login(loginData)
|
||||
if (result.success) {
|
||||
ElMessage.success('登录成功')
|
||||
|
||||
// 登录成功后,主动获取用户信息以确保数据完整
|
||||
try {
|
||||
const profileResult = await userStore.fetchUserProfile()
|
||||
if (profileResult.success) {
|
||||
console.log('用户信息获取成功:', profileResult.data)
|
||||
// 显示用户信息获取成功的提示
|
||||
ElMessage.success(`欢迎回来,${profileResult.data.phone || '用户'}!`)
|
||||
} else {
|
||||
console.warn('用户信息获取失败,但登录成功')
|
||||
ElMessage.warning('登录成功,但获取用户信息失败')
|
||||
}
|
||||
} catch (profileError) {
|
||||
console.warn('获取用户信息时出错:', profileError)
|
||||
// 即使获取用户信息失败,也不影响登录流程
|
||||
ElMessage.warning('登录成功,但获取用户信息时出错')
|
||||
}
|
||||
|
||||
// 跳转到产品页面
|
||||
router.push('/products')
|
||||
} else {
|
||||
// 登录失败,显示错误信息
|
||||
const errorMessage = result.error?.response?.data?.message || '登录失败,请检查输入信息'
|
||||
ElMessage.error(errorMessage)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('登录失败:', error)
|
||||
const errorMessage = error?.response?.data?.message || '登录失败,请稍后重试'
|
||||
ElMessage.error(errorMessage)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 组件卸载时清理定时器
|
||||
onUnmounted(() => {
|
||||
if (countdownTimer) {
|
||||
clearInterval(countdownTimer)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 自定义radio button样式 */
|
||||
:deep(.el-radio-button__inner) {
|
||||
width: 100% !important;
|
||||
border: none !important;
|
||||
background: transparent !important;
|
||||
color: #6b7280 !important;
|
||||
font-weight: 500 !important;
|
||||
transition: all 0.3s ease !important;
|
||||
}
|
||||
|
||||
:deep(.el-radio-button__original-radio:checked + .el-radio-button__inner) {
|
||||
background: white !important;
|
||||
color: #3b82f6 !important;
|
||||
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06) !important;
|
||||
}
|
||||
|
||||
/* 输入框样式优化 */
|
||||
:deep(.el-input__wrapper) {
|
||||
border-radius: 8px !important;
|
||||
transition: all 0.3s ease !important;
|
||||
}
|
||||
|
||||
:deep(.el-input__wrapper:hover) {
|
||||
border-color: #3b82f6 !important;
|
||||
}
|
||||
|
||||
:deep(.el-input__wrapper.is-focus) {
|
||||
border-color: #3b82f6 !important;
|
||||
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.1) !important;
|
||||
}
|
||||
|
||||
/* 按钮样式优化 */
|
||||
:deep(.el-button--primary) {
|
||||
border-radius: 8px !important;
|
||||
font-weight: 500 !important;
|
||||
transition: all 0.3s ease !important;
|
||||
}
|
||||
|
||||
:deep(.el-button--primary:hover) {
|
||||
transform: translateY(-1px) !important;
|
||||
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3) !important;
|
||||
}
|
||||
</style>
|
||||
272
src/pages/auth/Register.vue
Normal file
272
src/pages/auth/Register.vue
Normal file
@@ -0,0 +1,272 @@
|
||||
<template>
|
||||
<div class="w-full auth-fade-in">
|
||||
<!-- 标题 -->
|
||||
<div class="text-center mb-8">
|
||||
<h2 class="auth-title">创建账号</h2>
|
||||
<p class="auth-subtitle">请填写以下信息完成注册</p>
|
||||
</div>
|
||||
|
||||
<form class="space-y-4" @submit.prevent="onRegister">
|
||||
<!-- 手机号输入 -->
|
||||
<div>
|
||||
<label class="auth-label">手机号</label>
|
||||
<el-input
|
||||
v-model="form.phone"
|
||||
name="register-phone"
|
||||
placeholder="请输入手机号"
|
||||
size="large"
|
||||
clearable
|
||||
maxlength="11"
|
||||
:disabled="loading"
|
||||
class="auth-input"
|
||||
>
|
||||
<template #prefix>
|
||||
<svg class="w-4 h-4 text-gray-400" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
d="M2 3a1 1 0 011-1h2.153a1 1 0 01.986.836l.74 4.435a1 1 0 01-.54 1.06l-1.548.773a11.037 11.037 0 006.105 6.105l.774-1.548a1 1 0 011.059-.54l4.435.74a1 1 0 01.836.986V17a1 1 0 01-1 1h-2C7.82 18 2 12.18 2 5V3z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
</el-input>
|
||||
</div>
|
||||
|
||||
<!-- 验证码输入 -->
|
||||
<div>
|
||||
<label class="auth-label">验证码</label>
|
||||
<div class="flex gap-3">
|
||||
<el-input
|
||||
v-model="form.code"
|
||||
name="register-code"
|
||||
placeholder="请输入验证码"
|
||||
size="large"
|
||||
maxlength="6"
|
||||
:disabled="loading"
|
||||
class="auth-input"
|
||||
>
|
||||
<template #prefix>
|
||||
<svg class="w-4 h-4 text-gray-400" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M18 8A6 6 0 006 8c0 3.314-4.03 6-6 6s6 2.686 6 6a6 6 0 0012 0c0-3.314 4.03-6 6-6s-6-2.686-6-6z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
</el-input>
|
||||
<el-button
|
||||
type="primary"
|
||||
size="large"
|
||||
:disabled="!canSendCode || loading"
|
||||
@click="sendCode"
|
||||
:loading="sendingCode"
|
||||
class="auth-button !px-6 !min-w-[120px]"
|
||||
>
|
||||
{{ countdown > 0 ? `${countdown}s` : '获取验证码' }}
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 密码输入 -->
|
||||
<div>
|
||||
<label class="auth-label">设置密码</label>
|
||||
<el-input
|
||||
v-model="form.password"
|
||||
name="register-password"
|
||||
type="password"
|
||||
placeholder="请设置密码(至少6位)"
|
||||
size="large"
|
||||
show-password
|
||||
:disabled="loading"
|
||||
class="auth-input"
|
||||
>
|
||||
<template #prefix>
|
||||
<svg class="w-4 h-4 text-gray-400" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
</el-input>
|
||||
</div>
|
||||
|
||||
<!-- 确认密码输入 -->
|
||||
<div>
|
||||
<label class="auth-label">确认密码</label>
|
||||
<el-input
|
||||
v-model="form.confirmPassword"
|
||||
name="register-confirm-password"
|
||||
type="password"
|
||||
placeholder="请再次输入密码"
|
||||
size="large"
|
||||
show-password
|
||||
:disabled="loading"
|
||||
class="auth-input"
|
||||
>
|
||||
<template #prefix>
|
||||
<svg class="w-4 h-4 text-gray-400" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
</el-input>
|
||||
</div>
|
||||
|
||||
<!-- 操作链接 -->
|
||||
<div class="text-center py-2">
|
||||
<router-link to="/auth/login" class="auth-link text-sm"> 已有账号?去登录 </router-link>
|
||||
</div>
|
||||
|
||||
<!-- 注册按钮 -->
|
||||
<el-button
|
||||
type="primary"
|
||||
size="large"
|
||||
class="auth-button w-full !h-12 !text-base !font-medium"
|
||||
native-type="submit"
|
||||
:loading="loading"
|
||||
:disabled="!canSubmit"
|
||||
>
|
||||
{{ loading ? '注册中...' : '注册' }}
|
||||
</el-button>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup name="UserRegister">
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
|
||||
// 表单数据
|
||||
const form = ref({
|
||||
phone: '',
|
||||
code: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
})
|
||||
|
||||
// 状态
|
||||
const loading = ref(false)
|
||||
const sendingCode = ref(false)
|
||||
const countdown = ref(0)
|
||||
let countdownTimer = null
|
||||
|
||||
// 计算属性
|
||||
const canSendCode = computed(() => {
|
||||
return form.value.phone && form.value.phone.length === 11 && countdown.value === 0
|
||||
})
|
||||
|
||||
const canSubmit = computed(() => {
|
||||
return (
|
||||
form.value.phone &&
|
||||
form.value.phone.length === 11 &&
|
||||
form.value.code &&
|
||||
form.value.code.length === 6 &&
|
||||
form.value.password &&
|
||||
form.value.password.length >= 6 &&
|
||||
form.value.confirmPassword &&
|
||||
form.value.confirmPassword === form.value.password
|
||||
)
|
||||
})
|
||||
|
||||
// 发送验证码
|
||||
const sendCode = async () => {
|
||||
if (!canSendCode.value) return
|
||||
|
||||
sendingCode.value = true
|
||||
try {
|
||||
const result = await userStore.sendCode(form.value.phone, 'register')
|
||||
if (result.success) {
|
||||
ElMessage.success('验证码发送成功')
|
||||
startCountdown()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('验证码发送失败:', error)
|
||||
} finally {
|
||||
sendingCode.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 开始倒计时
|
||||
const startCountdown = () => {
|
||||
countdown.value = 60
|
||||
countdownTimer = setInterval(() => {
|
||||
countdown.value--
|
||||
if (countdown.value <= 0) {
|
||||
clearInterval(countdownTimer)
|
||||
}
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
// 注册
|
||||
const onRegister = async () => {
|
||||
if (!canSubmit.value) return
|
||||
|
||||
if (form.value.password !== form.value.confirmPassword) {
|
||||
ElMessage.error('两次密码不一致')
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const registerData = {
|
||||
phone: form.value.phone,
|
||||
password: form.value.password,
|
||||
confirmPassword: form.value.confirmPassword,
|
||||
code: form.value.code,
|
||||
}
|
||||
|
||||
const result = await userStore.register(registerData)
|
||||
if (result.success) {
|
||||
ElMessage.success('注册成功,请登录')
|
||||
router.push('/auth/login')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('注册失败:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 组件卸载时清理定时器
|
||||
onUnmounted(() => {
|
||||
if (countdownTimer) {
|
||||
clearInterval(countdownTimer)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 输入框样式优化 */
|
||||
:deep(.el-input__wrapper) {
|
||||
border-radius: 8px !important;
|
||||
transition: all 0.3s ease !important;
|
||||
}
|
||||
|
||||
:deep(.el-input__wrapper:hover) {
|
||||
border-color: #3b82f6 !important;
|
||||
}
|
||||
|
||||
:deep(.el-input__wrapper.is-focus) {
|
||||
border-color: #3b82f6 !important;
|
||||
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.1) !important;
|
||||
}
|
||||
|
||||
/* 按钮样式优化 */
|
||||
:deep(.el-button--primary) {
|
||||
border-radius: 8px !important;
|
||||
font-weight: 500 !important;
|
||||
transition: all 0.3s ease !important;
|
||||
}
|
||||
|
||||
:deep(.el-button--primary:hover) {
|
||||
transform: translateY(-1px) !important;
|
||||
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3) !important;
|
||||
}
|
||||
</style>
|
||||
254
src/pages/auth/ResetPassword.vue
Normal file
254
src/pages/auth/ResetPassword.vue
Normal file
@@ -0,0 +1,254 @@
|
||||
<template>
|
||||
<div class="w-full auth-fade-in">
|
||||
<!-- 标题 -->
|
||||
<div class="text-center mb-8">
|
||||
<h2 class="auth-title">重置密码</h2>
|
||||
<p class="auth-subtitle">请输入手机号和验证码重置密码</p>
|
||||
</div>
|
||||
|
||||
<form class="space-y-4" @submit.prevent="onReset">
|
||||
<!-- 手机号输入 -->
|
||||
<div>
|
||||
<label class="auth-label">手机号</label>
|
||||
<el-input
|
||||
v-model="form.phone"
|
||||
name="reset-phone"
|
||||
placeholder="请输入手机号"
|
||||
size="large"
|
||||
clearable
|
||||
maxlength="11"
|
||||
:disabled="loading"
|
||||
class="auth-input"
|
||||
>
|
||||
<template #prefix>
|
||||
<svg class="w-4 h-4 text-gray-400" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M2 3a1 1 0 011-1h2.153a1 1 0 01.986.836l.74 4.435a1 1 0 01-.54 1.06l-1.548.773a11.037 11.037 0 006.105 6.105l.774-1.548a1 1 0 011.059-.54l4.435.74a1 1 0 01.836.986V17a1 1 0 01-1 1h-2C7.82 18 2 12.18 2 5V3z"/>
|
||||
</svg>
|
||||
</template>
|
||||
</el-input>
|
||||
</div>
|
||||
|
||||
<!-- 验证码输入 -->
|
||||
<div>
|
||||
<label class="auth-label">验证码</label>
|
||||
<div class="flex gap-3">
|
||||
<el-input
|
||||
v-model="form.code"
|
||||
name="reset-code"
|
||||
placeholder="请输入验证码"
|
||||
size="large"
|
||||
maxlength="6"
|
||||
:disabled="loading"
|
||||
class="auth-input"
|
||||
>
|
||||
<template #prefix>
|
||||
<svg class="w-4 h-4 text-gray-400" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M18 8A6 6 0 006 8c0 3.314-4.03 6-6 6s6 2.686 6 6a6 6 0 0012 0c0-3.314 4.03-6 6-6s-6-2.686-6-6z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
</template>
|
||||
</el-input>
|
||||
<el-button
|
||||
type="primary"
|
||||
size="large"
|
||||
:disabled="!canSendCode || loading"
|
||||
@click="sendCode"
|
||||
:loading="sendingCode"
|
||||
class="auth-button !px-6 !min-w-[120px]"
|
||||
>
|
||||
{{ countdown > 0 ? `${countdown}s` : '获取验证码' }}
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 新密码输入 -->
|
||||
<div>
|
||||
<label class="auth-label">新密码</label>
|
||||
<el-input
|
||||
v-model="form.newPassword"
|
||||
name="reset-new-password"
|
||||
type="password"
|
||||
placeholder="请输入新密码(至少6位)"
|
||||
size="large"
|
||||
show-password
|
||||
:disabled="loading"
|
||||
class="auth-input"
|
||||
>
|
||||
<template #prefix>
|
||||
<svg class="w-4 h-4 text-gray-400" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
</template>
|
||||
</el-input>
|
||||
</div>
|
||||
|
||||
<!-- 确认新密码输入 -->
|
||||
<div>
|
||||
<label class="auth-label">确认新密码</label>
|
||||
<el-input
|
||||
v-model="form.confirmNewPassword"
|
||||
name="reset-confirm-new-password"
|
||||
type="password"
|
||||
placeholder="请再次输入新密码"
|
||||
size="large"
|
||||
show-password
|
||||
:disabled="loading"
|
||||
class="auth-input"
|
||||
>
|
||||
<template #prefix>
|
||||
<svg class="w-4 h-4 text-gray-400" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
</template>
|
||||
</el-input>
|
||||
</div>
|
||||
|
||||
<!-- 操作链接 -->
|
||||
<div class="text-center py-2">
|
||||
<router-link to="/auth/login" class="auth-link text-sm">
|
||||
返回登录
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<!-- 重置按钮 -->
|
||||
<el-button
|
||||
type="primary"
|
||||
size="large"
|
||||
class="auth-button w-full !h-12 !text-base !font-medium"
|
||||
native-type="submit"
|
||||
:loading="loading"
|
||||
:disabled="!canSubmit"
|
||||
>
|
||||
{{ loading ? '重置中...' : '重置密码' }}
|
||||
</el-button>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup name="UserResetPassword">
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
|
||||
// 表单数据
|
||||
const form = ref({
|
||||
phone: '',
|
||||
code: '',
|
||||
newPassword: '',
|
||||
confirmNewPassword: ''
|
||||
})
|
||||
|
||||
// 状态
|
||||
const loading = ref(false)
|
||||
const sendingCode = ref(false)
|
||||
const countdown = ref(0)
|
||||
let countdownTimer = null
|
||||
|
||||
// 计算属性
|
||||
const canSendCode = computed(() => {
|
||||
return form.value.phone && form.value.phone.length === 11 && countdown.value === 0
|
||||
})
|
||||
|
||||
const canSubmit = computed(() => {
|
||||
return form.value.phone && form.value.phone.length === 11 &&
|
||||
form.value.code && form.value.code.length === 6 &&
|
||||
form.value.newPassword && form.value.newPassword.length >= 6 &&
|
||||
form.value.confirmNewPassword && form.value.confirmNewPassword === form.value.newPassword
|
||||
})
|
||||
|
||||
// 发送验证码
|
||||
const sendCode = async () => {
|
||||
if (!canSendCode.value) return
|
||||
|
||||
sendingCode.value = true
|
||||
try {
|
||||
const result = await userStore.sendCode(form.value.phone, 'reset_password')
|
||||
if (result.success) {
|
||||
ElMessage.success('验证码发送成功')
|
||||
startCountdown()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('验证码发送失败:', error)
|
||||
} finally {
|
||||
sendingCode.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 开始倒计时
|
||||
const startCountdown = () => {
|
||||
countdown.value = 60
|
||||
countdownTimer = setInterval(() => {
|
||||
countdown.value--
|
||||
if (countdown.value <= 0) {
|
||||
clearInterval(countdownTimer)
|
||||
}
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
// 重置密码
|
||||
const onReset = async () => {
|
||||
if (!canSubmit.value) return
|
||||
|
||||
if (form.value.newPassword !== form.value.confirmNewPassword) {
|
||||
ElMessage.error('两次密码不一致')
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const resetData = {
|
||||
phone: form.value.phone,
|
||||
newPassword: form.value.newPassword,
|
||||
confirmNewPassword: form.value.confirmNewPassword,
|
||||
code: form.value.code
|
||||
}
|
||||
|
||||
const result = await userStore.resetPassword(resetData)
|
||||
if (result.success) {
|
||||
ElMessage.success('密码重置成功')
|
||||
router.push('/auth/login')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('密码重置失败:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 组件卸载时清理定时器
|
||||
onUnmounted(() => {
|
||||
if (countdownTimer) {
|
||||
clearInterval(countdownTimer)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 输入框样式优化 */
|
||||
:deep(.el-input__wrapper) {
|
||||
border-radius: 8px !important;
|
||||
transition: all 0.3s ease !important;
|
||||
}
|
||||
|
||||
:deep(.el-input__wrapper:hover) {
|
||||
border-color: #3b82f6 !important;
|
||||
}
|
||||
|
||||
:deep(.el-input__wrapper.is-focus) {
|
||||
border-color: #3b82f6 !important;
|
||||
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.1) !important;
|
||||
}
|
||||
|
||||
/* 按钮样式优化 */
|
||||
:deep(.el-button--primary) {
|
||||
border-radius: 8px !important;
|
||||
font-weight: 500 !important;
|
||||
transition: all 0.3s ease !important;
|
||||
}
|
||||
|
||||
:deep(.el-button--primary:hover) {
|
||||
transform: translateY(-1px) !important;
|
||||
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3) !important;
|
||||
}
|
||||
</style>
|
||||
173
src/pages/auth/ResponseTest.vue
Normal file
173
src/pages/auth/ResponseTest.vue
Normal file
@@ -0,0 +1,173 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-gray-50 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div class="max-w-2xl w-full space-y-8">
|
||||
<div>
|
||||
<h2 class="auth-title text-center">
|
||||
响应格式测试
|
||||
</h2>
|
||||
<p class="auth-subtitle text-center">
|
||||
测试后端响应格式处理
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="auth-card py-8 px-6">
|
||||
<div class="space-y-4">
|
||||
<!-- 模拟响应数据 -->
|
||||
<div class="p-4 bg-blue-50 rounded-lg">
|
||||
<h3 class="text-lg font-medium text-blue-900 mb-2">模拟后端响应</h3>
|
||||
<pre class="text-sm text-blue-800 whitespace-pre-wrap">{{ mockResponse }}</pre>
|
||||
</div>
|
||||
|
||||
<!-- 测试按钮 -->
|
||||
<div class="space-y-2">
|
||||
<el-button
|
||||
type="primary"
|
||||
@click="testLoginResponse"
|
||||
class="auth-button w-full"
|
||||
>
|
||||
测试登录响应处理
|
||||
</el-button>
|
||||
|
||||
<el-button
|
||||
type="success"
|
||||
@click="testUserProfileResponse"
|
||||
class="auth-button w-full"
|
||||
>
|
||||
测试用户信息响应处理
|
||||
</el-button>
|
||||
|
||||
<el-button
|
||||
type="warning"
|
||||
@click="testSendCodeResponse"
|
||||
class="auth-button w-full"
|
||||
>
|
||||
测试验证码响应处理
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 测试结果 -->
|
||||
<div v-if="testResult" class="p-4 bg-green-50 rounded-lg">
|
||||
<h3 class="text-lg font-medium text-green-900 mb-2">处理结果</h3>
|
||||
<pre class="text-sm text-green-800 whitespace-pre-wrap">{{ testResult }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup name="ResponseTest">
|
||||
|
||||
const testResult = ref('')
|
||||
|
||||
// 模拟后端响应数据
|
||||
const mockResponse = ref(`{
|
||||
"success": true,
|
||||
"data": {
|
||||
"user": {
|
||||
"id": "4e213f08-eb3c-4a57-a8d8-b6176550083e",
|
||||
"phone": "18276151590",
|
||||
"created_at": "2025-07-11T18:49:22.428966+08:00",
|
||||
"updated_at": "2025-07-11T18:49:22.428966+08:00"
|
||||
},
|
||||
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 86400,
|
||||
"login_method": "sms"
|
||||
},
|
||||
"message": "登录成功",
|
||||
"requestId": "",
|
||||
"timestamp": 1752469413
|
||||
}`)
|
||||
|
||||
// 测试登录响应处理
|
||||
const testLoginResponse = () => {
|
||||
const mockLoginResponse = {
|
||||
success: true,
|
||||
data: {
|
||||
user: {
|
||||
id: "4e213f08-eb3c-4a57-a8d8-b6176550083e",
|
||||
phone: "18276151590",
|
||||
created_at: "2025-07-11T18:49:22.428966+08:00",
|
||||
updated_at: "2025-07-11T18:49:22.428966+08:00"
|
||||
},
|
||||
access_token: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
|
||||
token_type: "Bearer",
|
||||
expires_in: 86400,
|
||||
login_method: "sms"
|
||||
},
|
||||
message: "登录成功",
|
||||
requestId: "",
|
||||
timestamp: 1752469413
|
||||
}
|
||||
|
||||
// 模拟用户store的处理逻辑
|
||||
const loginData = mockLoginResponse.data
|
||||
|
||||
const result = {
|
||||
success: true,
|
||||
data: loginData,
|
||||
extractedData: {
|
||||
accessToken: loginData.access_token,
|
||||
tokenType: loginData.token_type,
|
||||
user: loginData.user,
|
||||
expiresIn: loginData.expires_in,
|
||||
loginMethod: loginData.login_method
|
||||
}
|
||||
}
|
||||
|
||||
testResult.value = JSON.stringify(result, null, 2)
|
||||
}
|
||||
|
||||
// 测试用户信息响应处理
|
||||
const testUserProfileResponse = () => {
|
||||
const mockProfileResponse = {
|
||||
success: true,
|
||||
data: {
|
||||
id: "4e213f08-eb3c-4a57-a8d8-b6176550083e",
|
||||
phone: "18276151590",
|
||||
created_at: "2025-07-11T18:49:22.428966+08:00",
|
||||
updated_at: "2025-07-11T18:49:22.428966+08:00"
|
||||
},
|
||||
message: "获取用户资料成功",
|
||||
requestId: "",
|
||||
timestamp: 1752469413
|
||||
}
|
||||
|
||||
// 模拟用户store的处理逻辑
|
||||
const userData = mockProfileResponse.data
|
||||
|
||||
const result = {
|
||||
success: true,
|
||||
data: userData,
|
||||
extractedData: {
|
||||
userId: userData.id,
|
||||
phone: userData.phone,
|
||||
createdAt: userData.created_at,
|
||||
updatedAt: userData.updated_at
|
||||
}
|
||||
}
|
||||
|
||||
testResult.value = JSON.stringify(result, null, 2)
|
||||
}
|
||||
|
||||
// 测试验证码响应处理
|
||||
const testSendCodeResponse = () => {
|
||||
const mockSendCodeResponse = {
|
||||
success: true,
|
||||
data: null, // 发送验证码通常不返回数据
|
||||
message: "验证码发送成功",
|
||||
requestId: "",
|
||||
timestamp: 1752469413
|
||||
}
|
||||
|
||||
// 模拟用户store的处理逻辑
|
||||
const result = {
|
||||
success: true,
|
||||
data: mockSendCodeResponse.data,
|
||||
message: mockSendCodeResponse.message
|
||||
}
|
||||
|
||||
testResult.value = JSON.stringify(result, null, 2)
|
||||
}
|
||||
</script>
|
||||
162
src/pages/auth/TestStore.vue
Normal file
162
src/pages/auth/TestStore.vue
Normal file
@@ -0,0 +1,162 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-gray-50 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div class="max-w-md w-full space-y-8">
|
||||
<div>
|
||||
<h2 class="auth-title text-center">
|
||||
用户Store测试
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div class="auth-card py-8 px-6">
|
||||
<div class="space-y-4">
|
||||
<!-- 登录状态 -->
|
||||
<div class="p-4 bg-gray-50 rounded-lg">
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-2">登录状态</h3>
|
||||
<p class="text-sm text-gray-600">
|
||||
已登录: {{ userStore.isLoggedIn ? '是' : '否' }}
|
||||
</p>
|
||||
<p class="text-sm text-gray-600">
|
||||
用户信息: {{ userStore.userInfo ? JSON.stringify(userStore.userInfo) : '无' }}
|
||||
</p>
|
||||
<p class="text-sm text-gray-600">
|
||||
Token: {{ userStore.accessToken ? '已设置' : '未设置' }}
|
||||
</p>
|
||||
<p class="text-sm text-gray-600">
|
||||
加载状态: {{ userStore.loading ? '加载中' : '空闲' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 测试按钮 -->
|
||||
<div class="space-y-2">
|
||||
<el-button
|
||||
type="primary"
|
||||
@click="testSendCode"
|
||||
:loading="sendingCode"
|
||||
class="auth-button w-full"
|
||||
>
|
||||
测试发送验证码
|
||||
</el-button>
|
||||
|
||||
<el-button
|
||||
type="success"
|
||||
@click="testLogin"
|
||||
:loading="userStore.loading"
|
||||
class="auth-button w-full"
|
||||
>
|
||||
测试登录
|
||||
</el-button>
|
||||
|
||||
<el-button
|
||||
type="warning"
|
||||
@click="testLogout"
|
||||
class="auth-button w-full"
|
||||
>
|
||||
测试登出
|
||||
</el-button>
|
||||
|
||||
<el-button
|
||||
type="info"
|
||||
@click="testCheckAuth"
|
||||
:loading="checkingAuth"
|
||||
class="auth-button w-full"
|
||||
>
|
||||
测试检查认证
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 测试结果 -->
|
||||
<div v-if="testResult" class="p-4 bg-blue-50 rounded-lg">
|
||||
<h3 class="text-lg font-medium text-blue-900 mb-2">测试结果</h3>
|
||||
<pre class="text-sm text-blue-800 whitespace-pre-wrap">{{ testResult }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup name="TestStore">
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const userStore = useUserStore()
|
||||
const sendingCode = ref(false)
|
||||
const checkingAuth = ref(false)
|
||||
const testResult = ref('')
|
||||
|
||||
// 测试发送验证码
|
||||
const testSendCode = async () => {
|
||||
sendingCode.value = true
|
||||
testResult.value = ''
|
||||
|
||||
try {
|
||||
const result = await userStore.sendCode('13800138000', 'login')
|
||||
testResult.value = JSON.stringify(result, null, 2)
|
||||
|
||||
if (result.success) {
|
||||
ElMessage.success('验证码发送成功')
|
||||
} else {
|
||||
ElMessage.error('验证码发送失败')
|
||||
}
|
||||
} catch (error) {
|
||||
testResult.value = `错误: ${error.message}`
|
||||
ElMessage.error('测试失败')
|
||||
} finally {
|
||||
sendingCode.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 测试登录
|
||||
const testLogin = async () => {
|
||||
testResult.value = ''
|
||||
|
||||
try {
|
||||
const loginData = {
|
||||
method: 'sms',
|
||||
phone: '13800138000',
|
||||
code: '123456'
|
||||
}
|
||||
|
||||
const result = await userStore.login(loginData)
|
||||
testResult.value = JSON.stringify(result, null, 2)
|
||||
|
||||
if (result.success) {
|
||||
ElMessage.success('登录测试成功')
|
||||
} else {
|
||||
ElMessage.error('登录测试失败')
|
||||
}
|
||||
} catch (error) {
|
||||
testResult.value = `错误: ${error.message}`
|
||||
ElMessage.error('测试失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 测试登出
|
||||
const testLogout = () => {
|
||||
userStore.logout()
|
||||
testResult.value = '已登出'
|
||||
ElMessage.success('登出成功')
|
||||
}
|
||||
|
||||
// 测试检查认证
|
||||
const testCheckAuth = async () => {
|
||||
checkingAuth.value = true
|
||||
testResult.value = ''
|
||||
|
||||
try {
|
||||
const result = await userStore.checkAuth()
|
||||
testResult.value = `认证检查结果: ${result}`
|
||||
|
||||
if (result) {
|
||||
ElMessage.success('认证检查通过')
|
||||
} else {
|
||||
ElMessage.warning('认证检查失败')
|
||||
}
|
||||
} catch (error) {
|
||||
testResult.value = `错误: ${error.message}`
|
||||
ElMessage.error('认证检查失败')
|
||||
} finally {
|
||||
checkingAuth.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
91
src/pages/certification/IframeCallback.vue
Normal file
91
src/pages/certification/IframeCallback.vue
Normal file
@@ -0,0 +1,91 @@
|
||||
<template>
|
||||
<div class="iframe-callback-page">
|
||||
<div class="callback-content">
|
||||
<div class="callback-icon">
|
||||
<el-icon :size="48" color="#10b981"><Check /></el-icon>
|
||||
</div>
|
||||
<h2 class="callback-title">{{ successText }}</h2>
|
||||
<p class="callback-desc">{{ descText }}</p>
|
||||
<div class="callback-loading">
|
||||
<span class="loading-spinner"></span>
|
||||
<span>正在确认结果,请稍候...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
|
||||
import { Check, Loading } from '@element-plus/icons-vue'
|
||||
|
||||
const route = useRoute()
|
||||
const scene = ref('auth') // auth: 企业认证, sign: 合同签署
|
||||
|
||||
// 解析路由参数
|
||||
onMounted(() => {
|
||||
const paramScene = route.params.scene
|
||||
if (paramScene === 'sign') {
|
||||
scene.value = 'sign'
|
||||
} else {
|
||||
scene.value = 'auth'
|
||||
}
|
||||
// 向父iframe发送消息
|
||||
window.parent.postMessage({ type: 'CHECK_STATUS', scene: scene.value }, '*')
|
||||
})
|
||||
|
||||
const successText = computed(() =>
|
||||
scene.value === 'sign' ? '合同签署成功' : '企业认证成功'
|
||||
)
|
||||
const descText = computed(() =>
|
||||
scene.value === 'sign'
|
||||
? '合同已签署,正在为您确认签署状态...'
|
||||
: '企业认证已完成,正在为您确认认证状态...'
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.iframe-callback-page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #f8fafc;
|
||||
}
|
||||
.callback-content {
|
||||
background: #fff;
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 8px 32px rgba(16, 185, 129, 0.08);
|
||||
padding: 48px 32px;
|
||||
text-align: center;
|
||||
min-width: 320px;
|
||||
}
|
||||
.callback-icon {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.callback-title {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: #10b981;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.callback-desc {
|
||||
font-size: 16px;
|
||||
color: #64748b;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
.callback-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
color: #64748b;
|
||||
font-size: 16px;
|
||||
}
|
||||
.loading-spinner {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
</style>
|
||||
333
src/pages/certification/README.md
Normal file
333
src/pages/certification/README.md
Normal file
@@ -0,0 +1,333 @@
|
||||
# 企业入驻流程
|
||||
|
||||
## 📋 概述
|
||||
|
||||
企业入驻流程是一个基于Vue 3 + Element Plus + Tailwind CSS构建的步骤式认证系统,用于引导用户完成企业信息填写、认证验证、合同签署等入驻流程。
|
||||
|
||||
## 🏗️ 架构设计
|
||||
|
||||
### 目录结构
|
||||
```
|
||||
certification/
|
||||
├── index.vue # 主页面容器
|
||||
├── components/ # 子组件目录
|
||||
│ ├── EnterpriseInfo.vue # 企业信息填写
|
||||
│ ├── EnterpriseVerify.vue # 企业认证验证
|
||||
│ ├── ContractSign.vue # 合同签署
|
||||
│ └── CertificationComplete.vue # 完成页面
|
||||
└── README.md # 本文档
|
||||
```
|
||||
|
||||
### 技术栈
|
||||
- **前端框架**: Vue 3 (Composition API)
|
||||
- **UI组件库**: Element Plus
|
||||
- **样式框架**: Tailwind CSS v4
|
||||
- **图标库**: Heroicons
|
||||
- **状态管理**: Pinia (用户状态)
|
||||
- **路由**: Vue Router
|
||||
|
||||
## 🔄 流程步骤
|
||||
|
||||
### 1. 填写企业信息 (`enterprise_info`)
|
||||
**组件**: `EnterpriseInfo.vue`
|
||||
|
||||
**功能特性**:
|
||||
- 企业基本信息收集
|
||||
- 法人信息验证
|
||||
- 手机号验证码验证
|
||||
- 实时表单验证
|
||||
|
||||
**表单字段**:
|
||||
- 企业名称 (2-100字符)
|
||||
- 统一社会信用代码 (18位)
|
||||
- 法人姓名 (2-20字符)
|
||||
- 法人身份证号 (18位)
|
||||
- 法人手机号 (11位)
|
||||
- 验证码 (6位数字)
|
||||
|
||||
**验证规则**:
|
||||
```javascript
|
||||
// 统一社会信用代码验证
|
||||
const pattern = /^[0-9A-HJ-NPQRTUWXY]{18}$/
|
||||
|
||||
// 身份证号验证
|
||||
const pattern = /^[1-9]\d{5}(18|19|20)\d{2}((0[1-9])|(1[0-2]))(([0-2][1-9])|10|20|30|31)\d{3}[0-9Xx]$/
|
||||
|
||||
// 手机号验证
|
||||
const pattern = /^1[3-9]\d{9}$/
|
||||
```
|
||||
|
||||
### 2. 企业认证 (`enterprise_verify`)
|
||||
**组件**: `EnterpriseVerify.vue`
|
||||
|
||||
**功能特性**:
|
||||
- 集成e签宝企业认证服务
|
||||
- iframe嵌入认证页面
|
||||
- 认证状态监控
|
||||
- 加载状态管理
|
||||
|
||||
**集成服务**:
|
||||
- 认证URL: `https://smlt.esign.cn/Z1Dc9Ts`
|
||||
- 支持iframe加载
|
||||
- 认证完成回调处理
|
||||
|
||||
### 3. 签署合同 (`contract_sign`)
|
||||
**组件**: `ContractSign.vue`
|
||||
|
||||
**功能特性**:
|
||||
- 集成电子合同签署服务
|
||||
- iframe嵌入签署页面
|
||||
- 签署状态监控
|
||||
- 完成确认机制
|
||||
|
||||
**集成服务**:
|
||||
- 签署URL: `https://smlt.esign.cn/95wqyN2`
|
||||
- 支持iframe加载
|
||||
- 签署完成回调处理
|
||||
|
||||
### 4. 完成入驻 (`completed`)
|
||||
**组件**: `CertificationComplete.vue`
|
||||
|
||||
**功能特性**:
|
||||
- 入驻成功展示
|
||||
- 企业信息汇总
|
||||
- 后续操作引导
|
||||
- 状态标签显示
|
||||
|
||||
**操作入口**:
|
||||
- 前往控制台
|
||||
- 返回账户中心
|
||||
|
||||
## 🎨 UI/UX 设计
|
||||
|
||||
### 视觉设计原则
|
||||
- **现代化**: 毛玻璃效果、渐变背景、圆角设计
|
||||
- **一致性**: 统一的色彩系统和间距规范
|
||||
- **可访问性**: 清晰的视觉层次和状态反馈
|
||||
|
||||
### 响应式设计
|
||||
```css
|
||||
/* 桌面端 */
|
||||
@media (min-width: 1200px) {
|
||||
.iframe-container { height: 700px; }
|
||||
}
|
||||
|
||||
/* 移动端 */
|
||||
@media (max-width: 768px) {
|
||||
.iframe-container { height: 400px; }
|
||||
.form-section { padding: 20px; }
|
||||
}
|
||||
```
|
||||
|
||||
### 状态指示
|
||||
- **未开始**: 灰色图标,等待状态
|
||||
- **进行中**: 蓝色图标,脉冲动画
|
||||
- **已完成**: 绿色图标,完成状态
|
||||
|
||||
## 🔧 技术实现
|
||||
|
||||
### 状态管理
|
||||
```javascript
|
||||
// 主要状态
|
||||
const currentStep = ref('enterprise_info')
|
||||
const certificationData = ref(null)
|
||||
const enterpriseForm = ref({
|
||||
companyName: '',
|
||||
unifiedSocialCode: '',
|
||||
legalPersonName: '',
|
||||
legalPersonID: '',
|
||||
legalPersonPhone: '',
|
||||
legalPersonCode: ''
|
||||
})
|
||||
```
|
||||
|
||||
### 组件通信
|
||||
```javascript
|
||||
// 事件定义
|
||||
const emit = defineEmits(['submit', 'apply', 'check-status', 'go-dashboard', 'go-profile'])
|
||||
|
||||
// 数据传递
|
||||
const props = defineProps({
|
||||
formData: Object,
|
||||
enterpriseData: Object,
|
||||
companyName: String,
|
||||
certificationData: Object
|
||||
})
|
||||
```
|
||||
|
||||
### 表单验证
|
||||
```javascript
|
||||
// 自定义验证器
|
||||
const validateUnifiedSocialCode = (rule, value, callback) => {
|
||||
const pattern = /^[0-9A-HJ-NPQRTUWXY]{18}$/
|
||||
if (!pattern.test(value)) {
|
||||
callback(new Error('统一社会信用代码格式不正确'))
|
||||
return
|
||||
}
|
||||
callback()
|
||||
}
|
||||
```
|
||||
|
||||
## 🚀 开发特性
|
||||
|
||||
### 开发模式
|
||||
```javascript
|
||||
// 开发模式控制器
|
||||
const isDevelopment = ref(false)
|
||||
const devCurrentStep = ref('enterprise_info')
|
||||
|
||||
// 步骤切换功能
|
||||
const switchToStep = (stepKey) => {
|
||||
currentStep.value = stepKey
|
||||
}
|
||||
|
||||
const resetToFirstStep = () => {
|
||||
currentStep.value = 'enterprise_info'
|
||||
}
|
||||
```
|
||||
|
||||
### 错误处理
|
||||
- 表单验证错误提示
|
||||
- API调用异常处理
|
||||
- 网络错误友好提示
|
||||
- 加载状态管理
|
||||
|
||||
### 性能优化
|
||||
- 组件懒加载
|
||||
- 表单数据缓存
|
||||
- 防抖处理
|
||||
- 内存清理
|
||||
|
||||
## 📱 移动端适配
|
||||
|
||||
### 布局调整
|
||||
- 垂直布局适配
|
||||
- 按钮全宽显示
|
||||
- 步骤图标缩小
|
||||
- 间距优化
|
||||
|
||||
### 交互优化
|
||||
- 触摸友好的按钮尺寸
|
||||
- 简化的表单布局
|
||||
- 优化的iframe高度
|
||||
- 响应式步骤条
|
||||
|
||||
## 🔌 第三方集成
|
||||
|
||||
### e签宝服务
|
||||
- **企业认证**: 统一身份认证
|
||||
- **合同签署**: 电子合同服务
|
||||
- **iframe集成**: 无缝嵌入体验
|
||||
|
||||
### 短信服务
|
||||
- **验证码发送**: 手机号验证
|
||||
- **倒计时功能**: 防止重复发送
|
||||
- **错误处理**: 发送失败重试
|
||||
|
||||
## 📊 状态流转
|
||||
|
||||
### 认证状态映射
|
||||
```javascript
|
||||
const setCurrentStepByStatus = () => {
|
||||
switch (certificationData.value.status) {
|
||||
case 'not_started':
|
||||
case 'pending':
|
||||
currentStep.value = 'enterprise_info'
|
||||
break
|
||||
case 'info_submitted':
|
||||
currentStep.value = 'enterprise_verify'
|
||||
break
|
||||
case 'enterprise_verified':
|
||||
case 'contract_applied':
|
||||
case 'contract_pending':
|
||||
case 'contract_approved':
|
||||
currentStep.value = 'contract_sign'
|
||||
break
|
||||
case 'contract_signed':
|
||||
case 'completed':
|
||||
currentStep.value = 'completed'
|
||||
break
|
||||
default:
|
||||
currentStep.value = 'enterprise_info'
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 🧪 测试指南
|
||||
|
||||
### 开发模式测试
|
||||
1. 启用开发模式控制器
|
||||
2. 使用步骤选择器切换不同步骤
|
||||
3. 测试表单验证和提交
|
||||
4. 验证响应式布局
|
||||
|
||||
### 功能测试
|
||||
1. 表单验证测试
|
||||
2. 验证码发送测试
|
||||
3. iframe加载测试
|
||||
4. 状态切换测试
|
||||
|
||||
### 兼容性测试
|
||||
1. 不同浏览器测试
|
||||
2. 移动端适配测试
|
||||
3. 网络异常测试
|
||||
4. 性能压力测试
|
||||
|
||||
## 📝 使用说明
|
||||
|
||||
### 基本使用
|
||||
```vue
|
||||
<template>
|
||||
<div class="certification-page">
|
||||
<!-- 步骤条 -->
|
||||
<CustomSteps :steps="certificationSteps" :current-step="currentStep" />
|
||||
|
||||
<!-- 内容区域 -->
|
||||
<EnterpriseInfo v-if="currentStep === 'enterprise_info'" @submit="handleSubmit" />
|
||||
<EnterpriseVerify v-if="currentStep === 'enterprise_verify'" @submit="handleVerify" />
|
||||
<ContractSign v-if="currentStep === 'contract_sign'" @apply="handleApply" />
|
||||
<CertificationComplete v-if="currentStep === 'completed'" @go-dashboard="goToDashboard" />
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
### 配置步骤
|
||||
```javascript
|
||||
const certificationSteps = [
|
||||
{
|
||||
key: 'enterprise_info',
|
||||
title: '填写企业信息',
|
||||
description: '填写企业基本信息和法人信息',
|
||||
icon: BuildingOfficeIcon
|
||||
},
|
||||
// ... 其他步骤
|
||||
]
|
||||
```
|
||||
|
||||
## 🔮 未来规划
|
||||
|
||||
### 功能增强
|
||||
- [ ] 支持多语言国际化
|
||||
- [ ] 增加进度保存功能
|
||||
- [ ] 支持离线填写
|
||||
- [ ] 增加数据导出功能
|
||||
|
||||
### 技术优化
|
||||
- [ ] 引入TypeScript支持
|
||||
- [ ] 增加单元测试覆盖
|
||||
- [ ] 优化包体积
|
||||
- [ ] 增加PWA支持
|
||||
|
||||
### 用户体验
|
||||
- [ ] 增加引导提示
|
||||
- [ ] 优化加载动画
|
||||
- [ ] 增加操作确认
|
||||
- [ ] 支持快捷键操作
|
||||
|
||||
## 📞 技术支持
|
||||
|
||||
如有问题或建议,请联系开发团队或提交Issue。
|
||||
|
||||
---
|
||||
|
||||
*最后更新: 2024年*
|
||||
538
src/pages/certification/components/CertificationComplete.vue
Normal file
538
src/pages/certification/components/CertificationComplete.vue
Normal file
@@ -0,0 +1,538 @@
|
||||
<template>
|
||||
<el-card class="step-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<div class="header-icon">
|
||||
<el-icon class="text-green-600">
|
||||
<CheckCircleIcon />
|
||||
</el-icon>
|
||||
</div>
|
||||
<div class="header-content">
|
||||
<h2 class="header-title">入驻完成</h2>
|
||||
<p class="header-subtitle">恭喜您完成企业入驻,现在可以使用完整的API服务功能</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="completion-content">
|
||||
<div class="success-card">
|
||||
<div class="success-icon">
|
||||
<div class="icon-wrapper">
|
||||
<el-icon class="text-green-500">
|
||||
<CheckCircleIcon />
|
||||
</el-icon>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="success-text">
|
||||
<h2 class="success-title">恭喜!企业入驻已完成</h2>
|
||||
<p class="success-desc">您的企业已完成入驻,现在可以使用完整的API服务功能。</p>
|
||||
|
||||
<div class="completion-info">
|
||||
<h3 class="info-title">入驻信息</h3>
|
||||
<div class="info-grid">
|
||||
<div class="info-item">
|
||||
<label class="info-label">入驻状态</label>
|
||||
<div class="info-value">
|
||||
<el-tag type="success" size="large" class="status-tag">已入驻</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<label class="info-label">入驻时间</label>
|
||||
<div class="info-value">
|
||||
{{ formatDate(certificationData.completed_at || formatDate(new Date())) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<label class="info-label">企业名称</label>
|
||||
<div class="info-value">{{ enterpriseInfo.company_name || '' }}</div>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<label class="info-label">统一社会信用代码</label>
|
||||
<div class="info-value">{{ enterpriseInfo.unified_social_code || '' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="completion-actions">
|
||||
<el-button
|
||||
type="primary"
|
||||
size="large"
|
||||
@click="showContract"
|
||||
class="action-btn primary-btn"
|
||||
>
|
||||
<el-icon class="mr-2">
|
||||
<DocumentTextIcon />
|
||||
</el-icon>
|
||||
查看合同
|
||||
</el-button>
|
||||
<el-button size="large" @click="goToProfile" class="action-btn secondary-btn">
|
||||
<el-icon class="mr-2">
|
||||
<UserIcon />
|
||||
</el-icon>
|
||||
返回账户中心
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 合同查看弹窗 -->
|
||||
<el-dialog
|
||||
v-model="contractDialogVisible"
|
||||
title="合同查看"
|
||||
width="70%"
|
||||
:close-on-click-modal="false"
|
||||
:close-on-press-escape="true"
|
||||
class="contract-dialog"
|
||||
>
|
||||
<div class="contract-container">
|
||||
<iframe
|
||||
v-if="contractUrl"
|
||||
:src="contractUrl"
|
||||
class="contract-iframe"
|
||||
frameborder="0"
|
||||
allowfullscreen
|
||||
></iframe>
|
||||
<div v-else class="no-contract">
|
||||
<el-icon class="text-gray-400" size="48">
|
||||
<DocumentIcon />
|
||||
</el-icon>
|
||||
<p>暂无合同文件</p>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<el-button @click="contractDialogVisible = false">关闭</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { CheckCircleIcon, DocumentIcon, DocumentTextIcon, UserIcon } from '@heroicons/vue/24/outline'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
|
||||
const userStore = useUserStore()
|
||||
const { enterpriseForm, certificationData } = defineProps({
|
||||
enterpriseForm: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
certificationData: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['go-dashboard', 'go-profile'])
|
||||
|
||||
// 合同弹窗状态
|
||||
const contractDialogVisible = ref(false)
|
||||
|
||||
// 响应式获取企业信息
|
||||
const enterpriseInfo = computed(() => {
|
||||
return userStore.userInfo?.enterprise_info || {}
|
||||
})
|
||||
|
||||
// 获取合同URL
|
||||
const contractUrl = computed(() => {
|
||||
return certificationData?.metadata?.contract_url || ''
|
||||
})
|
||||
|
||||
// 显示合同
|
||||
const showContract = () => {
|
||||
if (contractUrl.value) {
|
||||
contractDialogVisible.value = true
|
||||
} else {
|
||||
ElMessage.warning('暂无合同文件')
|
||||
}
|
||||
}
|
||||
|
||||
// 跳转到控制台
|
||||
const goToDashboard = () => {
|
||||
emit('go-dashboard')
|
||||
}
|
||||
|
||||
// 跳转到账户中心
|
||||
const goToProfile = () => {
|
||||
emit('go-profile')
|
||||
}
|
||||
|
||||
// 格式化日期
|
||||
const formatDate = (date) => {
|
||||
return new Date(date).toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
// 格式化手机号
|
||||
const formatPhone = (phone) => {
|
||||
if (!phone) return ''
|
||||
return phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2')
|
||||
}
|
||||
|
||||
// 组件挂载时重新获取用户信息
|
||||
onMounted(async () => {
|
||||
try {
|
||||
await userStore.fetchUserProfile()
|
||||
console.log('认证完成页面:已重新获取用户信息')
|
||||
} catch (error) {
|
||||
console.error('认证完成页面:重新获取用户信息失败:', error)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.step-card {
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.1);
|
||||
border: none;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
/* 卡片头部 */
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.header-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, #dcfce7 0%, #bbf7d0 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: #1e293b;
|
||||
margin: 0 0 4px 0;
|
||||
}
|
||||
|
||||
.header-subtitle {
|
||||
font-size: 14px;
|
||||
color: #64748b;
|
||||
margin: 0;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 完成内容 */
|
||||
.completion-content {
|
||||
text-align: center;
|
||||
padding: 40px 0;
|
||||
}
|
||||
|
||||
.success-card {
|
||||
background: linear-gradient(135deg, #f0fdf4 0%, #dcfce7 100%);
|
||||
border-radius: 24px;
|
||||
padding: 48px;
|
||||
border: 1px solid rgba(34, 197, 94, 0.2);
|
||||
box-shadow: 0 16px 48px rgba(34, 197, 94, 0.15);
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.success-icon {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.icon-wrapper {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #dcfce7 0%, #bbf7d0 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0 auto;
|
||||
font-size: 60px;
|
||||
box-shadow: 0 16px 32px rgba(34, 197, 94, 0.3);
|
||||
animation: successPulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.success-text {
|
||||
max-width: 700px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.success-title {
|
||||
font-size: 36px;
|
||||
font-weight: 700;
|
||||
color: #166534;
|
||||
margin: 0 0 16px 0;
|
||||
background: linear-gradient(135deg, #166534 0%, #16a34a 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.success-desc {
|
||||
font-size: 18px;
|
||||
color: #16a34a;
|
||||
margin: 0 0 40px 0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* 认证信息 */
|
||||
.completion-info {
|
||||
text-align: left;
|
||||
margin: 40px 0;
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
padding: 32px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.08);
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.info-title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
margin: 0 0 24px 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.info-title::before {
|
||||
content: '';
|
||||
width: 4px;
|
||||
height: 20px;
|
||||
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
font-size: 14px;
|
||||
color: #64748b;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
font-size: 16px;
|
||||
color: #1e293b;
|
||||
font-weight: 600;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.status-tag {
|
||||
font-weight: 600;
|
||||
border-radius: 8px;
|
||||
padding: 6px 16px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* 操作按钮 */
|
||||
.completion-actions {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 40px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
min-width: 180px;
|
||||
height: 56px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
border-radius: 12px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.primary-btn {
|
||||
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
|
||||
border: none;
|
||||
box-shadow: 0 8px 24px rgba(16, 185, 129, 0.3);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.primary-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 12px 32px rgba(16, 185, 129, 0.4);
|
||||
}
|
||||
|
||||
.secondary-btn {
|
||||
border: 2px solid #e2e8f0;
|
||||
background: white;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.secondary-btn:hover {
|
||||
border-color: #10b981;
|
||||
color: #10b981;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 24px rgba(16, 185, 129, 0.15);
|
||||
}
|
||||
|
||||
/* 合同弹窗样式 */
|
||||
.contract-dialog {
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.contract-container {
|
||||
height: 70vh;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.contract-iframe {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.no-contract {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: #64748b;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.no-contract p {
|
||||
margin-top: 16px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
text-align: right;
|
||||
padding-top: 16px;
|
||||
}
|
||||
|
||||
/* 动画效果 */
|
||||
@keyframes successPulse {
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(1);
|
||||
box-shadow: 0 16px 32px rgba(34, 197, 94, 0.3);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 20px 40px rgba(34, 197, 94, 0.4);
|
||||
}
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.completion-content {
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.success-card {
|
||||
padding: 32px 24px;
|
||||
margin: 0 16px;
|
||||
}
|
||||
|
||||
.success-title {
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.success-desc {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.icon-wrapper {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
font-size: 40px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.header-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.completion-info {
|
||||
padding: 24px;
|
||||
margin: 24px 0;
|
||||
}
|
||||
|
||||
.info-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.completion-actions {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.contract-container {
|
||||
height: 60vh;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.success-card {
|
||||
padding: 24px 16px;
|
||||
}
|
||||
|
||||
.success-title {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.icon-wrapper {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
font-size: 30px;
|
||||
}
|
||||
|
||||
.contract-container {
|
||||
height: 50vh;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
303
src/pages/certification/components/ContractExpired.vue
Normal file
303
src/pages/certification/components/ContractExpired.vue
Normal file
@@ -0,0 +1,303 @@
|
||||
<template>
|
||||
<div class="contract-expired-page">
|
||||
<div class="expired-content">
|
||||
<div class="expired-icon">
|
||||
<el-icon :size="64" color="#f59e0b"><Clock /></el-icon>
|
||||
</div>
|
||||
|
||||
<h2 class="expired-title">合同签署已过期</h2>
|
||||
|
||||
<div class="expired-info">
|
||||
<h3 class="info-title">过期说明:</h3>
|
||||
<p class="info-text">合同签署时间已超过规定期限,需要重新申请签署</p>
|
||||
</div>
|
||||
|
||||
<div class="expired-details">
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">合同生成时间:</span>
|
||||
<span class="detail-value">{{ formatTime(contractCreatedAt) }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">签署期限:</span>
|
||||
<span class="detail-value">50分钟</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">过期时间:</span>
|
||||
<span class="detail-value">{{ formatTime(expiredAt) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="expired-actions">
|
||||
<el-button
|
||||
type="primary"
|
||||
size="large"
|
||||
@click="reapplyContract"
|
||||
:loading="reapplyLoading"
|
||||
class="reapply-btn"
|
||||
>
|
||||
<el-icon><Refresh /></el-icon>
|
||||
重新申请签署
|
||||
</el-button>
|
||||
|
||||
<el-button
|
||||
type="info"
|
||||
size="large"
|
||||
@click="goBack"
|
||||
class="back-btn"
|
||||
>
|
||||
返回上一步
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div class="expired-tips">
|
||||
<h4>温馨提示:</h4>
|
||||
<ul>
|
||||
<li>合同签署有效期为50分钟,请及时完成签署</li>
|
||||
<li>重新申请后,您将获得新的签署链接</li>
|
||||
<li>建议在网络良好的环境下完成签署操作</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { Clock, Refresh } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const props = defineProps({
|
||||
contractCreatedAt: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['reapply-contract', 'go-back'])
|
||||
|
||||
const reapplyLoading = ref(false)
|
||||
|
||||
const expiredAt = computed(() => {
|
||||
if (!props.contractCreatedAt) return ''
|
||||
const created = new Date(props.contractCreatedAt)
|
||||
const expired = new Date(created.getTime() + 50 * 60 * 1000) // 50分钟后过期
|
||||
return expired.toISOString()
|
||||
})
|
||||
|
||||
const formatTime = (timeStr) => {
|
||||
if (!timeStr) return '未知'
|
||||
const date = new Date(timeStr)
|
||||
return date.toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
const reapplyContract = async () => {
|
||||
try {
|
||||
reapplyLoading.value = true
|
||||
emit('reapply-contract')
|
||||
} catch (error) {
|
||||
ElMessage.error('重新申请失败,请稍后重试')
|
||||
} finally {
|
||||
reapplyLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const goBack = () => {
|
||||
emit('go-back')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.contract-expired-page {
|
||||
min-height: 600px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px 20px;
|
||||
}
|
||||
|
||||
.expired-content {
|
||||
background: #fff;
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.1);
|
||||
padding: 48px 40px;
|
||||
text-align: center;
|
||||
max-width: 600px;
|
||||
width: 100%;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.expired-icon {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.expired-title {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
color: #f59e0b;
|
||||
margin-bottom: 32px;
|
||||
background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.expired-info {
|
||||
background: #fffbeb;
|
||||
border: 1px solid #fed7aa;
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
margin-bottom: 24px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.info-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #d97706;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.info-text {
|
||||
font-size: 16px;
|
||||
color: #92400e;
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.expired-details {
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
margin-bottom: 32px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.detail-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.detail-item:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
color: #64748b;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
color: #1e293b;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.expired-actions {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
justify-content: center;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.reapply-btn {
|
||||
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
padding: 12px 32px;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 4px 12px rgba(16, 185, 129, 0.3);
|
||||
}
|
||||
|
||||
.reapply-btn:hover {
|
||||
background: linear-gradient(135deg, #059669 0%, #047857 100%);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 20px rgba(16, 185, 129, 0.4);
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
padding: 12px 32px;
|
||||
font-weight: 600;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.back-btn:hover {
|
||||
background: #f1f5f9;
|
||||
border-color: #cbd5e1;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.expired-tips {
|
||||
background: #f0f9ff;
|
||||
border: 1px solid #bae6fd;
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.expired-tips h4 {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #0369a1;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.expired-tips ul {
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.expired-tips li {
|
||||
font-size: 14px;
|
||||
color: #0c4a6e;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.expired-tips li:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.contract-expired-page {
|
||||
padding: 20px 16px;
|
||||
}
|
||||
|
||||
.expired-content {
|
||||
padding: 32px 24px;
|
||||
}
|
||||
|
||||
.expired-title {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.expired-actions {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.reapply-btn,
|
||||
.back-btn {
|
||||
width: 100%;
|
||||
max-width: 280px;
|
||||
}
|
||||
|
||||
.detail-item {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 4px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
280
src/pages/certification/components/ContractPreview.vue
Normal file
280
src/pages/certification/components/ContractPreview.vue
Normal file
@@ -0,0 +1,280 @@
|
||||
<template>
|
||||
<el-card class="step-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<div class="header-icon">
|
||||
<el-icon class="text-blue-600">
|
||||
<DocumentTextIcon />
|
||||
</el-icon>
|
||||
</div>
|
||||
<div class="header-content">
|
||||
<h2 class="header-title">合同预览</h2>
|
||||
<p class="header-subtitle">请仔细阅读合同内容,确认无误后再进行签署</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div class="contract-preview-section">
|
||||
<div v-if="contractUrl" class="pdf-viewer-wrapper">
|
||||
<vue-pdf-embed
|
||||
ref="pdfRef"
|
||||
:source="contractUrl"
|
||||
:page="page"
|
||||
:scale="pdfScale"
|
||||
@loaded="handleLoaded"
|
||||
class="pdf-embed"
|
||||
/>
|
||||
<div class="pdf-controls">
|
||||
<el-button size="large" :disabled="page <= 1" @click="page--"> 上一页 </el-button>
|
||||
<span class="pdf-page-info">第 {{ page }} / {{ pageCount }} 页</span>
|
||||
<el-button size="large" :disabled="page >= pageCount" @click="page++"> 下一页 </el-button>
|
||||
<el-divider direction="vertical" />
|
||||
<el-button size="large" @click="fullscreen = true"> 全屏预览 </el-button>
|
||||
</div>
|
||||
</div>
|
||||
<el-button type="primary" @click="emit('start-sign')" :loading="props.loading">
|
||||
<el-icon class="mr-2"><DocumentTextIcon /></el-icon>
|
||||
开始签署
|
||||
</el-button>
|
||||
</div>
|
||||
<!-- 极简全屏预览弹窗,无顶部栏 -->
|
||||
<el-dialog
|
||||
v-model="fullscreen"
|
||||
fullscreen
|
||||
append-to-body
|
||||
:close-on-click-modal="true"
|
||||
class="fullscreen-dialog"
|
||||
:show-close="false"
|
||||
lock-scroll
|
||||
:header="false"
|
||||
>
|
||||
<div class="fullscreen-pdf-simple">
|
||||
<vue-pdf-embed
|
||||
:source="contractUrl"
|
||||
:page="page"
|
||||
:scale="fullscreenScale"
|
||||
class="fullscreen-pdf-embed"
|
||||
/>
|
||||
<div class="fullscreen-pdf-controls">
|
||||
<el-button size="large" :disabled="page <= 1" @click="page--">上一页</el-button>
|
||||
<span class="pdf-page-info">第 {{ page }} / {{ pageCount }} 页</span>
|
||||
<el-button size="large" :disabled="page >= pageCount" @click="page++">下一页</el-button>
|
||||
<el-button type="primary" size="large" @click="fullscreen = false"> 关闭预览 </el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
|
||||
import { DocumentTextIcon } from '@heroicons/vue/24/outline'
|
||||
import VuePdfEmbed from 'vue-pdf-embed'
|
||||
|
||||
const props = defineProps({
|
||||
contractUrl: String,
|
||||
loading: Boolean
|
||||
})
|
||||
const emit = defineEmits(['start-sign'])
|
||||
|
||||
const pdfRef = ref(null)
|
||||
const page = ref(1)
|
||||
const pageCount = ref(0)
|
||||
const pdfScale = ref(1.3)
|
||||
const fullscreen = ref(false)
|
||||
const fullscreenScale = ref(1.7)
|
||||
|
||||
function handleLoaded(pdf) {
|
||||
pageCount.value = pdf.numPages
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.step-card {
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.1);
|
||||
border: none;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
.header-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, #dbeafe 0%, #bfdbfe 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 24px;
|
||||
}
|
||||
.header-content {
|
||||
flex: 1;
|
||||
}
|
||||
.header-title {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: #1e293b;
|
||||
margin: 0 0 4px 0;
|
||||
}
|
||||
.header-subtitle {
|
||||
font-size: 14px;
|
||||
color: #64748b;
|
||||
margin: 0;
|
||||
font-weight: 500;
|
||||
}
|
||||
.contract-preview-section {
|
||||
padding: 32px 0;
|
||||
text-align: center;
|
||||
}
|
||||
.pdf-viewer-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.pdf-embed {
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
min-height: 600px;
|
||||
max-height: 600px;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.08);
|
||||
background: #fafbfc;
|
||||
overflow-y: auto;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #cbd5e1 #f1f5f9;
|
||||
}
|
||||
.pdf-embed::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
.pdf-embed::-webkit-scrollbar-thumb {
|
||||
background: #cbd5e1;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.pdf-embed::-webkit-scrollbar-track {
|
||||
background: #f1f5f9;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.pdf-controls {
|
||||
margin: 24px 0 0 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
}
|
||||
.pdf-btn {
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%);
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
border: none;
|
||||
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.1);
|
||||
transition:
|
||||
background 0.2s,
|
||||
color 0.2s,
|
||||
box-shadow 0.2s;
|
||||
min-width: 110px;
|
||||
height: 44px;
|
||||
font-size: 16px;
|
||||
}
|
||||
.pdf-btn:disabled {
|
||||
background: #e2e8f0;
|
||||
color: #94a3b8;
|
||||
box-shadow: none;
|
||||
}
|
||||
.pdf-btn:hover:not(:disabled) {
|
||||
background: #2563eb;
|
||||
color: #fff;
|
||||
box-shadow: 0 4px 16px rgba(59, 130, 246, 0.18);
|
||||
}
|
||||
.pdf-page-info {
|
||||
color: #64748b;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.contract-sign-btn {
|
||||
min-width: 220px;
|
||||
height: 52px;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%);
|
||||
border: none;
|
||||
box-shadow: 0 8px 24px rgba(59, 130, 246, 0.18);
|
||||
transition:
|
||||
background 0.2s,
|
||||
color 0.2s,
|
||||
box-shadow 0.2s;
|
||||
margin-top: 32px;
|
||||
}
|
||||
.contract-sign-btn:hover:not(:disabled) {
|
||||
background: #2563eb;
|
||||
color: #fff;
|
||||
box-shadow: 0 12px 32px rgba(59, 130, 246, 0.22);
|
||||
}
|
||||
.contract-sign-btn:disabled {
|
||||
background: #e2e8f0;
|
||||
color: #94a3b8;
|
||||
box-shadow: none;
|
||||
}
|
||||
.fullscreen-dialog >>> .el-dialog__body {
|
||||
padding: 0;
|
||||
}
|
||||
.fullscreen-pdf-simple {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #fafbfc;
|
||||
overflow: auto;
|
||||
}
|
||||
.fullscreen-pdf-embed {
|
||||
width: 100vw;
|
||||
max-width: 1000px;
|
||||
height: calc(100vh - 116px);
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
background: #fff;
|
||||
overflow: auto;
|
||||
box-shadow: none;
|
||||
}
|
||||
.fullscreen-pdf-controls {
|
||||
width: 100%;
|
||||
height: 68px;
|
||||
background-color: #e2e8f0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
justify-content: center;
|
||||
font-size: 18px;
|
||||
}
|
||||
.fullscreen-close-btn {
|
||||
min-width: 120px;
|
||||
height: 44px;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%);
|
||||
border: none;
|
||||
box-shadow: 0 4px 16px rgba(59, 130, 246, 0.18);
|
||||
margin-left: 32px;
|
||||
transition:
|
||||
background 0.2s,
|
||||
color 0.2s,
|
||||
box-shadow 0.2s;
|
||||
}
|
||||
.fullscreen-close-btn:hover {
|
||||
background: #2563eb;
|
||||
color: #fff;
|
||||
box-shadow: 0 8px 24px rgba(59, 130, 246, 0.22);
|
||||
}
|
||||
</style>
|
||||
223
src/pages/certification/components/ContractRejected.vue
Normal file
223
src/pages/certification/components/ContractRejected.vue
Normal file
@@ -0,0 +1,223 @@
|
||||
<template>
|
||||
<div class="contract-rejected-page">
|
||||
<div class="rejected-content">
|
||||
<div class="rejected-icon">
|
||||
<el-icon :size="64" color="#ef4444"><Close /></el-icon>
|
||||
</div>
|
||||
|
||||
<h2 class="rejected-title">合同签署被拒绝</h2>
|
||||
|
||||
<div class="rejected-reason">
|
||||
<h3 class="reason-title">拒绝原因:</h3>
|
||||
<p class="reason-text">{{ failureMessage || '用户主动拒绝签署合同' }}</p>
|
||||
</div>
|
||||
|
||||
<div class="rejected-actions">
|
||||
<el-button
|
||||
type="primary"
|
||||
size="large"
|
||||
@click="contactCustomerService"
|
||||
class="contact-btn"
|
||||
>
|
||||
<el-icon><Service /></el-icon>
|
||||
联系客服
|
||||
</el-button>
|
||||
|
||||
<el-button
|
||||
type="info"
|
||||
size="large"
|
||||
@click="goBack"
|
||||
class="back-btn"
|
||||
>
|
||||
返回上一步
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div class="rejected-tips">
|
||||
<h4>温馨提示:</h4>
|
||||
<ul>
|
||||
<li>如果您对合同内容有疑问,请联系客服咨询</li>
|
||||
<li>客服将协助您解决相关问题并重新申请签署</li>
|
||||
<li>您也可以稍后重新申请合同签署</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { Close, Service } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const props = defineProps({
|
||||
failureMessage: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['contact-service', 'go-back'])
|
||||
|
||||
const contactCustomerService = () => {
|
||||
// 这里可以打开客服聊天窗口或跳转到客服页面
|
||||
ElMessage.success('正在为您连接客服...')
|
||||
emit('contact-service')
|
||||
}
|
||||
|
||||
const goBack = () => {
|
||||
emit('go-back')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.contract-rejected-page {
|
||||
min-height: 600px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px 20px;
|
||||
}
|
||||
|
||||
.rejected-content {
|
||||
background: #fff;
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.1);
|
||||
padding: 48px 40px;
|
||||
text-align: center;
|
||||
max-width: 600px;
|
||||
width: 100%;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.rejected-icon {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.rejected-title {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
color: #ef4444;
|
||||
margin-bottom: 32px;
|
||||
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.rejected-reason {
|
||||
background: #fef2f2;
|
||||
border: 1px solid #fecaca;
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
margin-bottom: 32px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.reason-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #dc2626;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.reason-text {
|
||||
font-size: 16px;
|
||||
color: #7f1d1d;
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.rejected-actions {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
justify-content: center;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.contact-btn {
|
||||
background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%);
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
padding: 12px 32px;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
|
||||
.contact-btn:hover {
|
||||
background: linear-gradient(135deg, #1d4ed8 0%, #1e40af 100%);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 20px rgba(59, 130, 246, 0.4);
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
padding: 12px 32px;
|
||||
font-weight: 600;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.back-btn:hover {
|
||||
background: #f1f5f9;
|
||||
border-color: #cbd5e1;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.rejected-tips {
|
||||
background: #f0f9ff;
|
||||
border: 1px solid #bae6fd;
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.rejected-tips h4 {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #0369a1;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.rejected-tips ul {
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.rejected-tips li {
|
||||
font-size: 14px;
|
||||
color: #0c4a6e;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.rejected-tips li:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.contract-rejected-page {
|
||||
padding: 20px 16px;
|
||||
}
|
||||
|
||||
.rejected-content {
|
||||
padding: 32px 24px;
|
||||
}
|
||||
|
||||
.rejected-title {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.rejected-actions {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.contact-btn,
|
||||
.back-btn {
|
||||
width: 100%;
|
||||
max-width: 280px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
259
src/pages/certification/components/ContractSign.vue
Normal file
259
src/pages/certification/components/ContractSign.vue
Normal file
@@ -0,0 +1,259 @@
|
||||
<template>
|
||||
<el-card class="step-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<div class="header-icon">
|
||||
<el-icon class="text-purple-600">
|
||||
<DocumentTextIcon />
|
||||
</el-icon>
|
||||
</div>
|
||||
<div class="header-content">
|
||||
<h2 class="header-title">签署合同</h2>
|
||||
<p class="header-subtitle">请完成合同签署流程</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="contract-content">
|
||||
<!-- 合同签署iframe -->
|
||||
<div class="contract-iframe-section">
|
||||
<div class="iframe-container">
|
||||
<iframe
|
||||
:src="iframeUrl"
|
||||
frameborder="0"
|
||||
class="contract-iframe"
|
||||
title="合同签署"
|
||||
@load="handleIframeLoad"
|
||||
></iframe>
|
||||
<div v-if="iframeLoading" class="iframe-loading">
|
||||
<div class="loading-spinner"></div>
|
||||
<p>正在加载合同签署页面...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {
|
||||
CheckIcon,
|
||||
DocumentTextIcon
|
||||
} from '@heroicons/vue/24/outline'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
defineProps({
|
||||
companyName: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
iframeUrl: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
})
|
||||
|
||||
// 合同签署状态
|
||||
const contractCompleted = ref(false)
|
||||
const iframeLoading = ref(true)
|
||||
|
||||
// iframe加载完成回调
|
||||
const handleIframeLoad = () => {
|
||||
iframeLoading.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.step-card {
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.1);
|
||||
border: none;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
/* 卡片头部 */
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.header-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, #f3e8ff 0%, #e9d5ff 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: #1e293b;
|
||||
margin: 0 0 4px 0;
|
||||
}
|
||||
|
||||
.header-subtitle {
|
||||
font-size: 14px;
|
||||
color: #64748b;
|
||||
margin: 0;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 合同内容 */
|
||||
.contract-content {
|
||||
max-width: 100%;
|
||||
margin: 0 auto;
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
/* 合同签署iframe */
|
||||
.contract-iframe-section {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.iframe-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 600px; /* 调整iframe高度 */
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
border: 1px solid #e2e8f0;
|
||||
background: white;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.contract-iframe {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.iframe-loading {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
border-radius: 12px;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.iframe-loading .loading-spinner {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border: 4px solid #e2e8f0;
|
||||
border-top: 4px solid #8b5cf6;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.iframe-loading p {
|
||||
font-size: 16px;
|
||||
color: #64748b;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.iframe-actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.contract-complete-btn {
|
||||
min-width: 180px;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%);
|
||||
border: none;
|
||||
font-weight: 500;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.contract-complete-btn:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(139, 92, 246, 0.3);
|
||||
}
|
||||
|
||||
.contract-complete-btn:disabled {
|
||||
background: #e2e8f0;
|
||||
color: #64748b;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* 动画 */
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.contract-content {
|
||||
max-width: 100%;
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.contract-iframe-section {
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.iframe-container {
|
||||
height: 400px; /* 移动端调整iframe高度 */
|
||||
}
|
||||
|
||||
.iframe-actions {
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.contract-complete-btn {
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.header-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 大屏幕优化 */
|
||||
@media (min-width: 1200px) {
|
||||
.iframe-container {
|
||||
height: 700px; /* 大屏幕增加iframe高度 */
|
||||
}
|
||||
}
|
||||
</style>
|
||||
725
src/pages/certification/components/EnterpriseInfo.vue
Normal file
725
src/pages/certification/components/EnterpriseInfo.vue
Normal file
@@ -0,0 +1,725 @@
|
||||
<template>
|
||||
<el-card class="step-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<div class="header-icon">
|
||||
<el-icon class="text-blue-600">
|
||||
<BuildingOfficeIcon />
|
||||
</el-icon>
|
||||
</div>
|
||||
<div class="header-content">
|
||||
<h2 class="header-title">企业信息</h2>
|
||||
<p class="header-subtitle">请填写企业基本信息,确保信息真实有效</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="enterprise-form">
|
||||
<el-form
|
||||
ref="enterpriseFormRef"
|
||||
:model="form"
|
||||
:rules="enterpriseRules"
|
||||
label-width="10em"
|
||||
class="enterprise-form-content"
|
||||
>
|
||||
<div class="form-section">
|
||||
<h3 class="section-title">基本信息</h3>
|
||||
|
||||
<!-- 企业名称和OCR识别区域 -->
|
||||
<el-row :gutter="16" class="mb-4">
|
||||
<el-col :span="16">
|
||||
<el-form-item label="企业名称" prop="companyName">
|
||||
<el-input
|
||||
v-model="form.companyName"
|
||||
placeholder="请输入企业名称"
|
||||
clearable
|
||||
size="default"
|
||||
class="form-input"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<div class="ocr-compact">
|
||||
<div class="ocr-header-compact">
|
||||
<el-icon class="text-green-600"><DocumentIcon /></el-icon>
|
||||
<span class="ocr-title-compact">OCR识别</span>
|
||||
<el-upload
|
||||
ref="uploadRef"
|
||||
:auto-upload="false"
|
||||
:show-file-list="false"
|
||||
:on-change="handleFileChange"
|
||||
:before-upload="beforeUpload"
|
||||
accept="image/jpeg,image/jpg,image/png,image/webp"
|
||||
class="ocr-uploader-compact"
|
||||
>
|
||||
<el-button type="success" size="small" plain>
|
||||
<el-icon><ArrowUpTrayIcon /></el-icon>
|
||||
上传营业执照
|
||||
</el-button>
|
||||
</el-upload>
|
||||
</div>
|
||||
<div v-if="ocrLoading" class="ocr-status-compact">
|
||||
<el-icon class="is-loading"><Loading /></el-icon>
|
||||
<span>识别中...</span>
|
||||
</div>
|
||||
<div v-if="ocrResult" class="ocr-status-compact success">
|
||||
<el-icon class="text-green-600"><CheckIcon /></el-icon>
|
||||
<span>识别成功</span>
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="16" class="mb-4">
|
||||
<el-col :span="24">
|
||||
<el-form-item label="统一社会信用代码" prop="unifiedSocialCode">
|
||||
<el-input
|
||||
v-model="form.unifiedSocialCode"
|
||||
placeholder="请输入统一社会信用代码"
|
||||
clearable
|
||||
size="default"
|
||||
class="form-input"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="16" class="mb-4">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="法人姓名" prop="legalPersonName">
|
||||
<el-input
|
||||
v-model="form.legalPersonName"
|
||||
placeholder="请输入法人姓名"
|
||||
clearable
|
||||
size="default"
|
||||
class="form-input"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="法人身份证号" prop="legalPersonID">
|
||||
<el-input
|
||||
v-model="form.legalPersonID"
|
||||
placeholder="请输入法人身份证号"
|
||||
clearable
|
||||
size="default"
|
||||
class="form-input"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="16" class="mb-4">
|
||||
<el-col :span="24">
|
||||
<el-form-item label="企业地址" prop="enterpriseAddress">
|
||||
<el-input
|
||||
v-model="form.enterpriseAddress"
|
||||
placeholder="请输入企业地址"
|
||||
clearable
|
||||
size="default"
|
||||
class="form-input"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="16" class="mb-4">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="法人手机号" prop="legalPersonPhone">
|
||||
<el-input
|
||||
v-model="form.legalPersonPhone"
|
||||
placeholder="请输入法人手机号"
|
||||
clearable
|
||||
size="default"
|
||||
maxlength="11"
|
||||
class="form-input"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="验证码" prop="legalPersonCode">
|
||||
<div class="flex gap-2">
|
||||
<el-input
|
||||
v-model="form.legalPersonCode"
|
||||
placeholder="请输入验证码"
|
||||
clearable
|
||||
size="default"
|
||||
maxlength="6"
|
||||
class="form-input"
|
||||
/>
|
||||
<el-button
|
||||
type="primary"
|
||||
size="default"
|
||||
:disabled="!canSendCode || sendingCode"
|
||||
@click="sendCode"
|
||||
:loading="sendingCode"
|
||||
class="code-btn"
|
||||
>
|
||||
{{ countdown > 0 ? `${countdown}s` : '获取验证码' }}
|
||||
</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<el-button
|
||||
type="primary"
|
||||
size="default"
|
||||
@click="submitForm"
|
||||
:loading="submitting"
|
||||
class="submit-btn"
|
||||
>
|
||||
<el-icon class="mr-1"><CheckIcon /></el-icon>
|
||||
提交企业信息
|
||||
</el-button>
|
||||
</div>
|
||||
</el-form>
|
||||
</div>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { certificationApi } from '@/api'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { Loading } from '@element-plus/icons-vue'
|
||||
import {
|
||||
ArrowUpTrayIcon,
|
||||
BuildingOfficeIcon,
|
||||
CheckIcon,
|
||||
DocumentIcon
|
||||
} from '@heroicons/vue/24/outline'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const props = defineProps({
|
||||
formData: {
|
||||
type: Object,
|
||||
default: () => ({
|
||||
companyName: '',
|
||||
unifiedSocialCode: '',
|
||||
legalPersonName: '',
|
||||
legalPersonID: '',
|
||||
legalPersonPhone: '',
|
||||
enterpriseAddress: '',
|
||||
legalPersonCode: ''
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['submit'])
|
||||
|
||||
const userStore = useUserStore()
|
||||
|
||||
// 表单引用
|
||||
const enterpriseFormRef = ref()
|
||||
|
||||
// 表单数据
|
||||
const form = ref({
|
||||
companyName: '',
|
||||
unifiedSocialCode: '',
|
||||
legalPersonName: '',
|
||||
legalPersonID: '',
|
||||
legalPersonPhone: '',
|
||||
enterpriseAddress: '',
|
||||
legalPersonCode: ''
|
||||
})
|
||||
|
||||
// 验证码相关状态
|
||||
const sendingCode = ref(false)
|
||||
const countdown = ref(0)
|
||||
let countdownTimer = null
|
||||
|
||||
// 加载状态
|
||||
const submitting = ref(false)
|
||||
|
||||
// OCR相关状态
|
||||
const ocrLoading = ref(false)
|
||||
const ocrResult = ref(false)
|
||||
const uploadRef = ref()
|
||||
|
||||
// 计算属性
|
||||
const canSendCode = computed(() => {
|
||||
return form.value.legalPersonPhone && form.value.legalPersonPhone.length === 11 && countdown.value === 0
|
||||
})
|
||||
|
||||
// 统一社会信用代码验证函数
|
||||
const validateUnifiedSocialCode = (rule, value, callback) => {
|
||||
if (!value) {
|
||||
callback(new Error('请输入统一社会信用代码'))
|
||||
return
|
||||
}
|
||||
|
||||
// 统一社会信用代码格式:18位,由数字和大写字母组成
|
||||
const pattern = /^[0-9A-HJ-NPQRTUWXY]{18}$/
|
||||
if (!pattern.test(value)) {
|
||||
callback(new Error('统一社会信用代码格式不正确,应为18位数字和大写字母组合'))
|
||||
return
|
||||
}
|
||||
|
||||
callback()
|
||||
}
|
||||
|
||||
// 身份证号验证函数
|
||||
const validateIDCard = (rule, value, callback) => {
|
||||
if (!value) {
|
||||
callback(new Error('请输入法人身份证号'))
|
||||
return
|
||||
}
|
||||
|
||||
// 身份证号格式:18位,最后一位可能是X
|
||||
const pattern = /^[1-9]\d{5}(18|19|20)\d{2}((0[1-9])|(1[0-2]))(([0-2][1-9])|10|20|30|31)\d{3}[0-9Xx]$/
|
||||
if (!pattern.test(value)) {
|
||||
callback(new Error('身份证号格式不正确'))
|
||||
return
|
||||
}
|
||||
|
||||
callback()
|
||||
}
|
||||
|
||||
// 手机号验证函数
|
||||
const validatePhone = (rule, value, callback) => {
|
||||
if (!value) {
|
||||
callback(new Error('请输入法人手机号'))
|
||||
return
|
||||
}
|
||||
|
||||
// 手机号格式:11位数字
|
||||
const pattern = /^1[3-9]\d{9}$/
|
||||
if (!pattern.test(value)) {
|
||||
callback(new Error('手机号格式不正确'))
|
||||
return
|
||||
}
|
||||
|
||||
callback()
|
||||
}
|
||||
|
||||
// 表单验证规则
|
||||
const enterpriseRules = {
|
||||
companyName: [
|
||||
{ required: true, message: '请输入企业名称', trigger: 'blur' },
|
||||
{ min: 2, max: 100, message: '企业名称长度应在2-100个字符之间', trigger: 'blur' }
|
||||
],
|
||||
unifiedSocialCode: [
|
||||
{ required: true, message: '请输入统一社会信用代码', trigger: 'blur' },
|
||||
{ validator: validateUnifiedSocialCode, trigger: 'blur' }
|
||||
],
|
||||
legalPersonName: [
|
||||
{ required: true, message: '请输入法人姓名', trigger: 'blur' },
|
||||
{ min: 2, max: 20, message: '法人姓名长度应在2-20个字符之间', trigger: 'blur' },
|
||||
{ pattern: /^[\u4e00-\u9fa5a-zA-Z\s·]+$/, message: '法人姓名只能包含中文、英文和间隔号', trigger: 'blur' }
|
||||
],
|
||||
legalPersonID: [
|
||||
{ required: true, message: '请输入法人身份证号', trigger: 'blur' },
|
||||
{ validator: validateIDCard, trigger: 'blur' }
|
||||
],
|
||||
legalPersonPhone: [
|
||||
{ required: true, message: '请输入法人手机号', trigger: 'blur' },
|
||||
{ validator: validatePhone, trigger: 'blur' }
|
||||
],
|
||||
enterpriseAddress: [
|
||||
{ required: true, message: '请输入企业地址', trigger: 'blur' },
|
||||
{ min: 5, max: 200, message: '企业地址长度应在5-200个字符之间', trigger: 'blur' }
|
||||
],
|
||||
legalPersonCode: [
|
||||
{ required: true, message: '请输入验证码', trigger: 'blur' },
|
||||
{ len: 6, message: '验证码应为6位数字', trigger: 'blur' }
|
||||
]
|
||||
}
|
||||
|
||||
// 监听props变化
|
||||
watch(() => props.formData, (newData) => {
|
||||
if (newData) {
|
||||
// 同步父组件传递的数据到本地表单
|
||||
Object.keys(newData).forEach(key => {
|
||||
if (newData[key] && Object.prototype.hasOwnProperty.call(form.value, key)) {
|
||||
form.value[key] = newData[key]
|
||||
}
|
||||
})
|
||||
}
|
||||
}, { immediate: true, deep: true })
|
||||
|
||||
// 发送验证码
|
||||
const sendCode = async () => {
|
||||
if (!canSendCode.value) return
|
||||
|
||||
sendingCode.value = true
|
||||
try {
|
||||
const result = await userStore.sendCode(form.value.legalPersonPhone, 'certification')
|
||||
if (result.success) {
|
||||
ElMessage.success('验证码发送成功')
|
||||
startCountdown()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('验证码发送失败:', error)
|
||||
ElMessage.error('验证码发送失败,请重试')
|
||||
} finally {
|
||||
sendingCode.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 开始倒计时
|
||||
const startCountdown = () => {
|
||||
countdown.value = 60
|
||||
countdownTimer = setInterval(() => {
|
||||
countdown.value--
|
||||
if (countdown.value <= 0) {
|
||||
clearInterval(countdownTimer)
|
||||
}
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
// OCR文件上传前验证
|
||||
const beforeUpload = (file) => {
|
||||
const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp']
|
||||
const isValidType = allowedTypes.includes(file.type)
|
||||
const isValidSize = file.size / 1024 / 1024 < 5
|
||||
|
||||
if (!isValidType) {
|
||||
ElMessage.error('只支持 JPG、PNG、WEBP 格式的图片')
|
||||
return false
|
||||
}
|
||||
if (!isValidSize) {
|
||||
ElMessage.error('图片大小不能超过 5MB')
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// 处理文件变化
|
||||
const handleFileChange = async (file) => {
|
||||
if (!beforeUpload(file.raw)) {
|
||||
return
|
||||
}
|
||||
|
||||
ocrLoading.value = true
|
||||
ocrResult.value = false
|
||||
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('image', file.raw)
|
||||
|
||||
const result = await certificationApi.recognizeBusinessLicense(formData)
|
||||
|
||||
if (result.success && result.data) {
|
||||
// 自动填充表单
|
||||
const ocrData = result.data
|
||||
form.value.companyName = ocrData.company_name || ''
|
||||
form.value.unifiedSocialCode = ocrData.unified_social_code || ''
|
||||
form.value.legalPersonName = ocrData.legal_person_name || ''
|
||||
form.value.legalPersonID = ocrData.legal_person_id || ''
|
||||
form.value.enterpriseAddress = ocrData.address || ''
|
||||
|
||||
ocrResult.value = true
|
||||
ElMessage.success('营业执照识别成功,已自动填充表单')
|
||||
} else {
|
||||
ElMessage.error('营业执照识别失败,请重试')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('OCR识别失败:', error)
|
||||
ElMessage.error('营业执照识别失败,请重试')
|
||||
} finally {
|
||||
ocrLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 提交表单
|
||||
const submitForm = async () => {
|
||||
try {
|
||||
await enterpriseFormRef.value.validate()
|
||||
|
||||
submitting.value = true
|
||||
|
||||
// Mock API 调用
|
||||
await new Promise(resolve => setTimeout(resolve, 2000))
|
||||
|
||||
emit('submit', form.value)
|
||||
|
||||
} catch (error) {
|
||||
console.error('表单验证失败:', error)
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 组件卸载时清理定时器
|
||||
onUnmounted(() => {
|
||||
if (countdownTimer) {
|
||||
clearInterval(countdownTimer)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.step-card {
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.08);
|
||||
border: 1px solid rgba(0, 0, 0, 0.06);
|
||||
background: white;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 卡片头部 */
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 0;
|
||||
background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%);
|
||||
}
|
||||
|
||||
.header-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, #dbeafe 0%, #bfdbfe 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 24px;
|
||||
color: #2563eb;
|
||||
box-shadow: 0 4px 12px rgba(37, 99, 235, 0.15);
|
||||
}
|
||||
|
||||
.header-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: #1e293b;
|
||||
margin: 0 0 4px 0;
|
||||
letter-spacing: -0.025em;
|
||||
}
|
||||
|
||||
.header-subtitle {
|
||||
font-size: 14px;
|
||||
color: #64748b;
|
||||
margin: 0;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* OCR紧凑样式 */
|
||||
.ocr-compact {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
background: linear-gradient(135deg, #f0fdf4 0%, #ecfdf5 100%);
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(34, 197, 94, 0.2);
|
||||
height: 100%;
|
||||
min-height: 40px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.ocr-header-compact {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 4px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.ocr-title-compact {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #166534;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ocr-uploader-compact {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ocr-status-compact {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
font-size: 10px;
|
||||
color: #166534;
|
||||
font-weight: 500;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.ocr-status-compact.success {
|
||||
color: #16a34a;
|
||||
}
|
||||
|
||||
/* 表单内容 */
|
||||
.enterprise-form-content {
|
||||
max-width: 100%;
|
||||
margin: 0 auto;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.form-section {
|
||||
margin-bottom: 24px;
|
||||
padding: 24px;
|
||||
background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%);
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(226, 232, 240, 0.8);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
margin: 0 0 20px 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 2px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.section-title::before {
|
||||
content: '';
|
||||
width: 4px;
|
||||
height: 16px;
|
||||
background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* 表单输入框 */
|
||||
.form-input :deep(.el-input__wrapper) {
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e2e8f0;
|
||||
background: white;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.form-input :deep(.el-input__wrapper:hover) {
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.15);
|
||||
}
|
||||
|
||||
.form-input :deep(.el-input__wrapper.is-focus) {
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.form-input :deep(.el-input__inner) {
|
||||
font-size: 14px;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
/* 验证码按钮 */
|
||||
.code-btn {
|
||||
min-width: 100px;
|
||||
border-radius: 8px;
|
||||
background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%);
|
||||
border: none;
|
||||
font-weight: 500;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.code-btn:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
|
||||
.code-btn:disabled {
|
||||
background: #e2e8f0;
|
||||
color: #94a3b8;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* 表单操作 */
|
||||
.form-actions {
|
||||
text-align: center;
|
||||
padding: 24px;
|
||||
background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%);
|
||||
border-radius: 12px;
|
||||
margin-top: 24px;
|
||||
border: 1px solid rgba(226, 232, 240, 0.8);
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
min-width: 200px;
|
||||
height: 44px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%);
|
||||
border: none;
|
||||
box-shadow: 0 4px 16px rgba(59, 130, 246, 0.3);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.submit-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 24px rgba(59, 130, 246, 0.4);
|
||||
}
|
||||
|
||||
.submit-btn:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.enterprise-form-content {
|
||||
max-width: 100%;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.form-section {
|
||||
padding: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
/* 移动端OCR紧凑样式 */
|
||||
.ocr-compact {
|
||||
padding: 6px 8px;
|
||||
min-height: 36px;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.ocr-header-compact {
|
||||
gap: 4px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.ocr-title-compact {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.ocr-status-compact {
|
||||
font-size: 9px;
|
||||
gap: 2px;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.header-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.upload-area {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.upload-icon {
|
||||
font-size: 24px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
296
src/pages/certification/components/EnterpriseVerify.vue
Normal file
296
src/pages/certification/components/EnterpriseVerify.vue
Normal file
@@ -0,0 +1,296 @@
|
||||
<template>
|
||||
<el-card class="step-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<div class="header-icon">
|
||||
<el-icon class="text-green-600">
|
||||
<ShieldCheckIcon />
|
||||
</el-icon>
|
||||
</div>
|
||||
<div class="header-content">
|
||||
<h2 class="header-title">企业认证</h2>
|
||||
<p class="header-subtitle">请完成企业认证流程</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="verify-content">
|
||||
<!-- 企业认证iframe -->
|
||||
<div class="esign-iframe-section">
|
||||
<div class="iframe-container">
|
||||
<iframe
|
||||
:src="authUrl"
|
||||
frameborder="0"
|
||||
class="esign-iframe"
|
||||
title="企业认证"
|
||||
@load="handleIframeLoad"
|
||||
></iframe>
|
||||
<div v-if="iframeLoading" class="iframe-loading">
|
||||
<div class="loading-spinner"></div>
|
||||
<p>正在加载企业认证页面...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {
|
||||
CheckIcon,
|
||||
ShieldCheckIcon
|
||||
} from '@heroicons/vue/24/outline'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const props = defineProps({
|
||||
enterpriseData: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
authUrl: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['submit'])
|
||||
|
||||
// 企业认证状态
|
||||
const esignCompleted = ref(false)
|
||||
const iframeLoading = ref(true)
|
||||
|
||||
// 企业认证完成回调
|
||||
const handleEsignComplete = async () => {
|
||||
try {
|
||||
esignCompleted.value = true
|
||||
ElMessage.success('企业认证完成')
|
||||
emit('submit')
|
||||
} catch (error) {
|
||||
console.error('企业认证失败:', error)
|
||||
ElMessage.error('企业认证失败,请重试')
|
||||
}
|
||||
}
|
||||
|
||||
// 刷新iframe
|
||||
const refreshIframe = () => {
|
||||
const iframe = document.querySelector('.esign-iframe')
|
||||
if (iframe) {
|
||||
iframe.src = iframe.src // 刷新iframe
|
||||
}
|
||||
}
|
||||
|
||||
// iframe加载完成回调
|
||||
const handleIframeLoad = () => {
|
||||
iframeLoading.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.step-card {
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.1);
|
||||
border: none;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
/* 卡片头部 */
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.header-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, #dcfce7 0%, #bbf7d0 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: #1e293b;
|
||||
margin: 0 0 4px 0;
|
||||
}
|
||||
|
||||
.header-subtitle {
|
||||
font-size: 14px;
|
||||
color: #64748b;
|
||||
margin: 0;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 认证内容 */
|
||||
.verify-content {
|
||||
max-width: 100%;
|
||||
margin: 0 auto;
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
/* 企业认证iframe */
|
||||
.esign-iframe-section {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.iframe-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 600px; /* 调整iframe高度 */
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
border: 1px solid #e2e8f0;
|
||||
background: white;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.esign-iframe {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.iframe-loading {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
border-radius: 12px;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.iframe-loading .loading-spinner {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border: 4px solid #e2e8f0;
|
||||
border-top: 4px solid #3b82f6;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.iframe-loading p {
|
||||
font-size: 16px;
|
||||
color: #64748b;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.iframe-actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.esign-complete-btn {
|
||||
min-width: 180px;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
|
||||
border: none;
|
||||
font-weight: 500;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.esign-complete-btn:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(16, 185, 129, 0.3);
|
||||
}
|
||||
|
||||
.esign-complete-btn:disabled {
|
||||
background: #e2e8f0;
|
||||
color: #64748b;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.refresh-btn {
|
||||
min-width: 120px;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%);
|
||||
border: none;
|
||||
font-weight: 500;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.refresh-btn:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
|
||||
/* 动画 */
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.verify-content {
|
||||
max-width: 100%;
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.esign-iframe-section {
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.iframe-container {
|
||||
height: 400px; /* 移动端调整iframe高度 */
|
||||
}
|
||||
|
||||
.iframe-actions {
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.esign-complete-btn,
|
||||
.refresh-btn {
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.header-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 大屏幕优化 */
|
||||
@media (min-width: 1200px) {
|
||||
.iframe-container {
|
||||
height: 700px; /* 大屏幕增加iframe高度 */
|
||||
}
|
||||
}
|
||||
</style>
|
||||
730
src/pages/certification/index.vue
Normal file
730
src/pages/certification/index.vue
Normal file
@@ -0,0 +1,730 @@
|
||||
<template>
|
||||
<div class="certification-page">
|
||||
<!-- 加载状态 -->
|
||||
<div v-if="loading" class="loading-container">
|
||||
<div class="loading-content">
|
||||
<div class="loading-spinner"></div>
|
||||
<p class="loading-text">正在加载认证信息...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 认证步骤条 -->
|
||||
<div v-if="!loading && currentStep !== 'completed'" class="steps-container">
|
||||
<div class="steps-header">
|
||||
<h3 class="steps-title">企业入驻流程</h3>
|
||||
<p class="steps-subtitle">完成企业入驻,开启更多API服务功能</p>
|
||||
</div>
|
||||
|
||||
<!-- 开发模式步骤控制器 -->
|
||||
<div v-if="isDevelopment" class="dev-controller">
|
||||
<div class="dev-controller-content">
|
||||
<div class="dev-controller-label">
|
||||
<el-icon class="dev-icon"><CodeBracketIcon /></el-icon>
|
||||
<span>开发模式 - 步骤控制器</span>
|
||||
</div>
|
||||
<div class="dev-controller-controls">
|
||||
<el-select
|
||||
v-model="devCurrentStep"
|
||||
placeholder="选择步骤"
|
||||
size="small"
|
||||
class="dev-step-select"
|
||||
@change="handleDevStepChange"
|
||||
>
|
||||
<el-option
|
||||
v-for="step in certificationSteps"
|
||||
:key="step.key"
|
||||
:label="step.title"
|
||||
:value="step.key"
|
||||
/>
|
||||
</el-select>
|
||||
<el-button
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="switchToStep(devCurrentStep)"
|
||||
:disabled="!devCurrentStep"
|
||||
class="dev-switch-btn"
|
||||
>
|
||||
切换到该步骤
|
||||
</el-button>
|
||||
<el-button type="info" size="small" @click="resetToFirstStep" class="dev-reset-btn">
|
||||
重置到第一步
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CustomSteps
|
||||
:steps="certificationSteps"
|
||||
:current-step="currentStep"
|
||||
class="certification-steps"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 认证内容区域 -->
|
||||
<div v-if="!loading" class="certification-content">
|
||||
<EnterpriseInfo
|
||||
v-if="currentStep === 'enterprise_info'"
|
||||
:form-data="enterpriseForm"
|
||||
@submit="handleEnterpriseSubmit"
|
||||
/>
|
||||
|
||||
<EnterpriseVerify
|
||||
v-if="currentStep === 'enterprise_verify'"
|
||||
:enterprise-data="enterpriseForm"
|
||||
:auth-url="stepMeta.auth_url"
|
||||
@submit="handleEnterpriseVerifySubmit"
|
||||
/>
|
||||
|
||||
<div v-if="currentStep === 'contract_sign'">
|
||||
<ContractPreview
|
||||
v-if="certificationData?.status === 'enterprise_verified'"
|
||||
:contract-url="stepMeta.ContractURL"
|
||||
:loading="contractSignLoading"
|
||||
@start-sign="handleStartContractSign"
|
||||
/>
|
||||
<ContractSign
|
||||
v-else-if="certificationData?.status === 'contract_applied'"
|
||||
:company-name="enterpriseForm.companyName"
|
||||
:iframe-url="stepMeta.contract_sign_url"
|
||||
/>
|
||||
<ContractRejected
|
||||
v-else-if="certificationData?.status === 'contract_rejected'"
|
||||
:failure-message="certificationData?.failure_message"
|
||||
@contact-service="handleContactService"
|
||||
@go-back="handleGoBackFromRejected"
|
||||
/>
|
||||
<ContractExpired
|
||||
v-else-if="certificationData?.status === 'contract_expired'"
|
||||
:contract-created-at="
|
||||
certificationData?.contract_info?.generated_at || certificationData?.contract_applied_at
|
||||
"
|
||||
@reapply-contract="handleReapplyContract"
|
||||
@go-back="handleGoBackFromExpired"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<CertificationComplete
|
||||
v-if="currentStep === 'completed'"
|
||||
:certification-data="certificationData"
|
||||
@go-dashboard="goToDashboard"
|
||||
@go-profile="goToProfile"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { certificationApi } from '@/api/index.js'
|
||||
import CustomSteps from '@/components/common/CustomSteps.vue'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import {
|
||||
BuildingOfficeIcon,
|
||||
CheckCircleIcon,
|
||||
CodeBracketIcon,
|
||||
DocumentTextIcon,
|
||||
UserIcon,
|
||||
} from '@heroicons/vue/24/outline'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
import CertificationComplete from './components/CertificationComplete.vue'
|
||||
import ContractExpired from './components/ContractExpired.vue'
|
||||
import ContractPreview from './components/ContractPreview.vue'
|
||||
import ContractRejected from './components/ContractRejected.vue'
|
||||
import ContractSign from './components/ContractSign.vue'
|
||||
import EnterpriseInfo from './components/EnterpriseInfo.vue'
|
||||
import EnterpriseVerify from './components/EnterpriseVerify.vue'
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
// 认证步骤配置
|
||||
const certificationSteps = [
|
||||
{
|
||||
key: 'enterprise_info',
|
||||
title: '填写企业信息',
|
||||
description: '填写企业基本信息和法人信息',
|
||||
icon: BuildingOfficeIcon,
|
||||
},
|
||||
{
|
||||
key: 'enterprise_verify',
|
||||
title: '企业认证',
|
||||
description: '完成企业认证流程',
|
||||
icon: UserIcon,
|
||||
},
|
||||
{
|
||||
key: 'contract_sign',
|
||||
title: '签署合同',
|
||||
description: '签署认证合同',
|
||||
icon: DocumentTextIcon,
|
||||
},
|
||||
{
|
||||
key: 'completed',
|
||||
title: '完成',
|
||||
description: '认证流程完成',
|
||||
icon: CheckCircleIcon,
|
||||
},
|
||||
]
|
||||
|
||||
// 认证状态数据
|
||||
const certificationData = ref(null)
|
||||
const loading = ref(false)
|
||||
|
||||
// 当前步骤状态
|
||||
const currentStep = ref('enterprise_info')
|
||||
const currentStepIndex = computed(() => {
|
||||
return certificationSteps.findIndex((step) => step.key === currentStep.value)
|
||||
})
|
||||
|
||||
// 步骤特定元数据
|
||||
const stepMeta = ref({}) // 用于存储当前步骤的metadata
|
||||
|
||||
// 表单数据
|
||||
const enterpriseForm = ref({
|
||||
companyName: '',
|
||||
unifiedSocialCode: '',
|
||||
legalPersonName: '',
|
||||
legalPersonID: '',
|
||||
legalPersonPhone: '',
|
||||
legalPersonCode: '',
|
||||
enterpriseAddress: '',
|
||||
enterpriseEmail: '',
|
||||
})
|
||||
|
||||
// 开发模式控制
|
||||
const isDevelopment = ref(false)
|
||||
const devCurrentStep = ref('enterprise_info')
|
||||
|
||||
// 合同签署加载状态
|
||||
const contractSignLoading = ref(false)
|
||||
|
||||
// 事件处理
|
||||
const handleEnterpriseSubmit = async (formData) => {
|
||||
try {
|
||||
loading.value = true
|
||||
// 字段映射
|
||||
const payload = {
|
||||
company_name: formData.companyName,
|
||||
unified_social_code: formData.unifiedSocialCode,
|
||||
legal_person_name: formData.legalPersonName,
|
||||
legal_person_id: formData.legalPersonID,
|
||||
legal_person_phone: formData.legalPersonPhone,
|
||||
enterprise_address: formData.enterpriseAddress,
|
||||
enterprise_email: formData.enterpriseEmail,
|
||||
verification_code: formData.legalPersonCode,
|
||||
}
|
||||
await certificationApi.submitEnterpriseInfo(payload)
|
||||
ElMessage.success('企业信息提交成功')
|
||||
// 提交成功后刷新认证详情
|
||||
await getCertificationDetails()
|
||||
} catch (error) {
|
||||
ElMessage.error(error?.message || '提交失败,请检查表单信息')
|
||||
// 提交失败时不刷新认证详情,保持用户填写的信息
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleEnterpriseVerifySubmit = async () => {
|
||||
try {
|
||||
ElMessage.success('企业认证成功')
|
||||
currentStep.value = 'contract_sign'
|
||||
} catch {
|
||||
ElMessage.error('企业认证失败,请重试')
|
||||
}
|
||||
}
|
||||
|
||||
const handleStartContractSign = async () => {
|
||||
try {
|
||||
contractSignLoading.value = true
|
||||
// 调用后端接口,发起合同签署
|
||||
await certificationApi.applyContract()
|
||||
ElMessage.success('合同签署流程已启动')
|
||||
await getCertificationDetails()
|
||||
} catch (error) {
|
||||
ElMessage.error(error?.message || '发起签署失败,请稍后重试')
|
||||
} finally {
|
||||
contractSignLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleContactService = () => {
|
||||
// 这里可以打开客服聊天窗口或跳转到客服页面
|
||||
ElMessage.success('正在为您连接客服...')
|
||||
// 可以添加具体的客服逻辑,比如打开聊天窗口
|
||||
}
|
||||
|
||||
const handleGoBackFromRejected = () => {
|
||||
// 从拒签状态返回,重置到企业认证状态
|
||||
currentStep.value = 'enterprise_verify'
|
||||
}
|
||||
|
||||
const handleGoBackFromExpired = () => {
|
||||
// 从过期状态返回,重置到企业认证状态
|
||||
currentStep.value = 'enterprise_verify'
|
||||
}
|
||||
|
||||
const handleReapplyContract = async () => {
|
||||
try {
|
||||
contractSignLoading.value = true
|
||||
// 重新申请合同签署
|
||||
await certificationApi.applyContract()
|
||||
ElMessage.success('合同重新申请成功')
|
||||
await getCertificationDetails()
|
||||
} catch (error) {
|
||||
ElMessage.error(error?.message || '重新申请失败,请稍后重试')
|
||||
} finally {
|
||||
contractSignLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleDevStepChange = (value) => {
|
||||
devCurrentStep.value = value
|
||||
}
|
||||
|
||||
const switchToStep = (stepKey) => {
|
||||
currentStep.value = stepKey
|
||||
}
|
||||
|
||||
const resetToFirstStep = () => {
|
||||
currentStep.value = 'enterprise_info'
|
||||
}
|
||||
|
||||
// 跳转到控制台
|
||||
const goToDashboard = () => {
|
||||
router.push('/products')
|
||||
}
|
||||
|
||||
// 跳转到账户中心
|
||||
const goToProfile = () => {
|
||||
router.push('/profile')
|
||||
}
|
||||
|
||||
// 获取认证详情
|
||||
const getCertificationDetails = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
const res = await certificationApi.getCertificationDetails()
|
||||
certificationData.value = res.data
|
||||
console.log('s', res)
|
||||
// 回显企业信息 - 修复字段路径问题
|
||||
if (res.data && res.data.metadata?.enterprise_info) {
|
||||
const enterpriseInfo = res.data.metadata.enterprise_info
|
||||
// 只有当后端有数据时才覆盖,避免清空用户刚填写的信息
|
||||
if (enterpriseInfo.company_name) {
|
||||
enterpriseForm.value.companyName = enterpriseInfo.company_name
|
||||
}
|
||||
if (enterpriseInfo.unified_social_code) {
|
||||
enterpriseForm.value.unifiedSocialCode = enterpriseInfo.unified_social_code
|
||||
}
|
||||
if (enterpriseInfo.legal_person_name) {
|
||||
enterpriseForm.value.legalPersonName = enterpriseInfo.legal_person_name
|
||||
}
|
||||
if (enterpriseInfo.legal_person_id) {
|
||||
enterpriseForm.value.legalPersonID = enterpriseInfo.legal_person_id
|
||||
}
|
||||
if (enterpriseInfo.legal_person_phone) {
|
||||
enterpriseForm.value.legalPersonPhone = enterpriseInfo.legal_person_phone
|
||||
}
|
||||
if (enterpriseInfo.enterprise_address) {
|
||||
enterpriseForm.value.enterpriseAddress = enterpriseInfo.enterprise_address
|
||||
}
|
||||
if (enterpriseInfo.enterprise_email) {
|
||||
enterpriseForm.value.enterpriseEmail = enterpriseInfo.enterprise_email
|
||||
}
|
||||
}
|
||||
// 步骤特定元数据
|
||||
stepMeta.value = res.data?.metadata || {}
|
||||
await setCurrentStepByStatus()
|
||||
} catch (error) {
|
||||
console.error('获取认证详情失败:', error)
|
||||
ElMessage.error('获取认证详情失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 根据认证状态设置当前步骤
|
||||
const setCurrentStepByStatus = async () => {
|
||||
if (!certificationData.value) {
|
||||
currentStep.value = 'enterprise_info'
|
||||
return
|
||||
}
|
||||
|
||||
const previousStatus = currentStep.value
|
||||
const newStatus = certificationData.value.status
|
||||
|
||||
switch (newStatus) {
|
||||
case 'pending':
|
||||
currentStep.value = 'enterprise_info'
|
||||
break
|
||||
case 'info_submitted':
|
||||
currentStep.value = 'enterprise_verify'
|
||||
break
|
||||
case 'enterprise_verified':
|
||||
case 'contract_applied':
|
||||
case 'contract_rejected':
|
||||
case 'contract_expired':
|
||||
currentStep.value = 'contract_sign'
|
||||
break
|
||||
case 'contract_signed':
|
||||
case 'completed':
|
||||
currentStep.value = 'completed'
|
||||
// 认证完成时重新获取用户信息
|
||||
if (previousStatus !== 'completed') {
|
||||
try {
|
||||
await userStore.fetchUserProfile()
|
||||
console.log('认证完成,已重新获取用户信息')
|
||||
} catch (error) {
|
||||
console.error('重新获取用户信息失败:', error)
|
||||
}
|
||||
}
|
||||
break
|
||||
case 'info_rejected':
|
||||
currentStep.value = 'enterprise_info'
|
||||
break
|
||||
default:
|
||||
currentStep.value = 'enterprise_info'
|
||||
}
|
||||
}
|
||||
|
||||
// 认证详情轮询(用于iframe回调后确认状态变化)
|
||||
async function pollCertificationDetails(maxTries = 5) {
|
||||
let tries = 0
|
||||
let lastStatus = certificationData.value?.status
|
||||
let changed = false
|
||||
while (tries < maxTries && !changed) {
|
||||
try {
|
||||
// 不显示loading
|
||||
const res = await certificationApi.getCertificationDetails()
|
||||
const newStatus = res.data?.status
|
||||
if (newStatus !== lastStatus) {
|
||||
certificationData.value = res.data
|
||||
// 回显企业信息 - 修复字段路径问题
|
||||
if (res.data && res.data.metadata?.enterprise_info) {
|
||||
const enterpriseInfo = res.data.metadata.enterprise_info
|
||||
// 只有当后端有数据时才覆盖,避免清空用户刚填写的信息
|
||||
if (enterpriseInfo.company_name) {
|
||||
enterpriseForm.value.companyName = enterpriseInfo.company_name
|
||||
}
|
||||
if (enterpriseInfo.unified_social_code) {
|
||||
enterpriseForm.value.unifiedSocialCode = enterpriseInfo.unified_social_code
|
||||
}
|
||||
if (enterpriseInfo.legal_person_name) {
|
||||
enterpriseForm.value.legalPersonName = enterpriseInfo.legal_person_name
|
||||
}
|
||||
if (enterpriseInfo.legal_person_id) {
|
||||
enterpriseForm.value.legalPersonID = enterpriseInfo.legal_person_id
|
||||
}
|
||||
if (enterpriseInfo.legal_person_phone) {
|
||||
enterpriseForm.value.legalPersonPhone = enterpriseInfo.legal_person_phone
|
||||
}
|
||||
}
|
||||
stepMeta.value = res.data?.metadata || {}
|
||||
await setCurrentStepByStatus()
|
||||
changed = true
|
||||
break
|
||||
}
|
||||
} catch (error) {
|
||||
// 可选:打印错误
|
||||
console.warn('轮询认证详情失败:', error)
|
||||
}
|
||||
tries++
|
||||
const delay = Math.min(tries, 5) * 1000 // 1s,2s,3s,4s,5s
|
||||
await new Promise((resolve) => setTimeout(resolve, delay))
|
||||
}
|
||||
}
|
||||
|
||||
// Iframe 回调消息监听
|
||||
const handledScenes = new Set()
|
||||
async function handleIframeMessage(event) {
|
||||
// 这里可以加安全校验:event.origin === '你的主站点origin'
|
||||
// 目前只打印消息,后续可补充具体逻辑
|
||||
const { type, scene } = event.data || {}
|
||||
if (type === 'CHECK_STATUS') {
|
||||
if (handledScenes.has(scene)) {
|
||||
console.log('该scene已处理,忽略重复消息:', scene)
|
||||
return
|
||||
}
|
||||
console.log('收到iframe消息:', event)
|
||||
handledScenes.add(scene)
|
||||
console.log('需要检查后端状态,scene:', scene)
|
||||
try {
|
||||
if (scene === 'sign') {
|
||||
// 合同签署场景,调用 confirmSign 接口
|
||||
const response = await certificationApi.confirmSign()
|
||||
console.log('confirmSign 接口返回:', response)
|
||||
|
||||
// 确认签署状态后,重新获取完整的认证详情
|
||||
await getCertificationDetails()
|
||||
} else {
|
||||
// 企业认证场景,使用原有的轮询方式
|
||||
pollCertificationDetails()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('检查状态失败:', error)
|
||||
ElMessage.error('检查状态失败,请稍后重试')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化
|
||||
onMounted(() => {
|
||||
window.addEventListener('message', handleIframeMessage)
|
||||
getCertificationDetails()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('message', handleIframeMessage)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.certification-page {
|
||||
min-height: 100vh;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* 加载状态 */
|
||||
.loading-container {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.1);
|
||||
padding: 60px;
|
||||
margin: 40px auto;
|
||||
text-align: center;
|
||||
min-height: 600px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.loading-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border: 4px solid #e2e8f0;
|
||||
border-top: 4px solid #3b82f6;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
font-size: 16px;
|
||||
color: #64748b;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 步骤条容器 */
|
||||
.steps-container {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.1);
|
||||
padding: 36px;
|
||||
margin: 0 auto 32px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.steps-header {
|
||||
text-align: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.steps-title {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: #1e293b;
|
||||
margin-bottom: 6px;
|
||||
background: linear-gradient(135deg, #1e293b 0%, #475569 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.steps-subtitle {
|
||||
font-size: 16px;
|
||||
color: #64748b;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.certification-steps {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.certification-content {
|
||||
min-height: 600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* 开发模式控制器样式 */
|
||||
.dev-controller {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.1);
|
||||
padding: 24px;
|
||||
margin-bottom: 48px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.dev-controller-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.dev-controller-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.dev-controller-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.dev-step-select :deep(.el-input__inner) {
|
||||
border-radius: 8px;
|
||||
border-color: #e2e8f0;
|
||||
background-color: #f8fafc;
|
||||
}
|
||||
|
||||
.dev-step-select :deep(.el-input__inner:focus) {
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 0 0 2px #3b82f6;
|
||||
}
|
||||
|
||||
.dev-switch-btn :deep(.el-button--small) {
|
||||
border-radius: 8px;
|
||||
background-color: #3b82f6;
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
|
||||
.dev-switch-btn :deep(.el-button--small:hover) {
|
||||
background-color: #1d4ed8;
|
||||
border-color: #1d4ed8;
|
||||
}
|
||||
|
||||
.dev-reset-btn :deep(.el-button--small) {
|
||||
border-radius: 8px;
|
||||
background-color: #e2e8f0;
|
||||
border-color: #e2e8f0;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.dev-reset-btn :deep(.el-button--small:hover) {
|
||||
background-color: #cbd5e1;
|
||||
border-color: #cbd5e1;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.dev-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
/* 动画效果 */
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.certification-page {
|
||||
padding: 20px 16px;
|
||||
}
|
||||
|
||||
.steps-container {
|
||||
padding: 32px 24px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.steps-title {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.steps-subtitle {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.certification-steps :deep(.el-step__icon) {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.certification-steps :deep(.el-step__title) {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.certification-steps :deep(.el-step__description) {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.loading-container {
|
||||
margin: 20px 16px;
|
||||
padding: 40px 24px;
|
||||
}
|
||||
|
||||
.dev-controller {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 15px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.dev-controller-content {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.dev-controller-label {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.dev-controller-controls {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.dev-step-select :deep(.el-input__inner) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.dev-switch-btn,
|
||||
.dev-reset-btn {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.certification-steps :deep(.el-step__icon) {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.certification-steps :deep(.el-step__icon.is-text) {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
25
src/pages/error/NotFound.vue
Normal file
25
src/pages/error/NotFound.vue
Normal file
@@ -0,0 +1,25 @@
|
||||
<template>
|
||||
<div class="not-found-page">
|
||||
<div class="text-center">
|
||||
<h1 class="text-6xl font-bold text-gray-300 mb-4">404</h1>
|
||||
<h2 class="text-2xl font-semibold text-gray-700 mb-4">页面不存在</h2>
|
||||
<p class="text-gray-600 mb-8">抱歉,您访问的页面不存在或已被移除。</p>
|
||||
<el-button type="primary" @click="$router.push('/')">
|
||||
返回首页
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
// 404错误页面
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.not-found-page {
|
||||
min-height: 60vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
10
src/pages/finance/Finance.vue
Normal file
10
src/pages/finance/Finance.vue
Normal file
@@ -0,0 +1,10 @@
|
||||
<template>
|
||||
<div class="finance-page">
|
||||
<h1 class="text-3xl font-bold mb-6">发票管理</h1>
|
||||
<p class="text-gray-600 mb-8">发票管理页面 - 待开发</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
// 发票管理页面 - 待开发
|
||||
</script>
|
||||
1576
src/pages/finance/Invoice.vue
Normal file
1576
src/pages/finance/Invoice.vue
Normal file
File diff suppressed because it is too large
Load Diff
492
src/pages/finance/Transactions.vue
Normal file
492
src/pages/finance/Transactions.vue
Normal file
@@ -0,0 +1,492 @@
|
||||
<template>
|
||||
<ListPageLayout title="消费记录" subtitle="查看您的钱包消费历史记录">
|
||||
<template #filters>
|
||||
<FilterSection>
|
||||
<FilterItem label="交易ID">
|
||||
<el-input
|
||||
v-model="filters.transaction_id"
|
||||
placeholder="输入交易ID"
|
||||
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="金额范围">
|
||||
<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>
|
||||
|
||||
<FilterItem label="时间范围">
|
||||
<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="handleDateRangeChange"
|
||||
class="w-full"
|
||||
/>
|
||||
</FilterItem>
|
||||
|
||||
<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-if="transactions.length === 0" class="text-center py-12">
|
||||
<el-empty description="暂无消费记录">
|
||||
<el-button type="primary" @click="$router.push('/finance/wallet')">
|
||||
前往钱包充值
|
||||
</el-button>
|
||||
</el-empty>
|
||||
</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-700">{{ row.transaction_id }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column prop="product_name" label="产品名称" min-width="150">
|
||||
<template #default="{ row }">
|
||||
<span class="text-sm text-blue-600">{{ row.product_name || '-' }}</span>
|
||||
</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>
|
||||
</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="600px" class="detail-dialog">
|
||||
<div v-if="selectedTransaction" class="space-y-6">
|
||||
<div class="grid grid-cols-2 gap-6">
|
||||
<div class="detail-item">
|
||||
<label class="detail-label">记录ID</label>
|
||||
<span class="detail-value">{{ selectedTransaction.id }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<label class="detail-label">交易ID</label>
|
||||
<span class="detail-value font-mono text-blue-600">{{
|
||||
selectedTransaction.transaction_id
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<label class="detail-label">产品名称</label>
|
||||
<span class="detail-value">{{ selectedTransaction.product_name || '-' }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<label class="detail-label">用户ID</label>
|
||||
<span class="detail-value">{{ selectedTransaction.user_id }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<label class="detail-label">消费金额</label>
|
||||
<span class="detail-value">
|
||||
<span class="text-red-600 font-semibold"
|
||||
>¥{{ formatPrice(selectedTransaction.amount) }}</span
|
||||
>
|
||||
</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<label class="detail-label">创建时间</label>
|
||||
<span class="detail-value">{{ formatDateTime(selectedTransaction.created_at) }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<label class="detail-label">更新时间</label>
|
||||
<span class="detail-value">{{ formatDateTime(selectedTransaction.updated_at) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="transaction-info">
|
||||
<h4 class="info-title">交易说明</h4>
|
||||
<div class="info-content">
|
||||
<p class="text-gray-700">
|
||||
此交易记录记录了API调用产生的费用扣除。每次API调用成功后,系统会根据接口定价自动从您的钱包余额中扣除相应费用。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</template>
|
||||
</ListPageLayout>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { financeApi } 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 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 stats = ref({
|
||||
total_transactions: 0,
|
||||
total_amount: 0,
|
||||
})
|
||||
|
||||
// 筛选条件
|
||||
const filters = reactive({
|
||||
min_amount: '',
|
||||
max_amount: '',
|
||||
start_time: '',
|
||||
end_time: '',
|
||||
transaction_id: '',
|
||||
product_name: '',
|
||||
})
|
||||
|
||||
// 搜索防抖
|
||||
let searchTimer = null
|
||||
|
||||
// 初始化
|
||||
onMounted(() => {
|
||||
loadTransactions()
|
||||
loadStats()
|
||||
})
|
||||
|
||||
// 加载钱包交易记录
|
||||
const loadTransactions = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const params = {
|
||||
page: currentPage.value,
|
||||
page_size: pageSize.value,
|
||||
...filters,
|
||||
}
|
||||
|
||||
const response = await financeApi.getUserWalletTransactions(params)
|
||||
transactions.value = response.data?.items || []
|
||||
total.value = response.data?.total || 0
|
||||
} catch (error) {
|
||||
console.error('加载钱包交易记录失败:', error)
|
||||
ElMessage.error('加载钱包交易记录失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 加载统计数据
|
||||
const loadStats = async () => {
|
||||
try {
|
||||
// 计算统计数据
|
||||
const totalAmount = transactions.value.reduce((sum, transaction) => {
|
||||
return sum + Number(transaction.amount || 0)
|
||||
}, 0)
|
||||
|
||||
stats.value = {
|
||||
total_transactions: total.value,
|
||||
total_amount: totalAmount,
|
||||
}
|
||||
} 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 formatDateTime = (date) => {
|
||||
if (!date) return '-'
|
||||
return new Date(date).toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
// 处理筛选变化
|
||||
const handleFilterChange = () => {
|
||||
currentPage.value = 1
|
||||
loadTransactions()
|
||||
}
|
||||
|
||||
// 处理时间范围变化
|
||||
const handleDateRangeChange = (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 handleViewDetail = (transaction) => {
|
||||
selectedTransaction.value = transaction
|
||||
detailDialogVisible.value = true
|
||||
}
|
||||
</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;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
/* 详情弹窗样式 */
|
||||
.detail-dialog :deep(.el-dialog) {
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.detail-dialog :deep(.el-dialog__title) {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.detail-dialog :deep(.el-dialog__body) {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.detail-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;
|
||||
}
|
||||
|
||||
/* 详情项样式 */
|
||||
.detail-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
font-size: 16px;
|
||||
color: #1e293b;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 交易信息样式 */
|
||||
.transaction-info {
|
||||
background: rgba(248, 250, 252, 0.8);
|
||||
border: 1px solid rgba(226, 232, 240, 0.6);
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.info-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.info-content {
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
border: 1px solid rgba(226, 232, 240, 0.4);
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
/* 表格样式优化 */
|
||||
: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;
|
||||
}
|
||||
|
||||
.detail-item {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
1164
src/pages/finance/Wallet.vue
Normal file
1164
src/pages/finance/Wallet.vue
Normal file
File diff suppressed because it is too large
Load Diff
274
src/pages/finance/WalletFail.vue
Normal file
274
src/pages/finance/WalletFail.vue
Normal file
@@ -0,0 +1,274 @@
|
||||
<template>
|
||||
<div class="list-page-container">
|
||||
<div class="list-page-card">
|
||||
<div class="list-page-wrapper">
|
||||
<!-- 充值失败内容 -->
|
||||
<div class="fail-content">
|
||||
<div class="fail-icon">
|
||||
<el-icon size="64" color="#F56C6C">
|
||||
<CircleCloseFilled />
|
||||
</el-icon>
|
||||
</div>
|
||||
|
||||
<h1 class="fail-title">充值失败</h1>
|
||||
<p class="fail-subtitle">{{ getFailReason() }}</p>
|
||||
|
||||
<div class="fail-details">
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">订单号:</span>
|
||||
<span class="detail-value">{{ outTradeNo }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">失败原因:</span>
|
||||
<span class="detail-value reason">{{ getFailReasonText() }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">失败时间:</span>
|
||||
<span class="detail-value">{{ formatTime(new Date()) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="fail-actions">
|
||||
<el-button type="primary" size="large" @click="retryRecharge">
|
||||
重新充值
|
||||
</el-button>
|
||||
<el-button size="large" @click="goToWallet">
|
||||
返回钱包
|
||||
</el-button>
|
||||
<el-button size="large" @click="contactSupport">
|
||||
联系客服
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 温馨提示 -->
|
||||
<div class="fail-tips">
|
||||
<el-alert
|
||||
title="温馨提示"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
>
|
||||
<template #default>
|
||||
<ul class="tips-list">
|
||||
<li>如果您的银行卡已扣款但充值失败,请保留支付凭证并联系客服</li>
|
||||
<li>建议使用支付宝余额或绑定的银行卡进行充值</li>
|
||||
<li>如遇网络问题,请稍后重试</li>
|
||||
</ul>
|
||||
</template>
|
||||
</el-alert>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { CircleCloseFilled } from '@element-plus/icons-vue'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
// 响应式数据
|
||||
const outTradeNo = ref('')
|
||||
const failReason = ref('')
|
||||
|
||||
// 初始化
|
||||
onMounted(() => {
|
||||
// 从URL参数获取失败信息
|
||||
outTradeNo.value = route.query.out_trade_no || ''
|
||||
failReason.value = route.query.reason || ''
|
||||
})
|
||||
|
||||
// 获取失败原因
|
||||
const getFailReason = () => {
|
||||
switch (failReason.value) {
|
||||
case 'TRADE_CLOSED':
|
||||
return '支付已关闭'
|
||||
case 'TRADE_CANCELED':
|
||||
return '支付已取消'
|
||||
case 'TRADE_FAILED':
|
||||
return '支付失败'
|
||||
default:
|
||||
return '充值失败'
|
||||
}
|
||||
}
|
||||
|
||||
// 获取失败原因详细说明
|
||||
const getFailReasonText = () => {
|
||||
switch (failReason.value) {
|
||||
case 'TRADE_CLOSED':
|
||||
return '订单已超时关闭,请重新发起充值'
|
||||
case 'TRADE_CANCELED':
|
||||
return '您取消了本次支付'
|
||||
case 'TRADE_FAILED':
|
||||
return '支付过程中发生错误,请重试'
|
||||
default:
|
||||
return '支付过程中发生未知错误'
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化时间
|
||||
const formatTime = (date) => {
|
||||
return date.toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
// 重新充值
|
||||
const retryRecharge = () => {
|
||||
router.push('/finance/wallet')
|
||||
}
|
||||
|
||||
// 返回钱包
|
||||
const goToWallet = () => {
|
||||
router.push('/finance/wallet')
|
||||
}
|
||||
|
||||
// 联系客服
|
||||
const contactSupport = () => {
|
||||
// 这里可以跳转到客服页面或打开客服对话框
|
||||
window.open('https://support.example.com', '_blank')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fail-content {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.fail-icon {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.fail-title {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
color: #F56C6C;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.fail-subtitle {
|
||||
font-size: 18px;
|
||||
color: #606266;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.fail-details {
|
||||
background: #f8f9fa;
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
margin-bottom: 40px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.detail-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid #e9ecef;
|
||||
}
|
||||
|
||||
.detail-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
font-size: 16px;
|
||||
color: #606266;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
font-size: 16px;
|
||||
color: #303133;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.detail-value.reason {
|
||||
color: #F56C6C;
|
||||
max-width: 300px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.fail-actions {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
justify-content: center;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.fail-actions .el-button {
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.fail-tips {
|
||||
max-width: 500px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.tips-list {
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
line-height: 1.6;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.tips-list li {
|
||||
margin-bottom: 8px;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.tips-list li:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.fail-content {
|
||||
padding: 40px 16px;
|
||||
}
|
||||
|
||||
.fail-title {
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.fail-subtitle {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.fail-details {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.detail-item {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.detail-value.reason {
|
||||
max-width: none;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.fail-actions {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.fail-actions .el-button {
|
||||
width: 100%;
|
||||
max-width: 200px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
415
src/pages/finance/WalletProcessing.vue
Normal file
415
src/pages/finance/WalletProcessing.vue
Normal file
@@ -0,0 +1,415 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-gray-50 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div class="max-w-md w-full space-y-8">
|
||||
<!-- 处理中状态 -->
|
||||
<div v-if="isProcessing" class="text-center">
|
||||
<div class="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-blue-100">
|
||||
<svg
|
||||
class="animate-spin h-6 w-6 text-blue-600"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
class="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
></circle>
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h2 class="mt-6 text-3xl font-extrabold text-gray-900">支付处理中</h2>
|
||||
<p class="mt-2 text-sm text-gray-600">正在确认您的支付结果,请稍候...</p>
|
||||
|
||||
<!-- 订单信息 -->
|
||||
<div class="mt-8 bg-white shadow rounded-lg p-6">
|
||||
<div class="space-y-4">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500">订单号:</span>
|
||||
<span class="font-medium">{{ orderInfo.out_trade_no }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500">金额:</span>
|
||||
<span class="font-medium text-green-600">¥{{ orderInfo.amount }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500">创建时间:</span>
|
||||
<span class="font-medium">{{ formatTime(orderInfo.created_at) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 轮询状态 -->
|
||||
<div class="mt-4 text-xs text-gray-500">
|
||||
<p>正在查询支付状态... ({{ pollCount }}/{{ maxPollCount }})</p>
|
||||
</div>
|
||||
|
||||
<!-- 提示信息 -->
|
||||
<div class="mt-6 p-4 bg-blue-50 rounded-lg">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<svg
|
||||
class="h-5 w-5 text-blue-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<p class="text-sm text-blue-700">
|
||||
支付完成后会自动到账,您可以关闭此页面。如有问题,请联系客服。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 联系客服 -->
|
||||
<div class="mt-4">
|
||||
<button
|
||||
@click="contactService"
|
||||
class="inline-flex items-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
>
|
||||
<svg class="h-4 w-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
|
||||
></path>
|
||||
</svg>
|
||||
联系客服
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 成功状态 -->
|
||||
<div v-else-if="orderStatus === 'success'" class="text-center">
|
||||
<div class="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-green-100">
|
||||
<svg class="h-6 w-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 13l4 4L19 7"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h2 class="mt-6 text-3xl font-extrabold text-gray-900">支付成功!</h2>
|
||||
<p class="mt-2 text-sm text-gray-600">您的充值已成功到账,资金已自动转入您的钱包</p>
|
||||
|
||||
<!-- 成功信息 -->
|
||||
<div class="mt-8 bg-white shadow rounded-lg p-6">
|
||||
<div class="space-y-4">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500">订单号:</span>
|
||||
<span class="font-medium">{{ orderInfo.out_trade_no }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500">支付宝交易号:</span>
|
||||
<span class="font-medium">{{ orderInfo.trade_no || '暂无' }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500">付费金额:</span>
|
||||
<span class="font-medium text-green-600">¥{{ orderInfo.amount }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500">支付时间:</span>
|
||||
<span class="font-medium">{{
|
||||
formatTime(orderInfo.notify_time || orderInfo.updated_at)
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 提示信息 -->
|
||||
<div class="mt-6 p-4 bg-green-50 rounded-lg">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<svg
|
||||
class="h-5 w-5 text-green-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<p class="text-sm text-green-700">
|
||||
支付成功!资金已自动转入您的钱包,您可以继续使用平台服务。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="mt-8 space-y-3">
|
||||
<button
|
||||
@click="goToWallet"
|
||||
class="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
>
|
||||
查看钱包余额
|
||||
</button>
|
||||
<!-- <button
|
||||
@click="goToRechargeRecords"
|
||||
class="w-full flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
>
|
||||
查看充值记录
|
||||
</button> -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 失败状态 -->
|
||||
<div v-else-if="orderStatus === 'failed'" class="text-center">
|
||||
<div class="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-red-100">
|
||||
<svg class="h-6 w-6 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h2 class="mt-6 text-3xl font-extrabold text-gray-900">支付失败</h2>
|
||||
<p class="mt-2 text-sm text-gray-600">
|
||||
{{ orderInfo.error_message || '支付过程中出现错误' }}
|
||||
</p>
|
||||
|
||||
<!-- 失败信息 -->
|
||||
<div class="mt-8 bg-white shadow rounded-lg p-6">
|
||||
<div class="space-y-4">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500">订单号:</span>
|
||||
<span class="font-medium">{{ orderInfo.out_trade_no }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500">金额:</span>
|
||||
<span class="font-medium text-red-600">¥{{ orderInfo.amount }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500">错误码:</span>
|
||||
<span class="font-medium">{{ orderInfo.error_code || '无' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="mt-8 space-y-3">
|
||||
<button
|
||||
v-if="orderInfo.can_retry"
|
||||
@click="retryPayment"
|
||||
class="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
>
|
||||
重新支付
|
||||
</button>
|
||||
<button
|
||||
@click="goToWallet"
|
||||
class="w-full flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
>
|
||||
返回钱包
|
||||
</button>
|
||||
<button
|
||||
@click="contactService"
|
||||
class="w-full flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
>
|
||||
<svg class="h-4 w-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
|
||||
></path>
|
||||
</svg>
|
||||
联系客服
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 其他状态 -->
|
||||
<div v-else class="text-center">
|
||||
<div class="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-yellow-100">
|
||||
<svg
|
||||
class="h-6 w-6 text-yellow-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h2 class="mt-6 text-3xl font-extrabold text-gray-900">订单状态异常</h2>
|
||||
<p class="mt-2 text-sm text-gray-600">订单状态:{{ orderInfo.status }}</p>
|
||||
|
||||
<div class="mt-8">
|
||||
<button
|
||||
@click="goToWallet"
|
||||
class="w-full flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
>
|
||||
返回钱包
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { financeApi } from '@/api'
|
||||
|
||||
export default {
|
||||
name: 'WalletProcessing',
|
||||
setup() {
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
const orderInfo = ref({})
|
||||
const orderStatus = ref('processing')
|
||||
const isProcessing = ref(true)
|
||||
const pollCount = ref(0)
|
||||
const maxPollCount = ref(30) // 最多轮询30次
|
||||
const pollInterval = ref(null)
|
||||
|
||||
// 获取URL参数
|
||||
const outTradeNo = route.query.out_trade_no
|
||||
const amount = route.query.amount
|
||||
|
||||
// 初始化订单信息
|
||||
orderInfo.value = {
|
||||
out_trade_no: outTradeNo,
|
||||
amount: amount,
|
||||
}
|
||||
|
||||
// 格式化时间
|
||||
const formatTime = (timeStr) => {
|
||||
if (!timeStr) return '暂无'
|
||||
return new Date(timeStr).toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
// 查询订单状态
|
||||
const queryOrderStatus = async () => {
|
||||
try {
|
||||
const response = await financeApi.getAlipayOrderStatus({ out_trade_no: outTradeNo })
|
||||
orderInfo.value = response.data
|
||||
// 根据状态更新页面
|
||||
if (response.data.status === 'success') {
|
||||
orderStatus.value = 'success'
|
||||
isProcessing.value = false
|
||||
stopPolling()
|
||||
} else if (response.data.status === 'failed') {
|
||||
orderStatus.value = 'failed'
|
||||
isProcessing.value = false
|
||||
stopPolling()
|
||||
} else if (response.data.status === 'pending') {
|
||||
// 继续轮询,轮询次数在setInterval中已经增加
|
||||
if (pollCount.value >= maxPollCount.value) {
|
||||
// 超过最大轮询次数,停止轮询
|
||||
stopPolling()
|
||||
// 可以显示超时提示
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('查询订单状态失败:', error)
|
||||
// 查询失败时继续轮询,不要停止
|
||||
}
|
||||
}
|
||||
|
||||
// 开始轮询
|
||||
const startPolling = () => {
|
||||
// 立即查询一次
|
||||
queryOrderStatus()
|
||||
|
||||
// 每3秒查询一次
|
||||
pollInterval.value = setInterval(() => {
|
||||
pollCount.value++ // 增加轮询次数
|
||||
queryOrderStatus()
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
// 停止轮询
|
||||
const stopPolling = () => {
|
||||
if (pollInterval.value) {
|
||||
clearInterval(pollInterval.value)
|
||||
pollInterval.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// 页面跳转
|
||||
const goToWallet = () => {
|
||||
router.push('/finance/wallet')
|
||||
}
|
||||
|
||||
const goToRechargeRecords = () => {
|
||||
router.push('/finance/wallet/recharge-records')
|
||||
}
|
||||
|
||||
const retryPayment = () => {
|
||||
// 重新创建支付订单
|
||||
router.push({
|
||||
path: '/finance/wallet',
|
||||
query: { retry: 'true', amount: amount },
|
||||
})
|
||||
}
|
||||
|
||||
const contactService = () => {
|
||||
// 打开客服聊天窗口或跳转到客服页面
|
||||
// 这里可以根据实际需求实现
|
||||
window.open('https://wpa.qq.com/msgrd?v=3&uin=123456789&site=qq&menu=yes', '_blank')
|
||||
// 或者使用其他客服系统
|
||||
// window.open('/customer-service', '_blank')
|
||||
}
|
||||
|
||||
// 组件挂载时开始轮询
|
||||
onMounted(() => {
|
||||
if (outTradeNo) {
|
||||
startPolling()
|
||||
} else {
|
||||
// 没有订单号,跳转到钱包页面
|
||||
router.push('/finance/wallet')
|
||||
}
|
||||
})
|
||||
|
||||
// 组件卸载时停止轮询
|
||||
onUnmounted(() => {
|
||||
stopPolling()
|
||||
})
|
||||
|
||||
return {
|
||||
orderInfo,
|
||||
orderStatus,
|
||||
isProcessing,
|
||||
pollCount,
|
||||
maxPollCount,
|
||||
formatTime,
|
||||
goToWallet,
|
||||
goToRechargeRecords,
|
||||
retryPayment,
|
||||
contactService,
|
||||
}
|
||||
},
|
||||
}
|
||||
</script>
|
||||
203
src/pages/finance/WalletSuccess.vue
Normal file
203
src/pages/finance/WalletSuccess.vue
Normal file
@@ -0,0 +1,203 @@
|
||||
<template>
|
||||
<div class="list-page-container">
|
||||
<div class="list-page-card">
|
||||
<div class="list-page-wrapper">
|
||||
<!-- 充值成功内容 -->
|
||||
<div class="success-content">
|
||||
<div class="success-icon">
|
||||
<el-icon size="64" color="#67C23A">
|
||||
<CircleCheckFilled />
|
||||
</el-icon>
|
||||
</div>
|
||||
|
||||
<h1 class="success-title">充值成功</h1>
|
||||
<p class="success-subtitle">您的钱包已成功充值</p>
|
||||
|
||||
<div class="success-details">
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">付费金额:</span>
|
||||
<span class="detail-value amount">¥{{ formatPrice(amount) }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">订单号:</span>
|
||||
<span class="detail-value">{{ outTradeNo }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">支付宝交易号:</span>
|
||||
<span class="detail-value">{{ tradeNo }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">充值时间:</span>
|
||||
<span class="detail-value">{{ formatTime(new Date()) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="success-actions">
|
||||
<el-button type="primary" size="large" @click="goToWallet">
|
||||
返回钱包
|
||||
</el-button>
|
||||
<el-button size="large" @click="goToTransactions">
|
||||
查看交易记录
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { CircleCheckFilled } from '@element-plus/icons-vue'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
// 响应式数据
|
||||
const amount = ref('0.00')
|
||||
const outTradeNo = ref('')
|
||||
const tradeNo = ref('')
|
||||
|
||||
// 初始化
|
||||
onMounted(() => {
|
||||
// 从URL参数获取充值信息
|
||||
amount.value = route.query.amount || '0.00'
|
||||
outTradeNo.value = route.query.out_trade_no || ''
|
||||
tradeNo.value = route.query.trade_no || ''
|
||||
})
|
||||
|
||||
// 格式化价格
|
||||
const formatPrice = (price) => {
|
||||
if (!price) return '0.00'
|
||||
return Number(price).toFixed(2)
|
||||
}
|
||||
|
||||
// 格式化时间
|
||||
const formatTime = (date) => {
|
||||
return date.toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
// 返回钱包
|
||||
const goToWallet = () => {
|
||||
router.push('/finance/wallet')
|
||||
}
|
||||
|
||||
// 查看交易记录
|
||||
const goToTransactions = () => {
|
||||
router.push('/finance/transactions')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.success-content {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.success-icon {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.success-title {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
color: #67C23A;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.success-subtitle {
|
||||
font-size: 18px;
|
||||
color: #606266;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.success-details {
|
||||
background: #f8f9fa;
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
margin-bottom: 40px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.detail-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid #e9ecef;
|
||||
}
|
||||
|
||||
.detail-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
font-size: 16px;
|
||||
color: #606266;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
font-size: 16px;
|
||||
color: #303133;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.detail-value.amount {
|
||||
color: #67C23A;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.success-actions {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.success-actions .el-button {
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.success-content {
|
||||
padding: 40px 16px;
|
||||
}
|
||||
|
||||
.success-title {
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.success-subtitle {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.success-details {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.detail-item {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.success-actions {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.success-actions .el-button {
|
||||
width: 100%;
|
||||
max-width: 200px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
357
src/pages/finance/recharge-records/index.vue
Normal file
357
src/pages/finance/recharge-records/index.vue
Normal file
@@ -0,0 +1,357 @@
|
||||
<template>
|
||||
<ListPageLayout
|
||||
title="充值记录"
|
||||
subtitle="查看您的所有充值记录"
|
||||
>
|
||||
<template #filters>
|
||||
<FilterSection>
|
||||
<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="pending" />
|
||||
<el-option label="成功" value="success" />
|
||||
<el-option label="失败" value="failed" />
|
||||
<el-option label="已取消" value="cancelled" />
|
||||
</el-select>
|
||||
</FilterItem>
|
||||
|
||||
<FilterItem label="开始时间">
|
||||
<el-date-picker
|
||||
v-model="filters.start_time"
|
||||
type="datetime"
|
||||
placeholder="选择开始时间"
|
||||
format="YYYY-MM-DD HH:mm:ss"
|
||||
value-format="YYYY-MM-DD HH:mm:ss"
|
||||
@change="handleFilterChange"
|
||||
class="w-full"
|
||||
/>
|
||||
</FilterItem>
|
||||
|
||||
<FilterItem label="结束时间">
|
||||
<el-date-picker
|
||||
v-model="filters.end_time"
|
||||
type="datetime"
|
||||
placeholder="选择结束时间"
|
||||
format="YYYY-MM-DD HH:mm:ss"
|
||||
value-format="YYYY-MM-DD HH:mm:ss"
|
||||
@change="handleFilterChange"
|
||||
class="w-full"
|
||||
/>
|
||||
</FilterItem>
|
||||
|
||||
<template #stats>
|
||||
共找到 {{ total }} 条充值记录
|
||||
</template>
|
||||
|
||||
<template #buttons>
|
||||
<el-button @click="resetFilters">重置筛选</el-button>
|
||||
<el-button type="primary" @click="loadRecords">应用筛选</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="records.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="records"
|
||||
style="width: 100%"
|
||||
:header-cell-style="{
|
||||
background: '#f8fafc',
|
||||
color: '#475569',
|
||||
fontWeight: '600',
|
||||
fontSize: '14px'
|
||||
}"
|
||||
:cell-style="{
|
||||
fontSize: '14px',
|
||||
color: '#1e293b'
|
||||
}"
|
||||
>
|
||||
<el-table-column prop="alipay_order_id" label="订单号">
|
||||
<template #default="{ row }">
|
||||
<div class="space-y-1">
|
||||
<div v-if="row.alipay_order_id" class="text-sm">
|
||||
<span class="text-gray-500">支付宝订单:</span>
|
||||
<span class="font-mono">{{ row.alipay_order_id }}</span>
|
||||
</div>
|
||||
<div v-if="row.transfer_order_id" class="text-sm">
|
||||
<span class="text-gray-500">转账订单:</span>
|
||||
<span class="font-mono">{{ row.transfer_order_id }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column prop="amount" label="充值金额" width="180">
|
||||
<template #default="{ row }">
|
||||
<span class="font-medium text-green-600">
|
||||
¥{{ formatMoney(row.amount) }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column prop="recharge_type" label="充值类型" width="180">
|
||||
<template #default="{ row }">
|
||||
<el-tag
|
||||
:type="getRechargeTypeTagType(row.recharge_type)"
|
||||
size="small"
|
||||
>
|
||||
{{ getRechargeTypeText(row.recharge_type) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column prop="status" label="状态" width="160">
|
||||
<template #default="{ row }">
|
||||
<el-tag
|
||||
:type="getStatusTagType(row.status)"
|
||||
size="small"
|
||||
>
|
||||
{{ getStatusText(row.status) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column prop="notes" label="备注" min-width="150">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.notes" class="text-sm text-gray-600">
|
||||
{{ row.notes }}
|
||||
</span>
|
||||
<span v-else class="text-sm text-gray-400">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column prop="created_at" label="充值时间" width="180">
|
||||
<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>
|
||||
</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>
|
||||
</ListPageLayout>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { financeApi } 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 loading = ref(false)
|
||||
const records = ref([])
|
||||
const total = ref(0)
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(20)
|
||||
|
||||
// 筛选条件
|
||||
const filters = reactive({
|
||||
recharge_type: '',
|
||||
status: '',
|
||||
start_time: '',
|
||||
end_time: ''
|
||||
})
|
||||
|
||||
// 搜索防抖定时器
|
||||
let searchTimer = null
|
||||
|
||||
// 加载充值记录
|
||||
const loadRecords = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const params = {
|
||||
page: currentPage.value,
|
||||
page_size: pageSize.value,
|
||||
...filters
|
||||
}
|
||||
|
||||
const response = await financeApi.getUserRechargeRecords(params)
|
||||
records.value = response.data?.items || []
|
||||
total.value = response.data?.total || 0
|
||||
} catch (error) {
|
||||
console.error('加载充值记录失败:', error)
|
||||
ElMessage.error('加载充值记录失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化金额
|
||||
const formatMoney = (amount) => {
|
||||
if (!amount) return '0.00'
|
||||
const num = parseFloat(amount)
|
||||
if (isNaN(num)) return '0.00'
|
||||
return num.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 getRechargeTypeTagType = (type) => {
|
||||
const typeMap = {
|
||||
alipay: 'primary',
|
||||
transfer: 'warning',
|
||||
gift: 'success'
|
||||
}
|
||||
return typeMap[type] || 'info'
|
||||
}
|
||||
|
||||
// 获取充值类型文本
|
||||
const getRechargeTypeText = (type) => {
|
||||
const typeMap = {
|
||||
alipay: '支付宝充值',
|
||||
transfer: '对公转账',
|
||||
gift: '赠送'
|
||||
}
|
||||
return typeMap[type] || type
|
||||
}
|
||||
|
||||
// 获取状态标签样式
|
||||
const getStatusTagType = (status) => {
|
||||
const statusMap = {
|
||||
pending: 'warning',
|
||||
success: 'success',
|
||||
failed: 'danger',
|
||||
cancelled: 'info'
|
||||
}
|
||||
return statusMap[status] || 'info'
|
||||
}
|
||||
|
||||
// 获取状态文本
|
||||
const getStatusText = (status) => {
|
||||
const statusMap = {
|
||||
pending: '待处理',
|
||||
success: '成功',
|
||||
failed: '失败',
|
||||
cancelled: '已取消'
|
||||
}
|
||||
return statusMap[status] || status
|
||||
}
|
||||
|
||||
// 处理筛选变化
|
||||
const handleFilterChange = () => {
|
||||
currentPage.value = 1
|
||||
loadRecords()
|
||||
}
|
||||
|
||||
// 重置筛选
|
||||
const resetFilters = () => {
|
||||
Object.keys(filters).forEach(key => {
|
||||
filters[key] = ''
|
||||
})
|
||||
currentPage.value = 1
|
||||
loadRecords()
|
||||
}
|
||||
|
||||
// 处理分页大小变化
|
||||
const handleSizeChange = (size) => {
|
||||
pageSize.value = size
|
||||
currentPage.value = 1
|
||||
loadRecords()
|
||||
}
|
||||
|
||||
// 处理当前页变化
|
||||
const handleCurrentChange = (page) => {
|
||||
currentPage.value = page
|
||||
loadRecords()
|
||||
}
|
||||
|
||||
// 页面加载时获取数据
|
||||
onMounted(() => {
|
||||
loadRecords()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 表格样式优化 */
|
||||
: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;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
1336
src/pages/products/detail.vue
Normal file
1336
src/pages/products/detail.vue
Normal file
File diff suppressed because it is too large
Load Diff
207
src/pages/products/index.vue
Normal file
207
src/pages/products/index.vue
Normal file
@@ -0,0 +1,207 @@
|
||||
<template>
|
||||
<ListPageLayout title="数据大厅" subtitle="发现并订阅您需要的数据产品">
|
||||
<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_package" placeholder="选择类型" clearable @change="handleFilterChange"
|
||||
class="w-full">
|
||||
<el-option label="单品" value="false" />
|
||||
<el-option label="组合包" value="true" />
|
||||
</el-select>
|
||||
</FilterItem>
|
||||
|
||||
<FilterItem label="订阅状态">
|
||||
<el-select v-model="filters.is_subscribed" 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.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>
|
||||
<div v-if="loading" class="flex justify-center items-center py-12">
|
||||
<el-loading size="large" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="products.length === 0" class="text-center py-12">
|
||||
<el-empty description="暂无产品数据" />
|
||||
</div>
|
||||
|
||||
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6 p-4">
|
||||
<ProductCard v-for="product in products" :key="product.id" :product="product"
|
||||
:is-subscribed="product.is_subscribed" @view-detail="handleViewDetail"
|
||||
@subscribe="handleSubscribe" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #pagination>
|
||||
<el-pagination v-if="total > 0" v-model:current-page="currentPage" v-model:page-size="pageSize"
|
||||
:page-sizes="[12, 24, 48, 96]" :total="total" layout="total, sizes, prev, pager, next, jumper"
|
||||
@size-change="handleSizeChange" @current-change="handleCurrentChange" />
|
||||
</template>
|
||||
</ListPageLayout>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { categoryApi, productApi } from '@/api'
|
||||
import FilterItem from '@/components/common/FilterItem.vue'
|
||||
import FilterSection from '@/components/common/FilterSection.vue'
|
||||
import ListPageLayout from '@/components/common/ListPageLayout.vue'
|
||||
import ProductCard from '@/components/product/ProductCard.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(12)
|
||||
|
||||
// 筛选条件
|
||||
const filters = reactive({
|
||||
category_id: '',
|
||||
is_package: null,
|
||||
is_subscribed: 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 productApi.getProducts(params)
|
||||
products.value = response.data?.items || []
|
||||
total.value = response.data?.total || 0
|
||||
} catch (error) {
|
||||
console.error('加载产品失败:', error)
|
||||
ElMessage.error('加载产品失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 处理筛选变化
|
||||
const handleFilterChange = () => {
|
||||
currentPage.value = 1
|
||||
loadProducts()
|
||||
}
|
||||
|
||||
// 处理搜索
|
||||
const handleSearch = () => {
|
||||
if (searchTimer) {
|
||||
clearTimeout(searchTimer)
|
||||
}
|
||||
searchTimer = setTimeout(() => {
|
||||
currentPage.value = 1
|
||||
loadProducts()
|
||||
}, 500)
|
||||
}
|
||||
|
||||
// 重置筛选
|
||||
const resetFilters = () => {
|
||||
filters.category_id = ''
|
||||
filters.is_package = null
|
||||
filters.is_subscribed = 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 handleViewDetail = (product) => {
|
||||
router.push(`/products/${product.id}`)
|
||||
}
|
||||
|
||||
// 订阅产品
|
||||
const handleSubscribe = async (product) => {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定要订阅产品"${product.name}"吗?`,
|
||||
'确认订阅',
|
||||
{
|
||||
confirmButtonText: '确定订阅',
|
||||
cancelButtonText: '取消',
|
||||
type: 'info'
|
||||
}
|
||||
)
|
||||
|
||||
await productApi.subscribeProduct(product.id)
|
||||
ElMessage.success('订阅成功')
|
||||
|
||||
// 重新加载产品列表以更新订阅状态
|
||||
await loadProducts()
|
||||
} catch (error) {
|
||||
if (error !== 'cancel') {
|
||||
console.error('订阅失败:', error)
|
||||
ElMessage.error('订阅失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 页面特定样式可以在这里添加 */
|
||||
</style>
|
||||
1130
src/pages/profile/Profile.vue
Normal file
1130
src/pages/profile/Profile.vue
Normal file
File diff suppressed because it is too large
Load Diff
10
src/pages/profile/Settings.vue
Normal file
10
src/pages/profile/Settings.vue
Normal file
@@ -0,0 +1,10 @@
|
||||
<template>
|
||||
<div class="settings-page">
|
||||
<h1 class="text-3xl font-bold mb-6">我的订阅</h1>
|
||||
<p class="text-gray-600 mb-8">我的订阅页面 - 待开发</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
// 我的订阅页面 - 待开发
|
||||
</script>
|
||||
552
src/pages/subscriptions/index.vue
Normal file
552
src/pages/subscriptions/index.vue
Normal file
@@ -0,0 +1,552 @@
|
||||
<template>
|
||||
<ListPageLayout
|
||||
title="我的订阅"
|
||||
subtitle="管理您已订阅的数据产品"
|
||||
>
|
||||
<!-- 统计信息 -->
|
||||
<template #actions>
|
||||
<div class="flex gap-4">
|
||||
<div class="stat-item">
|
||||
<div class="stat-value">{{ stats.total_subscriptions || 0 }}</div>
|
||||
<div class="stat-label">总订阅数</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #filters>
|
||||
<FilterSection>
|
||||
<FilterItem label="搜索订阅">
|
||||
<el-input
|
||||
v-model="filters.keyword"
|
||||
placeholder="输入产品名称或编号"
|
||||
clearable
|
||||
@input="handleSearch"
|
||||
class="w-full"
|
||||
/>
|
||||
</FilterItem>
|
||||
|
||||
<FilterItem label="产品名称">
|
||||
<el-input
|
||||
v-model="filters.product_name"
|
||||
placeholder="输入产品名称"
|
||||
clearable
|
||||
@input="handleSearch"
|
||||
class="w-full"
|
||||
/>
|
||||
</FilterItem>
|
||||
|
||||
<FilterItem label="订阅时间">
|
||||
<el-date-picker
|
||||
v-model="filters.timeRange"
|
||||
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>
|
||||
|
||||
<template #stats>
|
||||
共找到 {{ total }} 个订阅
|
||||
</template>
|
||||
|
||||
<template #buttons>
|
||||
<el-button @click="resetFilters">重置筛选</el-button>
|
||||
<el-button type="primary" @click="loadSubscriptions">应用筛选</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="subscriptions.length === 0" class="text-center py-12">
|
||||
<el-empty description="暂无订阅数据">
|
||||
<el-button type="primary" @click="$router.push('/products')">
|
||||
去数据大厅订阅产品
|
||||
</el-button>
|
||||
</el-empty>
|
||||
</div>
|
||||
|
||||
<div v-else class="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
|
||||
<el-table
|
||||
:data="subscriptions"
|
||||
style="width: 100%"
|
||||
:header-cell-style="{
|
||||
background: '#f8fafc',
|
||||
color: '#475569',
|
||||
fontWeight: '600',
|
||||
fontSize: '14px'
|
||||
}"
|
||||
:cell-style="{
|
||||
fontSize: '14px',
|
||||
color: '#1e293b'
|
||||
}"
|
||||
>
|
||||
<el-table-column prop="product.name" label="产品名称" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<div>
|
||||
<div class="font-medium text-gray-900">{{ row.product?.name || '未知产品' }}</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column prop="product.category.name" label="产品分类" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" type="info" effect="light">
|
||||
{{ row.product?.category?.name || '未分类' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column prop="product.code" label="产品编码" width="120">
|
||||
<template #default="{ row }">
|
||||
<span class="font-mono text-sm text-gray-600">{{ row.product?.code || '-' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column prop="price" label="订阅价格" width="120">
|
||||
<template #default="{ row }">
|
||||
<span class="font-semibold text-green-600">¥{{ formatPrice(row.price) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<!-- <el-table-column prop="api_used" label="API调用次数" width="140">
|
||||
<template #default="{ row }">
|
||||
<span class="font-medium">{{ row.api_used || 0 }}</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="320" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<div class="flex items-center space-x-2">
|
||||
<el-button
|
||||
size="small"
|
||||
type="primary"
|
||||
@click="handleViewProduct(row.product)"
|
||||
>
|
||||
查看产品
|
||||
</el-button>
|
||||
<el-button
|
||||
size="small"
|
||||
type="success"
|
||||
@click="handleViewUsage(row)"
|
||||
>
|
||||
使用情况
|
||||
</el-button>
|
||||
<el-button
|
||||
size="small"
|
||||
type="warning"
|
||||
@click="goToApiDebugger(row.product)"
|
||||
>
|
||||
在线调试
|
||||
</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="usageDialogVisible"
|
||||
title="订阅使用情况"
|
||||
width="600px"
|
||||
class="usage-dialog"
|
||||
>
|
||||
<div v-if="selectedSubscription" class="space-y-6">
|
||||
<div class="grid grid-cols-2 gap-6">
|
||||
<!-- <div class="usage-stat-card">
|
||||
<div class="usage-stat-value">{{ selectedSubscription.api_used || 0 }}</div>
|
||||
<div class="usage-stat-label">已使用API调用次数</div>
|
||||
</div> -->
|
||||
<div class="usage-stat-card">
|
||||
<div class="usage-stat-value">¥{{ formatPrice(selectedSubscription.price) }}</div>
|
||||
<div class="usage-stat-label">订阅价格</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="usage-info">
|
||||
<div class="info-item">
|
||||
<span class="info-label">产品名称:</span>
|
||||
<span class="info-value">{{ selectedSubscription.product?.name }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">订阅时间:</span>
|
||||
<span class="info-value">{{ formatDate(selectedSubscription.created_at) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</template>
|
||||
</ListPageLayout>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { subscriptionApi } 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 subscriptions = ref([])
|
||||
const total = ref(0)
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(10)
|
||||
const usageDialogVisible = ref(false)
|
||||
const selectedSubscription = ref(null)
|
||||
|
||||
// 统计数据
|
||||
const stats = ref({
|
||||
total_subscriptions: 0
|
||||
})
|
||||
|
||||
// 筛选条件
|
||||
const filters = reactive({
|
||||
keyword: '',
|
||||
product_name: '',
|
||||
timeRange: []
|
||||
})
|
||||
|
||||
// 搜索防抖
|
||||
let searchTimer = null
|
||||
|
||||
// 初始化
|
||||
onMounted(() => {
|
||||
loadSubscriptions()
|
||||
loadStats()
|
||||
})
|
||||
|
||||
// 加载订阅列表
|
||||
const loadSubscriptions = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const params = {
|
||||
page: currentPage.value,
|
||||
page_size: pageSize.value,
|
||||
keyword: filters.keyword,
|
||||
product_name: filters.product_name
|
||||
}
|
||||
|
||||
// 添加时间范围参数
|
||||
if (filters.timeRange && filters.timeRange.length === 2) {
|
||||
params.start_time = filters.timeRange[0]
|
||||
params.end_time = filters.timeRange[1]
|
||||
}
|
||||
|
||||
const response = await subscriptionApi.getMySubscriptions(params)
|
||||
subscriptions.value = response.data?.items || []
|
||||
total.value = response.data?.total || 0
|
||||
} catch (error) {
|
||||
console.error('加载订阅失败:', error)
|
||||
ElMessage.error('加载订阅失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 加载统计数据
|
||||
const loadStats = async () => {
|
||||
try {
|
||||
const response = await subscriptionApi.getMySubscriptionStats()
|
||||
stats.value = response.data || {
|
||||
total_subscriptions: 0
|
||||
}
|
||||
} 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 handleFilterChange = () => {
|
||||
currentPage.value = 1
|
||||
loadSubscriptions()
|
||||
}
|
||||
|
||||
// 处理搜索
|
||||
const handleSearch = () => {
|
||||
if (searchTimer) {
|
||||
clearTimeout(searchTimer)
|
||||
}
|
||||
searchTimer = setTimeout(() => {
|
||||
currentPage.value = 1
|
||||
loadSubscriptions()
|
||||
}, 500)
|
||||
}
|
||||
|
||||
// 处理时间范围变化
|
||||
const handleTimeRangeChange = () => {
|
||||
currentPage.value = 1
|
||||
loadSubscriptions()
|
||||
}
|
||||
|
||||
// 重置筛选
|
||||
const resetFilters = () => {
|
||||
Object.keys(filters).forEach(key => {
|
||||
if (key === 'timeRange') {
|
||||
filters[key] = []
|
||||
} else {
|
||||
filters[key] = ''
|
||||
}
|
||||
})
|
||||
currentPage.value = 1
|
||||
loadSubscriptions()
|
||||
}
|
||||
|
||||
// 处理分页大小变化
|
||||
const handleSizeChange = (size) => {
|
||||
pageSize.value = size
|
||||
currentPage.value = 1
|
||||
loadSubscriptions()
|
||||
}
|
||||
|
||||
// 处理当前页变化
|
||||
const handleCurrentChange = (page) => {
|
||||
currentPage.value = page
|
||||
loadSubscriptions()
|
||||
}
|
||||
|
||||
// 查看产品
|
||||
const handleViewProduct = (product) => {
|
||||
if (product) {
|
||||
// 跳转到产品详情页面
|
||||
router.push(`/products/${product.id}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 查看使用情况
|
||||
const handleViewUsage = (subscription) => {
|
||||
selectedSubscription.value = subscription
|
||||
usageDialogVisible.value = true
|
||||
}
|
||||
|
||||
// 跳转到在线调试页面
|
||||
const goToApiDebugger = (product) => {
|
||||
if (product) {
|
||||
router.push({
|
||||
name: 'ApiDebugger',
|
||||
params: { productId: product.id }
|
||||
})
|
||||
}
|
||||
}
|
||||
</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;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
/* 使用情况弹窗样式 */
|
||||
.usage-dialog :deep(.el-dialog) {
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.usage-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;
|
||||
}
|
||||
|
||||
.usage-dialog :deep(.el-dialog__title) {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.usage-dialog :deep(.el-dialog__body) {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.usage-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;
|
||||
}
|
||||
|
||||
/* 使用情况统计卡片 */
|
||||
.usage-stat-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;
|
||||
}
|
||||
|
||||
.usage-stat-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);
|
||||
}
|
||||
|
||||
.usage-stat-value {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: #1e293b;
|
||||
line-height: 1;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.usage-stat-label {
|
||||
font-size: 14px;
|
||||
color: #64748b;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 使用情况信息 */
|
||||
.usage-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;
|
||||
}
|
||||
|
||||
/* 表格样式优化 */
|
||||
: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;
|
||||
}
|
||||
|
||||
.usage-stat-card {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.usage-stat-value {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.usage-stat-label {
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user