feat: NEXT Store 2.0 重大更新 - 完整重构前后端

🎉 主要更新:

后端:
- 全新华为应用市场爬虫系统
- 三表分离数据库设计 (app_info, app_metrics, app_rating)
- 完整的API接口 (搜索、分类、热门、上新等)
- 元服务自动识别和分类
- 智能Token管理和数据处理
- 修复热门应用重复显示问题

前端:
- 全新首页设计 (今日上架、热门应用)
- 应用页面 (彩色分类磁贴、智能图标匹配)
- 今日上新页面 (日期切换)
- 热门应用页面 (卡片布局)
- 应用详情页面 (完整信息展示)
- Apple风格搜索栏
- Footer组件
- 底部导航栏优化 (4个导航项)
- 骨架屏加载效果
- FontAwesome图标集成

UI/UX:
- 统一浅色背景 (#F5F5F7)
- 流畅的过渡动画
- 响应式设计
- 毛玻璃效果

文档:
- CHANGELOG.md - 完整更新日志
- QUICKSTART.md - 快速开始
- 多个技术文档和使用指南

版本: v2.0.0
This commit is contained in:
Nvex
2025-10-25 21:20:32 +08:00
parent c0f81dbbe2
commit 720402ffe7
38 changed files with 5682 additions and 407 deletions

93
frontend/DEBUG.md Normal file
View File

@@ -0,0 +1,93 @@
# 应用详情页重复显示问题诊断
## 问题描述
应用 C6917559384755888642 在详情页显示两次
## 诊断步骤
### 1. 检查数据库
✅ 已确认数据库中只有1条记录
### 2. 检查前端代码
✅ 已确认:
- App.vue 只有一个 `<router-view />`
- AppDetail.vue 没有重复的元素
- 路由配置正常
### 3. 可能的原因
#### A. 浏览器缓存
**解决方案**
1. 硬刷新页面:`Cmd + Shift + R` (Mac) 或 `Ctrl + Shift + R` (Windows)
2. 清除浏览器缓存
3. 使用无痕模式测试
#### B. Vue DevTools 检查
1. 打开浏览器开发者工具
2. 切换到 Vue DevTools
3. 检查组件树,看是否有重复的 AppDetail 组件
#### C. 控制台检查
打开浏览器控制台,运行:
```javascript
// 检查页面上有多少个 app-detail-container
document.querySelectorAll('.app-detail-container').length
// 检查应用名称显示了几次
document.querySelectorAll('h1').length
```
#### D. 网络请求检查
1. 打开开发者工具 Network 标签
2. 刷新页面
3. 检查是否有重复的 API 请求到 `/api/apps/C6917559384755888642`
### 4. 临时解决方案
如果问题持续,可以尝试:
1. **重启前端开发服务器**
```bash
cd frontend
# 停止当前服务器 (Ctrl+C)
npm run dev
```
2. **清除 node_modules 缓存**
```bash
cd frontend
rm -rf node_modules/.vite
npm run dev
```
3. **检查是否有多个前端实例在运行**
```bash
lsof -i :5173
```
### 5. 代码检查清单
- [ ] App.vue 中只有一个 `<router-view />`
- [ ] AppDetail.vue 中没有 `v-for` 循环包裹整个内容
- [ ] 没有在 main.ts 中多次挂载应用
- [ ] 路由配置中没有重复的路由定义
## 如果问题仍然存在
请提供以下信息:
1. 截图显示"重复"的具体表现
2. 浏览器控制台的错误信息
3. Vue DevTools 中的组件树截图
4. Network 标签中的 API 请求记录
## 快速测试
在浏览器控制台运行:
```javascript
console.log('AppDetail 组件数量:', document.querySelectorAll('.app-detail-container').length);
console.log('应用标题数量:', document.querySelectorAll('.app-title h1').length);
console.log('应用图标数量:', document.querySelectorAll('.app-icon img').length);
```
如果这些数字都是 1说明页面没有重复渲染。
如果大于 1说明确实有重复渲染的问题。

View File

@@ -5,6 +5,8 @@
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>鸿蒙应用展示平台</title>
<!-- Font Awesome Icons -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" crossorigin="anonymous" referrerpolicy="no-referrer" />
</head>
<body>
<div id="app"></div>

View File

@@ -2,6 +2,7 @@
<div id="app">
<main class="main-content">
<router-view />
<Footer v-if="!isProfilePage" />
</main>
<nav class="bottom-nav">
<router-link to="/" class="nav-item">
@@ -19,6 +20,13 @@
</svg>
<span>应用</span>
</router-link>
<router-link to="/new_apps" class="nav-item">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<polyline points="12 6 12 12 16 14"/>
</svg>
<span>上新</span>
</router-link>
<router-link to="/profile" class="nav-item">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M4 6h16M4 12h16M4 18h16"/>
@@ -30,6 +38,12 @@
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import Footer from '@/components/Footer.vue'
const route = useRoute()
const isProfilePage = computed(() => route.path === '/profile')
</script>
<style scoped>
@@ -79,7 +93,7 @@
.main-content {
min-height: 100vh;
padding-bottom: 70px;
background: #fff;
background: #F5F5F7;
}
/* 确保在 Safari 上也有毛玻璃效果 */
@@ -91,7 +105,7 @@
@media (max-width: 768px) {
.nav-item {
padding: 4px 16px;
padding: 4px 12px;
}
.nav-icon {
@@ -103,4 +117,19 @@
font-size: 11px;
}
}
@media (max-width: 480px) {
.nav-item {
padding: 4px 8px;
}
.nav-icon {
width: 20px;
height: 20px;
}
.nav-item span {
font-size: 10px;
}
}
</style>

View File

@@ -40,12 +40,27 @@ export interface AppDetail extends AppItem {
description: string
privacy_url: string
is_pay: boolean
price: string
size_bytes: number
star_1_count: number
star_2_count: number
star_3_count: number
star_4_count: number
star_5_count: number
// 新增字段
dev_id?: string
supplier?: string
kind_id?: string
tag_name?: string
main_device_codes?: string[]
target_sdk?: string
min_sdk?: string
compile_sdk_version?: number
min_hmos_api_level?: number
api_release_type?: string
ctype?: number
app_level?: number
packing_type?: number
}
export interface Category {
@@ -73,7 +88,10 @@ export const appsApi = {
api.get<any, ApiResponse<AppItem[]>>('/apps/top-ratings', { params: { limit } }),
getDetail: (appId: string) =>
api.get<any, ApiResponse<AppDetail>>(`/apps/${appId}`)
api.get<any, ApiResponse<AppDetail>>(`/apps/${appId}`),
getAppsByDate: (date: string) =>
api.get<any, ApiResponse<AppItem[]>>('/apps/by-date', { params: { date } })
}
export default api

View File

@@ -0,0 +1,233 @@
<template>
<footer class="footer">
<div class="footer-container">
<div class="footer-grid">
<div class="footer-column">
<h4 class="footer-column-title">关于 NEXT Store</h4>
<p class="footer-description">NEXT Store 是一个展示和分享鸿蒙应用的平台</p>
</div>
<div class="footer-column">
<h4 class="footer-column-title">快速链接</h4>
<div class="footer-links-group">
<router-link to="/" class="footer-link">探索</router-link>
<router-link to="/apps" class="footer-link">应用</router-link>
<router-link to="/new_apps" class="footer-link">上新</router-link>
<router-link to="/hot_apps" class="footer-link">热门</router-link>
</div>
</div>
<div class="footer-column">
<h4 class="footer-column-title">法律信息</h4>
<div class="license-info">
<div class="license-icons">
<i class="fab fa-creative-commons"></i>
<i class="fab fa-creative-commons-by"></i>
<i class="fab fa-creative-commons-nc"></i>
<i class="fab fa-creative-commons-sa"></i>
</div>
<div class="license-text">
<span>本站内容采用 CC BY-NC-SA 4.0 许可协议</span>
</div>
</div>
</div>
</div>
<div class="footer-bottom">
<div class="footer-bottom-content">
<div class="footer-links">
<div class="footer-copyright">© 2024-2025 NEXT Store. 保留所有权利</div>
<span class="divider">|</span>
<span class="footer-text">本网站仅用于演示目的所有展示的应用仅供参考</span>
</div>
</div>
</div>
</div>
</footer>
</template>
<script setup lang="ts">
</script>
<style scoped>
.footer {
background-color: #f8f9fa;
padding: 40px 0 20px;
margin-top: 40px;
}
.footer-container {
max-width: 1200px;
margin: 0 auto;
padding: 0 20px;
}
.footer-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 40px;
margin-bottom: 30px;
}
.footer-column {
display: flex;
flex-direction: column;
gap: 15px;
}
.footer-column-title {
font-size: 16px;
font-weight: 600;
color: #333;
margin: 0;
}
.footer-description {
font-size: 14px;
color: #666;
line-height: 1.6;
margin: 0;
}
.footer-links-group {
display: flex;
flex-direction: column;
gap: 10px;
}
.footer-links-group .footer-link {
font-size: 14px;
color: #666;
text-decoration: none;
transition: color 0.2s;
}
.footer-links-group .footer-link:hover {
color: #007AFF;
}
.license-info {
display: flex;
flex-direction: column;
gap: 10px;
}
.license-icons {
display: flex;
gap: 8px;
font-size: 20px;
color: #666;
}
.license-text {
font-size: 13px;
color: #666;
line-height: 1.5;
}
.license-text a {
color: #007AFF;
text-decoration: none;
}
.license-text a:hover {
text-decoration: underline;
}
.footer-bottom {
border-top: 1px solid rgba(0, 0, 0, 0.1);
padding-top: 20px;
}
.footer-bottom-content {
display: flex;
justify-content: center;
align-items: center;
}
.footer-links {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
justify-content: center;
}
.footer-copyright {
font-size: 14px;
color: #666;
}
.footer-text {
color: #666;
font-size: 14px;
}
.divider {
color: #ccc;
font-size: 14px;
}
/* 响应式设计 */
@media (max-width: 768px) {
.footer {
padding: 30px 0 15px;
margin-top: 30px;
}
.footer-container {
padding: 0 15px;
}
.footer-grid {
grid-template-columns: 1fr;
gap: 25px;
margin-bottom: 20px;
}
.footer-column-title {
font-size: 15px;
}
.footer-description,
.footer-links-group .footer-link,
.license-text {
font-size: 13px;
}
.footer-links {
font-size: 12px;
gap: 8px;
}
.footer-copyright,
.footer-text {
font-size: 12px;
}
.license-icons {
font-size: 18px;
}
}
@media (max-width: 480px) {
.footer {
padding: 20px 0 10px;
margin-top: 20px;
}
.footer-grid {
gap: 20px;
}
.footer-links {
flex-direction: column;
gap: 6px;
text-align: center;
}
.divider {
display: none;
}
}
</style>

View File

@@ -3,6 +3,8 @@ import Home from '@/views/Home.vue'
import Apps from '@/views/Apps.vue'
import AppDetail from '@/views/AppDetail.vue'
import Profile from '@/views/Profile.vue'
import NewApps from '@/views/NewApps.vue'
import HotApps from '@/views/HotApps.vue'
const router = createRouter({
history: createWebHistory(),
@@ -26,6 +28,16 @@ const router = createRouter({
path: '/profile',
name: 'Profile',
component: Profile
},
{
path: '/new_apps',
name: 'NewApps',
component: NewApps
},
{
path: '/hot_apps',
name: 'HotApps',
component: HotApps
}
]
})

View File

@@ -1,88 +1,165 @@
<template>
<div class="app-detail" v-if="app">
<div class="container">
<div class="detail-header">
<div class="app-detail-container" v-if="app">
<!-- 返回按钮和头部 -->
<div class="app-header">
<div class="back-button-wrapper">
<button @click="goBack" class="back-link">
<i class="fas fa-arrow-left"></i>
返回
</button>
</div>
<div class="app-basic-info">
<div class="app-icon">
<img :src="app.icon_url" :alt="app.name" />
<img :src="app.icon_url" :alt="app.name" loading="lazy" />
</div>
<div class="app-header-info">
<h1 class="app-title">{{ app.name }}</h1>
<div class="app-title">
<div class="title-row">
<div class="title-info">
<h1>{{ app.name }}</h1>
<span class="category-tag">{{ app.kind_name }}</span>
</div>
<div class="action-buttons">
<a
v-if="getAppStoreUrl()"
:href="getAppStoreUrl()"
class="download-button"
target="_blank"
>
<i class="fas fa-download"></i>
下载
</a>
</div>
</div>
<p class="app-developer">{{ app.developer_name }}</p>
<div class="app-stats">
<div class="stat">
<span class="stat-label">评分</span>
<span class="stat-value">{{ app.average_rating.toFixed(1) }} </span>
</div>
<div class="stat">
<span class="stat-label">下载</span>
<span class="stat-value">{{ formatDownloads(app.download_count) }}</span>
</div>
<div class="stat">
<span class="stat-label">大小</span>
<span class="stat-value">{{ formatSize(app.size_bytes) }}</span>
</div>
</div>
</div>
<!-- 应用统计信息卡片 -->
<div class="app-stats-card">
<div class="stat-item">
<div class="stat-value">{{ app.average_rating.toFixed(1) }}</div>
<div class="stat-label">
<span class="stars">{{ '⭐'.repeat(Math.round(app.average_rating)) }}</span>
<span class="rating-count">{{ formatNumber(app.total_rating_count || 0) }}个评分</span>
</div>
</div>
<div class="stat-divider"></div>
<div class="stat-item">
<div class="stat-value">{{ formatDownloads(app.download_count) }}</div>
<div class="stat-label">下载量</div>
</div>
<div class="stat-divider"></div>
<div class="stat-item">
<div class="stat-value">{{ formatSize(app.size_bytes) }}</div>
<div class="stat-label">大小</div>
</div>
</div>
<!-- 应用内容 -->
<div class="app-content">
<!-- 应用描述 -->
<div class="app-section">
<h2>应用描述</h2>
<div class="description">{{ app.description || app.brief_desc || '暂无描述' }}</div>
</div>
<!-- 评分分布 -->
<div class="app-section" v-if="app.total_rating_count">
<h2>评分分布</h2>
<div class="rating-distribution">
<div v-for="i in 5" :key="i" class="rating-row">
<span class="star-label">{{ 6 - i }}</span>
<div class="rating-bar-container">
<div class="rating-bar" :style="{ width: getRatingPercent(6 - i) + '%' }"></div>
</div>
<span class="rating-count">{{ getRatingCount(6 - i) }}</span>
</div>
</div>
</div>
<div class="detail-content">
<section class="section">
<h2 class="section-title">应用简介</h2>
<p class="app-description">{{ app.description }}</p>
</section>
<section class="section" v-if="app.total_rating_count">
<h2 class="section-title">评分分布</h2>
<div class="rating-bars">
<div v-for="i in 5" :key="i" class="rating-bar">
<span class="star-label">{{ 6 - i }}</span>
<div class="bar">
<div class="bar-fill" :style="{ width: getRatingPercent(6 - i) + '%' }"></div>
</div>
<span class="count">{{ getRatingCount(6 - i) }}</span>
</div>
<!-- 应用信息 -->
<div class="app-section">
<h2>应用信息</h2>
<div class="info-list">
<div class="info-item">
<label>版本</label>
<span>{{ app.version || '暂无' }}</span>
</div>
</section>
<section class="section">
<h2 class="section-title">应用信息</h2>
<div class="info-list">
<div class="info-item">
<span class="info-label">版本</span>
<span class="info-value">{{ app.version }}</span>
</div>
<div class="info-item">
<span class="info-label">分类</span>
<span class="info-value">{{ app.kind_name }}</span>
</div>
<div class="info-item">
<span class="info-label">上架时间</span>
<span class="info-value">{{ formatDate(app.listed_at) }}</span>
</div>
<div class="info-item">
<span class="info-label">包名</span>
<span class="info-value">{{ app.pkg_name }}</span>
</div>
<div class="info-item">
<label>分类</label>
<span>{{ app.kind_name }}</span>
</div>
</section>
<div class="info-item">
<label>上架时间</label>
<span>{{ formatDate(app.listed_at) }}</span>
</div>
<div class="info-item">
<label>开发者</label>
<span>{{ app.developer_name }}</span>
</div>
<div class="info-item">
<label>包名</label>
<span class="package-name">{{ app.pkg_name }}</span>
</div>
<div class="info-item">
<label>价格</label>
<span :class="!app.is_pay ? 'free-tag' : ''">{{ app.is_pay ? app.price : '免费' }}</span>
</div>
<div class="info-item">
<label>支持平台</label>
<span class="platform-support">
<span
v-for="(device, index) in getDeviceList()"
:key="index"
:class="['platform-tag', device.type]"
>
<i :class="device.icon"></i>
{{ device.name }}
</span>
</span>
</div>
</div>
</div>
<!-- 隐私政策 -->
<div class="app-section" v-if="app.privacy_url">
<h2>隐私政策</h2>
<a :href="app.privacy_url" target="_blank" class="privacy-link">
<i class="fas fa-external-link-alt"></i>
查看隐私政策
</a>
</div>
</div>
</div>
<div v-else class="loading">加载中...</div>
<div v-else class="loading-container">
<div class="loading-spinner"></div>
<p>加载中...</p>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import { appsApi, type AppDetail } from '@/api'
const route = useRoute()
const router = useRouter()
const app = ref<AppDetail | null>(null)
const goBack = () => {
router.back()
}
onMounted(async () => {
try {
const res = await appsApi.getDetail(route.params.id as string)
if (res.success) app.value = res.data
if (res.success) {
app.value = res.data
console.log('应用详情:', app.value)
}
} catch (error) {
console.error('加载应用详情失败:', error)
}
@@ -94,210 +171,532 @@ const formatDownloads = (count: number): string => {
return count.toString()
}
const formatNumber = (num: number): string => {
if (num >= 10000) return `${(num / 10000).toFixed(1)}`
return num.toString()
}
const formatSize = (bytes: number): string => {
if (bytes >= 1073741824) return `${(bytes / 1073741824).toFixed(1)} GB`
if (bytes >= 1048576) return `${(bytes / 1048576).toFixed(1)} MB`
return `${(bytes / 1024).toFixed(1)} KB`
if (bytes >= 1073741824) return `${(bytes / 1073741824).toFixed(1)}GB`
if (bytes >= 1048576) return `${(bytes / 1048576).toFixed(1)}MB`
return `${(bytes / 1024).toFixed(1)}KB`
}
const formatDate = (date: string): string => {
return new Date(date).toLocaleDateString('zh-CN')
if (!date) return '暂无'
return new Date(date).toLocaleDateString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit'
})
}
const getRatingCount = (star: number): number => {
if (!app.value) return 0
return app.value[`star_${star}_count` as keyof AppDetail] as number
return app.value[`star_${star}_count` as keyof AppDetail] as number || 0
}
const getRatingPercent = (star: number): number => {
if (!app.value || !app.value.total_rating_count) return 0
const count = getRatingCount(star)
return (count / app.value.total_rating_count) * 100
return (count / (app.value.total_rating_count || 1)) * 100
}
const getDeviceList = () => {
if (!app.value?.main_device_codes || app.value.main_device_codes.length === 0) {
return [{ name: '手机端', icon: 'fas fa-mobile-alt', type: 'mobile' }]
}
const deviceMap: Record<string, { name: string; icon: string; type: string }> = {
'0': { name: '手机端', icon: 'fas fa-mobile-alt', type: 'mobile' },
'1': { name: '平板端', icon: 'fas fa-tablet-alt', type: 'tablet' },
'2': { name: '智慧屏', icon: 'fas fa-tv', type: 'tv' },
'3': { name: '手表', icon: 'fas fa-clock', type: 'watch' },
'4': { name: '车机', icon: 'fas fa-car', type: 'car' },
'5': { name: 'PC', icon: 'fas fa-desktop', type: 'pc' }
}
return app.value.main_device_codes
.map(code => deviceMap[code] || { name: `设备${code}`, icon: 'fas fa-question', type: 'unknown' })
}
const getAppStoreUrl = (): string => {
if (!app.value) return ''
// 构建华为应用市场的URL
return `https://appgallery.huawei.com/app/${app.value.app_id}`
}
</script>
<style scoped>
.app-detail {
padding: 40px 0;
.app-detail-container {
max-width: 800px;
margin: 0 auto;
padding: 15px;
padding-bottom: 80px;
background: #F5F5F7;
min-height: 100vh;
}
.detail-header {
display: flex;
gap: 24px;
margin-bottom: 40px;
padding: 32px;
.app-header {
background: #fff;
border-radius: var(--border-radius);
box-shadow: var(--card-shadow);
padding: 15px;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
margin-bottom: 15px;
}
.back-button-wrapper {
margin-bottom: 15px;
}
.back-link {
display: inline-flex;
align-items: center;
color: #666;
text-decoration: none;
padding: 6px 12px;
border-radius: 6px;
background: #f5f5f7;
border: none;
cursor: pointer;
transition: all 0.3s ease;
font-size: 14px;
}
.back-link i {
margin-right: 8px;
}
.back-link:hover {
background: #e8e8e8;
color: #333;
}
.app-basic-info {
display: flex;
align-items: flex-start;
gap: 15px;
}
.app-icon {
width: 120px;
height: 120px;
border-radius: var(--border-radius);
overflow: hidden;
width: 80px;
height: 80px;
flex-shrink: 0;
}
.app-icon img {
width: 100%;
height: 100%;
border-radius: 16px;
object-fit: cover;
}
.app-header-info {
.app-title {
flex: 1;
min-width: 0;
}
.app-title {
font-size: 32px;
font-weight: 600;
.title-row {
display: flex;
justify-content: space-between;
align-items: flex-start;
width: 100%;
gap: 15px;
margin-bottom: 8px;
}
.app-developer {
font-size: 16px;
color: #86868b;
margin-bottom: 20px;
}
.app-stats {
display: flex;
gap: 32px;
}
.stat {
.title-info {
display: flex;
flex-direction: column;
gap: 4px;
gap: 6px;
flex: 1;
min-width: 0;
}
.title-info h1 {
margin: 0 0 6px 0;
font-size: 22px;
font-weight: 600;
color: #1a1a1a;
line-height: 1.2;
}
.category-tag {
background: #f0f0f0;
padding: 2px 8px;
border-radius: 12px;
font-size: 12px;
color: #666;
display: inline-flex;
align-items: center;
justify-content: center;
line-height: 1.4;
width: fit-content;
}
.action-buttons {
display: flex;
gap: 10px;
margin-left: auto;
flex-shrink: 0;
}
.download-button {
display: inline-flex;
align-items: center;
gap: 8px;
background: #007AFF;
color: white;
text-decoration: none;
padding: 8px 16px;
border-radius: 20px;
font-size: 14px;
transition: all 0.3s ease;
white-space: nowrap;
}
.download-button:hover {
background: #0056b3;
}
.download-button i {
font-size: 14px;
}
.app-developer {
font-size: 14px;
color: #666;
margin: 0;
}
/* 统计信息卡片 */
.app-stats-card {
background: #fff;
padding: 20px;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
margin-bottom: 15px;
display: flex;
justify-content: space-around;
align-items: center;
}
.stat-item {
text-align: center;
flex: 1;
}
.stat-value {
font-size: 24px;
font-weight: 600;
color: #1a1a1a;
margin-bottom: 4px;
}
.stat-label {
font-size: 12px;
color: #86868b;
}
.stat-value {
font-size: 18px;
font-weight: 600;
}
.detail-content {
color: #666;
display: flex;
flex-direction: column;
gap: 32px;
align-items: center;
gap: 2px;
}
.section {
.stars {
font-size: 14px;
line-height: 1;
}
.rating-count {
font-size: 11px;
color: #999;
}
.stat-divider {
width: 1px;
height: 40px;
background: #e8e8e8;
}
/* 应用内容 */
.app-content {
background: #fff;
padding: 32px;
border-radius: var(--border-radius);
box-shadow: var(--card-shadow);
padding: 15px;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.section-title {
font-size: 20px;
.app-section {
margin-bottom: 24px;
}
.app-section:last-child {
margin-bottom: 0;
}
.app-section h2 {
font-size: 16px;
font-weight: 600;
margin-bottom: 16px;
margin: 0 0 12px 0;
color: #1a1a1a;
}
.app-description {
.description {
line-height: 1.6;
color: #1d1d1f;
color: #444;
white-space: pre-wrap;
font-size: 14px;
word-break: break-word;
}
.rating-bars {
/* 评分分布 */
.rating-distribution {
display: flex;
flex-direction: column;
gap: 12px;
gap: 10px;
}
.rating-bar {
.rating-row {
display: flex;
align-items: center;
gap: 12px;
gap: 10px;
}
.star-label {
width: 40px;
font-size: 14px;
color: #86868b;
font-size: 13px;
color: #666;
}
.bar {
.rating-bar-container {
flex: 1;
height: 8px;
background: #f5f5f7;
background: #f0f0f0;
border-radius: 4px;
overflow: hidden;
}
.bar-fill {
.rating-bar {
height: 100%;
background: #f5a623;
transition: width 0.3s;
background: linear-gradient(90deg, #FFD700, #FFA500);
border-radius: 4px;
transition: width 0.3s ease;
}
.count {
width: 60px;
.rating-count {
width: 80px;
text-align: right;
font-size: 14px;
color: #86868b;
font-size: 13px;
color: #666;
}
/* 信息列表 */
.info-list {
display: flex;
flex-direction: column;
gap: 16px;
gap: 0;
}
.info-item {
background: #f8f9fa;
padding: 12px;
border-radius: 8px;
display: flex;
justify-content: space-between;
padding-bottom: 16px;
border-bottom: 1px solid #f5f5f7;
align-items: center;
margin-bottom: 8px;
}
.info-item:last-child {
border-bottom: none;
padding-bottom: 0;
margin-bottom: 0;
}
.info-label {
.info-item label {
font-size: 13px;
color: #666;
font-weight: 500;
}
.info-item span {
font-size: 14px;
color: #86868b;
color: #1a1a1a;
text-align: right;
max-width: 60%;
word-break: break-all;
}
.info-value {
.package-name {
font-family: 'Courier New', monospace;
font-size: 12px;
}
.free-tag {
color: #34C759;
font-weight: 600;
}
.platform-support {
display: flex;
gap: 8px;
flex-wrap: wrap;
justify-content: flex-end;
}
.platform-tag {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
border-radius: 15px;
font-size: 12px;
background: #f5f5f7;
font-weight: 500;
}
.platform-tag i {
font-size: 12px;
}
.platform-tag.mobile {
color: #007AFF;
background: rgba(0, 122, 255, 0.1);
}
.platform-tag.tablet {
color: #5856D6;
background: rgba(88, 86, 214, 0.1);
}
.platform-tag.tv {
color: #FF9500;
background: rgba(255, 149, 0, 0.1);
}
.platform-tag.watch {
color: #FF2D55;
background: rgba(255, 45, 85, 0.1);
}
.platform-tag.car {
color: #34C759;
background: rgba(52, 199, 89, 0.1);
}
.platform-tag.pc {
color: #5AC8FA;
background: rgba(90, 200, 250, 0.1);
}
/* 隐私政策链接 */
.privacy-link {
display: inline-flex;
align-items: center;
gap: 6px;
color: #007AFF;
text-decoration: none;
font-size: 14px;
color: #1d1d1f;
padding: 8px 12px;
border-radius: 8px;
background: rgba(0, 122, 255, 0.1);
transition: all 0.3s ease;
}
.privacy-link:hover {
background: rgba(0, 122, 255, 0.2);
}
.privacy-link i {
font-size: 12px;
}
/* 加载状态 */
.loading-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 400px;
color: #666;
}
.loading-spinner {
width: 40px;
height: 40px;
border: 3px solid #f0f0f0;
border-top-color: #007AFF;
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin-bottom: 16px;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* 响应式设计 */
@media (max-width: 768px) {
.detail-header {
flex-direction: column;
padding: 20px;
.app-detail-container {
padding: 10px;
padding-bottom: 80px;
}
.app-icon {
width: 100px;
height: 100px;
}
.app-title {
font-size: 24px;
}
.section {
padding: 20px;
}
}
.loading {
text-align: center;
padding: 60px 20px;
color: #86868b;
font-size: 16px;
.app-header {
padding: 12px;
}
.app-icon {
width: 70px;
height: 70px;
}
.title-info h1 {
font-size: 20px;
}
.title-row {
flex-direction: column;
gap: 10px;
}
.action-buttons {
width: 100%;
justify-content: flex-start;
}
.app-stats-card {
padding: 15px 10px;
}
.stat-value {
font-size: 20px;
}
.stat-label {
font-size: 11px;
}
.app-content {
padding: 12px;
}
.info-item {
flex-direction: column;
align-items: flex-start;
gap: 4px;
}
.info-item span {
max-width: 100%;
font-size: 13px;
text-align: left;
}
.platform-support {
justify-content: flex-start;
}
}
</style>

View File

@@ -1,39 +1,118 @@
<template>
<div class="apps-page">
<div class="container">
<div class="search-bar">
<input
v-model="searchQuery"
@keyup.enter="handleSearch"
type="text"
placeholder="搜索应用..."
class="search-input"
/>
<button @click="handleSearch" class="search-btn">搜索</button>
<!-- 网站标题栏 -->
<div class="site-header">
<div class="site-title">
<h1>应用</h1>
</div>
</div>
<div class="categories">
<button
v-for="category in categories"
<!-- 搜索栏 -->
<div class="search-bar">
<div class="search-container">
<form class="search-form" @submit.prevent="handleSearch">
<div class="search-wrapper" :class="{ 'search-focused': isSearchFocused }">
<svg class="search-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<circle cx="11" cy="11" r="8"/>
<path d="m21 21-4.35-4.35"/>
</svg>
<input
v-model="searchQuery"
type="search"
placeholder="应用、游戏等"
@focus="isSearchFocused = true"
@blur="isSearchFocused = false"
@input="handleSearchInput"
/>
<button
v-if="searchQuery"
type="button"
class="clear-button"
@click="clearSearch"
>
<svg viewBox="0 0 20 20" fill="currentColor">
<circle cx="10" cy="10" r="10" opacity="0.3"/>
<path d="M6.5 6.5l7 7M13.5 6.5l-7 7" stroke="white" stroke-width="1.5" stroke-linecap="round"/>
</svg>
</button>
</div>
<button
v-if="searchQuery || selectedCategory"
type="button"
class="cancel-button"
@click="cancelSearch"
>
取消
</button>
</form>
</div>
</div>
<div class="app-showcase">
<!-- 分类磁贴区域 -->
<div v-if="!searchQuery && !selectedCategory" class="categories-grid">
<div
v-for="(category, index) in categories"
:key="category.name"
:class="['category-tile', `category-color-${index % 16}`]"
@click="selectCategory(category.name)"
:class="['category-tag', { active: selectedCategory === category.name }]"
>
{{ category.name }} ({{ category.count }})
</button>
<i :class="'category-icon fas ' + getCategoryIcon(category.name)"></i>
<div class="category-tile-header">
<h3>{{ category.name }}</h3>
<span class="app-count">{{ category.count }}个应用</span>
</div>
</div>
</div>
<div class="apps-list">
<div class="app-grid" v-if="apps.length">
<AppCard v-for="app in apps" :key="app.app_id" :app="app" />
<!-- 搜索/分类结果区域 -->
<div v-else>
<!-- 加载状态 -->
<div v-if="loading" class="apps-grid">
<div v-for="i in 12" :key="`skeleton-${i}`" class="app-card skeleton">
<div class="app-icon skeleton-box"></div>
<div class="app-info">
<div class="skeleton-text"></div>
</div>
</div>
</div>
<div v-else-if="loading" class="loading">加载中...</div>
<div v-else class="empty">暂无应用</div>
<div v-if="total > pageSize" class="pagination">
<button @click="prevPage" :disabled="page === 1" class="page-btn">上一页</button>
<!-- 应用列表 -->
<div v-else-if="apps.length" class="apps-grid">
<div
v-for="app in apps"
:key="app.app_id"
class="app-card"
@click="goToApp(app.app_id)"
>
<div class="app-icon">
<img :src="app.icon_url" :alt="app.name" loading="lazy" />
</div>
<div class="app-info">
<h3>{{ app.name }}</h3>
</div>
</div>
</div>
<!-- 无结果状态 -->
<div v-else class="no-results">
<div class="no-results-icon">
<i class="fas fa-search"></i>
</div>
<div class="no-results-text">未找到相关应用</div>
</div>
<!-- 分页 -->
<div v-if="total > pageSize && apps.length" class="pagination">
<button @click="prevPage" :disabled="page === 1" class="page-btn">
<i class="fas fa-chevron-left"></i>
上一页
</button>
<span class="page-info">{{ page }} / {{ Math.ceil(total / pageSize) }}</span>
<button @click="nextPage" :disabled="page >= Math.ceil(total / pageSize)" class="page-btn">下一页</button>
<button @click="nextPage" :disabled="page >= Math.ceil(total / pageSize)" class="page-btn">
下一页
<i class="fas fa-chevron-right"></i>
</button>
</div>
</div>
</div>
@@ -42,9 +121,10 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { appsApi, type AppItem, type Category } from '@/api'
import AppCard from '@/components/AppCard.vue'
const router = useRouter()
const searchQuery = ref('')
const selectedCategory = ref('')
const categories = ref<Category[]>([])
@@ -53,16 +133,24 @@ const page = ref(1)
const pageSize = ref(20)
const total = ref(0)
const loading = ref(false)
const isSearchFocused = ref(false)
onMounted(async () => {
try {
const res = await appsApi.getCategories()
if (res.success) categories.value = res.data
if (res.success) {
categories.value = res.data
console.log('分类数据:', categories.value)
}
} catch (error) {
console.error('加载分类失败:', error)
}
})
const goToApp = (appId: string) => {
router.push(`/app/${appId}`)
}
const selectCategory = async (category: string) => {
selectedCategory.value = category
searchQuery.value = ''
@@ -71,12 +159,36 @@ const selectCategory = async (category: string) => {
}
const handleSearch = async () => {
if (!searchQuery.value.trim()) return
if (!searchQuery.value.trim()) {
// 如果搜索框为空,返回分类视图
selectedCategory.value = ''
apps.value = []
return
}
selectedCategory.value = ''
page.value = 1
await loadApps()
}
const handleSearchInput = () => {
// 实时搜索(可选)
if (searchQuery.value.trim()) {
// 可以添加防抖逻辑
}
}
const clearSearch = () => {
searchQuery.value = ''
selectedCategory.value = ''
apps.value = []
}
const cancelSearch = () => {
searchQuery.value = ''
selectedCategory.value = ''
apps.value = []
}
const loadApps = async () => {
loading.value = true
try {
@@ -102,6 +214,7 @@ const prevPage = () => {
if (page.value > 1) {
page.value--
loadApps()
window.scrollTo({ top: 0, behavior: 'smooth' })
}
}
@@ -109,114 +222,569 @@ const nextPage = () => {
if (page.value < Math.ceil(total.value / pageSize.value)) {
page.value++
loadApps()
window.scrollTo({ top: 0, behavior: 'smooth' })
}
}
// 根据分类名称返回对应的图标
const getCategoryIcon = (categoryName: string): string => {
console.log('获取图标:', categoryName)
const iconMap: Record<string, string> = {
// 常见分类
'游戏': 'fa-gamepad',
'社交': 'fa-comments',
'通讯': 'fa-comment-dots',
'娱乐': 'fa-film',
'影音': 'fa-play-circle',
'视频': 'fa-video',
'音乐': 'fa-music',
'摄影': 'fa-camera',
'图像': 'fa-image',
'工具': 'fa-wrench',
'效率': 'fa-briefcase',
'办公': 'fa-file-alt',
'生活': 'fa-home',
'购物': 'fa-shopping-cart',
'美食': 'fa-utensils',
'旅游': 'fa-plane',
'出行': 'fa-car',
'导航': 'fa-map-marked-alt',
'新闻': 'fa-newspaper',
'阅读': 'fa-book',
'教育': 'fa-graduation-cap',
'学习': 'fa-book-reader',
'儿童': 'fa-child',
'健康': 'fa-heartbeat',
'运动': 'fa-running',
'财务': 'fa-wallet',
'金融': 'fa-chart-line',
'理财': 'fa-coins',
'商务': 'fa-building',
'医疗': 'fa-hospital',
'天气': 'fa-cloud-sun',
'美化': 'fa-palette',
'主题': 'fa-paint-brush',
'壁纸': 'fa-image',
'输入法': 'fa-keyboard',
'浏览器': 'fa-globe',
'安全': 'fa-shield-alt',
'系统': 'fa-cog',
'设置': 'fa-sliders-h',
'开发': 'fa-code',
'编程': 'fa-laptop-code',
'设计': 'fa-pencil-ruler',
'参考': 'fa-book-open',
'杂志': 'fa-newspaper',
'漫画': 'fa-book-open',
'小说': 'fa-book',
'体育': 'fa-football-ball',
'直播': 'fa-broadcast-tower',
'短视频': 'fa-video',
'电台': 'fa-podcast',
'播客': 'fa-microphone',
'笔记': 'fa-sticky-note',
'日历': 'fa-calendar-alt',
'时钟': 'fa-clock',
'计算器': 'fa-calculator',
'翻译': 'fa-language',
'词典': 'fa-book',
'地图': 'fa-map',
'天气': 'fa-cloud-sun-rain',
'邮件': 'fa-envelope',
'云盘': 'fa-cloud',
'文件': 'fa-folder',
'压缩': 'fa-file-archive',
'清理': 'fa-broom',
'优化': 'fa-tachometer-alt',
'省电': 'fa-battery-three-quarters',
'网络': 'fa-wifi',
'蓝牙': 'fa-bluetooth',
'投屏': 'fa-tv',
'遥控': 'fa-mobile-alt',
'智能家居': 'fa-home',
'物联网': 'fa-network-wired',
'汽车': 'fa-car',
'驾驶': 'fa-car-side',
'停车': 'fa-parking',
'加油': 'fa-gas-pump',
'违章': 'fa-exclamation-triangle',
'保险': 'fa-shield-alt',
'银行': 'fa-university',
'支付': 'fa-credit-card',
'股票': 'fa-chart-line',
'基金': 'fa-chart-pie',
'彩票': 'fa-ticket-alt',
'外卖': 'fa-hamburger',
'团购': 'fa-users',
'酒店': 'fa-hotel',
'机票': 'fa-plane-departure',
'火车': 'fa-train',
'租车': 'fa-car',
'打车': 'fa-taxi',
'共享': 'fa-bicycle',
'快递': 'fa-shipping-fast',
'物流': 'fa-truck',
'二手': 'fa-recycle',
'招聘': 'fa-user-tie',
'房产': 'fa-home',
'装修': 'fa-hammer',
'家政': 'fa-broom',
'维修': 'fa-tools',
'宠物': 'fa-paw',
'母婴': 'fa-baby',
'亲子': 'fa-baby-carriage',
'婚恋': 'fa-heart',
'交友': 'fa-user-friends',
'社区': 'fa-users',
'论坛': 'fa-comments',
'博客': 'fa-blog',
'微博': 'fa-comment',
'问答': 'fa-question-circle',
'百科': 'fa-book',
'搜索': 'fa-search',
'浏览': 'fa-eye',
'下载': 'fa-download',
'上传': 'fa-upload',
'分享': 'fa-share-alt',
'收藏': 'fa-star',
'标签': 'fa-tags',
'分类': 'fa-th-list',
'排行': 'fa-trophy',
'推荐': 'fa-thumbs-up',
'热门': 'fa-fire',
'最新': 'fa-clock',
'精选': 'fa-gem',
'专题': 'fa-folder-open',
'合集': 'fa-layer-group',
'系列': 'fa-list-ol',
'套装': 'fa-box',
'限免': 'fa-gift',
'折扣': 'fa-percentage',
'会员': 'fa-crown',
'VIP': 'fa-crown',
'付费': 'fa-dollar-sign',
'免费': 'fa-gift',
'试用': 'fa-vial',
'测试': 'fa-flask',
'预览': 'fa-eye',
'演示': 'fa-desktop',
'教程': 'fa-chalkboard-teacher',
'帮助': 'fa-question-circle',
'反馈': 'fa-comment-dots',
'设置': 'fa-cog',
'关于': 'fa-info-circle',
'更多': 'fa-ellipsis-h'
}
// 尝试精确匹配
if (iconMap[categoryName]) {
console.log('精确匹配:', categoryName, '->', iconMap[categoryName])
return iconMap[categoryName]
}
// 尝试模糊匹配
for (const [key, icon] of Object.entries(iconMap)) {
if (categoryName.includes(key) || key.includes(categoryName)) {
console.log('模糊匹配:', categoryName, '->', icon)
return icon
}
}
// 默认图标
console.log('使用默认图标:', categoryName)
return 'fa-th-large'
}
</script>
<style scoped>
.apps-page {
padding: 40px 0;
background: #fff;
background: #F5F5F7;
min-height: 100vh;
padding-top: 60px;
}
/* 网站标题栏 */
.site-header {
background: rgba(255, 255, 255, 0.8);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
padding: 8px 15px 3px 15px;
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 999;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
}
.site-title h1 {
font-size: 28px;
font-weight: 700;
color: #000;
margin: 0;
}
/* 搜索栏 */
.search-bar {
display: flex;
gap: 12px;
margin-bottom: 32px;
}
.search-input {
flex: 1;
padding: 12px 16px;
border: 1px solid #d2d2d7;
border-radius: var(--border-radius);
font-size: 15px;
outline: none;
transition: var(--transition);
}
.search-input:focus {
border-color: #0071e3;
}
.search-btn {
padding: 12px 24px;
background: #0071e3;
color: #fff;
border: none;
border-radius: var(--border-radius);
font-size: 15px;
cursor: pointer;
transition: var(--transition);
}
.search-btn:hover {
background: #0077ed;
}
.categories {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 32px;
}
.category-tag {
background: #F5F5F7;
padding: 8px 16px;
background: #fff;
border: 1px solid #d2d2d7;
border-radius: var(--border-radius);
font-size: 14px;
position: sticky;
top: 60px;
z-index: 998;
}
.search-container {
max-width: 1200px;
margin: 0 auto;
}
.search-form {
display: flex;
align-items: center;
gap: 8px;
}
.search-wrapper {
flex: 1;
display: flex;
align-items: center;
background: white;
border-radius: 22px;
padding: 0 12px;
height: 44px;
transition: all 0.2s ease;
position: relative;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
.search-wrapper.search-focused {
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.12);
}
.search-icon {
width: 20px;
height: 20px;
color: #999;
flex-shrink: 0;
stroke-linecap: round;
stroke-linejoin: round;
}
.search-wrapper input {
flex: 1;
border: none;
outline: none;
font-size: 16px;
padding: 0 10px;
background: transparent;
color: #333;
font-weight: 400;
}
.search-wrapper input::placeholder {
color: #999;
font-weight: 400;
}
.search-wrapper input::-webkit-search-cancel-button {
display: none;
}
.clear-button {
width: 20px;
height: 20px;
padding: 0;
border: none;
background: transparent;
cursor: pointer;
transition: var(--transition);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: opacity 0.2s;
}
.category-tag:hover {
border-color: #0071e3;
color: #0071e3;
.clear-button:hover {
opacity: 0.7;
}
.category-tag.active {
background: #0071e3;
color: #fff;
border-color: #0071e3;
.clear-button svg {
width: 20px;
height: 20px;
color: #999;
}
.app-grid {
.cancel-button {
background: transparent;
border: none;
color: #007AFF;
font-size: 16px;
font-weight: 500;
cursor: pointer;
padding: 0;
white-space: nowrap;
transition: opacity 0.2s;
}
.cancel-button:hover {
opacity: 0.7;
}
.cancel-button:active {
opacity: 0.4;
}
/* 应用展示区域 */
.app-showcase {
max-width: 1200px;
margin: 0 auto;
padding: 0 15px 20px;
}
/* 分类磁贴网格 */
.categories-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 20px;
margin-bottom: 40px;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 12px;
padding: 15px 0;
}
@media (max-width: 768px) {
.app-grid {
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 12px;
.category-tile {
position: relative;
overflow: hidden;
border-radius: 12px;
padding: 20px 15px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
cursor: pointer;
transition: transform 0.3s ease;
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
}
.category-tile::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 0;
}
.category-tile:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.category-tile:hover .category-icon {
transform: rotate(0deg);
opacity: 0.15;
}
.category-icon {
position: absolute;
right: -10px;
bottom: -10px;
font-size: 48px;
color: white;
opacity: 0.1;
transform: rotate(-15deg);
transition: all 0.3s ease;
z-index: 1;
}
.category-tile-header {
position: relative;
z-index: 1;
text-align: center;
display: flex;
flex-direction: column;
gap: 6px;
}
.category-tile-header h3 {
font-size: 16px;
color: #fff;
margin: 0;
font-weight: 600;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
.app-count {
font-size: 13px;
color: rgba(255, 255, 255, 0.95);
background: rgba(255, 255, 255, 0.25);
padding: 4px 10px;
border-radius: 12px;
display: inline-block;
font-weight: 500;
}
/* 分类颜色 */
.category-color-0::before { background: #FF6B6B; }
.category-color-1::before { background: #4ECDC4; }
.category-color-2::before { background: #45B7D1; }
.category-color-3::before { background: #96CEB4; }
.category-color-4::before { background: #FFB75E; }
.category-color-5::before { background: #D4A5A5; }
.category-color-6::before { background: #9B59B6; }
.category-color-7::before { background: #3498DB; }
.category-color-8::before { background: #2ECC71; }
.category-color-9::before { background: #F1C40F; }
.category-color-10::before { background: #E74C3C; }
.category-color-11::before { background: #1ABC9C; }
.category-color-12::before { background: #8E44AD; }
.category-color-13::before { background: #D35400; }
.category-color-14::before { background: #16A085; }
.category-color-15::before { background: #E67E22; }
/* 应用网格 */
.apps-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(64px, 1fr));
gap: 15px;
background: white;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
padding: 15px;
margin: 15px 0;
}
.app-card {
background: white;
border-radius: 8px;
padding: 8px;
cursor: pointer;
transition: all 0.3s ease;
display: flex;
flex-direction: column;
align-items: center;
}
.app-card:hover {
transform: translateY(-2px);
background: #f5f5f5;
}
.app-icon {
width: 60px;
height: 60px;
margin: 0 auto 8px;
border-radius: 14px;
overflow: hidden;
}
.app-icon img {
width: 100%;
height: 100%;
object-fit: cover;
}
.app-info {
width: 100%;
text-align: center;
}
.app-info h3 {
margin: 0;
font-size: 12px;
color: #333;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
line-height: 1.3;
font-weight: 500;
}
/* 无结果状态 */
.no-results {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 20px;
background: white;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
margin: 15px 0;
}
.no-results-icon {
font-size: 48px;
color: #ccc;
margin-bottom: 15px;
}
.no-results-text {
font-size: 16px;
color: #666;
}
/* 骨架屏 */
.skeleton {
pointer-events: none;
}
.skeleton-box {
background: linear-gradient(90deg, #f0f0f0 25%, #e8e8e8 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: loading 1.5s ease-in-out infinite;
border-radius: 14px;
}
.skeleton-text {
height: 10px;
width: 90%;
margin: 0 auto;
background: linear-gradient(90deg, #f0f0f0 25%, #e8e8e8 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: loading 1.5s ease-in-out infinite;
border-radius: 4px;
}
@keyframes loading {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
/* 分页 */
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 20px;
margin-top: 40px;
margin: 30px 0;
}
.page-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 10px 20px;
background: #fff;
border: 1px solid #d2d2d7;
border-radius: var(--border-radius);
background: white;
border: 1px solid #e0e0e0;
border-radius: 8px;
cursor: pointer;
transition: var(--transition);
transition: all 0.2s;
font-size: 14px;
color: #333;
}
.page-btn:hover:not(:disabled) {
border-color: #0071e3;
color: #0071e3;
border-color: #007AFF;
color: #007AFF;
transform: translateY(-1px);
}
.page-btn:disabled {
@@ -225,14 +793,78 @@ const nextPage = () => {
}
.page-info {
color: #86868b;
color: #666;
font-size: 14px;
font-weight: 500;
}
.loading, .empty {
text-align: center;
padding: 60px 20px;
color: #86868b;
font-size: 16px;
/* 响应式布局 */
@media (max-width: 768px) {
.categories-grid {
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
gap: 8px;
}
.category-tile {
padding: 15px 10px;
}
.category-icon {
font-size: 42px;
right: -8px;
bottom: -8px;
}
.category-tile-header h3 {
font-size: 15px;
}
.app-count {
font-size: 12px;
padding: 3px 8px;
}
.apps-grid {
grid-template-columns: repeat(4, 1fr);
gap: 10px;
padding: 12px;
}
.app-icon {
width: 52px;
height: 52px;
}
.app-info h3 {
font-size: 11px;
}
}
@media (max-width: 480px) {
.site-title h1 {
font-size: 24px;
}
.search-bar {
padding: 8px 12px;
}
.search-wrapper {
height: 40px;
border-radius: 20px;
}
.search-wrapper input {
font-size: 15px;
}
.cancel-button {
font-size: 15px;
}
.page-btn {
padding: 8px 16px;
font-size: 13px;
}
}
</style>

View File

@@ -14,22 +14,34 @@
<img src="/new.png" alt="今日上架" class="explore-bg" />
<div class="new-apps-bar">
<div class="apps-row">
<div
v-for="app in todayApps.slice(0, 10)"
:key="app.app_id"
class="app-card"
@click.stop="goToApp(app.app_id)"
>
<div class="app-icon">
<img :src="app.icon_url" :alt="app.name" loading="lazy" />
<!-- 加载中的骨架屏 -->
<template v-if="isLoading">
<div v-for="i in 10" :key="`skeleton-${i}`" class="app-card skeleton">
<div class="app-icon skeleton-box"></div>
<div class="app-info">
<div class="skeleton-text"></div>
</div>
</div>
<div class="app-info">
<h3>{{ app.name }}</h3>
</template>
<!-- 实际内容 -->
<template v-else>
<div
v-for="app in todayApps.slice(0, 10)"
:key="app.app_id"
class="app-card"
@click.stop="goToApp(app.app_id)"
>
<div class="app-icon">
<img :src="app.icon_url" :alt="app.name" loading="lazy" />
</div>
<div class="app-info">
<h3>{{ app.name }}</h3>
</div>
</div>
</div>
<div v-if="!todayApps.length" class="no-apps-message">
今日暂无新应用
</div>
<div v-if="!todayApps.length" class="no-apps-message">
今天暂无新上架应用
</div>
</template>
</div>
</div>
</article>
@@ -50,28 +62,41 @@
<div class="section-title">
<h2>热门应用</h2>
</div>
<router-link to="/apps" class="view-all">
<router-link to="/hot_apps" class="view-all">
查看全部 <i class="fas fa-chevron-right"></i>
</router-link>
</div>
<div class="apps-list">
<div
v-for="app in topDownloads.slice(0, 5)"
:key="app.app_id"
class="app-item"
@click="goToApp(app.app_id)"
>
<div class="app-icon">
<img :src="app.icon_url" :alt="app.name" loading="lazy" />
<!-- 加载中的骨架屏 -->
<template v-if="isLoading">
<div v-for="i in 5" :key="`skeleton-${i}`" class="app-item skeleton">
<div class="app-icon skeleton-box"></div>
<div class="app-info">
<div class="skeleton-text skeleton-title"></div>
<div class="skeleton-text skeleton-subtitle"></div>
</div>
</div>
<div class="app-info">
<h3>{{ app.name }}</h3>
<p class="app-category">{{ app.kind_name }}</p>
</template>
<!-- 实际内容 -->
<template v-else>
<div
v-for="app in topDownloads.slice(0, 5)"
:key="app.app_id"
class="app-item"
@click="goToApp(app.app_id)"
>
<div class="app-icon">
<img :src="app.icon_url" :alt="app.name" loading="lazy" />
</div>
<div class="app-info">
<h3>{{ app.name }}</h3>
<p class="app-category">{{ app.kind_name }}</p>
</div>
</div>
</div>
<div v-if="!topDownloads.length" class="no-apps-message">
暂无热门应用
</div>
<div v-if="!topDownloads.length" class="no-apps-message">
暂无热门应用
</div>
</template>
</div>
</section>
</section>
@@ -87,37 +112,47 @@ import { appsApi, type AppItem } from '@/api'
const router = useRouter()
const todayApps = ref<AppItem[]>([])
const topDownloads = ref<AppItem[]>([])
const isLoading = ref(true)
const goToApp = (appId: string) => {
router.push(`/app/${appId}`)
}
const goToNewApps = () => {
router.push('/apps')
router.push('/new_apps')
}
const openHarmonyOS = () => {
window.open('https://consumer.huawei.com/cn/harmonyos-computer/harmonyos-5/', '_blank')
}
onMounted(async () => {
// 预加载数据,在组件创建时就开始
const loadData = async () => {
try {
const [today, downloads] = await Promise.all([
appsApi.getTodayApps(20),
appsApi.getTodayApps(100),
appsApi.getTopDownloads(100)
])
if (today.success) {
todayApps.value = today.data
console.log('今日上架应用数量:', todayApps.value.length)
}
if (downloads.success) {
topDownloads.value = downloads.data
console.log('热门应用数量:', topDownloads.value.length)
}
} catch (error) {
console.error('加载数据失败:', error)
} finally {
isLoading.value = false
}
}
// 立即开始加载数据
loadData()
onMounted(() => {
// 如果数据还没加载完成,这里不需要做任何事
// 数据已经在 loadData() 中开始加载了
})
</script>
@@ -392,6 +427,72 @@ onMounted(async () => {
color: #666;
}
/* 骨架屏样式 */
.skeleton {
pointer-events: none;
}
.skeleton-box {
background: linear-gradient(90deg, #f0f0f0 25%, #e8e8e8 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: loading 1.5s ease-in-out infinite;
}
.skeleton-text {
height: 12px;
background: linear-gradient(90deg, #f0f0f0 25%, #e8e8e8 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: loading 1.5s ease-in-out infinite;
border-radius: 4px;
margin: 2px 0;
}
.skeleton-title {
width: 80%;
height: 14px;
margin-bottom: 6px;
}
.skeleton-subtitle {
width: 50%;
height: 12px;
}
.app-card.skeleton .app-icon {
background: linear-gradient(90deg, #f0f0f0 25%, #e8e8e8 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: loading 1.5s ease-in-out infinite;
}
.app-card.skeleton .skeleton-text {
width: 90%;
height: 10px;
margin: 0 auto;
}
.app-item.skeleton {
cursor: default;
}
.app-item.skeleton:hover {
background-color: transparent;
}
.app-item.skeleton .app-icon {
background: linear-gradient(90deg, #f0f0f0 25%, #e8e8e8 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: loading 1.5s ease-in-out infinite;
}
@keyframes loading {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
/* 响应式调整 */
@media (max-width: 1024px) {
.explore-grid {

View File

@@ -0,0 +1,349 @@
<template>
<div class="hot-apps-page">
<div class="header">
<button @click="goBack" class="back-link" title="返回首页">
<i class="fas fa-arrow-left"></i>
</button>
<h1>热门应用</h1>
</div>
<!-- 加载状态 -->
<div v-if="isLoading" class="apps-grid">
<div v-for="i in 12" :key="`skeleton-${i}`" class="app-tile skeleton">
<div class="app-tile-content">
<div class="app-tile-icon skeleton-box"></div>
<div class="app-tile-info">
<div class="app-tile-header">
<div class="skeleton-text skeleton-title"></div>
<div class="skeleton-text skeleton-tag"></div>
</div>
<div class="app-tile-meta">
<div class="skeleton-text skeleton-version"></div>
</div>
</div>
</div>
</div>
</div>
<!-- 应用列表 -->
<div v-else-if="apps.length > 0" class="apps-grid">
<div
v-for="app in apps"
:key="app.app_id"
class="app-tile"
@click="goToApp(app.app_id)"
>
<div class="app-tile-content">
<div class="app-tile-icon">
<img :src="app.icon_url" :alt="app.name" loading="lazy" />
</div>
<div class="app-tile-info">
<div class="app-tile-header">
<h3>{{ app.name }}</h3>
<span class="category-tag">{{ app.kind_name }}</span>
</div>
<div class="app-tile-meta">
<span v-if="app.version" class="version-tag">{{ app.version }}</span>
<span class="download-count">{{ formatDownloads(app.download_count) }}下载</span>
</div>
</div>
</div>
</div>
</div>
<!-- 空状态 -->
<div v-else class="empty-state">
<i class="fas fa-inbox"></i>
<p>暂无热门应用</p>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { appsApi, type AppItem } from '@/api'
const router = useRouter()
const apps = ref<AppItem[]>([])
const isLoading = ref(true)
const goBack = () => {
router.back()
}
const goToApp = (appId: string) => {
router.push(`/app/${appId}`)
}
const formatDownloads = (count: number): string => {
if (count >= 100000000) return `${(count / 100000000).toFixed(1)}亿`
if (count >= 10000) return `${(count / 10000).toFixed(1)}`
return count.toString()
}
const loadApps = async () => {
isLoading.value = true
try {
const res = await appsApi.getTopDownloads(100)
if (res.success) {
apps.value = res.data
}
} catch (error) {
console.error('加载热门应用失败:', error)
apps.value = []
} finally {
isLoading.value = false
}
}
onMounted(() => {
loadApps()
})
</script>
<style scoped>
.hot-apps-page {
max-width: 1200px;
margin: 0 auto;
padding: 15px;
padding-top: 5px;
background: #F5F5F7;
min-height: 100vh;
}
.header {
display: flex;
align-items: center;
gap: 15px;
margin-bottom: 20px;
background: white;
padding: 12px 20px;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.back-link {
color: #666;
text-decoration: none;
padding: 8px;
border-radius: 50%;
background: #f5f5f7;
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
cursor: pointer;
transition: all 0.2s;
}
.back-link:hover {
background: #e5e5e7;
color: #333;
}
.header h1 {
margin: 0;
font-size: 20px;
color: #333;
font-weight: 600;
}
/* 应用网格 */
.apps-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 15px;
}
.app-tile {
background: #fff;
border-radius: 12px;
overflow: hidden;
cursor: pointer;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
transition: all 0.2s;
}
.app-tile:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
transform: translateY(-2px);
}
.app-tile-content {
padding: 15px;
display: flex;
gap: 12px;
}
.app-tile-icon {
width: 64px;
height: 64px;
flex-shrink: 0;
border-radius: 16px;
overflow: hidden;
}
.app-tile-icon img {
width: 100%;
height: 100%;
object-fit: cover;
}
.app-tile-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.app-tile-header {
margin-bottom: 8px;
}
.app-tile-header h3 {
margin: 0 0 6px 0;
font-size: 16px;
color: #333;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.category-tag {
display: inline-block;
font-size: 12px;
color: #666;
background: #f0f0f0;
padding: 2px 8px;
border-radius: 12px;
}
.app-tile-meta {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.version-tag {
font-size: 12px;
color: #007AFF;
background: #E3F2FD;
padding: 2px 8px;
border-radius: 4px;
font-weight: 500;
}
.download-count {
font-size: 12px;
color: #999;
}
/* 空状态 */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 20px;
background: white;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.empty-state i {
font-size: 48px;
color: #ccc;
margin-bottom: 16px;
}
.empty-state p {
font-size: 16px;
color: #666;
margin: 0;
}
/* 骨架屏 */
.skeleton {
pointer-events: none;
}
.skeleton-box {
background: linear-gradient(90deg, #f0f0f0 25%, #e8e8e8 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: loading 1.5s ease-in-out infinite;
border-radius: 16px;
}
.skeleton-text {
background: linear-gradient(90deg, #f0f0f0 25%, #e8e8e8 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: loading 1.5s ease-in-out infinite;
border-radius: 4px;
margin-bottom: 6px;
}
.skeleton-title {
height: 16px;
width: 70%;
}
.skeleton-tag {
height: 12px;
width: 40%;
}
.skeleton-version {
height: 12px;
width: 30%;
}
@keyframes loading {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
/* 响应式布局 */
@media (max-width: 768px) {
.apps-grid {
grid-template-columns: 1fr;
}
.hot-apps-page {
padding: 10px;
padding-top: 5px;
}
.header {
padding: 10px 15px;
}
.header h1 {
font-size: 18px;
}
}
@media (max-width: 480px) {
.app-tile-content {
padding: 12px;
}
.app-tile-icon {
width: 56px;
height: 56px;
}
.app-tile-header h3 {
font-size: 15px;
}
}
</style>

View File

@@ -0,0 +1,412 @@
<template>
<div class="new-apps-page">
<div class="header">
<button @click="goBack" class="back-link" title="返回首页">
<i class="fas fa-arrow-left"></i>
</button>
<h1>{{ dateText }}</h1>
</div>
<div class="date-switcher">
<button
@click="switchDate('today')"
:class="['date-btn', { active: currentDate === 'today' }]"
>
今日
</button>
<button
@click="switchDate('yesterday')"
:class="['date-btn', { active: currentDate === 'yesterday' }]"
>
昨日
</button>
<button
@click="switchDate('before_yesterday')"
:class="['date-btn', { active: currentDate === 'before_yesterday' }]"
>
前日
</button>
</div>
<!-- 加载状态 -->
<div v-if="isLoading" class="apps-grid">
<div v-for="i in 12" :key="`skeleton-${i}`" class="app-tile skeleton">
<div class="app-tile-icon skeleton-box"></div>
<div class="app-tile-info">
<div class="skeleton-text"></div>
</div>
</div>
</div>
<!-- 应用列表 -->
<div v-else-if="apps.length > 0" class="apps-grid">
<div
v-for="app in apps"
:key="app.app_id"
class="app-tile"
@click="goToApp(app.app_id)"
>
<div class="app-tile-content">
<div class="app-tile-icon">
<img :src="app.icon_url" :alt="app.name" loading="lazy" />
</div>
<div class="app-tile-info">
<div class="app-tile-header">
<h3>{{ app.name }}</h3>
</div>
</div>
</div>
</div>
</div>
<!-- 空状态 -->
<div v-else class="empty-state">
<i class="fas fa-inbox"></i>
<p>{{ emptyText }}</p>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { appsApi, type AppItem } from '@/api'
const router = useRouter()
const apps = ref<AppItem[]>([])
const isLoading = ref(true)
const currentDate = ref<'today' | 'yesterday' | 'before_yesterday'>('today')
const dateText = computed(() => {
const dateMap = {
today: '今日上新',
yesterday: '昨日上新',
before_yesterday: '前日上新'
}
return dateMap[currentDate.value]
})
const emptyText = computed(() => {
const textMap = {
today: '今日暂无新上架应用',
yesterday: '昨日暂无新上架应用',
before_yesterday: '前日暂无新上架应用'
}
return textMap[currentDate.value]
})
const goBack = () => {
router.back()
}
const goToApp = (appId: string) => {
router.push(`/app/${appId}`)
}
const switchDate = async (date: 'today' | 'yesterday' | 'before_yesterday') => {
currentDate.value = date
await loadApps()
}
const loadApps = async () => {
isLoading.value = true
try {
// 根据选择的日期计算日期
const today = new Date()
let targetDate = new Date()
if (currentDate.value === 'yesterday') {
targetDate.setDate(today.getDate() - 1)
} else if (currentDate.value === 'before_yesterday') {
targetDate.setDate(today.getDate() - 2)
}
// 格式化日期为 YYYY-MM-DD
const year = targetDate.getFullYear()
const month = String(targetDate.getMonth() + 1).padStart(2, '0')
const day = String(targetDate.getDate()).padStart(2, '0')
const dateStr = `${year}-${month}-${day}`
// 调用API获取指定日期的应用
const res = await appsApi.getAppsByDate(dateStr)
if (res.success) {
apps.value = res.data
}
} catch (error) {
console.error('加载应用失败:', error)
apps.value = []
} finally {
isLoading.value = false
}
}
onMounted(() => {
loadApps()
})
</script>
<style scoped>
.new-apps-page {
max-width: 1200px;
margin: 0 auto;
padding: 15px;
padding-top: 5px;
background: #F5F5F7;
min-height: 100vh;
}
.header {
display: flex;
align-items: center;
gap: 15px;
margin-bottom: 15px;
background: white;
padding: 12px 20px;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.back-link {
color: #666;
text-decoration: none;
padding: 8px;
border-radius: 50%;
background: #f0f0f0;
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
cursor: pointer;
transition: all 0.2s;
}
.back-link:hover {
background: #e0e0e0;
}
.header h1 {
margin: 0;
font-size: 20px;
color: #333;
font-weight: 600;
}
/* 日期切换器 */
.date-switcher {
display: flex;
gap: 8px;
margin-bottom: 15px;
background: white;
padding: 10px;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
justify-content: center;
}
.date-btn {
padding: 8px 20px;
border-radius: 8px;
background: #f0f0f0;
color: #666;
border: none;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.date-btn:hover {
background: #e0e0e0;
}
.date-btn.active {
background: #007AFF;
color: white;
}
/* 应用网格 */
.apps-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
gap: 12px;
background: white;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
padding: 15px;
}
.app-tile {
cursor: pointer;
transition: transform 0.2s;
text-align: center;
padding: 8px;
border-radius: 8px;
display: flex;
flex-direction: column;
align-items: center;
}
.app-tile:hover {
transform: translateY(-2px);
background: #f5f5f5;
}
.app-tile-content {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
}
.app-tile-icon {
width: 60px;
height: 60px;
margin: 0 auto 8px;
border-radius: 14px;
overflow: hidden;
}
.app-tile-icon img {
width: 100%;
height: 100%;
object-fit: cover;
}
.app-tile-info {
width: 100%;
}
.app-tile-header h3 {
margin: 0;
font-size: 12px;
color: #333;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
line-height: 1.3;
max-height: 2.6em;
text-align: center;
font-weight: 500;
}
/* 空状态 */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 20px;
background: white;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.empty-state i {
font-size: 48px;
color: #ccc;
margin-bottom: 16px;
}
.empty-state p {
font-size: 16px;
color: #666;
margin: 0;
}
/* 骨架屏 */
.skeleton {
pointer-events: none;
}
.skeleton-box {
background: linear-gradient(90deg, #f0f0f0 25%, #e8e8e8 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: loading 1.5s ease-in-out infinite;
border-radius: 14px;
}
.skeleton-text {
height: 10px;
width: 90%;
margin: 0 auto;
background: linear-gradient(90deg, #f0f0f0 25%, #e8e8e8 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: loading 1.5s ease-in-out infinite;
border-radius: 4px;
}
@keyframes loading {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
/* 响应式布局 */
@media (max-width: 768px) {
.apps-grid {
grid-template-columns: repeat(auto-fill, minmax(70px, 1fr));
gap: 10px;
padding: 12px;
}
.app-tile-icon {
width: 52px;
height: 52px;
}
.app-tile-header h3 {
font-size: 11px;
}
}
@media (max-width: 480px) {
.new-apps-page {
padding: 10px;
padding-top: 70px;
}
.header {
padding: 10px 15px;
}
.header h1 {
font-size: 18px;
}
.date-switcher {
padding: 8px;
}
.date-btn {
padding: 6px 16px;
font-size: 13px;
}
.apps-grid {
grid-template-columns: repeat(4, 1fr);
gap: 8px;
padding: 10px;
}
.app-tile {
padding: 6px;
}
.app-tile-icon {
width: 48px;
height: 48px;
}
.app-tile-header h3 {
font-size: 11px;
}
}
</style>