1061 lines
28 KiB
Vue
1061 lines
28 KiB
Vue
<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>
|