初始化鸿蒙应用展示平台项目 - 前后端分离架构
This commit is contained in:
1
frontend/.env.example
Normal file
1
frontend/.env.example
Normal file
@@ -0,0 +1 @@
|
||||
VITE_API_BASE_URL=http://localhost:8000/api
|
||||
31
frontend/README.md
Normal file
31
frontend/README.md
Normal 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
34
frontend/README_IMAGES.md
Normal 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
13
frontend/index.html
Normal 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
1699
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
22
frontend/package.json
Normal file
22
frontend/package.json
Normal 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
BIN
frontend/public/coming.png
Executable file
Binary file not shown.
|
After Width: | Height: | Size: 66 KiB |
BIN
frontend/public/new.png
Executable file
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
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
106
frontend/src/App.vue
Normal 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
79
frontend/src/api/index.ts
Normal 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
|
||||
36
frontend/src/assets/styles/main.css
Normal file
36
frontend/src/assets/styles/main.css
Normal 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;
|
||||
}
|
||||
}
|
||||
122
frontend/src/components/AppCard.vue
Normal file
122
frontend/src/components/AppCard.vue
Normal 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
10
frontend/src/main.ts
Normal 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')
|
||||
33
frontend/src/router/index.ts
Normal file
33
frontend/src/router/index.ts
Normal 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
|
||||
303
frontend/src/views/AppDetail.vue
Normal file
303
frontend/src/views/AppDetail.vue
Normal 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
238
frontend/src/views/Apps.vue
Normal 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
429
frontend/src/views/Home.vue
Normal 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>
|
||||
99
frontend/src/views/Profile.vue
Normal file
99
frontend/src/views/Profile.vue
Normal 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
25
frontend/tsconfig.json
Normal 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" }]
|
||||
}
|
||||
10
frontend/tsconfig.node.json
Normal file
10
frontend/tsconfig.node.json
Normal 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
21
frontend/vite.config.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user