初始化鸿蒙应用展示平台项目 - 前后端分离架构

This commit is contained in:
Nvex
2025-10-25 11:45:17 +08:00
commit c0f81dbbe2
92 changed files with 40210 additions and 0 deletions

1
frontend/.env.example Normal file
View File

@@ -0,0 +1 @@
VITE_API_BASE_URL=http://localhost:8000/api

31
frontend/README.md Normal file
View File

@@ -0,0 +1,31 @@
# 前端应用
基于 Vue 3 + TypeScript 的鸿蒙应用展示平台前端。
## 安装
```bash
npm install
```
## 开发
```bash
npm run dev
```
应用将在 http://localhost:5173 启动
## 构建
```bash
npm run build
```
构建产物将输出到 `dist` 目录
## 预览
```bash
npm run preview
```

34
frontend/README_IMAGES.md Normal file
View File

@@ -0,0 +1,34 @@
# 图片资源说明
请将以下图片放置在 `frontend/public/` 目录下:
## 必需图片
1. **new.png** - 今日上架背景图
- 尺寸370x370px
- 格式PNG
- 说明:用于首页左上角的今日上架卡片背景
2. **harmonyos.png** - 鸿蒙系统背景图
- 尺寸370x370px
- 格式PNG
- 说明:用于首页右上角的鸿蒙系统卡片背景
3. **coming.png** - 即将上线背景图
- 尺寸370x370px
- 格式PNG
- 说明:用于首页左下角的即将上线卡片背景
## 图片要求
- 所有图片应为正方形,推荐尺寸 370x370px
- 使用 PNG 格式以支持透明背景
- 文件大小建议控制在 200KB 以内
- 图片应清晰,适合 Retina 显示屏
## 临时方案
如果暂时没有图片,可以使用纯色背景:
- 今日上架:渐变蓝色 (#007AFF#5856D6)
- 鸿蒙系统:渐变紫色 (#667eea#764ba2)
- 即将上线:渐变橙色 (#FF9500#FF3B30)

13
frontend/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>鸿蒙应用展示平台</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

1699
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

22
frontend/package.json Normal file
View File

@@ -0,0 +1,22 @@
{
"name": "harmonyos-app-gallery",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.4.0",
"vue-router": "^4.2.5",
"pinia": "^2.1.7",
"axios": "^1.6.5"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.0",
"typescript": "^5.3.3",
"vite": "^5.0.0",
"vue-tsc": "^1.8.27"
}
}

BIN
frontend/public/coming.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

BIN
frontend/public/new.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

BIN
frontend/public/pc-harmonyos5.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 253 KiB

106
frontend/src/App.vue Normal file
View File

@@ -0,0 +1,106 @@
<template>
<div id="app">
<main class="main-content">
<router-view />
</main>
<nav class="bottom-nav">
<router-link to="/" class="nav-item">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"/>
</svg>
<span>探索</span>
</router-link>
<router-link to="/apps" class="nav-item">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="7" height="7" rx="1"/>
<rect x="14" y="3" width="7" height="7" rx="1"/>
<rect x="14" y="14" width="7" height="7" rx="1"/>
<rect x="3" y="14" width="7" height="7" rx="1"/>
</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"/>
</svg>
<span>我的</span>
</router-link>
</nav>
</div>
</template>
<script setup lang="ts">
</script>
<style scoped>
.bottom-nav {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: rgba(255, 255, 255, 0.8);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
display: flex;
justify-content: space-around;
padding: 8px 0;
box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.05);
z-index: 1000;
border-top: 1px solid rgba(0, 0, 0, 0.05);
}
.nav-item {
display: flex;
flex-direction: column;
align-items: center;
text-decoration: none;
color: #666;
font-size: 12px;
gap: 4px;
padding: 4px 20px;
transition: color 0.3s ease;
}
.nav-icon {
width: 24px;
height: 24px;
stroke-linecap: round;
stroke-linejoin: round;
}
.nav-item span {
font-weight: 400;
}
.nav-item.router-link-active {
color: #007AFF;
}
.main-content {
min-height: 100vh;
padding-bottom: 70px;
background: #fff;
}
/* 确保在 Safari 上也有毛玻璃效果 */
@supports not (backdrop-filter: blur(10px)) {
.bottom-nav {
background: rgba(255, 255, 255, 0.95);
}
}
@media (max-width: 768px) {
.nav-item {
padding: 4px 16px;
}
.nav-icon {
width: 22px;
height: 22px;
}
.nav-item span {
font-size: 11px;
}
}
</style>

79
frontend/src/api/index.ts Normal file
View File

@@ -0,0 +1,79 @@
import axios from 'axios'
const api = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || '/api',
timeout: 30000
})
api.interceptors.response.use(
response => response.data,
error => {
console.error('API Error:', error)
return Promise.reject(error)
}
)
export interface ApiResponse<T = any> {
success: boolean
data: T
total?: number
limit?: number
timestamp: string
}
export interface AppItem {
app_id: string
name: string
pkg_name: string
developer_name: string
kind_name: string
icon_url: string
brief_desc: string
download_count: number
version: string
average_rating: number
total_rating_count?: number
listed_at: string
}
export interface AppDetail extends AppItem {
description: string
privacy_url: string
is_pay: boolean
size_bytes: number
star_1_count: number
star_2_count: number
star_3_count: number
star_4_count: number
star_5_count: number
}
export interface Category {
name: string
count: number
}
export const appsApi = {
search: (q: string, page = 1, pageSize = 20) =>
api.get<any, ApiResponse<AppItem[]>>('/apps/search', { params: { q, page, page_size: pageSize } }),
getCategories: () =>
api.get<any, ApiResponse<Category[]>>('/apps/categories'),
getByCategory: (category: string, page = 1, pageSize = 20) =>
api.get<any, ApiResponse<AppItem[]>>(`/apps/category/${category}`, { params: { page, page_size: pageSize } }),
getTodayApps: (pageSize = 20) =>
api.get<any, ApiResponse<AppItem[]>>('/apps/today', { params: { page_size: pageSize } }),
getTopDownloads: (limit = 100) =>
api.get<any, ApiResponse<AppItem[]>>('/apps/top-downloads', { params: { limit } }),
getTopRatings: (limit = 100) =>
api.get<any, ApiResponse<AppItem[]>>('/apps/top-ratings', { params: { limit } }),
getDetail: (appId: string) =>
api.get<any, ApiResponse<AppDetail>>(`/apps/${appId}`)
}
export default api

View File

@@ -0,0 +1,36 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Helvetica', 'Arial', sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background: #F5F5F7;
color: #1d1d1f;
}
#app {
min-height: 100vh;
}
:root {
--border-radius: 8px;
--card-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
--card-hover-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
--transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 0 24px;
}
@media (max-width: 768px) {
.container {
padding: 0 16px;
}
}

View File

@@ -0,0 +1,122 @@
<template>
<router-link :to="`/app/${app.app_id}`" class="app-card">
<div class="app-icon">
<img :src="app.icon_url" :alt="app.name" />
</div>
<div class="app-info">
<h3 class="app-name">{{ app.name }}</h3>
<p class="app-desc">{{ app.brief_desc }}</p>
<div class="app-meta">
<span class="rating" v-if="app.average_rating">
{{ app.average_rating.toFixed(1) }}
</span>
<span class="downloads">{{ formatDownloads(app.download_count) }}</span>
</div>
</div>
</router-link>
</template>
<script setup lang="ts">
import type { AppItem } from '@/api'
defineProps<{
app: AppItem
}>()
const formatDownloads = (count: number): string => {
if (count >= 100000000) return `${(count / 100000000).toFixed(1)}亿`
if (count >= 10000) return `${(count / 10000).toFixed(1)}`
return count.toString()
}
</script>
<style scoped>
.app-card {
display: block;
background: #fff;
border-radius: var(--border-radius);
padding: 16px;
text-decoration: none;
color: inherit;
box-shadow: var(--card-shadow);
transition: var(--transition);
cursor: pointer;
}
.app-card:hover {
transform: translateY(-4px);
box-shadow: var(--card-hover-shadow);
}
.app-icon {
width: 80px;
height: 80px;
border-radius: var(--border-radius);
overflow: hidden;
margin-bottom: 12px;
}
.app-icon img {
width: 100%;
height: 100%;
object-fit: cover;
}
.app-info {
display: flex;
flex-direction: column;
gap: 6px;
}
.app-name {
font-size: 16px;
font-weight: 600;
color: #1d1d1f;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.app-desc {
font-size: 13px;
color: #86868b;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
line-height: 1.4;
min-height: 36px;
}
.app-meta {
display: flex;
align-items: center;
gap: 12px;
font-size: 12px;
color: #86868b;
}
.rating {
color: #f5a623;
}
@media (max-width: 768px) {
.app-card {
padding: 12px;
}
.app-icon {
width: 60px;
height: 60px;
}
.app-name {
font-size: 14px;
}
.app-desc {
font-size: 12px;
}
}
</style>

10
frontend/src/main.ts Normal file
View File

@@ -0,0 +1,10 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
import './assets/styles/main.css'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.mount('#app')

View File

@@ -0,0 +1,33 @@
import { createRouter, createWebHistory } from 'vue-router'
import Home from '@/views/Home.vue'
import Apps from '@/views/Apps.vue'
import AppDetail from '@/views/AppDetail.vue'
import Profile from '@/views/Profile.vue'
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/',
name: 'Home',
component: Home
},
{
path: '/apps',
name: 'Apps',
component: Apps
},
{
path: '/app/:id',
name: 'AppDetail',
component: AppDetail
},
{
path: '/profile',
name: 'Profile',
component: Profile
}
]
})
export default router

View File

@@ -0,0 +1,303 @@
<template>
<div class="app-detail" v-if="app">
<div class="container">
<div class="detail-header">
<div class="app-icon">
<img :src="app.icon_url" :alt="app.name" />
</div>
<div class="app-header-info">
<h1 class="app-title">{{ app.name }}</h1>
<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>
<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>
</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>
</section>
</div>
</div>
</div>
<div v-else class="loading">加载中...</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { appsApi, type AppDetail } from '@/api'
const route = useRoute()
const app = ref<AppDetail | null>(null)
onMounted(async () => {
try {
const res = await appsApi.getDetail(route.params.id as string)
if (res.success) app.value = res.data
} catch (error) {
console.error('加载应用详情失败:', error)
}
})
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 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`
}
const formatDate = (date: string): string => {
return new Date(date).toLocaleDateString('zh-CN')
}
const getRatingCount = (star: number): number => {
if (!app.value) return 0
return app.value[`star_${star}_count` as keyof AppDetail] as number
}
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
}
</script>
<style scoped>
.app-detail {
padding: 40px 0;
}
.detail-header {
display: flex;
gap: 24px;
margin-bottom: 40px;
padding: 32px;
background: #fff;
border-radius: var(--border-radius);
box-shadow: var(--card-shadow);
}
.app-icon {
width: 120px;
height: 120px;
border-radius: var(--border-radius);
overflow: hidden;
flex-shrink: 0;
}
.app-icon img {
width: 100%;
height: 100%;
object-fit: cover;
}
.app-header-info {
flex: 1;
}
.app-title {
font-size: 32px;
font-weight: 600;
margin-bottom: 8px;
}
.app-developer {
font-size: 16px;
color: #86868b;
margin-bottom: 20px;
}
.app-stats {
display: flex;
gap: 32px;
}
.stat {
display: flex;
flex-direction: column;
gap: 4px;
}
.stat-label {
font-size: 12px;
color: #86868b;
}
.stat-value {
font-size: 18px;
font-weight: 600;
}
.detail-content {
display: flex;
flex-direction: column;
gap: 32px;
}
.section {
background: #fff;
padding: 32px;
border-radius: var(--border-radius);
box-shadow: var(--card-shadow);
}
.section-title {
font-size: 20px;
font-weight: 600;
margin-bottom: 16px;
}
.app-description {
line-height: 1.6;
color: #1d1d1f;
white-space: pre-wrap;
}
.rating-bars {
display: flex;
flex-direction: column;
gap: 12px;
}
.rating-bar {
display: flex;
align-items: center;
gap: 12px;
}
.star-label {
width: 40px;
font-size: 14px;
color: #86868b;
}
.bar {
flex: 1;
height: 8px;
background: #f5f5f7;
border-radius: 4px;
overflow: hidden;
}
.bar-fill {
height: 100%;
background: #f5a623;
transition: width 0.3s;
}
.count {
width: 60px;
text-align: right;
font-size: 14px;
color: #86868b;
}
.info-list {
display: flex;
flex-direction: column;
gap: 16px;
}
.info-item {
display: flex;
justify-content: space-between;
padding-bottom: 16px;
border-bottom: 1px solid #f5f5f7;
}
.info-item:last-child {
border-bottom: none;
padding-bottom: 0;
}
.info-label {
font-size: 14px;
color: #86868b;
}
.info-value {
font-size: 14px;
color: #1d1d1f;
}
@media (max-width: 768px) {
.detail-header {
flex-direction: column;
padding: 20px;
}
.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;
}
</style>

238
frontend/src/views/Apps.vue Normal file
View File

@@ -0,0 +1,238 @@
<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>
<div class="categories">
<button
v-for="category in categories"
:key="category.name"
@click="selectCategory(category.name)"
:class="['category-tag', { active: selectedCategory === category.name }]"
>
{{ category.name }} ({{ category.count }})
</button>
</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>
<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>
<span class="page-info">{{ page }} / {{ Math.ceil(total / pageSize) }}</span>
<button @click="nextPage" :disabled="page >= Math.ceil(total / pageSize)" class="page-btn">下一页</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { appsApi, type AppItem, type Category } from '@/api'
import AppCard from '@/components/AppCard.vue'
const searchQuery = ref('')
const selectedCategory = ref('')
const categories = ref<Category[]>([])
const apps = ref<AppItem[]>([])
const page = ref(1)
const pageSize = ref(20)
const total = ref(0)
const loading = ref(false)
onMounted(async () => {
try {
const res = await appsApi.getCategories()
if (res.success) categories.value = res.data
} catch (error) {
console.error('加载分类失败:', error)
}
})
const selectCategory = async (category: string) => {
selectedCategory.value = category
searchQuery.value = ''
page.value = 1
await loadApps()
}
const handleSearch = async () => {
if (!searchQuery.value.trim()) return
selectedCategory.value = ''
page.value = 1
await loadApps()
}
const loadApps = async () => {
loading.value = true
try {
let res
if (searchQuery.value) {
res = await appsApi.search(searchQuery.value, page.value, pageSize.value)
} else if (selectedCategory.value) {
res = await appsApi.getByCategory(selectedCategory.value, page.value, pageSize.value)
}
if (res?.success) {
apps.value = res.data
total.value = res.total || 0
}
} catch (error) {
console.error('加载应用失败:', error)
} finally {
loading.value = false
}
}
const prevPage = () => {
if (page.value > 1) {
page.value--
loadApps()
}
}
const nextPage = () => {
if (page.value < Math.ceil(total.value / pageSize.value)) {
page.value++
loadApps()
}
}
</script>
<style scoped>
.apps-page {
padding: 40px 0;
background: #fff;
min-height: 100vh;
}
.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 {
padding: 8px 16px;
background: #fff;
border: 1px solid #d2d2d7;
border-radius: var(--border-radius);
font-size: 14px;
cursor: pointer;
transition: var(--transition);
}
.category-tag:hover {
border-color: #0071e3;
color: #0071e3;
}
.category-tag.active {
background: #0071e3;
color: #fff;
border-color: #0071e3;
}
.app-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 20px;
margin-bottom: 40px;
}
@media (max-width: 768px) {
.app-grid {
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 12px;
}
}
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 20px;
margin-top: 40px;
}
.page-btn {
padding: 10px 20px;
background: #fff;
border: 1px solid #d2d2d7;
border-radius: var(--border-radius);
cursor: pointer;
transition: var(--transition);
}
.page-btn:hover:not(:disabled) {
border-color: #0071e3;
color: #0071e3;
}
.page-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.page-info {
color: #86868b;
font-size: 14px;
}
.loading, .empty {
text-align: center;
padding: 60px 20px;
color: #86868b;
font-size: 16px;
}
</style>

429
frontend/src/views/Home.vue Normal file
View File

@@ -0,0 +1,429 @@
<template>
<div class="home">
<!-- 顶部标题栏 -->
<header class="site-header">
<div class="site-title">
<h1>探索</h1>
</div>
</header>
<main class="explore-container">
<section class="explore-grid">
<!-- 左上今日上架卡片 -->
<article class="explore-item fixed-size" @click="goToNewApps">
<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" />
</div>
<div class="app-info">
<h3>{{ app.name }}</h3>
</div>
</div>
<div v-if="!todayApps.length" class="no-apps-message">
今日暂无新应用
</div>
</div>
</div>
</article>
<!-- 右上鸿蒙系统卡片 -->
<article class="explore-item fixed-size" @click="openHarmonyOS">
<img src="/pc-harmonyos5.png" alt="鸿蒙操作系统" class="explore-bg" />
</article>
<!-- 左下即将上线卡片 -->
<article class="explore-item fixed-size">
<img src="/coming.png" alt="即将上线" class="explore-bg" />
</article>
<!-- 右下热门应用区域 -->
<section class="hot-apps-section">
<div class="section-header">
<div class="section-title">
<h2>热门应用</h2>
</div>
<router-link to="/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" />
</div>
<div class="app-info">
<h3>{{ app.name }}</h3>
<p class="app-category">{{ app.kind_name }}</p>
</div>
</div>
<div v-if="!topDownloads.length" class="no-apps-message">
暂无热门应用
</div>
</div>
</section>
</section>
</main>
</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 todayApps = ref<AppItem[]>([])
const topDownloads = ref<AppItem[]>([])
const goToApp = (appId: string) => {
router.push(`/app/${appId}`)
}
const goToNewApps = () => {
router.push('/apps')
}
const openHarmonyOS = () => {
window.open('https://consumer.huawei.com/cn/harmonyos-computer/harmonyos-5/', '_blank')
}
onMounted(async () => {
try {
const [today, downloads] = await Promise.all([
appsApi.getTodayApps(20),
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)
}
})
</script>
<style scoped>
/* 顶部标题栏 */
.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;
}
.home {
padding-top: 60px;
background: #F5F5F7;
min-height: 100vh;
}
.explore-container {
padding: 15px;
margin-bottom: 20px;
width: 100%;
max-width: 800px;
margin-left: auto;
margin-right: auto;
}
.explore-grid {
display: grid;
grid-template-columns: repeat(2, 370px);
grid-template-rows: repeat(2, auto);
gap: 15px;
justify-content: center;
}
.explore-item {
background: white;
border-radius: 24px;
overflow: hidden;
transition: transform 0.3s ease;
position: relative;
width: 370px;
height: 370px;
line-height: 0;
cursor: pointer;
}
.explore-item:hover {
transform: translateY(-2px);
}
.explore-bg {
width: 100%;
height: 100%;
object-fit: cover;
}
/* 今日上架应用条 */
.new-apps-bar {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: transparent;
padding: 5px 0;
height: 100px;
}
.apps-row {
display: flex;
overflow-x: auto;
overflow-y: hidden;
scroll-behavior: smooth;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
-ms-overflow-style: none;
gap: 2px;
height: 85px;
align-items: center;
}
.apps-row::-webkit-scrollbar {
display: none;
}
.app-card {
flex: 0 0 auto;
width: 64px;
height: 85px;
padding: 4px;
background: transparent;
cursor: pointer;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.app-card .app-icon {
width: 48px;
height: 48px;
margin: 0 auto 4px;
border-radius: 12px;
overflow: hidden;
background: white;
}
.app-card .app-icon img {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 12px;
}
.app-card .app-info {
width: 100%;
text-align: center;
}
.app-card .app-info h3 {
font-size: 11px;
margin: 0;
color: #333;
line-height: 1.2;
max-height: 2.4em;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
padding: 0 2px;
}
.no-apps-message {
width: 100%;
text-align: center;
padding: 15px;
color: #333;
font-size: 14px;
display: flex;
align-items: center;
justify-content: center;
}
/* 热门应用区域 */
.hot-apps-section {
background: white;
border-radius: 24px;
padding: 15px;
height: 370px;
overflow-y: auto;
width: 370px;
}
.hot-apps-section::-webkit-scrollbar {
width: 4px;
}
.hot-apps-section::-webkit-scrollbar-track {
background: transparent;
}
.hot-apps-section::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.1);
border-radius: 2px;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
}
.section-title h2 {
margin: 0;
font-size: 18px;
font-weight: 600;
color: #1a1a1a;
}
.view-all {
color: #007AFF;
text-decoration: none;
font-size: 15px;
font-weight: 500;
display: flex;
align-items: center;
gap: 4px;
}
.view-all i {
font-size: 12px;
}
.apps-list {
display: flex;
flex-direction: column;
}
.app-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 8px;
border-bottom: 1px solid #f0f0f0;
transition: background-color 0.3s ease;
cursor: pointer;
}
.app-item:last-child {
border-bottom: none;
}
.app-item:hover {
background-color: #f5f5f5;
}
.app-item .app-icon {
width: 56px;
height: 56px;
border-radius: 12px;
overflow: hidden;
background: #f5f5f5;
flex-shrink: 0;
}
.app-item .app-icon img {
width: 100%;
height: 100%;
object-fit: cover;
}
.app-item .app-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
justify-content: center;
padding-right: 5px;
}
.app-item .app-info h3 {
margin: 0 0 4px;
font-size: 17px;
font-weight: 500;
color: #1a1a1a;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.app-category {
margin: 0;
font-size: 13px;
color: #666;
}
/* 响应式调整 */
@media (max-width: 1024px) {
.explore-grid {
grid-template-columns: repeat(2, 370px);
}
}
@media (max-width: 800px) {
.explore-grid {
grid-template-columns: 370px;
gap: 10px;
}
.explore-container {
padding: 10px;
}
.hot-apps-section {
margin: 0 auto;
height: auto;
}
}
@media (max-width: 400px) {
.explore-item.fixed-size {
width: calc(100% - 20px);
margin: 0 10px;
}
.hot-apps-section {
width: calc(100% - 20px);
margin: 20px 10px;
}
}
</style>

View File

@@ -0,0 +1,99 @@
<template>
<div class="profile">
<div class="container">
<div class="profile-card">
<h1 class="title">个人中心</h1>
<p class="subtitle">此页面正在开发中</p>
<div class="info-section">
<h2 class="section-title">登录信息</h2>
<p class="placeholder">登录功能即将上线</p>
</div>
<div class="info-section">
<h2 class="section-title">相关链接</h2>
<div class="links">
<a href="#" class="link">隐私政策</a>
<a href="#" class="link">Cookie 使用条款</a>
<a href="#" class="link">服务条款</a>
<a href="#" class="link">关于我们</a>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
</script>
<style scoped>
.profile {
padding: 40px 0;
min-height: calc(100vh - 60px);
background: #fff;
}
.profile-card {
background: #fff;
border-radius: var(--border-radius);
padding: 48px;
box-shadow: var(--card-shadow);
max-width: 800px;
margin: 0 auto;
}
.title {
font-size: 32px;
font-weight: 600;
margin-bottom: 8px;
}
.subtitle {
font-size: 16px;
color: #86868b;
margin-bottom: 40px;
}
.info-section {
margin-bottom: 32px;
}
.section-title {
font-size: 20px;
font-weight: 600;
margin-bottom: 16px;
}
.placeholder {
color: #86868b;
font-size: 14px;
}
.links {
display: flex;
flex-direction: column;
gap: 12px;
}
.link {
color: #0071e3;
text-decoration: none;
font-size: 15px;
transition: var(--transition);
}
.link:hover {
color: #0077ed;
}
@media (max-width: 768px) {
.profile-card {
padding: 24px;
}
.title {
font-size: 24px;
}
}
</style>

25
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "preserve",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

21
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,21 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': resolve(__dirname, 'src')
}
},
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:8000',
changeOrigin: true
}
}
}
})