Files
tyapi-frontend/src/pages/products/detail.vue
2025-12-04 12:44:54 +08:00

1448 lines
37 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>
<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>
<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>
<el-button
v-if="!isSubscribed"
type="primary"
@click="handleSubscribe"
:loading="subscribing"
>
订阅产品
</el-button>
<el-button
v-else
type="danger"
@click="handleCancelSubscription"
:loading="cancelling"
>
取消订阅
</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'
import { DocumentCopy, Download } from '@element-plus/icons-vue'
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)
const cancelling = ref(false)
const downloading = ref(false)
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)
})
// 获取当前产品的订阅信息
const currentSubscription = computed(() => {
if (!product.value || !userSubscriptions.value.length) return null
return userSubscriptions.value.find(sub => sub.product_id === product.value.id)
})
// 初始化
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 {
const response = await subscriptionApi.getMySubscriptions({ page: 1, page_size: 1000 })
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)
const errorMessage = error.response?.data?.message || error.message || '订阅失败'
ElMessage.error(errorMessage)
}
} finally {
subscribing.value = false
}
}
// 取消订阅
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
}
}
// 前往在线调试
const goToApiDebugger = () => {
if (!product.value) return
router.push({
name: 'ApiDebugger',
query: { productId: product.value.id }
})
}
// 获取请求方法标签类型
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 | 业务失败 |`
}
// 下载接口文档
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
}
}
// 下载 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>