Files
jczxw-pc/app/pages/console/apps.vue
2026-04-23 16:30:57 +08:00

1061 lines
28 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>
<!-- 页面头部 -->
<div class="page-header">
<div>
<h2 class="page-title">应用中心</h2>
<p class="page-desc">管理你订阅的所有应用快速进入后台</p>
</div>
<a-space>
<a-button type="primary" @click="navigateTo('/market')">
<template #icon><ShopOutlined /></template>
浏览应用商店
</a-button>
</a-space>
</div>
<!-- Tab 切换 -->
<a-tabs v-model:activeKey="activeTab" class="apps-tabs" @change="handleTabChange">
<a-tab-pane key="my-apps" tab="我的应用">
<AppsCenter :user-id="userId" />
</a-tab-pane>
<a-tab-pane key="purchased" tab="已购应用">
<div class="purchased-section">
<!-- 统计概览 -->
<div class="stats-overview">
<div class="stat-card">
<div class="stat-value">{{ purchasedApps.length }}</div>
<div class="stat-label">全部应用</div>
</div>
<div class="stat-card">
<div class="stat-value text-success">{{ statusCounts.active }}</div>
<div class="stat-label">生效中</div>
</div>
<div class="stat-card">
<div class="stat-value text-warning">{{ statusCounts.expiringSoon }}</div>
<div class="stat-label">即将到期</div>
</div>
<div class="stat-card">
<div class="stat-value text-error">{{ statusCounts.expired }}</div>
<div class="stat-label">已过期</div>
</div>
</div>
<!-- 筛选栏 -->
<div class="filter-bar">
<a-input-search
v-model:value="purchasedSearch"
placeholder="搜索已购应用"
style="width: 280px"
allow-clear
@search="() => {}"
/>
<a-radio-group v-model:value="purchasedFilter" button-style="solid">
<a-radio-button value="">全部</a-radio-button>
<a-radio-button value="active">生效中</a-radio-button>
<a-radio-button value="expiring">即将到期</a-radio-button>
<a-radio-button value="expired">已过期</a-radio-button>
</a-radio-group>
</div>
<!-- 应用卡片网格 -->
<div v-if="filteredPurchasedApps.length > 0" class="apps-grid">
<div
v-for="app in filteredPurchasedApps"
:key="app.id"
class="app-card"
:class="{ 'is-expired': app.status === 'expired' || app.status === 'cancelled' }"
>
<!-- 卡片右上角菜单 -->
<div class="card-actions">
<a-dropdown :trigger="['click']">
<a-button type="text" size="small" class="more-btn">
<MoreOutlined />
</a-button>
<template #overlay>
<a-menu>
<a-menu-item @click="handleConfig(app)">应用配置</a-menu-item>
<a-menu-item @click="handleViewDetail(app)">查看详情</a-menu-item>
<a-menu-divider v-if="app.status === 'active'" />
<a-menu-item v-if="app.status === 'active'" @click="handleRenew(app)">
<span class="menu-renew">续费</span>
</a-menu-item>
<a-menu-item v-if="app.status === 'active'" danger @click="handleUnsubscribe(app)">
退订
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</div>
<!-- 应用图标 -->
<div class="card-icon-wrapper" :style="{ background: iconBgColor(app.appName) }">
<img v-if="app.appIcon" :src="app.appIcon" class="card-icon" />
<span v-else class="card-icon-text">{{ (app.appName || 'A').charAt(0).toUpperCase() }}</span>
</div>
<!-- 应用信息 -->
<div class="card-info">
<h3 class="card-title">{{ app.appName }}</h3>
<p class="card-developer">开发者{{ app.developerName }}</p>
<div class="card-meta">
<span v-if="app.priceType === 'free'" class="price-tag free">免费</span>
<span v-else class="price-tag paid">¥{{ (app.price || 0) / 100 }}</span>
<span v-if="app.priceType === 'subscription'" class="price-type-tag">/</span>
</div>
<div v-if="app.endTime" class="card-expire" :class="getExpireClass(app)">
<ClockCircleOutlined class="expire-icon" />
<span>{{ app.status === 'expired' || app.status === 'cancelled' ? '已到期' : '到期' }}{{ app.endTime }}</span>
</div>
</div>
<!-- 状态标签 -->
<div class="card-status">
<a-tag :color="subscriptionStatusColor(app.status)" class="status-tag">
{{ subscriptionStatusText(app.status) }}
</a-tag>
<a-switch
v-if="app.status === 'active'"
v-model:checked="app.enabled"
size="small"
@change="(val) => handleToggleApp(app, val)"
/>
</div>
<!-- 主要操作按钮 -->
<a-button
type="primary"
block
size="large"
class="enter-btn"
:disabled="app.status !== 'active'"
@click="handleEnterApp(app)"
>
<RocketOutlined v-if="app.status === 'active'" />
{{ app.status === 'active' ? '进入应用' : '已过期' }}
</a-button>
</div>
</div>
<!-- 空状态 -->
<div v-else-if="!purchasedLoading" class="empty-state">
<div class="empty-illustration">
<svg width="120" height="120" viewBox="0 0 120 120" fill="none">
<circle cx="60" cy="60" r="50" fill="#f5f5f5"/>
<path d="M45 50h30M45 60h20M45 70h25" stroke="#d9d9d9" stroke-width="3" stroke-linecap="round"/>
<circle cx="80" cy="80" r="15" fill="#fff" stroke="#d9d9d9" stroke-width="2"/>
<path d="M75 80l4 4 8-8" stroke="#52c41a" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</div>
<h3 class="empty-title">{{ purchasedSearch || purchasedFilter ? '未找到匹配的应用' : '暂无已购应用' }}</h3>
<p class="empty-desc">{{ purchasedSearch || purchasedFilter ? '尝试调整筛选条件' : '去应用商店发现更多优质应用' }}</p>
<a-button v-if="!purchasedSearch && !purchasedFilter" type="primary" @click="navigateTo('/market')">
前往应用商店
</a-button>
<a-button v-else @click="clearFilters">清除筛选</a-button>
</div>
</div>
</a-tab-pane>
<a-tab-pane key="store" tab="应用商店">
<div class="store-section">
<!-- 快捷入口卡片 -->
<div class="quick-cards">
<div class="quick-card" @click="navigateTo('/market')">
<div class="quick-card-icon blue">
<ShopOutlined />
</div>
<div class="quick-card-content">
<div class="quick-card-title">应用市场</div>
<div class="quick-card-desc">浏览和购买各类应用</div>
</div>
<RightOutlined class="quick-card-arrow" />
</div>
<div class="quick-card" @click="navigateTo('/market?type=plugin')">
<div class="quick-card-icon purple">
<AppstoreOutlined />
</div>
<div class="quick-card-content">
<div class="quick-card-title">插件中心</div>
<div class="quick-card-desc">扩展功能的插件</div>
</div>
<RightOutlined class="quick-card-arrow" />
</div>
<div class="quick-card" @click="navigateTo('/developer')">
<div class="quick-card-icon green">
<ToolOutlined />
</div>
<div class="quick-card-content">
<div class="quick-card-title">开发者中心</div>
<div class="quick-card-desc">开发自己的应用</div>
</div>
<RightOutlined class="quick-card-arrow" />
</div>
</div>
<!-- 推荐应用 -->
<div class="recommend-section">
<div class="section-header">
<h3> 推荐应用</h3>
<a-button type="link" @click="navigateTo('/market')">查看更多</a-button>
</div>
<div class="recommend-apps">
<div
v-for="app in recommendApps"
:key="app.productId"
class="recommend-card"
@click="navigateTo(`/market?app=${app.productId}`)"
>
<img v-if="app.icon" :src="app.icon" class="recommend-icon" />
<div v-else class="recommend-icon-placeholder">{{ (app.productName || 'A').charAt(0) }}</div>
<div class="recommend-info">
<div class="recommend-name">{{ app.productName }}</div>
<div class="recommend-desc">{{ app.description || '暂无描述' }}</div>
</div>
<div class="recommend-price">
<span v-if="app.priceType === 'free'" class="price-free">免费</span>
<span v-else class="price-paid">¥{{ (app.price || 0) / 100 }}</span>
</div>
</div>
</div>
</div>
</div>
</a-tab-pane>
</a-tabs>
<!-- 应用配置弹窗 -->
<a-modal
v-model:open="configModalVisible"
title="应用配置"
width="500px"
:footer="null"
>
<a-form :model="configForm" layout="vertical" class="config-form">
<a-form-item label="应用名称">
<a-input v-model:value="configForm.appName" disabled />
</a-form-item>
<a-form-item label="启用状态">
<a-switch v-model:checked="configForm.enabled" />
</a-form-item>
<a-form-item label="自动续费">
<a-switch v-model:checked="configForm.autoRenew" />
</a-form-item>
<a-form-item label="配置参数">
<a-textarea v-model:value="configForm.config" :rows="4" placeholder="JSON 格式的配置参数" />
</a-form-item>
<div class="form-actions">
<a-button @click="configModalVisible = false">取消</a-button>
<a-button type="primary" @click="handleSaveConfig">保存配置</a-button>
</div>
</a-form>
</a-modal>
</div>
</template>
<script setup lang="ts">
import {
ShopOutlined,
RightOutlined,
MoreOutlined,
RocketOutlined,
ClockCircleOutlined,
AppstoreOutlined,
ToolOutlined,
} from '@ant-design/icons-vue'
import { message } from 'ant-design-vue'
import AppsCenter from '@/components/console/AppsCenter.vue'
import { pageAppProduct } from '@/api/app/appProduct'
import type { AppProduct } from '@/api/app/appProduct/model'
import { getUserInfo } from '@/api/layout'
import { mySubscriptions, cancelSubscription, toggleEnable as toggleSubscriptionEnable } from '@/api/app/subscription'
import type { AppSubscription } from '@/api/app/subscription/model'
definePageMeta({ layout: 'console' })
// ─── 用户信息 ────────────────────────────────────────────────
const userId = ref<string | null>(import.meta.client ? localStorage.getItem('UserId') : null)
const userIdNum = computed(() => (userId.value ? Number(userId.value) : 0))
async function ensureUser() {
if (userIdNum.value) return
try {
const user = await getUserInfo()
const uid = user.userId
if (uid) {
userId.value = String(uid)
}
} catch {}
}
// Tab 状态
const activeTab = ref('my-apps')
// 已购应用状态
const purchasedLoading = ref(false)
const purchasedSearch = ref('')
const purchasedFilter = ref('')
// 推荐应用
const recommendApps = ref<AppProduct[]>([])
// ─── 已购应用数据从订阅API加载 ──────────────────────────
interface PurchasedApp {
id: number
subscriptionNo: string
appName: string
appIcon?: string
productId?: number
developerName: string
priceType: 'free' | 'one_time' | 'subscription'
price: number
status: 'active' | 'expired' | 'cancelled' | 'pending'
enabled: boolean
startTime: string
endTime?: string
autoRenew: boolean
instanceDomain?: string
instanceAdminUrl?: string
}
const purchasedApps = ref<PurchasedApp[]>([])
function transformSubscriptionToApp(sub: AppSubscription): PurchasedApp {
return {
id: sub.id || 0,
subscriptionNo: sub.subscriptionNo || '',
appName: sub.productName || '未知应用',
appIcon: sub.productLogo || sub.productIcon,
productId: sub.productId,
developerName: sub.developerName || '官方',
priceType: (sub.priceType as any) || 'free',
price: Math.round((sub.payPrice || 0) * 100), // 元→分(兼容前端显示逻辑)
status: (sub.status as any) || 'pending',
enabled: sub.status === 'active',
startTime: sub.startTime ? String(sub.startTime).replace(/T.*/, '') : sub.createTime ? String(sub.createTime).replace(/T.*/, '') : '',
endTime: sub.expireTime ? String(sub.expireTime).replace(/T.*/, '') : undefined,
autoRenew: sub.autoRenew === 1,
instanceDomain: sub.instanceDomain,
instanceAdminUrl: sub.instanceAdminUrl,
}
}
// 配置弹窗
const configModalVisible = ref(false)
const configForm = reactive({
appName: '',
enabled: true,
autoRenew: false,
config: '',
})
// ─── 统计计算 ────────────────────────────────────────────────
const statusCounts = computed(() => {
const now = Date.now()
const thirtyDaysMs = 30 * 24 * 60 * 60 * 1000
let expiringSoon = 0
purchasedApps.value.forEach(app => {
if (app.status === 'active' && app.endTime) {
const expireTime = new Date(app.endTime).getTime()
if (expireTime - now < thirtyDaysMs && expireTime > now) {
expiringSoon++
}
}
})
return {
active: purchasedApps.value.filter(a => a.status === 'active').length,
expired: purchasedApps.value.filter(a => a.status === 'expired' || a.status === 'cancelled').length,
expiringSoon,
}
})
// ─── 筛选逻辑 ────────────────────────────────────────────────
const filteredPurchasedApps = computed(() => {
let result = [...purchasedApps.value]
const now = Date.now()
const thirtyDaysMs = 30 * 24 * 60 * 60 * 1000
// 状态筛选
if (purchasedFilter.value) {
if (purchasedFilter.value === 'expiring') {
// 即将到期生效中且30天内到期
result = result.filter(app => {
if (app.status !== 'active' || !app.endTime) return false
const expireTime = new Date(app.endTime).getTime()
return expireTime - now < thirtyDaysMs && expireTime > now
})
} else {
result = result.filter(app => app.status === purchasedFilter.value)
}
}
// 搜索
if (purchasedSearch.value) {
const kw = purchasedSearch.value.toLowerCase()
result = result.filter(app =>
app.appName.toLowerCase().includes(kw) ||
app.developerName.toLowerCase().includes(kw)
)
}
return result
})
// ─── 辅助函数 ────────────────────────────────────────────────
function getExpireClass(app: PurchasedApp) {
if (app.status === 'expired' || app.status === 'cancelled') return 'expired'
if (!app.endTime) return ''
const now = Date.now()
const expireTime = new Date(app.endTime).getTime()
const thirtyDaysMs = 30 * 24 * 60 * 60 * 1000
if (expireTime - now < thirtyDaysMs) return 'warning'
return ''
}
function subscriptionStatusColor(status: string) {
const map: Record<string, string> = {
active: 'success',
expired: 'error',
cancelled: 'default',
}
return map[status] || 'default'
}
function subscriptionStatusText(status: string) {
const map: Record<string, string> = {
active: '生效中',
expired: '已过期',
cancelled: '已取消',
}
return map[status] || status
}
function iconBgColor(name?: string) {
const PALETTE = ['#4e6ef2', '#f4a261', '#e76f51', '#2a9d8f', '#e9c46a']
if (!name) return PALETTE[0]
let h = 0
for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) & 0xffffffff
return PALETTE[Math.abs(h) % PALETTE.length]
}
// ─── 数据加载 ────────────────────────────────────────────────
async function loadRecommendApps() {
try {
const res = await pageAppProduct({
page: 1,
limit: 4,
status: 1,
})
recommendApps.value = res.list || []
} catch (e) {
console.error('加载推荐应用失败', e)
}
}
async function loadPurchasedApps() {
purchasedLoading.value = true
try {
await ensureUser()
const uid = userIdNum.value
if (!uid) {
purchasedApps.value = []
return
}
const data = await mySubscriptions({
page: 1,
limit: 100,
})
const list = data?.list || []
purchasedApps.value = list.map(transformSubscriptionToApp)
} catch (e) {
console.error('加载已购应用失败', e)
purchasedApps.value = []
} finally {
purchasedLoading.value = false
}
}
// ─── 事件处理 ────────────────────────────────────────────────
function handleTabChange(key: string) {
if (key === 'store') {
loadRecommendApps()
} else if (key === 'purchased') {
loadPurchasedApps()
}
}
function handleToggleApp(record: PurchasedApp, enabled: boolean) {
if (!record.id) return
toggleSubscriptionEnable(record.id, enabled)
.then(() => {
message.success(`${record.appName}${enabled ? '启用' : '禁用'}`)
record.enabled = enabled
})
.catch(() => {
message.error('操作失败')
})
}
function handleEnterApp(record: PurchasedApp) {
const url = record.instanceAdminUrl || record.instanceDomain
if (url) {
window.open(url, '_blank', 'noopener,noreferrer')
} else {
message.info('应用实例尚未分配,请联系管理员')
}
}
function handleConfig(record: PurchasedApp) {
configForm.appName = record.appName
configForm.enabled = record.enabled
configForm.autoRenew = record.autoRenew
configForm.config = record.config || '{}'
configModalVisible.value = true
}
function handleSaveConfig() {
message.success('配置保存成功')
configModalVisible.value = false
}
function handleRenew(record: PurchasedApp) {
navigateTo('/market')
}
function handleViewDetail(record: PurchasedApp) {
if (record.productId) {
navigateTo(`/market/${record.productId}`)
}
}
function handleUnsubscribe(record: PurchasedApp) {
if (!record.id) return
cancelSubscription(record.id)
.then(() => {
message.success(`${record.appName} 已退订`)
loadPurchasedApps()
})
.catch((e: any) => {
message.error(e.message || '退订失败')
})
}
function clearFilters() {
purchasedSearch.value = ''
purchasedFilter.value = ''
}
onMounted(() => {
ensureUser()
loadRecommendApps()
})
</script>
<style scoped>
/* ─── 页面基础 ──────────────────────────────────────────────── */
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 24px;
}
.page-title {
font-size: 20px;
font-weight: 700;
color: #1f2937;
margin: 0;
line-height: 1.4;
}
.page-desc {
font-size: 14px;
color: #9ca3af;
margin: 4px 0 0;
}
/* Tab 样式 */
.apps-tabs :deep(.ant-tabs-nav) {
margin-bottom: 24px;
}
/* ─── 已购应用区域 ──────────────────────────────────────────── */
.purchased-section {
background: #fafbfc;
border-radius: 12px;
padding: 24px;
}
/* 统计概览 */
.stats-overview {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
margin-bottom: 24px;
}
.stat-card {
background: #fff;
border-radius: 12px;
padding: 20px;
text-align: center;
border: 1px solid #f0f0f0;
transition: all 0.2s;
}
.stat-card:hover {
border-color: #d9d9d9;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
}
.stat-value {
font-size: 28px;
font-weight: 700;
color: #1f2937;
line-height: 1.2;
}
.stat-value.text-success { color: #52c41a; }
.stat-value.text-warning { color: #fa8c16; }
.stat-value.text-error { color: #ff4d4f; }
.stat-label {
font-size: 13px;
color: #9ca3af;
margin-top: 4px;
}
/* 筛选栏 */
.filter-bar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
flex-wrap: wrap;
gap: 12px;
}
.filter-bar :deep(.ant-input-search) {
width: 280px !important;
}
/* 应用卡片网格 */
.apps-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 20px;
}
.app-card {
position: relative;
background: #fff;
border-radius: 16px;
padding: 24px;
border: 1px solid #f0f0f0;
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
display: flex;
flex-direction: column;
}
.app-card:hover {
border-color: #d9d9d9;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08);
transform: translateY(-4px);
}
.app-card.is-expired {
opacity: 0.7;
}
.app-card.is-expired:hover {
opacity: 0.85;
}
/* 卡片右上角菜单 */
.card-actions {
position: absolute;
top: 12px;
right: 12px;
z-index: 1;
}
.more-btn {
color: #9ca3af;
width: 28px;
height: 28px;
}
.more-btn:hover {
color: #666;
background: #f5f5f5;
}
.menu-renew {
color: #1890ff;
}
/* 应用图标 */
.card-icon-wrapper {
width: 64px;
height: 64px;
border-radius: 16px;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 16px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.card-icon {
width: 64px;
height: 64px;
border-radius: 16px;
object-fit: cover;
}
.card-icon-text {
font-size: 28px;
font-weight: 700;
color: #fff;
}
/* 应用信息 */
.card-info {
flex: 1;
margin-bottom: 16px;
}
.card-title {
font-size: 18px;
font-weight: 600;
color: #1f2937;
margin: 0 0 6px;
line-height: 1.3;
}
.card-developer {
font-size: 13px;
color: #9ca3af;
margin: 0 0 12px;
}
.card-meta {
display: flex;
align-items: baseline;
gap: 4px;
margin-bottom: 8px;
}
.price-tag {
font-size: 18px;
font-weight: 600;
}
.price-tag.free {
color: #52c41a;
}
.price-tag.paid {
color: #fa8c16;
}
.price-type-tag {
font-size: 13px;
color: #9ca3af;
}
.card-expire {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: #666;
}
.card-expire.warning {
color: #fa8c16;
}
.card-expire.expired {
color: #ff4d4f;
}
.expire-icon {
font-size: 12px;
}
/* 状态标签 */
.card-status {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
padding-top: 12px;
border-top: 1px solid #f5f5f5;
}
.status-tag {
border-radius: 4px;
}
/* 进入应用按钮 */
.enter-btn {
height: 44px;
font-size: 15px;
font-weight: 500;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.enter-btn:not(:disabled):hover {
transform: scale(1.02);
}
.enter-btn:disabled {
background: #f5f5f5;
border-color: #d9d9d9;
color: #bfbfbf;
}
/* ─── 空状态 ────────────────────────────────────────────────── */
.empty-state {
padding: 60px 20px;
text-align: center;
background: #fff;
border-radius: 16px;
}
.empty-illustration {
margin-bottom: 24px;
}
.empty-title {
font-size: 18px;
font-weight: 600;
color: #1f2937;
margin: 0 0 8px;
}
.empty-desc {
font-size: 14px;
color: #9ca3af;
margin: 0 0 24px;
}
/* ─── 应用商店区域 ──────────────────────────────────────────── */
.store-section {
display: flex;
flex-direction: column;
gap: 24px;
}
.quick-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 16px;
}
.quick-card {
display: flex;
align-items: center;
gap: 16px;
padding: 20px;
background: #fff;
border: 1px solid #f0f0f0;
border-radius: 12px;
cursor: pointer;
transition: all 0.2s;
}
.quick-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
border-color: #d9d9d9;
}
.quick-card-icon {
width: 48px;
height: 48px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: 22px;
flex-shrink: 0;
}
.quick-card-icon.blue { background: #eff6ff; color: #4e6ef2; }
.quick-card-icon.purple { background: #f5f3ff; color: #722ed1; }
.quick-card-icon.green { background: #f0fdf4; color: #52c41a; }
.quick-card-content {
flex: 1;
min-width: 0;
}
.quick-card-title {
font-size: 15px;
font-weight: 600;
color: rgba(0, 0, 0, 0.85);
margin-bottom: 4px;
}
.quick-card-desc {
font-size: 13px;
color: rgba(0, 0, 0, 0.45);
}
.quick-card-arrow {
color: rgba(0, 0, 0, 0.25);
transition: transform 0.2s;
}
.quick-card:hover .quick-card-arrow {
transform: translateX(4px);
color: rgba(0, 0, 0, 0.45);
}
/* 推荐应用 */
.recommend-section {
background: #fff;
border-radius: 12px;
padding: 20px;
border: 1px solid #f0f0f0;
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.section-header h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
}
.recommend-apps {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 12px;
}
.recommend-card {
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
background: #fafafa;
border-radius: 10px;
cursor: pointer;
transition: all 0.2s;
}
.recommend-card:hover {
background: #f0f0f0;
}
.recommend-icon {
width: 44px;
height: 44px;
border-radius: 10px;
object-fit: cover;
}
.recommend-icon-placeholder {
width: 44px;
height: 44px;
border-radius: 10px;
background: #4e6ef2;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
font-weight: 600;
}
.recommend-info {
flex: 1;
min-width: 0;
}
.recommend-name {
font-size: 14px;
font-weight: 500;
color: rgba(0, 0, 0, 0.85);
margin-bottom: 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.recommend-desc {
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.recommend-price {
flex-shrink: 0;
}
.price-free {
color: #22c55e;
font-weight: 500;
}
.price-paid {
color: #f59e0b;
font-weight: 600;
}
/* 配置表单 */
.config-form {
padding-top: 8px;
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 24px;
padding-top: 16px;
border-top: 1px solid #f0f0f0;
}
/* 响应式 */
@media (max-width: 768px) {
.stats-overview {
grid-template-columns: repeat(2, 1fr);
}
.apps-grid {
grid-template-columns: 1fr;
}
.filter-bar {
flex-direction: column;
align-items: stretch;
}
.filter-bar :deep(.ant-input-search) {
width: 100% !important;
}
}
</style>