Files
dafuweng-saiadmin6.x/saiadmin-artd/src/views/plugin/saipackage/install/index.vue
2026-03-03 09:53:54 +08:00

989 lines
29 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="art-full-height">
<ElCard class="flex flex-col flex-1 min-h-0 art-table-card" shadow="never">
<!-- 提示警告 -->
<ElAlert type="warning" :closable="false">
仅支持上传由插件市场下载的zip压缩包进行安装请您务必确认插件包文件来自官方渠道或经由官方认证的插件作者
</ElAlert>
<!-- 工具栏 -->
<div class="flex flex-wrap items-center my-2">
<ElButton @click="getList" v-ripple>
<template #icon>
<ArtSvgIcon icon="ri:refresh-line" />
</template>
</ElButton>
<ElButton @click="handleUpload" v-ripple>
<template #icon>
<ArtSvgIcon icon="ri:upload-line" />
</template>
上传插件包
</ElButton>
<ElButton type="danger" @click="handleTerminal" v-ripple>
<template #icon>
<ArtSvgIcon icon="ri:terminal-box-line" />
</template>
</ElButton>
<div class="flex items-center gap-1 ml-auto">
<div class="version-title">saiadmin版本</div>
<div class="version-value">{{ version?.saiadmin_version?.describe }}</div>
<div class="version-title">状态</div>
<div
class="version-value"
:class="[
version?.saiadmin_version?.notes === '正常' ? 'text-green-500' : 'text-red-500'
]"
>
{{ version?.saiadmin_version?.notes }}
</div>
<div class="version-title">saipackage安装器</div>
<div class="version-value">{{ version?.saipackage_version?.describe }}</div>
<div class="version-title">状态</div>
<div
class="version-value"
:class="[
version?.saipackage_version?.notes === '正常' ? 'text-green-500' : 'text-red-500'
]"
>
{{ version?.saipackage_version?.notes }}
</div>
</div>
</div>
<!-- Tab切换 -->
<ElTabs v-model="activeTab" type="border-card">
<!-- 本地安装 Tab -->
<ElTabPane label="本地安装" name="local">
<ArtTable
:loading="loading"
:data="installList"
:columns="columns"
:show-table-header="false"
>
<!-- 插件标识列 -->
<template #app="{ row }">
<ElLink :href="row.website" target="_blank" type="primary">{{ row.app }}</ElLink>
</template>
<!-- 状态列 -->
<template #state="{ row }">
<ElTag v-if="row.state === 0" type="danger">已卸载</ElTag>
<ElTag v-else-if="row.state === 1" type="success">已安装</ElTag>
<ElTag v-else-if="row.state === 2" type="primary">等待安装</ElTag>
<ElTag v-else-if="row.state === 4" type="warning">等待安装依赖</ElTag>
</template>
<!-- 前端依赖列 -->
<template #npm="{ row }">
<ElLink
v-if="row.npm_dependent_wait_install === 1"
type="primary"
@click="handleExecFront(row)"
>
<ArtSvgIcon icon="ri:download-line" class="mr-1" />点击安装
</ElLink>
<ElTag v-else-if="row.state === 2" type="info">-</ElTag>
<ElTag v-else type="success">已安装</ElTag>
</template>
<!-- 后端依赖列 -->
<template #composer="{ row }">
<ElLink
v-if="row.composer_dependent_wait_install === 1"
type="primary"
@click="handleExecBackend(row)"
>
<ArtSvgIcon icon="ri:download-line" class="mr-1" />点击安装
</ElLink>
<ElTag v-else-if="row.state === 2" type="info">-</ElTag>
<ElTag v-else type="success">已安装</ElTag>
</template>
<!-- 操作列 -->
<template #operation="{ row }">
<ElSpace>
<ElPopconfirm
title="确定要安装当前插件吗?"
@confirm="handleInstall(row)"
confirm-button-text="确定"
cancel-button-text="取消"
>
<template #reference>
<ElLink type="warning">
<ArtSvgIcon icon="ri:apps-2-add-line" class="mr-1" />安装
</ElLink>
</template>
</ElPopconfirm>
<ElPopconfirm
title="确定要卸载当前插件吗?"
@confirm="handleUninstall(row)"
confirm-button-text="确定"
cancel-button-text="取消"
>
<template #reference>
<ElLink type="danger">
<ArtSvgIcon icon="ri:delete-bin-5-line" class="mr-1" />卸载
</ElLink>
</template>
</ElPopconfirm>
</ElSpace>
</template>
</ArtTable>
</ElTabPane>
<!-- 在线商店 Tab -->
<ElTabPane label="在线商店" name="online">
<!-- 搜索栏 -->
<div class="flex flex-wrap items-center gap-4 mb-4">
<ElInput
v-model="searchForm.keywords"
placeholder="请输入关键词"
clearable
class="!w-48"
@keyup.enter="fetchOnlineApps"
>
<template #prefix>
<ArtSvgIcon icon="ri:search-line" />
</template>
</ElInput>
<ElSelect v-model="searchForm.type" placeholder="类型" clearable class="!w-32">
<ElOption label="全部" value="" />
<ElOption label="插件" :value="1" />
<ElOption label="系统" :value="2" />
<ElOption label="组件" :value="3" />
<ElOption label="项目" :value="4" />
</ElSelect>
<ElSelect v-model="searchForm.price" placeholder="价格" class="!w-32">
<ElOption label="全部" value="all" />
<ElOption label="免费" value="free" />
<ElOption label="付费" value="paid" />
</ElSelect>
<ElButton type="primary" @click="fetchOnlineApps">搜索</ElButton>
<!-- 商店账号 -->
<div class="ml-auto flex items-center gap-2">
<template v-if="storeUser">
<ElAvatar :size="24">
<img v-if="storeUser.avatar" :src="storeUser.avatar" />
<ArtSvgIcon v-else icon="ri:user-line" />
</ElAvatar>
<span class="font-medium">{{ storeUser.nickname || storeUser.username }}</span>
<ElButton size="small" @click="showPurchasedApps">已购应用</ElButton>
<ElButton size="small" @click="handleLogout">退出</ElButton>
</template>
<template v-else>
<ElButton size="small" @click="handleLogin">登录</ElButton>
<ElButton size="small" @click="handleRegister">注册</ElButton>
<span class="text-sm text-gray-400">来管理已购插件</span>
</template>
</div>
</div>
<!-- 应用网格 -->
<div class="app-grid">
<div
v-for="item in onlineApps"
:key="item.id"
class="app-card"
@click="showDetail(item)"
>
<div class="app-card-header">
<img :src="item.logo" :alt="item.title" class="app-logo" />
<div class="app-info">
<div class="app-title">{{ item.title }}</div>
<div class="app-version">v{{ item.version }}</div>
</div>
<div class="app-price" :class="{ free: item.price === '0.00' }">
{{ item.price === '0.00' ? '免费' : '¥' + item.price }}
</div>
</div>
<div class="app-about">{{ item.about }}</div>
<div class="app-footer">
<div class="app-author">
<img
:src="item.avatar || 'https://via.placeholder.com/24'"
class="author-avatar"
/>
<span>{{ item.username }}</span>
</div>
<div class="app-sales">
<ArtSvgIcon icon="ri:user-line" class="mr-1" />
{{ item.sales_num }} 销量
</div>
</div>
</div>
</div>
<!-- 分页 -->
<div class="flex justify-center mt-4">
<ElPagination
v-model:current-page="onlinePagination.current"
v-model:page-size="onlinePagination.size"
:total="onlinePagination.total"
:page-sizes="[12, 24, 48]"
layout="total, prev, pager, next, sizes"
@size-change="fetchOnlineApps"
@current-change="fetchOnlineApps"
/>
</div>
</ElTabPane>
</ElTabs>
</ElCard>
<!-- 上传插件弹窗 -->
<InstallForm ref="installFormRef" @success="getList" />
<!-- 终端弹窗 -->
<TerminalBox ref="terminalRef" @success="getList" />
<!-- 详情抽屉 -->
<ElDrawer v-model="detailVisible" :size="600" :with-header="true">
<template #header>
<div class="flex items-center gap-3">
<img :src="currentApp?.logo" class="w-9 h-9 rounded-lg" />
<div>
<div class="text-lg font-semibold">{{ currentApp?.title }}</div>
<div class="text-xs text-gray-400">
v{{ currentApp?.version }} · {{ currentApp?.username }}
</div>
</div>
</div>
</template>
<div class="detail-content">
<div class="detail-price" :class="{ free: currentApp?.price === '0.00' }">
{{ currentApp?.price === '0.00' ? '免费' : '¥' + currentApp?.price }}
</div>
<div class="detail-about">{{ currentApp?.about }}</div>
<!-- 截图预览 -->
<div v-if="currentApp?.screenshots?.length" class="mb-6">
<div class="text-base font-semibold mb-3">截图预览</div>
<ElSpace wrap :size="12">
<ElImage
v-for="(img, idx) in currentApp?.screenshots"
:key="idx"
:src="img"
:preview-src-list="currentApp?.screenshots"
:preview-teleported="true"
fit="cover"
class="w-36 h-24 rounded-lg cursor-pointer"
/>
</ElSpace>
</div>
<!-- 详情描述 -->
<div class="detail-desc">
<div class="text-base font-semibold mb-3">详细介绍</div>
<div class="desc-content" v-html="renderMarkdown(currentApp?.content)"></div>
</div>
<!-- 购买按钮 -->
<div class="mt-6 pt-4 border-t border-gray-200 dark:border-gray-700">
<ElButton type="primary" size="large" class="w-full" @click="handleBuy">
<template #icon>
<ArtSvgIcon icon="ri:shopping-cart-line" />
</template>
前往购买
</ElButton>
</div>
</div>
</ElDrawer>
<!-- 登录弹窗 -->
<ElDialog v-model="loginVisible" title="登录应用商店" width="400" :close-on-click-modal="false">
<ElForm :model="loginForm" @submit.prevent="submitLogin" label-position="top">
<ElFormItem label="用户名/邮箱" required>
<ElInput v-model="loginForm.username" placeholder="请输入用户名或邮箱" clearable>
<template #prefix>
<ArtSvgIcon icon="ri:user-line" />
</template>
</ElInput>
</ElFormItem>
<ElFormItem label="密码" required>
<ElInput
v-model="loginForm.password"
type="password"
placeholder="请输入密码"
show-password
clearable
>
<template #prefix>
<ArtSvgIcon icon="ri:lock-line" />
</template>
</ElInput>
</ElFormItem>
<ElFormItem label="验证码" required>
<div class="flex gap-2 w-full">
<ElInput v-model="loginForm.code" placeholder="请输入验证码" clearable class="flex-1">
<template #prefix>
<ArtSvgIcon icon="ri:shield-check-line" />
</template>
</ElInput>
<img
:src="captchaImage"
@click="getCaptcha"
class="h-8 w-24 cursor-pointer rounded"
title="点击刷新"
/>
</div>
</ElFormItem>
<ElFormItem>
<ElButton type="primary" native-type="submit" class="w-full" :loading="loginLoading">
登录
</ElButton>
</ElFormItem>
<div class="text-center text-sm text-gray-400">
还没有账号
<ElLink type="primary" @click="handleRegister">立即注册</ElLink>
</div>
</ElForm>
</ElDialog>
<!-- 已购应用抽屉 -->
<ElDrawer v-model="purchasedVisible" title="已购应用" :size="720">
<div v-loading="purchasedLoading" class="purchased-list">
<div v-for="app in purchasedApps" :key="app.id" class="purchased-card">
<img :src="app.logo" class="purchased-logo" />
<div class="purchased-info">
<div class="purchased-title">{{ app.title }}</div>
<div class="purchased-version">v{{ app.version }} · {{ app.developer }}</div>
<div class="purchased-about">{{ app.about }}</div>
</div>
<div class="gap-2">
<ElButton size="small" @click="viewDocs(app)">
<template #icon>
<ArtSvgIcon icon="ri:book-line" />
</template>
文档
</ElButton>
<ElButton type="primary" size="small" @click="showVersions(app)">
<template #icon>
<ArtSvgIcon icon="ri:download-line" />
</template>
下载
</ElButton>
</div>
</div>
<ElEmpty
v-if="!purchasedLoading && purchasedApps.length === 0"
description="暂无已购应用"
/>
</div>
</ElDrawer>
<!-- 版本选择对话框 -->
<ElDialog
v-model="versionVisible"
:title="'选择版本 - ' + (currentPurchasedApp?.title || '')"
width="500"
>
<div v-loading="versionLoading" class="version-list">
<div v-for="ver in versionList" :key="ver.id" class="version-item">
<div>
<div class="version-info-row">
<span class="version-name">v{{ ver.version }}</span>
<span class="version-date">{{ ver.create_time }}</span>
</div>
<div class="version-remark">{{ ver.remark }}</div>
</div>
<ElButton
type="primary"
size="small"
:loading="downloadingId === ver.id"
@click="downloadVersion(ver)"
>
下载安装
</ElButton>
</div>
<ElEmpty v-if="!versionLoading && versionList.length === 0" description="暂无可用版本" />
</div>
</ElDialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, watch } from 'vue'
import { ElMessage } from 'element-plus'
import type { ColumnOption } from '@/types'
import saipackageApi, {
type AppInfo,
type VersionInfo,
type StoreApp,
type StoreUser,
type PurchasedApp,
type AppVersion
} from '../api/index'
import InstallForm from './install-box.vue'
import TerminalBox from './terminal.vue'
// ========== 基础状态 ==========
const activeTab = ref('local')
const version = ref<VersionInfo>({})
const loading = ref(false)
const installFormRef = ref()
const terminalRef = ref()
const installList = ref<AppInfo[]>([])
// ========== 本地安装相关 ==========
const handleUpload = () => {
installFormRef.value?.open()
}
// 检查版本兼容性
const checkVersionCompatibility = (support: string | undefined): boolean => {
if (!support || !version.value?.saiadmin_version?.describe) {
return false // 如果没有兼容性信息,默认不允许安装
}
const currentVersion = version.value.saiadmin_version.describe
const currentMatch = currentVersion.match(/^(\d+)\./)
if (!currentMatch) {
return true
}
const currentMajor = currentMatch[1]
// support 格式为 "5.x" 或 "5.x|6.x",用 | 分隔多个支持的版本
const supportVersions = support.split('|').map((v) => v.trim())
// 检查当前版本是否匹配任意一个支持的版本
return supportVersions.some((ver) => {
const supportMatch = ver.match(/^(\d+)\.x$/i)
return supportMatch && supportMatch[1] === currentMajor
})
}
const handleInstall = async (record: AppInfo) => {
// 检查
if (version.value?.saipackage_version?.state === 'fail') {
ElMessage.error('插件市场saipackage版本检测失败')
return
}
// 检查版本兼容性
if (!checkVersionCompatibility(record.support)) {
ElMessage.error(
`此插件仅支持 ${record.support} 版本框架,当前框架版本为 ${version.value?.saiadmin_version?.describe},不兼容无法安装`
)
return
}
try {
await saipackageApi.installApp({ appName: record.app })
ElMessage.success('安装成功')
getList()
saipackageApi.reloadBackend()
} catch {
// Error already handled by http utility
}
}
const handleUninstall = async (record: AppInfo) => {
await saipackageApi.uninstallApp({ appName: record.app })
ElMessage.success('卸载成功')
getList()
}
const handleExecFront = (record: AppInfo) => {
const extend = 'module-install:' + record.app
terminalRef.value?.open()
setTimeout(() => {
terminalRef.value?.frontInstall(extend)
}, 500)
}
const handleExecBackend = (record: AppInfo) => {
const extend = 'module-install:' + record.app
terminalRef.value?.open()
setTimeout(() => {
terminalRef.value?.backendInstall(extend)
}, 500)
}
const handleTerminal = () => {
terminalRef.value?.open()
}
const columns: ColumnOption[] = [
{ prop: 'app', label: '插件标识', width: 120, useSlot: true },
{ prop: 'title', label: '插件名称', width: 150 },
{ prop: 'about', label: '插件描述', showOverflowTooltip: true },
{ prop: 'author', label: '作者', width: 120 },
{ prop: 'version', label: '版本', width: 100 },
{ prop: 'support', label: '框架兼容', width: 120, align: 'center' },
{ prop: 'state', label: '插件状态', width: 100, useSlot: true },
{ prop: 'npm', label: '前端依赖', width: 100, useSlot: true },
{ prop: 'composer', label: '后端依赖', width: 100, useSlot: true },
{ prop: 'operation', label: '操作', width: 140, fixed: 'right', useSlot: true }
]
const getList = async () => {
loading.value = true
try {
const resp = await saipackageApi.getAppList()
installList.value = resp?.data || []
version.value = resp?.version || {}
} catch {
// Error already handled by http utility
} finally {
loading.value = false
}
}
// ========== 在线商店相关 ==========
const detailVisible = ref(false)
const currentApp = ref<StoreApp | null>(null)
const storeUser = ref<StoreUser | null>(null)
const storeToken = ref(localStorage.getItem('storeToken') || '')
const onlineApps = ref<StoreApp[]>([])
const onlineLoading = ref(false)
const onlinePagination = reactive({
current: 1,
size: 12,
total: 0
})
// 登录相关
const loginVisible = ref(false)
const loginLoading = ref(false)
const captchaImage = ref('')
const captchaUuid = ref('')
const loginForm = reactive({
username: '',
password: '',
code: ''
})
// 搜索表单
const searchForm = reactive({
keywords: '',
type: '' as string | number,
price: 'all'
})
// 已购应用相关
const purchasedVisible = ref(false)
const purchasedLoading = ref(false)
const purchasedApps = ref<PurchasedApp[]>([])
const versionVisible = ref(false)
const versionLoading = ref(false)
const versionList = ref<AppVersion[]>([])
const currentPurchasedApp = ref<PurchasedApp | null>(null)
const downloadingId = ref<number | null>(null)
const handleLogin = () => {
loginVisible.value = true
getCaptcha()
}
const handleRegister = () => {
window.open('https://saas.saithink.top/register', '_blank')
}
const handleLogout = () => {
storeUser.value = null
storeToken.value = ''
localStorage.removeItem('storeToken')
}
const getCaptcha = async () => {
try {
const response = await saipackageApi.getStoreCaptcha()
captchaImage.value = response?.image || ''
captchaUuid.value = response?.uuid || ''
} catch {
// Error already handled by http utility
}
}
const submitLogin = async () => {
if (!loginForm.username || !loginForm.password || !loginForm.code) {
ElMessage.warning('请填写完整信息')
return
}
loginLoading.value = true
try {
const response = await saipackageApi.storeLogin({
username: loginForm.username,
password: loginForm.password,
code: loginForm.code,
uuid: captchaUuid.value
})
storeToken.value = response?.access_token || ''
localStorage.setItem('storeToken', response?.access_token || '')
loginVisible.value = false
loginForm.username = ''
loginForm.password = ''
loginForm.code = ''
await fetchStoreUser()
ElMessage.success('登录成功')
} catch {
getCaptcha()
// Error already handled by http utility
} finally {
loginLoading.value = false
}
}
const fetchStoreUser = async () => {
if (!storeToken.value) return
try {
const response = await saipackageApi.getStoreUserInfo(storeToken.value)
storeUser.value = response || null
} catch {
handleLogout()
}
}
const fetchOnlineApps = async () => {
onlineLoading.value = true
try {
const response = await saipackageApi.getOnlineAppList({
page: onlinePagination.current,
limit: onlinePagination.size,
price: searchForm.price,
type: searchForm.type,
keywords: searchForm.keywords
})
onlineApps.value = response?.data || []
onlinePagination.total = response?.total || 0
} catch {
// Error already handled by http utility
} finally {
onlineLoading.value = false
}
}
const showDetail = (item: StoreApp) => {
currentApp.value = item
detailVisible.value = true
}
const renderMarkdown = (content?: string) => {
if (!content) return ''
return content
.replace(/^### (.+)$/gm, '<h3>$1</h3>')
.replace(/^## (.+)$/gm, '<h2>$1</h2>')
.replace(/^# (.+)$/gm, '<h1>$1</h1>')
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.+?)\*/g, '<em>$1</em>')
.replace(/`(.+?)`/g, '<code>$1</code>')
.replace(/^- (.+)$/gm, '<li>$1</li>')
.replace(/(<li>.*<\/li>)/s, '<ul>$1</ul>')
.replace(/\n/g, '<br/>')
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank">$1</a>')
}
const handleBuy = () => {
window.open('https://saas.saithink.top/store', '_blank')
}
const showPurchasedApps = async () => {
purchasedVisible.value = true
purchasedLoading.value = true
try {
const response = await saipackageApi.getPurchasedApps(storeToken.value)
purchasedApps.value = response || []
} catch {
// Error already handled by http utility
}
purchasedLoading.value = false
}
const viewDocs = (app: PurchasedApp) => {
window.open(`https://saas.saithink.top/store/docs-${app.app_id}`, '_blank')
}
const showVersions = async (app: PurchasedApp) => {
currentPurchasedApp.value = app
versionVisible.value = true
versionLoading.value = true
try {
const response = await saipackageApi.getAppVersions(storeToken.value, app.app_id)
versionList.value = response || []
} catch {
// Error already handled by http utility
}
versionLoading.value = false
}
const downloadVersion = async (ver: AppVersion) => {
downloadingId.value = ver.id
try {
await saipackageApi.downloadApp({
token: storeToken.value,
id: ver.id
})
ElMessage.success('下载成功,即将刷新插件列表...')
versionVisible.value = false
purchasedVisible.value = false
activeTab.value = 'local'
getList()
} catch {
// Error already handled by http utility
}
downloadingId.value = null
}
// 监听 tab 切换
watch(activeTab, (val) => {
if (val === 'online') {
fetchOnlineApps()
fetchStoreUser()
}
})
onMounted(() => {
getList()
})
</script>
<style lang="scss" scoped>
.version-title {
padding: 5px 10px;
background: var(--el-fill-color-light);
border: 1px solid var(--el-border-color);
font-size: 12px;
}
.version-value {
padding: 5px 10px;
border: 1px solid var(--el-border-color);
font-size: 12px;
}
.app-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 16px;
max-height: calc(100vh - 380px);
overflow-y: auto;
}
.app-card {
background: var(--el-bg-color);
border-radius: 8px;
padding: 16px;
cursor: pointer;
transition: all 0.3s ease;
border: 1px solid var(--el-border-color);
&:hover {
box-shadow: var(--el-box-shadow-light);
transform: translateY(-2px);
}
}
.app-card-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
.app-logo {
width: 48px;
height: 48px;
border-radius: 8px;
object-fit: cover;
}
.app-info {
flex: 1;
}
.app-title {
font-size: 16px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.app-version {
font-size: 12px;
color: var(--el-text-color-secondary);
}
.app-price {
font-size: 16px;
font-weight: 600;
color: var(--el-color-danger);
&.free {
color: var(--el-color-success);
}
}
.app-about {
font-size: 13px;
color: var(--el-text-color-regular);
line-height: 1.5;
margin-bottom: 12px;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.app-footer {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 12px;
color: var(--el-text-color-secondary);
}
.app-author {
display: flex;
align-items: center;
gap: 6px;
}
.author-avatar {
width: 20px;
height: 20px;
border-radius: 50%;
}
.app-sales {
display: flex;
align-items: center;
}
.detail-content {
padding: 16px 0;
}
.detail-price {
font-size: 24px;
font-weight: 600;
color: var(--el-color-danger);
margin-bottom: 16px;
&.free {
color: var(--el-color-success);
}
}
.detail-about {
font-size: 14px;
color: var(--el-text-color-regular);
line-height: 1.6;
margin-bottom: 24px;
}
.desc-content {
font-size: 14px;
color: var(--el-text-color-regular);
line-height: 1.8;
:deep(code) {
background: var(--el-fill-color);
padding: 2px 6px;
border-radius: 4px;
font-size: 13px;
}
:deep(a) {
color: var(--el-color-primary);
}
}
.purchased-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.purchased-card {
display: flex;
align-items: center;
gap: 16px;
padding: 16px;
background: var(--el-bg-color);
border-radius: 8px;
border: 1px solid var(--el-border-color);
}
.purchased-logo {
width: 56px;
height: 56px;
border-radius: 8px;
object-fit: cover;
}
.purchased-info {
flex: 1;
min-width: 0;
}
.purchased-title {
font-size: 15px;
font-weight: 600;
color: var(--el-text-color-primary);
margin-bottom: 4px;
}
.purchased-version {
font-size: 12px;
color: var(--el-text-color-secondary);
margin-bottom: 6px;
}
.purchased-about {
font-size: 13px;
color: var(--el-text-color-regular);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.version-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.version-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px;
background: var(--el-fill-color-light);
border-radius: 6px;
}
.version-info-row {
display: flex;
align-items: center;
gap: 12px;
}
.version-name {
font-weight: 600;
color: var(--el-text-color-primary);
}
.version-date {
font-size: 12px;
color: var(--el-text-color-secondary);
}
.version-remark {
flex: 1;
font-size: 13px;
color: var(--el-text-color-regular);
}
</style>