1448 lines
37 KiB
Vue
1448 lines
37 KiB
Vue
<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>
|