Files
tyapi-frontend/src/pages/subscriptions/index.vue
2025-12-10 14:17:31 +08:00

822 lines
20 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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">
<div :class="['table-container', { 'mobile-table-container': isMobile }]">
<el-table
:data="subscriptions"
:style="isMobile ? { width: '100%', minWidth: '600px' } : { width: '100%' }"
:header-cell-style="{
background: '#f8fafc',
color: '#475569',
fontWeight: '600',
fontSize: isMobile ? '12px' : '14px'
}"
:cell-style="{
fontSize: isMobile ? '12px' : '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
v-if="!isMobile"
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
v-if="!isMobile"
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="isMobile ? 100 : 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="isMobile ? 120 : 160"
>
<template #default="{ row }">
<div class="text-sm">
<div class="text-gray-900">{{ formatDate(row.created_at) }}</div>
<div v-if="!isMobile" class="text-gray-500">{{ formatTime(row.created_at) }}</div>
</div>
</template>
</el-table-column>
<el-table-column
label="操作"
:width="isMobile ? 120 : 400"
:fixed="isMobile ? false : 'right'"
class-name="action-column"
>
<template #default="{ row }">
<!-- 桌面端显示所有按钮 -->
<div v-if="!isMobile" class="flex items-center space-x-2 action-buttons-desktop">
<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>
<el-button
size="small"
type="danger"
@click="handleCancelSubscription(row)"
>
取消订阅
</el-button>
</div>
<!-- 移动端主要操作 + 下拉菜单 -->
<div v-else class="action-buttons-mobile">
<el-button
size="small"
type="primary"
@click="handleViewProduct(row.product)"
class="mobile-primary-btn"
>
查看
</el-button>
<el-dropdown @command="(cmd) => handleMobileAction(cmd, row)" trigger="click" placement="bottom-end">
<el-button size="small" type="info" class="mobile-more-btn">
更多
<el-icon class="el-icon--right">
<ArrowDown />
</el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="usage">
使用情况
</el-dropdown-item>
<el-dropdown-item command="debug">
在线调试
</el-dropdown-item>
<el-dropdown-item command="cancel" divided>
取消订阅
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</template>
</el-table-column>
</el-table>
</div>
</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 v-if="loadingUsage" class="flex justify-center items-center py-8">
<el-loading />
</div>
<div v-else class="grid grid-cols-2 gap-6">
<div class="usage-stat-card">
<div class="usage-stat-value">{{ usageData?.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 { useMobileTable } from '@/composables/useMobileTable'
import { ArrowDown } from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus'
const router = useRouter()
const { isMobile } = useMobileTable()
// 响应式数据
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 usageData = ref(null)
const loadingUsage = ref(false)
// 统计数据
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 = async (subscription) => {
selectedSubscription.value = subscription
usageDialogVisible.value = true
usageData.value = null
// 加载使用情况数据
if (subscription && subscription.id) {
loadingUsage.value = true
try {
const response = await subscriptionApi.getMySubscriptionUsage(subscription.id)
usageData.value = response.data
} catch (error) {
console.error('加载使用情况失败:', error)
ElMessage.error('加载使用情况失败')
} finally {
loadingUsage.value = false
}
}
}
// 跳转到在线调试页面
const goToApiDebugger = (product) => {
if (product) {
router.push({
name: 'ApiDebugger',
params: { productId: product.id }
})
}
}
// 处理移动端操作
const handleMobileAction = (command, row) => {
switch (command) {
case 'usage':
handleViewUsage(row)
break
case 'debug':
goToApiDebugger(row.product)
break
case 'cancel':
handleCancelSubscription(row)
break
}
}
// 取消订阅
const handleCancelSubscription = async (subscription) => {
if (!subscription || !subscription.id) {
ElMessage.error('订阅信息不完整')
return
}
try {
// 显示确认对话框
await ElMessageBox.confirm(
`确定要取消订阅 "${subscription.product?.name || '该产品'}" 吗取消后将无法继续使用该产品的API服务。`,
'取消订阅确认',
{
confirmButtonText: '确定取消',
cancelButtonText: '我再想想',
type: 'warning',
dangerouslyUseHTMLString: false
}
)
// 用户确认后执行取消操作
loading.value = true
try {
await subscriptionApi.cancelMySubscription(subscription.id)
ElMessage.success('取消订阅成功')
// 重新加载订阅列表
await loadSubscriptions()
// 重新加载统计数据
await loadStats()
} catch (error) {
console.error('取消订阅失败:', error)
const errorMessage = error.response?.data?.message || error.message || '取消订阅失败'
ElMessage.error(errorMessage)
} finally {
loading.value = false
}
} catch (error) {
// 用户取消操作,不做任何处理
if (error !== 'cancel') {
console.error('取消订阅操作异常:', error)
}
}
}
</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;
}
/* 表格容器 */
.table-container {
width: 100%;
overflow-x: auto;
}
.mobile-table-container {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.mobile-table-container::-webkit-scrollbar {
height: 6px;
}
.mobile-table-container::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 3px;
}
.mobile-table-container::-webkit-scrollbar-track {
background: #f1f5f9;
}
/* 表格样式优化 */
: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;
}
/* 操作列样式 */
.action-buttons-desktop {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.action-buttons-mobile {
display: flex;
align-items: center;
gap: 4px;
flex-direction: row;
flex-wrap: nowrap;
justify-content: flex-start;
}
.mobile-primary-btn,
.mobile-more-btn {
flex: 1;
min-width: 50px;
padding: 6px 8px;
font-size: 12px;
white-space: nowrap;
}
/* 移动端表格优化 */
@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;
}
/* 移动端表格样式 */
.table-container {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
:deep(.el-table) {
font-size: 12px;
min-width: 600px;
}
:deep(.el-table th),
:deep(.el-table td) {
padding: 8px 4px;
}
:deep(.el-table .cell) {
padding: 0 4px;
word-break: break-word;
line-height: 1.4;
}
/* 移动端产品名称列 */
:deep(.el-table-column[prop='product.name']) {
min-width: 150px;
}
/* 移动端操作列 */
.action-buttons-mobile {
gap: 4px;
flex-direction: row;
width: 100%;
}
.mobile-primary-btn,
.mobile-more-btn {
padding: 4px 6px;
font-size: 11px;
min-width: 45px;
flex: 1;
}
/* 移动端隐藏固定列阴影 */
:deep(.el-table__fixed-right) {
box-shadow: none !important;
}
}
@media (max-width: 480px) {
.action-buttons-mobile {
gap: 3px;
flex-direction: row;
}
.mobile-primary-btn,
.mobile-more-btn {
padding: 3px 4px;
font-size: 10px;
min-width: 40px;
flex: 1;
}
:deep(.el-table th),
:deep(.el-table td) {
padding: 6px 2px;
}
:deep(.el-table .cell) {
padding: 0 2px;
}
}
</style>