Files
tyapi-frontend/src/pages/products/detail.vue

1448 lines
37 KiB
Vue
Raw Normal View History

2025-11-24 16:06:44 +08:00
<template>
<div class="list-page-container">
<div class="list-page-card">
<!-- 页面头部 -->
<div class="list-page-header">
<div class="flex justify-between items-start">
<div>
<h1 class="list-page-title">{{ product?.name || '产品详情' }}</h1>
<p class="list-page-subtitle">{{ product?.description || '查看产品详细信息' }}</p>
</div>
<div class="list-page-actions">
<el-button @click="$router.back()">返回</el-button>
2025-12-04 12:44:54 +08:00
<el-button
v-if="product?.documentation"
type="info"
@click="downloadDocumentation"
:loading="downloading"
>
<el-icon><Download /></el-icon>
{{ product?.documentation?.pdf_file_path ? '下载PDF文档' : '下载接口文档' }}
</el-button>
2025-11-24 16:06:44 +08:00
<el-button
v-if="!isSubscribed"
type="primary"
@click="handleSubscribe"
:loading="subscribing"
>
订阅产品
</el-button>
<el-button
v-else
2025-12-04 12:44:54 +08:00
type="danger"
@click="handleCancelSubscription"
:loading="cancelling"
2025-11-24 16:06:44 +08:00
>
2025-12-04 12:44:54 +08:00
取消订阅
2025-11-24 16:06:44 +08:00
</el-button>
<el-button
v-if="isSubscribed"
type="warning"
@click="goToApiDebugger"
>
前往在线调试
</el-button>
</div>
</div>
</div>
<!-- 产品详情内容 -->
<div class="list-page-table">
<div v-if="loading" class="flex justify-center items-center py-12">
<el-loading size="large" />
</div>
<div v-else-if="!product" class="text-center py-12">
<el-empty description="产品不存在" />
</div>
<div v-else class="product-detail-content">
<!-- 基本信息 -->
<div class="detail-section">
<h3 class="section-title">基本信息</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="info-item">
<label class="info-label">产品编号</label>
<span class="info-value">{{ product.code }}</span>
</div>
<div class="info-item">
<label class="info-label">产品名称</label>
<span class="info-value">{{ product.name }}</span>
</div>
<div class="info-item">
<label class="info-label">产品分类</label>
<span class="info-value">{{ product.category?.name || '未分类' }}</span>
</div>
<div class="info-item">
<label class="info-label">产品类型</label>
<span class="info-value">
<el-tag :type="product.is_package ? 'success' : 'info'" size="small">
{{ product.is_package ? '组合包' : '单品' }}
</el-tag>
</span>
</div>
<div class="info-item">
<label class="info-label">价格</label>
<span class="info-value price">¥{{ formatPrice(product.price) }}</span>
</div>
<div class="info-item">
<label class="info-label">状态</label>
<span class="info-value">
<el-tag :type="product.is_enabled ? 'success' : 'danger'" size="small">
{{ product.is_enabled ? '已启用' : '已禁用' }}
</el-tag>
</span>
</div>
</div>
</div>
<!-- 产品描述 -->
<div class="detail-section">
<h3 class="section-title">产品描述</h3>
<div class="description-content">
{{ product.description || '暂无描述' }}
</div>
</div>
<!-- 组合包信息 -->
<div v-if="product.is_package && product.package_items && product.package_items.length > 0" class="detail-section">
<h3 class="section-title">组合包内容</h3>
<div class="package-items-container">
<div class="package-summary">
<el-alert
title="此组合包包含以下产品"
type="info"
:closable="false"
show-icon
>
<template #default>
<p> {{ product.package_items.length }} 个产品总价值 ¥{{ calculatePackageTotalPrice() }}</p>
</template>
</el-alert>
</div>
<div class="package-items-grid">
<div
v-for="item in product.package_items"
:key="item.id"
class="package-item-card"
@click="openProductDetail(item.product_id)"
>
<div class="package-item-header">
<div class="package-item-info">
<h4 class="package-item-name">{{ item.product_name }}</h4>
</div>
<div class="package-item-quantity">
<el-tag type="primary" size="small">{{ item.product_code }}</el-tag>
</div>
</div>
<div class="package-item-price">
<span class="price-label">价值</span>
<span class="price-value">¥{{ formatPrice(item.price) }}</span>
</div>
</div>
</div>
</div>
</div>
<!-- 内容Tab -->
<div class="detail-section">
<el-tabs v-model="activeTab" type="card" class="content-tabs">
<!-- 产品内容Tab -->
<el-tab-pane label="产品内容" name="content">
<div class="tab-content">
<div class="content-richtext" v-html="product.content || '<p>暂无内容</p>'"></div>
</div>
</el-tab-pane>
<!-- 请求方式Tab -->
<el-tab-pane label="请求方式" name="basic_info">
<div class="tab-content">
<!-- 请求URL信息 -->
<div v-if="product.documentation?.request_url" class="request-url-section">
<h4 class="request-url-title">请求地址</h4>
<div class="request-url-content">
<div class="request-method">
<el-tag :type="getMethodTagType(product.documentation?.request_method)" size="small">
{{ product.documentation?.request_method || 'POST' }}
</el-tag>
</div>
<div class="request-url">
<code>{{ product.documentation?.request_url }}</code>
<el-button
type="text"
size="small"
@click="copyRequestUrl"
class="copy-btn"
>
<el-icon><DocumentCopy /></el-icon>
</el-button>
</div>
</div>
<!-- 时间戳备注 -->
<div class="timestamp-note">
<el-alert
title="时间戳说明"
type="info"
:closable="false"
show-icon
size="small"
>
<template #default>
<p>URL中的 <code>t=13位时间戳</code> 参数需要实时生成当前时间当前时间戳<strong>{{ currentTimestamp }}</strong></p>
</template>
</el-alert>
</div>
</div>
<div v-if="product.documentation?.basic_info" class="doc-content" ref="basicInfoRef" v-html="renderMarkdown(product.documentation.basic_info)"></div>
<div v-else class="doc-content" ref="basicInfoRef" v-html="renderMarkdown(getDefaultBasicInfo())"></div>
</div>
</el-tab-pane>
<!-- 请求参数Tab -->
<el-tab-pane label="请求参数" name="request_params">
<div class="tab-content">
<div v-if="product.documentation?.request_params" class="doc-content" ref="requestParamsRef" v-html="renderMarkdown(product.documentation.request_params)"></div>
<div v-else class="no-content">
<el-empty description="暂无请求参数说明" />
</div>
</div>
</el-tab-pane>
<!-- 返回字段说明Tab -->
<el-tab-pane label="返回字段说明" name="response_fields">
<div class="tab-content">
<div v-if="product.documentation?.response_fields">
<div class="tab-content-actions">
<el-button
size="small"
@click="downloadMarkdown('response_fields')"
class="download-btn"
>
下载文档
</el-button>
</div>
<div class="doc-content" ref="responseFieldsRef" v-html="renderMarkdown(product.documentation.response_fields)"></div>
</div>
<div v-else class="no-content">
<el-empty description="暂无返回字段说明" />
</div>
</div>
</el-tab-pane>
<!-- 响应示例Tab -->
<el-tab-pane label="响应示例" name="response_example">
<div class="tab-content">
<div v-if="product.documentation?.response_example">
<div class="tab-content-actions">
<el-button
size="small"
@click="downloadMarkdown('response_example')"
class="download-btn"
>
下载示例
</el-button>
</div>
<div class="doc-content" ref="responseExampleRef" v-html="renderMarkdown(product.documentation.response_example)"></div>
</div>
<div v-else class="no-content">
<el-empty description="暂无响应示例" />
</div>
</div>
</el-tab-pane>
<!-- 错误代码Tab -->
<el-tab-pane label="错误代码" name="error_codes">
<div class="tab-content">
<div v-if="product.documentation?.error_codes" class="doc-content" ref="errorCodesRef" v-html="renderMarkdown(product.documentation.error_codes)"></div>
<div v-else class="doc-content" ref="errorCodesRef" v-html="renderMarkdown(getDefaultErrorCodes())"></div>
</div>
</el-tab-pane>
</el-tabs>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { productApi, subscriptionApi } from '@/api'
2025-12-04 12:44:54 +08:00
import { DocumentCopy, Download } from '@element-plus/icons-vue'
2025-11-24 16:06:44 +08:00
import { ElMessage, ElMessageBox } from 'element-plus'
import { marked } from 'marked'
const route = useRoute()
const router = useRouter()
// 响应式数据
const loading = ref(false)
const product = ref(null)
const userSubscriptions = ref([])
const subscribing = ref(false)
2025-12-04 12:44:54 +08:00
const cancelling = ref(false)
const downloading = ref(false)
2025-11-24 16:06:44 +08:00
const activeTab = ref('content')
const currentTimestamp = ref('')
// DOM 引用
const basicInfoRef = ref(null)
const requestParamsRef = ref(null)
const responseFieldsRef = ref(null)
const responseExampleRef = ref(null)
const errorCodesRef = ref(null)
// 计算属性
const isSubscribed = computed(() => {
if (!product.value || !userSubscriptions.value.length) return false
return userSubscriptions.value.some(sub => sub.product_id === product.value.id)
})
2025-12-04 12:44:54 +08:00
// 获取当前产品的订阅信息
const currentSubscription = computed(() => {
if (!product.value || !userSubscriptions.value.length) return null
return userSubscriptions.value.find(sub => sub.product_id === product.value.id)
})
2025-11-24 16:06:44 +08:00
// 初始化
onMounted(() => {
loadUserSubscriptions()
loadProductDetail().then(() => {
addCollapsibleFeatures()
})
startTimestampUpdate()
})
// 组件卸载时清理定时器
onUnmounted(() => {
if (timestampTimer.value) {
clearInterval(timestampTimer.value)
}
})
// 时间戳更新定时器
const timestampTimer = ref(null)
// 开始时间戳更新
const startTimestampUpdate = () => {
// 立即更新一次
currentTimestamp.value = getCurrentTimestamp()
// 每秒更新一次
timestampTimer.value = setInterval(() => {
currentTimestamp.value = getCurrentTimestamp()
}, 1000)
}
// 加载用户订阅
const loadUserSubscriptions = async () => {
try {
2025-12-04 12:44:54 +08:00
const response = await subscriptionApi.getMySubscriptions({ page: 1, page_size: 1000 })
2025-11-24 16:06:44 +08:00
userSubscriptions.value = response.data?.items || []
} catch (error) {
console.error('加载用户订阅失败:', error)
}
}
// 加载产品详情
const loadProductDetail = async () => {
const productId = route.params.id
if (!productId) {
ElMessage.error('产品ID不存在')
router.push('/products')
return
}
loading.value = true
try {
// 添加 with_document 参数获取文档信息
const response = await productApi.getProductDetail(productId, { with_document: true })
product.value = response.data
} catch (error) {
console.error('加载产品详情失败:', error)
ElMessage.error('加载产品详情失败')
router.push('/products')
} finally {
loading.value = false
}
}
// 格式化价格
const formatPrice = (price) => {
if (!price) return '0.00'
return Number(price).toFixed(2)
}
// 计算组合包总价
const calculatePackageTotalPrice = () => {
if (!product.value?.package_items || !product.value.package_items.length) {
return '0.00'
}
const total = product.value.package_items.reduce((sum, item) => {
return sum + item.price
}, 0)
return formatPrice(total)
}
// Markdown渲染方法
const renderMarkdown = (content) => {
if (!content) return '<p>暂无内容</p>'
try {
return marked(content)
} catch (error) {
console.error('Markdown渲染失败:', error)
return content
}
}
// 添加折叠功能到文档内容
const addCollapsibleFeatures = () => {
nextTick(() => {
const refs = [basicInfoRef, requestParamsRef, responseFieldsRef, responseExampleRef, errorCodesRef]
refs.forEach(ref => {
if (!ref.value) return
const container = ref.value
// 为子产品标题h2添加折叠功能
const h2Elements = container.querySelectorAll('h2')
h2Elements.forEach((h2) => {
// 检查是否已经有折叠按钮
if (h2.querySelector('.collapse-toggle')) return
// 检查下一个兄弟元素是否是已经处理过的折叠内容
if (h2.nextElementSibling?.classList.contains('collapsible-content')) return
// 查找下一个 h2、h1 或分隔线hr之间的内容
let nextSibling = h2.nextElementSibling
let contentEnd = null
// 找到下一个 h2、h1 或分隔线hr
while (nextSibling) {
if (nextSibling.tagName === 'H2' || nextSibling.tagName === 'H1' || nextSibling.tagName === 'HR') {
contentEnd = nextSibling
break
}
nextSibling = nextSibling.nextElementSibling
}
// 如果找到内容,创建折叠容器
if (h2.nextElementSibling && h2.nextElementSibling !== contentEnd) {
// 创建折叠按钮(放在 h2 内部,作为可点击的标题)
const toggleBtn = document.createElement('button')
toggleBtn.className = 'collapse-toggle'
toggleBtn.innerHTML = '<span class="collapse-icon">▼</span>'
toggleBtn.setAttribute('aria-expanded', 'true')
toggleBtn.setAttribute('title', '点击展开/折叠')
// 将按钮插入到 h2 内部(在文本前面)
const h2Text = h2.textContent
h2.innerHTML = ''
h2.appendChild(toggleBtn)
h2.appendChild(document.createTextNode(' ' + h2Text))
h2.style.cursor = 'pointer'
h2.style.userSelect = 'none'
// 创建内容容器
const contentWrapper = document.createElement('div')
contentWrapper.className = 'collapsible-content'
// 移动内容到容器中
let sibling = h2.nextElementSibling
while (sibling && sibling !== contentEnd) {
const nextSibling = sibling.nextElementSibling
contentWrapper.appendChild(sibling)
sibling = nextSibling
}
// 插入内容容器到 h2 后面
h2.parentNode.insertBefore(contentWrapper, h2.nextSibling)
// 添加点击事件h2 和按钮都可以点击)
const toggleCollapse = () => {
const isExpanded = toggleBtn.getAttribute('aria-expanded') === 'true'
contentWrapper.style.display = isExpanded ? 'none' : 'block'
toggleBtn.setAttribute('aria-expanded', !isExpanded)
toggleBtn.querySelector('.collapse-icon').textContent = isExpanded ? '▶' : '▼'
}
toggleBtn.addEventListener('click', (e) => {
e.stopPropagation()
toggleCollapse()
})
h2.addEventListener('click', (e) => {
if (e.target !== toggleBtn && !toggleBtn.contains(e.target)) {
toggleCollapse()
}
})
}
})
// 为 JSON 代码块pre添加折叠功能
const preElements = container.querySelectorAll('pre')
preElements.forEach(pre => {
// 检查是否已经有折叠按钮
if (pre.previousElementSibling?.classList.contains('code-collapse-toggle')) return
// 创建折叠按钮
const toggleBtn = document.createElement('button')
toggleBtn.className = 'code-collapse-toggle'
toggleBtn.innerHTML = '<span class="collapse-icon">▼</span> <span class="collapse-text">展开/折叠代码</span>'
toggleBtn.setAttribute('aria-expanded', 'true')
// 插入按钮
pre.parentNode.insertBefore(toggleBtn, pre)
// 添加点击事件
toggleBtn.addEventListener('click', () => {
const isExpanded = toggleBtn.getAttribute('aria-expanded') === 'true'
pre.style.display = isExpanded ? 'none' : 'block'
toggleBtn.setAttribute('aria-expanded', !isExpanded)
toggleBtn.querySelector('.collapse-icon').textContent = isExpanded ? '▶' : '▼'
})
})
})
})
}
// 监听 activeTab 变化,重新添加折叠功能
watch(activeTab, () => {
addCollapsibleFeatures()
})
// 监听产品数据变化,重新添加折叠功能
watch(() => product.value?.documentation, () => {
addCollapsibleFeatures()
}, { deep: true })
// 订阅产品
const handleSubscribe = async () => {
if (!product.value) return
try {
await ElMessageBox.confirm(
`确定要订阅产品"${product.value.name}"吗?`,
'确认订阅',
{
confirmButtonText: '确定订阅',
cancelButtonText: '取消',
type: 'info'
}
)
subscribing.value = true
await productApi.subscribeProduct(product.value.id)
ElMessage.success('订阅成功')
// 重新加载用户订阅
await loadUserSubscriptions()
} catch (error) {
if (error !== 'cancel') {
console.error('订阅失败:', error)
2025-12-04 12:44:54 +08:00
const errorMessage = error.response?.data?.message || error.message || '订阅失败'
ElMessage.error(errorMessage)
2025-11-24 16:06:44 +08:00
}
} finally {
subscribing.value = false
}
}
2025-12-04 12:44:54 +08:00
// 取消订阅
const handleCancelSubscription = async () => {
if (!product.value || !currentSubscription.value) return
try {
await ElMessageBox.confirm(
`确定要取消订阅产品"${product.value.name}"吗取消后将无法继续使用该产品的API服务。`,
'取消订阅确认',
{
confirmButtonText: '确定取消',
cancelButtonText: '我再想想',
type: 'warning'
}
)
cancelling.value = true
await subscriptionApi.cancelMySubscription(currentSubscription.value.id)
ElMessage.success('取消订阅成功')
// 重新加载用户订阅
await loadUserSubscriptions()
} catch (error) {
if (error !== 'cancel') {
console.error('取消订阅失败:', error)
const errorMessage = error.response?.data?.message || error.message || '取消订阅失败'
ElMessage.error(errorMessage)
}
} finally {
cancelling.value = false
}
}
2025-11-24 16:06:44 +08:00
// 前往在线调试
const goToApiDebugger = () => {
if (!product.value) return
router.push({
name: 'ApiDebugger',
2025-12-04 12:44:54 +08:00
query: { productId: product.value.id }
2025-11-24 16:06:44 +08:00
})
}
// 获取请求方法标签类型
const getMethodTagType = (method) => {
switch (method) {
case 'GET':
return 'info'
case 'POST':
return 'primary'
case 'PUT':
return 'warning'
case 'DELETE':
return 'danger'
default:
return 'info'
}
}
// 复制请求URL
const copyRequestUrl = () => {
if (!product.value?.documentation?.request_url) {
ElMessage.warning('请求地址不存在')
return
}
navigator.clipboard.writeText(product.value.documentation.request_url).then(() => {
ElMessage.success('请求地址已复制到剪贴板')
}).catch(err => {
console.error('复制失败:', err)
ElMessage.error('复制失败')
})
}
// 获取当前时间戳
const getCurrentTimestamp = () => {
return Date.now().toString()
}
// 打开产品详情页(新标签页)
const openProductDetail = (productId) => {
if (!productId) {
ElMessage.warning('产品ID不存在')
return
}
const url = `/products/${productId}`
window.open(url, '_blank')
}
// 获取默认的请求方式内容
const getDefaultBasicInfo = () => {
return `## 请求头
| 字段名 | 类型 | 必填 | 描述 |
|--------|------|------|------|
| Access-Id | string | | 账号的 Access-Id |
对于业务请求参数
通过加密后得到 Base64 字符串将其放入到请求体中字段名为 \`data\`,以此方式进行传参。
\`\`\`json
{
"data": "xxxx(base64)"
}
\`\`\`
对接响应得到的公共参数
\`\`\`json
{
"code": "int",
"message": "string",
"transaction_id": "string", // 流水号
"data": "string"
}
\`\`\`
**data** 字段为加密的数据需要解密后查看
## 加密和解密机制
账户获得的密钥**Access Key**是一个 16 进制字符串使用 AES-128 加密算法
### 加密过程
- 加密模式**AES-CBC 模式**
- 密钥长度**128 16 字节**
- 填充方式**PKCS7 填充**
- **IV初始化向量**IV 长度为 16 字节128 每次加密时随机生成
- 加密后 **IV** 和密文拼接在一起进行传输
- 最后将拼接了 IV 的密文通过 **Base64 编码**方便在网络或文件中传输
### 解密过程
- 解密时首先从 Base64 解码后的数据中提取前 16 字节作为 **IV**
- 然后使用提取的 **IV**通过 AES-CBC 模式解密剩余部分的密文
- 解密后去除 **PKCS7 填充**即可得到原始明文`
}
// 获取默认的错误代码内容
const getDefaultErrorCodes = () => {
return `## 错误代码
| code | message |
|------|---------|
| 0 | 业务成功 |
| 1000 | 查询为空 |
| 1001 | 接口异常 |
| 1002 | 参数解密失败 |
| 1003 | 基础参数校验不正确 |
| 1004 | 未经授权的IP |
| 1005 | 缺少Access-Id |
| 1006 | 未经授权的AccessId |
| 1007 | 账户余额不足无法请求 |
| 1008 | 未开通此产品 |
| 2001 | 业务失败 |`
}
2025-12-04 12:44:54 +08:00
// 下载接口文档
const downloadDocumentation = async () => {
if (!product.value) {
ElMessage.warning('产品信息不存在')
return
}
downloading.value = true
try {
// 根据是否有PDF文件路径判断文件类型
const hasPDF = product.value.documentation?.pdf_file_path
// 使用原生fetch以获取完整的响应信息包括headers
const token = localStorage.getItem('access_token')
const tokenType = localStorage.getItem('token_type') || 'Bearer'
const headers = {
'Authorization': token ? `${tokenType} ${token}` : ''
}
const response = await fetch(`/api/v1/products/${product.value.id}/documentation/download`, {
headers
})
if (!response.ok) {
throw new Error('下载失败')
}
// 获取Content-Type
const contentType = response.headers.get('content-type') || ''
const isPDF = contentType.includes('application/pdf') || hasPDF
// 获取文件内容
const blob = await response.blob()
// 创建下载链接
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
const extension = isPDF ? 'pdf' : 'md'
const filename = `${product.value.name || '产品'}_接口文档.${extension}`
link.download = filename
// 触发下载
document.body.appendChild(link)
link.click()
// 清理
document.body.removeChild(link)
URL.revokeObjectURL(url)
ElMessage.success('文档下载成功')
} catch (error) {
console.error('下载接口文档失败:', error)
ElMessage.error('下载接口文档失败')
} finally {
downloading.value = false
}
}
2025-11-24 16:06:44 +08:00
// 下载 Markdown 文档
const downloadMarkdown = (type) => {
if (!product.value?.documentation) {
ElMessage.warning('文档内容不存在')
return
}
let content = ''
let filename = ''
if (type === 'response_fields') {
content = product.value.documentation.response_fields || ''
filename = `${product.value.name || '产品'}_返回字段说明.md`
} else if (type === 'response_example') {
content = product.value.documentation.response_example || ''
filename = `${product.value.name || '产品'}_响应示例.md`
}
if (!content) {
ElMessage.warning('该部分内容为空,无法下载')
return
}
// 创建 Blob 对象
const blob = new Blob([content], { type: 'text/markdown;charset=utf-8' })
// 创建下载链接
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename
// 触发下载
document.body.appendChild(link)
link.click()
// 清理
document.body.removeChild(link)
URL.revokeObjectURL(url)
ElMessage.success('文档下载成功')
}
</script>
<style scoped>
.product-detail-content {
padding: 0;
}
.detail-section {
margin-bottom: 32px;
}
.detail-section:last-child {
margin-bottom: 0;
}
.section-title {
font-size: 18px;
font-weight: 600;
color: #1e293b;
margin-bottom: 16px;
padding-bottom: 8px;
border-bottom: 1px solid rgba(226, 232, 240, 0.6);
}
.info-item {
display: flex;
flex-direction: column;
gap: 4px;
}
.info-label {
font-size: 14px;
font-weight: 500;
color: #64748b;
}
.info-value {
font-size: 16px;
color: #1e293b;
font-weight: 500;
}
.info-value.price {
color: #dc2626;
font-weight: 600;
font-size: 18px;
}
.description-content {
background: rgba(248, 250, 252, 0.5);
border: 1px solid rgba(226, 232, 240, 0.4);
border-radius: 8px;
padding: 16px;
line-height: 1.6;
color: #475569;
}
.documentation-content {
background: rgba(255, 255, 255, 0.8);
border: 1px solid rgba(226, 232, 240, 0.6);
border-radius: 8px;
padding: 16px;
}
.subscription-info {
margin-top: 16px;
}
/* 组合包样式 */
.package-items-container {
margin-top: 16px;
}
.package-summary {
margin-bottom: 20px;
}
.package-items-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 16px;
}
.package-item-card {
background: rgba(255, 255, 255, 0.8);
border: 1px solid rgba(226, 232, 240, 0.6);
border-radius: 8px;
padding: 16px;
transition: all 0.2s ease;
cursor: pointer;
}
.package-item-card:hover {
border-color: rgba(59, 130, 246, 0.5);
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.15);
transform: translateY(-2px);
background: rgba(255, 255, 255, 0.95);
}
.package-item-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 12px;
}
.package-item-info {
flex: 1;
}
.package-item-name {
font-size: 16px;
font-weight: 600;
color: #1e293b;
margin: 0 0 4px 0;
line-height: 1.4;
}
.package-item-code {
font-size: 12px;
color: #64748b;
margin: 0;
font-family: 'Courier New', monospace;
}
.package-item-quantity {
flex-shrink: 0;
margin-left: 12px;
}
.package-item-price,
.package-item-total {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.package-item-price:last-child,
.package-item-total:last-child {
margin-bottom: 0;
}
.price-label,
.total-label {
font-size: 14px;
color: #64748b;
}
.price-value {
font-size: 14px;
color: #1e293b;
font-weight: 500;
}
.total-value {
font-size: 16px;
color: #dc2626;
font-weight: 600;
}
/* Tab样式 */
.content-tabs {
margin-top: 16px;
}
.content-tabs :deep(.el-tabs__header) {
margin-bottom: 0;
background: linear-gradient(135deg, #464daa 0%, #a1a6f6 100%);
border-radius: 12px 12px 0 0;
padding: 0 20px;
}
.content-tabs :deep(.el-tabs__nav-wrap) {
padding: 0;
}
.content-tabs :deep(.el-tabs__nav) {
border: none;
}
.content-tabs :deep(.el-tabs__item) {
color: rgba(255, 255, 255, 0.9);
font-weight: 500;
padding: 16px 24px;
border: none;
background: transparent;
position: relative;
}
.content-tabs :deep(.el-tabs__item:hover) {
color: #ffffff;
background: rgba(255, 255, 255, 0.15);
}
.content-tabs :deep(.el-tabs__item.is-active) {
color: #ffffff;
background: rgba(255, 255, 255, 0.25);
border-bottom: 2px solid #ffffff;
}
.content-tabs :deep(.el-tabs__active-bar) {
display: none;
}
.content-tabs :deep(.el-tabs__content) {
background: #ffffff;
border-radius: 0 0 12px 12px;
border: 1px solid #e5e7eb;
border-top: none;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
}
.tab-content {
padding: 24px;
min-height: 400px;
}
.doc-content {
padding: 20px;
background: #fafafa;
border: 1px solid #e5e7eb;
border-radius: 8px;
line-height: 1.7;
color: #374151;
font-size: 14px;
}
.doc-content :deep(h1) {
font-size: 24px;
font-weight: 700;
color: #111827;
margin: 24px 0 16px 0;
padding-bottom: 8px;
border-bottom: 2px solid #e5e7eb;
}
.doc-content :deep(h2) {
font-size: 20px;
font-weight: 600;
color: #111827;
margin: 20px 0 12px 0;
padding-bottom: 6px;
border-bottom: 1px solid #e5e7eb;
}
.doc-content :deep(h3) {
font-size: 18px;
font-weight: 600;
color: #374151;
margin: 16px 0 8px 0;
}
.doc-content :deep(h4) {
font-size: 16px;
font-weight: 600;
color: #374151;
margin: 12px 0 6px 0;
}
.doc-content :deep(p) {
margin: 12px 0;
line-height: 1.6;
}
.doc-content :deep(table) {
width: 100%;
border-collapse: collapse;
margin: 20px 0;
font-size: 14px;
background: #ffffff;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.doc-content :deep(th) {
background: linear-gradient(135deg, #7179e6 0%, #8b91f0 100%);
color: #ffffff;
font-weight: 600;
padding: 12px 16px;
text-align: left;
border: none;
}
.doc-content :deep(td) {
padding: 12px 16px;
border-bottom: 1px solid #e5e7eb;
border-right: 1px solid #e5e7eb;
}
.doc-content :deep(td:last-child) {
border-right: none;
}
.doc-content :deep(tr:last-child td) {
border-bottom: none;
}
.doc-content :deep(tr:nth-child(even)) {
background-color: #f9fafb;
}
.doc-content :deep(code) {
background-color: #f3f4f6;
color: #dc2626;
padding: 2px 6px;
border-radius: 4px;
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
font-size: 13px;
}
.doc-content :deep(pre) {
background: #1f2937;
color: #f9fafb;
border-radius: 8px;
padding: 20px;
overflow-x: auto;
margin: 20px 0;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
}
.doc-content :deep(pre code) {
background-color: transparent;
color: #f9fafb;
padding: 0;
font-size: 13px;
line-height: 1.5;
}
.doc-content :deep(ul),
.doc-content :deep(ol) {
margin: 12px 0;
padding-left: 24px;
}
.doc-content :deep(li) {
margin: 6px 0;
line-height: 1.6;
}
.doc-content :deep(blockquote) {
border-left: 4px solid #667eea;
margin: 16px 0;
padding: 12px 20px;
background: #f8fafc;
border-radius: 0 8px 8px 0;
color: #475569;
}
/* 折叠功能样式 */
.doc-content :deep(.collapse-toggle) {
background: none;
border: none;
padding: 0;
margin-right: 8px;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
color: #667eea;
font-size: 14px;
transition: transform 0.2s ease;
vertical-align: middle;
}
.doc-content :deep(.collapse-toggle:hover) {
color: #4f46e5;
}
.doc-content :deep(.collapse-toggle .collapse-icon) {
display: inline-block;
transition: transform 0.2s ease;
font-size: 12px;
width: 16px;
text-align: center;
}
.doc-content :deep(h2 .collapse-toggle) {
margin-right: 10px;
}
.doc-content :deep(.collapsible-content) {
margin-left: 24px;
margin-top: 8px;
margin-bottom: 16px;
padding-left: 16px;
border-left: 2px solid #e5e7eb;
transition: all 0.3s ease;
}
.doc-content :deep(.code-collapse-toggle) {
display: flex;
align-items: center;
gap: 8px;
background: #f3f4f6;
border: 1px solid #e5e7eb;
border-radius: 6px;
padding: 8px 12px;
margin: 12px 0 8px 0;
cursor: pointer;
font-size: 13px;
color: #374151;
transition: all 0.2s ease;
user-select: none;
}
.doc-content :deep(.code-collapse-toggle:hover) {
background: #e5e7eb;
border-color: #d1d5db;
}
.doc-content :deep(.code-collapse-toggle .collapse-icon) {
font-size: 12px;
color: #667eea;
transition: transform 0.2s ease;
}
.doc-content :deep(.code-collapse-toggle .collapse-text) {
font-weight: 500;
}
/* Tab 标签下载按钮样式 */
.tab-content-actions {
display: flex;
justify-content: flex-start;
margin-bottom: 12px;
}
.download-btn {
padding: 6px 12px;
min-height: auto;
color: #409eff;
}
.download-btn:hover {
color: #66b1ff;
}
.no-content {
padding: 60px 0;
text-align: center;
background: #f9fafb;
border-radius: 8px;
border: 2px dashed #d1d5db;
}
/* 请求URL样式 */
.request-url-section {
margin-bottom: 20px;
padding: 16px;
background: #f8fafc;
border: 1px solid #e5e7eb;
border-radius: 8px;
}
.request-url-title {
font-size: 16px;
font-weight: 600;
color: #1e293b;
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 1px solid #e5e7eb;
}
.request-url-content {
display: flex;
align-items: center;
gap: 12px;
}
.request-method {
flex-shrink: 0;
}
.request-url {
flex: 1;
display: flex;
align-items: center;
gap: 8px;
background: #ffffff;
border: 1px solid #e5e7eb;
border-radius: 6px;
padding: 4px 8px;
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
font-size: 13px;
color: #374151;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.copy-btn {
flex-shrink: 0;
padding: 4px 8px;
background-color: #e0e7ff;
border-radius: 6px;
color: #4f46e5;
font-size: 13px;
cursor: pointer;
transition: background-color 0.2s ease;
}
.copy-btn:hover {
background-color: #d1d5db;
}
.copy-btn :deep(.el-icon) {
font-size: 16px;
}
/* 时间戳备注样式 */
.timestamp-note {
margin-top: 16px;
padding-top: 16px;
border-top: 1px dashed #e5e7eb;
}
/* 响应式设计 */
@media (max-width: 768px) {
.detail-section {
margin-bottom: 24px;
}
.section-title {
font-size: 16px;
margin-bottom: 12px;
}
.info-value {
font-size: 14px;
}
.info-value.price {
font-size: 16px;
}
.package-items-grid {
grid-template-columns: 1fr;
gap: 12px;
}
.package-item-card {
padding: 12px;
}
.package-item-name {
font-size: 14px;
}
.package-item-price,
.package-item-total {
margin-bottom: 6px;
}
.price-value,
.total-value {
font-size: 14px;
}
.content-tabs :deep(.el-tabs__header) {
padding: 0 16px;
}
.content-tabs :deep(.el-tabs__item) {
padding: 12px 16px;
font-size: 14px;
}
.tab-content {
padding: 16px;
min-height: 300px;
}
.doc-content {
padding: 16px;
font-size: 13px;
}
.doc-content :deep(h1) {
font-size: 20px;
}
.doc-content :deep(h2) {
font-size: 18px;
}
.doc-content :deep(h3) {
font-size: 16px;
}
.doc-content :deep(table) {
font-size: 13px;
}
.doc-content :deep(th),
.doc-content :deep(td) {
padding: 8px 12px;
}
/* 请求URL移动端样式 */
.request-url-section {
padding: 12px;
margin-bottom: 16px;
}
.request-url-title {
font-size: 14px;
margin-bottom: 8px;
}
.request-url-content {
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
.request-url {
width: 100%;
font-size: 12px;
padding: 6px 8px;
}
.copy-btn {
padding: 6px 10px;
font-size: 12px;
}
.copy-btn :deep(.el-icon) {
font-size: 14px;
}
}
</style>