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

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

37
.gitignore vendored Normal file
View File

@@ -0,0 +1,37 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
venv/
env/
ENV/
.venv
*.egg-info/
dist/
build/
# Node
node_modules/
dist/
.DS_Store
*.local
# Environment
.env
.env.local
# IDE
.vscode/
.idea/
*.swp
*.swo
# Logs
*.log
logs/
# Database
*.db
*.sqlite

166
README.md Normal file
View File

@@ -0,0 +1,166 @@
# 鸿蒙应用展示平台
基于华为应用市场数据的鸿蒙应用展示平台,采用前后端分离架构,风格简约美观,参考 Apple 设计。
## 项目特点
- 🎨 简约美观的 Apple 风格设计
- 📱 完美适配移动端和 PC 端
- 🎯 磁贴效果展示,统一 8px 圆角
- 🚀 前后端分离架构
- ⚡ 基于 Vue 3 + TypeScript + FastAPI
## 功能模块
### 首页
- **今日上架**: 展示当天上架的应用
- **热门应用 Top 100**: 按下载量排序的热门应用
- **高分应用 Top 100**: 按评分排序的优质应用
### 应用页面
- **分类标签**: 展示各个分类的 tag 标签
- **分类浏览**: 点击标签查看该分类下的应用
- **搜索功能**: 顶部搜索栏搜索数据库中的所有应用
- **分页展示**: 支持分页浏览应用列表
### 我的页面
- **个人信息**: 展示登录信息(待开发)
- **隐私政策**: 隐私政策链接
- **Cookie 使用条款**: Cookie 相关说明
- **服务条款**: 服务条款链接
## 技术栈
### 后端
- Python 3.10+
- FastAPI - Web 框架
- SQLAlchemy - ORM
- MySQL 8.0+ - 数据库
- aiomysql - 异步 MySQL 驱动
### 前端
- Vue 3 - 渐进式框架
- TypeScript - 类型安全
- Vite - 构建工具
- Vue Router - 路由管理
- Pinia - 状态管理
- Axios - HTTP 客户端
## 快速开始
### 环境要求
- Python 3.10+
- Node.js 18+
- MySQL 8.0+
### 后端设置
```bash
cd backend
# 创建虚拟环境
python -m venv venv
source venv/bin/activate # Windows: venv\Scripts\activate
# 安装依赖
pip install -r requirements.txt
# 配置环境变量
cp .env.example .env
# 编辑 .env 文件,配置数据库连接
# 运行服务
python -m app.main
```
后端服务将在 http://localhost:8000 启动
### 前端设置
```bash
cd frontend
# 安装依赖
npm install
# 配置环境变量
cp .env.example .env
# 启动开发服务器
npm run dev
```
前端服务将在 http://localhost:5173 启动
### 数据库设置
参考 `华为应用市场爬虫系统开发文档.md` 中的数据库设计章节,创建相应的数据表。
## 项目结构
```
.
├── backend/ # 后端项目
│ ├── app/
│ │ ├── api/ # API 路由
│ │ ├── models/ # 数据模型
│ │ ├── schemas/ # Pydantic 模型
│ │ ├── config.py # 配置文件
│ │ ├── database.py # 数据库连接
│ │ └── main.py # 应用入口
│ ├── requirements.txt # Python 依赖
│ └── .env.example # 环境变量示例
├── frontend/ # 前端项目
│ ├── src/
│ │ ├── api/ # API 封装
│ │ ├── assets/ # 静态资源
│ │ ├── components/ # 组件
│ │ ├── router/ # 路由配置
│ │ ├── views/ # 页面
│ │ ├── App.vue # 根组件
│ │ └── main.ts # 应用入口
│ ├── package.json # Node 依赖
│ ├── vite.config.ts # Vite 配置
│ └── .env.example # 环境变量示例
└── README.md # 项目说明
```
## API 接口
### 应用相关
- `GET /api/apps/search?q={query}` - 搜索应用
- `GET /api/apps/categories` - 获取所有分类
- `GET /api/apps/category/{category}` - 按分类获取应用
- `GET /api/apps/today` - 获取今日上架应用
- `GET /api/apps/top-downloads` - 热门应用 Top 100
- `GET /api/apps/top-ratings` - 高分应用 Top 100
- `GET /api/apps/{app_id}` - 获取应用详情
## 设计规范
- **圆角**: 统一使用 8px 圆角
- **配色**: 参考 Apple 设计,以白色、灰色为主
- **字体**: 使用系统默认字体栈
- **阴影**: 使用柔和的阴影效果
- **过渡**: 使用流畅的过渡动画
## 开发计划
- [x] 基础架构搭建
- [x] 首页模块
- [x] 应用列表页
- [x] 应用详情页
- [x] 搜索功能
- [x] 分类浏览
- [ ] 用户登录
- [ ] 个人中心完善
- [ ] 应用收藏
- [ ] 评论功能
## 许可证
MIT License

9
backend/.env.example Normal file
View File

@@ -0,0 +1,9 @@
MYSQL_HOST=localhost
MYSQL_PORT=3306
MYSQL_USER=root
MYSQL_PASSWORD=your_password
MYSQL_DATABASE=huawei_market
API_PREFIX=/api
DEBUG=False
CORS_ORIGINS=["http://localhost:5173","http://localhost:3000"]

40
backend/README.md Normal file
View File

@@ -0,0 +1,40 @@
# 后端 API 服务
基于 FastAPI 的鸿蒙应用展示平台后端服务。
## 安装
```bash
# 创建虚拟环境
python -m venv venv
source venv/bin/activate
# 安装依赖
pip install -r requirements.txt
```
## 配置
复制 `.env.example``.env` 并配置数据库连接:
```env
MYSQL_HOST=localhost
MYSQL_PORT=3306
MYSQL_USER=root
MYSQL_PASSWORD=your_password
MYSQL_DATABASE=huawei_market
```
## 运行
```bash
python -m app.main
```
服务将在 http://localhost:8000 启动
## API 文档
启动服务后访问:
- Swagger UI: http://localhost:8000/docs
- ReDoc: http://localhost:8000/redoc

1
backend/app/__init__.py Normal file
View File

@@ -0,0 +1 @@
# Backend Application

View File

@@ -0,0 +1 @@
# API Routes

331
backend/app/api/apps.py Normal file
View File

@@ -0,0 +1,331 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, and_, or_
from datetime import datetime, timedelta
from typing import Optional
from app.database import get_db
from app.models import AppInfo, AppMetrics, AppRating
from app.schemas import ApiResponse
router = APIRouter(prefix="/apps", tags=["应用"])
@router.get("/search")
async def search_apps(
q: str = Query(..., min_length=1),
page: int = Query(1, ge=1),
page_size: int = Query(20, le=100),
db: AsyncSession = Depends(get_db)
):
"""搜索应用"""
subquery = (
select(AppMetrics.app_id, func.max(AppMetrics.created_at).label('max_created_at'))
.group_by(AppMetrics.app_id)
.subquery()
)
query = (
select(AppInfo, AppMetrics, AppRating)
.join(AppMetrics, AppInfo.app_id == AppMetrics.app_id)
.outerjoin(AppRating, AppInfo.app_id == AppRating.app_id)
.join(subquery, and_(
AppMetrics.app_id == subquery.c.app_id,
AppMetrics.created_at == subquery.c.max_created_at
))
.where(or_(
AppInfo.name.like(f"%{q}%"),
AppInfo.pkg_name.like(f"%{q}%"),
AppInfo.developer_name.like(f"%{q}%")
))
.order_by(AppMetrics.download_count.desc())
)
count_query = select(func.count(AppInfo.app_id)).where(or_(
AppInfo.name.like(f"%{q}%"),
AppInfo.pkg_name.like(f"%{q}%"),
AppInfo.developer_name.like(f"%{q}%")
))
total_result = await db.execute(count_query)
total = total_result.scalar()
offset = (page - 1) * page_size
query = query.offset(offset).limit(page_size)
result = await db.execute(query)
rows = result.all()
data = [{
"app_id": row[0].app_id,
"name": row[0].name,
"pkg_name": row[0].pkg_name,
"developer_name": row[0].developer_name,
"kind_name": row[0].kind_name,
"icon_url": row[0].icon_url,
"brief_desc": row[0].brief_desc,
"download_count": row[1].download_count if len(row) > 1 else 0,
"version": row[1].version if len(row) > 1 else "",
"average_rating": float(row[2].average_rating) if len(row) > 2 and row[2] else 0,
"listed_at": row[0].listed_at.isoformat()
} for row in rows]
return ApiResponse(success=True, data=data, total=total, limit=page_size)
@router.get("/category/{category}")
async def get_apps_by_category(
category: str,
page: int = Query(1, ge=1),
page_size: int = Query(20, le=100),
db: AsyncSession = Depends(get_db)
):
"""按分类获取应用"""
subquery = (
select(AppMetrics.app_id, func.max(AppMetrics.created_at).label('max_created_at'))
.group_by(AppMetrics.app_id)
.subquery()
)
query = (
select(AppInfo, AppMetrics, AppRating)
.join(AppMetrics, AppInfo.app_id == AppMetrics.app_id)
.outerjoin(AppRating, AppInfo.app_id == AppRating.app_id)
.join(subquery, and_(
AppMetrics.app_id == subquery.c.app_id,
AppMetrics.created_at == subquery.c.max_created_at
))
.where(AppInfo.kind_name == category)
.order_by(AppMetrics.download_count.desc())
)
count_query = select(func.count(AppInfo.app_id)).where(AppInfo.kind_name == category)
total_result = await db.execute(count_query)
total = total_result.scalar()
offset = (page - 1) * page_size
query = query.offset(offset).limit(page_size)
result = await db.execute(query)
rows = result.all()
data = [{
"app_id": row[0].app_id,
"name": row[0].name,
"pkg_name": row[0].pkg_name,
"developer_name": row[0].developer_name,
"kind_name": row[0].kind_name,
"icon_url": row[0].icon_url,
"brief_desc": row[0].brief_desc,
"download_count": row[1].download_count if len(row) > 1 else 0,
"version": row[1].version if len(row) > 1 else "",
"average_rating": float(row[2].average_rating) if len(row) > 2 and row[2] else 0,
"listed_at": row[0].listed_at.isoformat()
} for row in rows]
return ApiResponse(success=True, data=data, total=total, limit=page_size)
@router.get("/categories")
async def get_categories(db: AsyncSession = Depends(get_db)):
"""获取所有分类"""
result = await db.execute(
select(AppInfo.kind_name, func.count(AppInfo.app_id).label('count'))
.group_by(AppInfo.kind_name)
.order_by(func.count(AppInfo.app_id).desc())
)
rows = result.all()
data = [{"name": row[0], "count": row[1]} for row in rows]
return ApiResponse(success=True, data=data)
@router.get("/today")
async def get_today_apps(
page_size: int = Query(20, le=100),
db: AsyncSession = Depends(get_db)
):
"""获取今日上架应用"""
today = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
subquery = (
select(AppMetrics.app_id, func.max(AppMetrics.created_at).label('max_created_at'))
.group_by(AppMetrics.app_id)
.subquery()
)
query = (
select(AppInfo, AppMetrics, AppRating)
.join(AppMetrics, AppInfo.app_id == AppMetrics.app_id)
.outerjoin(AppRating, AppInfo.app_id == AppRating.app_id)
.join(subquery, and_(
AppMetrics.app_id == subquery.c.app_id,
AppMetrics.created_at == subquery.c.max_created_at
))
.where(AppInfo.listed_at >= today)
.order_by(AppInfo.listed_at.desc())
.limit(page_size)
)
result = await db.execute(query)
rows = result.all()
data = [{
"app_id": row[0].app_id,
"name": row[0].name,
"pkg_name": row[0].pkg_name,
"developer_name": row[0].developer_name,
"kind_name": row[0].kind_name,
"icon_url": row[0].icon_url,
"brief_desc": row[0].brief_desc,
"download_count": row[1].download_count if len(row) > 1 else 0,
"version": row[1].version if len(row) > 1 else "",
"average_rating": float(row[2].average_rating) if len(row) > 2 and row[2] else 0,
"listed_at": row[0].listed_at.isoformat()
} for row in rows]
return ApiResponse(success=True, data=data, total=len(data))
@router.get("/top-downloads")
async def get_top_downloads(
limit: int = Query(100, le=100),
db: AsyncSession = Depends(get_db)
):
"""热门应用Top100"""
subquery = (
select(AppMetrics.app_id, func.max(AppMetrics.created_at).label('max_created_at'))
.group_by(AppMetrics.app_id)
.subquery()
)
query = (
select(AppInfo, AppMetrics, AppRating)
.join(AppMetrics, AppInfo.app_id == AppMetrics.app_id)
.outerjoin(AppRating, AppInfo.app_id == AppRating.app_id)
.join(subquery, and_(
AppMetrics.app_id == subquery.c.app_id,
AppMetrics.created_at == subquery.c.max_created_at
))
.order_by(AppMetrics.download_count.desc())
.limit(limit)
)
result = await db.execute(query)
rows = result.all()
data = [{
"app_id": row[0].app_id,
"name": row[0].name,
"pkg_name": row[0].pkg_name,
"developer_name": row[0].developer_name,
"kind_name": row[0].kind_name,
"icon_url": row[0].icon_url,
"brief_desc": row[0].brief_desc,
"download_count": row[1].download_count if len(row) > 1 else 0,
"version": row[1].version if len(row) > 1 else "",
"average_rating": float(row[2].average_rating) if len(row) > 2 and row[2] else 0
} for row in rows]
return ApiResponse(success=True, data=data, total=len(data))
@router.get("/top-ratings")
async def get_top_ratings(
limit: int = Query(100, le=100),
db: AsyncSession = Depends(get_db)
):
"""评分Top100"""
subquery_metric = (
select(AppMetrics.app_id, func.max(AppMetrics.created_at).label('max_created_at'))
.group_by(AppMetrics.app_id)
.subquery()
)
subquery_rating = (
select(AppRating.app_id, func.max(AppRating.created_at).label('max_created_at'))
.group_by(AppRating.app_id)
.subquery()
)
query = (
select(AppInfo, AppMetrics, AppRating)
.join(AppMetrics, AppInfo.app_id == AppMetrics.app_id)
.join(AppRating, AppInfo.app_id == AppRating.app_id)
.join(subquery_metric, and_(
AppMetrics.app_id == subquery_metric.c.app_id,
AppMetrics.created_at == subquery_metric.c.max_created_at
))
.join(subquery_rating, and_(
AppRating.app_id == subquery_rating.c.app_id,
AppRating.created_at == subquery_rating.c.max_created_at
))
.where(AppRating.total_rating_count >= 100)
.order_by(AppRating.average_rating.desc())
.limit(limit)
)
result = await db.execute(query)
rows = result.all()
data = [{
"app_id": row[0].app_id,
"name": row[0].name,
"pkg_name": row[0].pkg_name,
"developer_name": row[0].developer_name,
"kind_name": row[0].kind_name,
"icon_url": row[0].icon_url,
"brief_desc": row[0].brief_desc,
"download_count": row[1].download_count if len(row) > 1 else 0,
"version": row[1].version if len(row) > 1 else "",
"average_rating": float(row[2].average_rating) if len(row) > 2 and row[2] else 0,
"total_rating_count": row[2].total_rating_count if len(row) > 2 and row[2] else 0
} for row in rows]
return ApiResponse(success=True, data=data, total=len(data))
@router.get("/{app_id}")
async def get_app_detail(app_id: str, db: AsyncSession = Depends(get_db)):
"""获取应用详情"""
subquery = (
select(AppMetrics.app_id, func.max(AppMetrics.created_at).label('max_created_at'))
.where(AppMetrics.app_id == app_id)
.group_by(AppMetrics.app_id)
.subquery()
)
query = (
select(AppInfo, AppMetrics, AppRating)
.join(AppMetrics, AppInfo.app_id == AppMetrics.app_id)
.outerjoin(AppRating, AppInfo.app_id == AppRating.app_id)
.join(subquery, and_(
AppMetrics.app_id == subquery.c.app_id,
AppMetrics.created_at == subquery.c.max_created_at
))
.where(AppInfo.app_id == app_id)
)
result = await db.execute(query)
row = result.first()
if not row:
raise HTTPException(status_code=404, detail="应用不存在")
data = {
"app_id": row[0].app_id,
"name": row[0].name,
"pkg_name": row[0].pkg_name,
"developer_name": row[0].developer_name,
"kind_name": row[0].kind_name,
"icon_url": row[0].icon_url,
"brief_desc": row[0].brief_desc,
"description": row[0].description,
"privacy_url": row[0].privacy_url,
"is_pay": row[0].is_pay,
"listed_at": row[0].listed_at.isoformat(),
"download_count": row[1].download_count if len(row) > 1 else 0,
"version": row[1].version if len(row) > 1 else "",
"size_bytes": row[1].size_bytes if len(row) > 1 else 0,
"average_rating": float(row[2].average_rating) if len(row) > 2 and row[2] else 0,
"total_rating_count": row[2].total_rating_count if len(row) > 2 and row[2] else 0,
"star_1_count": row[2].star_1_count if len(row) > 2 and row[2] else 0,
"star_2_count": row[2].star_2_count if len(row) > 2 and row[2] else 0,
"star_3_count": row[2].star_3_count if len(row) > 2 and row[2] else 0,
"star_4_count": row[2].star_4_count if len(row) > 2 and row[2] else 0,
"star_5_count": row[2].star_5_count if len(row) > 2 and row[2] else 0
}
return ApiResponse(success=True, data=data)

25
backend/app/config.py Normal file
View File

@@ -0,0 +1,25 @@
from pydantic_settings import BaseSettings
from typing import List
class Settings(BaseSettings):
MYSQL_HOST: str = "localhost"
MYSQL_PORT: int = 3306
MYSQL_USER: str = "root"
MYSQL_PASSWORD: str = "password"
MYSQL_DATABASE: str = "huawei_market"
API_PREFIX: str = "/api"
API_TITLE: str = "鸿蒙应用展示平台API"
API_VERSION: str = "1.0.0"
DEBUG: bool = False
CORS_ORIGINS: List[str] = ["http://localhost:5173", "http://localhost:3000"]
@property
def database_url(self) -> str:
return f"mysql+aiomysql://{self.MYSQL_USER}:{self.MYSQL_PASSWORD}@{self.MYSQL_HOST}:{self.MYSQL_PORT}/{self.MYSQL_DATABASE}"
class Config:
env_file = ".env"
settings = Settings()

27
backend/app/database.py Normal file
View File

@@ -0,0 +1,27 @@
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
from app.config import settings
engine = create_async_engine(
settings.database_url,
echo=settings.DEBUG,
pool_size=10,
max_overflow=20,
pool_pre_ping=True
)
AsyncSessionLocal = sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False
)
Base = declarative_base()
async def get_db():
async with AsyncSessionLocal() as session:
try:
yield session
finally:
await session.close()

31
backend/app/main.py Normal file
View File

@@ -0,0 +1,31 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.config import settings
from app.api import apps
app = FastAPI(
title=settings.API_TITLE,
version=settings.API_VERSION
)
app.add_middleware(
CORSMiddleware,
allow_origins=settings.CORS_ORIGINS,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(apps.router, prefix=settings.API_PREFIX)
@app.get("/")
async def root():
return {"message": "鸿蒙应用展示平台API", "version": settings.API_VERSION}
@app.get("/health")
async def health_check():
return {"status": "healthy"}
if __name__ == "__main__":
import uvicorn
uvicorn.run("app.main:app", host="0.0.0.0", port=8000, reload=settings.DEBUG)

View File

@@ -0,0 +1,5 @@
from app.models.app_info import AppInfo
from app.models.app_metrics import AppMetrics
from app.models.app_rating import AppRating
__all__ = ["AppInfo", "AppMetrics", "AppRating"]

View File

@@ -0,0 +1,20 @@
from sqlalchemy import Column, String, Integer, Text, DateTime, Boolean, JSON
from sqlalchemy.sql import func
from app.database import Base
class AppInfo(Base):
__tablename__ = "app_info"
app_id = Column(String(50), primary_key=True)
name = Column(String(255), nullable=False, index=True)
pkg_name = Column(String(255), nullable=False, unique=True, index=True)
developer_name = Column(String(255), nullable=False, index=True)
kind_name = Column(String(100), nullable=False, index=True)
icon_url = Column(Text, nullable=False)
brief_desc = Column(Text, nullable=False)
description = Column(Text, nullable=False)
privacy_url = Column(Text, nullable=False)
is_pay = Column(Boolean, default=False)
listed_at = Column(DateTime, nullable=False)
created_at = Column(DateTime, nullable=False, server_default=func.now())
updated_at = Column(DateTime, nullable=False, server_default=func.now(), onupdate=func.now())

View File

@@ -0,0 +1,15 @@
from sqlalchemy import Column, String, Integer, BigInteger, DateTime, DECIMAL, ForeignKey
from sqlalchemy.sql import func
from app.database import Base
class AppMetrics(Base):
__tablename__ = "app_metrics"
id = Column(BigInteger, primary_key=True, autoincrement=True)
app_id = Column(String(50), ForeignKey("app_info.app_id", ondelete="CASCADE"), nullable=False, index=True)
pkg_name = Column(String(255), ForeignKey("app_info.pkg_name", ondelete="CASCADE"), nullable=False, index=True)
version = Column(String(50), nullable=False)
size_bytes = Column(BigInteger, nullable=False)
download_count = Column(BigInteger, nullable=False, index=True)
release_date = Column(BigInteger, nullable=False)
created_at = Column(DateTime, nullable=False, server_default=func.now(), index=True)

View File

@@ -0,0 +1,18 @@
from sqlalchemy import Column, String, Integer, BigInteger, DateTime, DECIMAL, ForeignKey
from sqlalchemy.sql import func
from app.database import Base
class AppRating(Base):
__tablename__ = "app_rating"
id = Column(BigInteger, primary_key=True, autoincrement=True)
app_id = Column(String(50), ForeignKey("app_info.app_id", ondelete="CASCADE"), nullable=False, index=True)
pkg_name = Column(String(255), ForeignKey("app_info.pkg_name", ondelete="CASCADE"), nullable=False, index=True)
average_rating = Column(DECIMAL(3, 2), nullable=False, index=True)
star_1_count = Column(Integer, nullable=False, default=0)
star_2_count = Column(Integer, nullable=False, default=0)
star_3_count = Column(Integer, nullable=False, default=0)
star_4_count = Column(Integer, nullable=False, default=0)
star_5_count = Column(Integer, nullable=False, default=0)
total_rating_count = Column(Integer, nullable=False, default=0)
created_at = Column(DateTime, nullable=False, server_default=func.now(), index=True)

View File

@@ -0,0 +1,3 @@
from app.schemas.response import ApiResponse
__all__ = ["ApiResponse"]

View File

@@ -0,0 +1,10 @@
from pydantic import BaseModel
from typing import Any, Optional
from datetime import datetime
class ApiResponse(BaseModel):
success: bool
data: Any
total: Optional[int] = None
limit: Optional[int] = None
timestamp: str = datetime.now().isoformat()

7
backend/requirements.txt Normal file
View File

@@ -0,0 +1,7 @@
fastapi==0.109.0
uvicorn[standard]==0.27.0
sqlalchemy==2.0.25
aiomysql==0.2.0
pydantic==2.5.3
pydantic-settings==2.1.0
python-dotenv==1.0.0

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
}
}
}
})

122
templates/404.html Executable file
View File

@@ -0,0 +1,122 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>404 - 页面未找到</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.4/css/all.min.css">
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "WenQuanYi Micro Hei", Roboto, "Helvetica Neue", Arial, sans-serif;
line-height: 1.6;
background: #f5f5f7;
color: #1d1d1f;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.error-container {
text-align: center;
padding: 40px 20px;
max-width: 600px;
width: 100%;
}
.error-code {
font-size: 120px;
font-weight: 700;
color: #007AFF;
line-height: 1;
margin-bottom: 20px;
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.1);
}
.error-title {
font-size: 24px;
color: #1d1d1f;
margin-bottom: 15px;
}
.error-message {
font-size: 16px;
color: #666;
margin-bottom: 30px;
}
.back-button {
display: inline-flex;
align-items: center;
gap: 8px;
background: #007AFF;
color: white;
text-decoration: none;
padding: 12px 24px;
border-radius: 8px;
font-size: 16px;
transition: all 0.3s ease;
box-shadow: 0 2px 8px rgba(0, 122, 255, 0.2);
}
.back-button:hover {
background: #0056b3;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 122, 255, 0.3);
}
.error-icon {
font-size: 64px;
color: #007AFF;
margin-bottom: 20px;
animation: float 3s ease-in-out infinite;
}
@keyframes float {
0%, 100% {
transform: translateY(0);
}
50% {
transform: translateY(-10px);
}
}
@media (max-width: 768px) {
.error-code {
font-size: 80px;
}
.error-title {
font-size: 20px;
}
.error-message {
font-size: 14px;
}
.error-icon {
font-size: 48px;
}
}
</style>
</head>
<body>
<div class="error-container">
<i class="fas fa-exclamation-circle error-icon"></i>
<div class="error-code">404</div>
<h1 class="error-title">页面未找到</h1>
<p class="error-message">抱歉,您访问的页面不存在或已被移除。</p>
<a href="/" class="back-button">
<i class="fas fa-home"></i>
返回首页
</a>
</div>
</body>
</html>

202
templates/admin.html Executable file
View File

@@ -0,0 +1,202 @@
{% extends "base.html" %}
{% block content %}
{% include 'admin_nav.html' %}
<div class="admin-container">
<div class="admin-content">
<!-- 添加应用部分 -->
<div class="admin-grid" id="add-app">
<div class="admin-card">
<div class="card-header">
<div class="header-left">
<i class="fas fa-plus-circle"></i>
<h3>添加新应用</h3>
</div>
</div>
<form onsubmit="submitAddAppForm(event, this)" enctype="multipart/form-data" class="admin-form">
<div class="form-group">
<label for="app-name">应用名称</label>
<input type="text" id="app-name" name="name" placeholder="请输入应用名称" required>
</div>
{% if is_superadmin() %}
<div class="form-group">
<label for="app-icon">应用图标</label>
<div class="file-input-wrapper">
<input type="file" id="app-icon" name="icon">
<label for="app-icon" class="file-input-label">
<i class="fas fa-cloud-upload-alt"></i>
<span>选择文件</span>
</label>
</div>
<div class="form-group">
<label for="icon-url">或输入图标URL</label>
<input type="url" id="icon-url" name="icon_url" placeholder="http://example.com/icon.png">
</div>
</div>
{% else %}
<div class="form-group">
<label for="icon-url">图标URL</label>
<input type="url" id="icon-url" name="icon_url" placeholder="http://example.com/icon.png" required>
</div>
{% endif %}
<div class="form-group">
<label for="category">分类</label>
<select id="category" name="category_id" required>
{% for category in categories %}
<option value="{{ category.id }}">{{ category.name }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label for="download-url">下载链接(可选)</label>
<input type="url" id="download-url" name="download_url" placeholder="http://example.com/download">
</div>
<button type="submit" class="btn-primary">
<i class="fas fa-plus"></i> 添加应用
</button>
</form>
</div>
</div>
<!-- 分类管理部分 -->
<div class="admin-grid" id="categories">
<div class="admin-card">
<div class="card-header">
<i class="fas fa-folder-plus"></i>
<h3>添加新分类</h3>
</div>
<form onsubmit="submitAddCategoryForm(event, this)" class="admin-form">
<div class="form-group">
<label for="category-name">分类名称</label>
<input type="text" id="category-name" name="name" placeholder="请输入分类名称" required>
</div>
<button type="submit" class="btn-primary">
<i class="fas fa-plus"></i> 添加分类
</button>
</form>
<!-- 分类磁贴区域 -->
<div class="category-tiles">
{% for category in categories %}
{% if is_superadmin() %}
<div class="category-tile" ondblclick="enableEdit(this, '{{ category.id }}')">
<span class="category-name" data-id="{{ category.id }}">{{ category.name }}</span>
<form action="{{ url_for('edit_category', category_id=category.id) }}"
method="POST"
class="edit-category-form"
style="display: none;">
<input type="text" name="name" value="{{ category.name }}"
class="edit-category-input"
onblur="submitForm(this.form)"
onkeydown="handleKeyPress(event, this.form)">
</form>
<a href="{{ url_for('delete_category', category_id=category.id) }}"
class="delete-category"
onclick="return confirm('确定要删除这个分类吗?')">
<i class="fas fa-times"></i>
</a>
</div>
{% else %}
<div class="category-tile">
<span class="category-name">{{ category.name }}</span>
</div>
{% endif %}
{% endfor %}
</div>
</div>
</div>
<!-- 应用管理部分 -->
<div class="admin-card full-width" id="apps">
<div class="card-header">
<div class="header-left">
<i class="fas fa-th-large"></i>
<h3>应用管理 {% if search %}(搜索结果){% else %}(共{{ apps|length }}个){% endif %}</h3>
</div>
<form class="search-form header-search" method="GET" action="{{ url_for('admin') }}">
<div class="search-wrapper">
<i class="fas fa-search"></i>
<input type="text" name="search" placeholder="搜索应用..." value="{{ search }}">
</div>
</form>
</div>
<!-- 批量操作按钮 -->
{% if is_superadmin() %}
<div class="batch-operations">
<button onclick="deleteSelected()" class="btn-delete" id="delete-selected" style="display: none;">
<i class="fas fa-trash"></i> 批量删除
</button>
</div>
{% endif %}
<div class="table-responsive">
<table class="admin-table">
<thead>
<tr>
<th><input type="checkbox" id="select-all" onclick="toggleSelectAll()"></th>
<th>图标</th>
<th>名称</th>
<th>分类</th>
<th>添加时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{% for app in apps %}
<tr>
<td><input type="checkbox" class="app-checkbox" value="{{ app.id }}"></td>
<td>
<div class="app-icon-small">
{% if 'http' in app.icon_path %}
<img src="{{ app.icon_path }}" alt="{{ app.name }}">
{% else %}
<img src="{{ url_for('static', filename='uploads/' + app.icon_path) }}" alt="{{ app.name }}">
{% endif %}
</div>
</td>
<td>
<form onsubmit="submitEditForm(event, this)" class="edit-form">
<input type="hidden" name="app_id" value="{{ app.id }}">
<div class="edit-group">
<input type="text" name="name" value="{{ app.name }}" required placeholder="应用名称">
<select name="category_id" required>
{% for category in categories %}
<option value="{{ category.id }}" {% if category.id == app.category_id %}selected{% endif %}>
{{ category.name }}
</option>
{% endfor %}
</select>
<button type="submit" class="btn-edit" title="保存">
<i class="fas fa-save"></i> 保存
</button>
</div>
</form>
</td>
<td>
{% for category in categories %}
{% if category.id == app.category_id %}
{{ category.name }}
{% endif %}
{% endfor %}
</td>
<td>{{ app.created_at }}</td>
<td>
<div class="action-buttons">
{% if is_superadmin() %}
<a href="#" class="btn-delete" onclick="deleteApp('{{ app.id }}'); return false;">
<i class="fas fa-trash"></i>
</a>
{% endif %}
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
{% endblock %}

581
templates/admin_add.html Executable file
View File

@@ -0,0 +1,581 @@
{% extends "base.html" %}
{% block content %}
{% include 'admin_nav.html' %}
<div class="admin-container">
<div class="admin-content">
<!-- 单个应用添加 -->
<div class="admin-card" style="margin-bottom: 20px;">
<div class="card-header">
<div class="header-left">
<i class="fas fa-plus-circle"></i>
<h3>添加单个应用</h3>
</div>
</div>
<form onsubmit="submitAddAppForm(event, this)" enctype="multipart/form-data" class="admin-form">
<div class="form-group">
<label for="app-name">应用名称</label>
<input type="text" id="app-name" name="name" placeholder="请输入应用名称" required>
</div>
<div class="form-group">
<label for="app-icon">应用图标</label>
<div class="file-input-wrapper">
<input type="file" id="app-icon" name="icon">
<label for="app-icon" class="file-input-label">
<i class="fas fa-cloud-upload-alt"></i>
<span>选择文件</span>
</label>
</div>
<div class="form-group">
<label for="icon-url">或输入图标URL</label>
<input type="url" id="icon-url" name="icon_url" placeholder="http://example.com/icon.png">
</div>
</div>
<div class="form-group">
<label for="category">分类</label>
<select id="category" name="category_id" required>
{% for category in categories %}
<option value="{{ category.id }}">{{ category.name }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label>显示区域</label>
<div class="platform-buttons">
<button type="button" class="platform-btn active" data-value="mobile">
<i class="fas fa-mobile-alt"></i>
仅手机区
</button>
<button type="button" class="platform-btn" data-value="tablet">
<i class="fas fa-tablet-alt"></i>
仅平板区
</button>
<button type="button" class="platform-btn" data-value="both">
<i class="fas fa-desktop"></i>
全部显示
</button>
</div>
<input type="hidden" name="platform" id="platform" value="mobile">
</div>
<div class="form-group">
<label for="download-url">下载链接(可选)</label>
<input type="url" id="download-url" name="download_url" placeholder="http://example.com/download">
</div>
<button type="submit" class="btn-primary">
<i class="fas fa-plus"></i> 添加应用
</button>
</form>
</div>
<!-- 批量导入 -->
<div class="admin-card">
<div class="card-header">
<div class="header-left">
<i class="fas fa-cloud-upload-alt"></i>
<h3>批量导入应用</h3>
</div>
</div>
<form id="batch-import-form" class="admin-form">
<div class="form-group">
<label for="batch-category">选择分类</label>
<select id="batch-category" name="category_id" required>
{% for category in categories %}
<option value="{{ category.id }}">{{ category.name }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label>图标类型</label>
<div class="icon-type-options">
<label class="icon-option">
<input type="radio" name="icon_type" value="service" onchange="updateIconUrl()" required>
<span>元服务</span>
</label>
<label class="icon-option">
<input type="radio" name="icon_type" value="app" onchange="updateIconUrl()" required>
<span>应用</span>
</label>
</div>
</div>
<input type="hidden" id="batch-icon-url" name="icon_url">
<div class="form-group">
<label for="batch-names">应用名称(用英文逗号分隔)</label>
<textarea id="batch-names" name="app_names" rows="5" placeholder="输入应用名称,多个应用用英文逗号分隔" required></textarea>
</div>
<button type="submit" class="btn-primary">
<i class="fas fa-cloud-upload-alt"></i> 批量导入
</button>
</form>
</div>
<!-- 修改导入结果列表的标题和图标 -->
<div class="admin-card full-width" id="import-result" style="display: none;">
<div class="card-header">
<div class="header-left">
<i class="fas fa-clock"></i>
<h3>最近导入</h3>
</div>
</div>
<div class="table-responsive">
<table class="admin-table">
<thead>
<tr>
<th>图标</th>
<th>名称</th>
<th>分类</th>
<th>操作</th>
</tr>
</thead>
<tbody id="result-tbody">
<!-- 导入结果将在这里显示 -->
</tbody>
</table>
</div>
</div>
</div>
</div>
<script>
// 添加通知函数
function showNotification(message, type = 'success') {
const notification = document.createElement('div');
notification.className = `notification ${type}`;
notification.innerHTML = message;
document.body.appendChild(notification);
// 添加动画样式
setTimeout(() => {
notification.classList.add('show');
}, 10);
// 3秒后移除通知
setTimeout(() => {
notification.classList.remove('show');
setTimeout(() => {
notification.remove();
}, 300);
}, 3000);
}
// 原有的单个应用添加函数
function submitAddAppForm(event, form) {
event.preventDefault();
const formData = new FormData(form);
fetch('{{ url_for("add_app") }}', {
method: 'POST',
body: formData
})
.then(response => response.json())
.then(data => {
if (data.success) {
showNotification(data.message, 'success');
form.reset();
} else {
showNotification(data.error, 'error');
}
})
.catch(error => {
showNotification('操作失败,请重试', 'error');
console.error('Error:', error);
});
}
// 修改图标URL更新函数
function updateIconUrl() {
const iconType = document.querySelector('input[name="icon_type"]:checked')?.value;
const iconUrlInput = document.getElementById('batch-icon-url');
if (iconType === 'service') {
iconUrlInput.value = 'https://developer.huawei.com/allianceCmsResource/resource/HUAWEI_Developer_VUE/images/yuanfuwuicon.png';
} else if (iconType === 'app') {
iconUrlInput.value = 'https://consumer.huawei.com/content/dam/huawei-cbg-site/cn/mkt/harmonyos-next/images/hero/harmonyos-next-kv-2x.webp';
} else {
iconUrlInput.value = '';
}
}
// 修改批量导入处理函数
document.getElementById('batch-import-form').onsubmit = function(e) {
e.preventDefault();
const categoryId = document.getElementById('batch-category').value;
const iconUrl = document.getElementById('batch-icon-url').value;
const names = document.getElementById('batch-names').value;
if (!iconUrl) {
showNotification('请选择图标类型', 'error');
return;
}
// 分割应用名称并过滤空值
const apps = names.split(',')
.map(name => name.trim())
.filter(name => name)
.map(name => ({
name: name,
icon_url: iconUrl,
category_id: categoryId
}));
if (apps.length === 0) {
showNotification('请输入至少一个应用名称', 'error');
return;
}
// 发送请求前显示加载状态
const submitBtn = this.querySelector('button[type="submit"]');
const originalText = submitBtn.innerHTML;
submitBtn.disabled = true;
submitBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> 导入中...';
fetch('{{ url_for("admin_batch_add") }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest' // 添加这个头部
},
body: JSON.stringify({apps: apps})
})
.then(response => {
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
})
.then(data => {
if (data.success) {
showNotification(data.message, 'success');
displayImportResult(apps, data.results);
this.reset();
updateIconUrl();
} else {
showNotification(data.error || '导入失败', 'error');
}
})
.catch(error => {
console.error('Error:', error);
showNotification('导入失败,请重试', 'error');
})
.finally(() => {
// 恢复按钮状态
submitBtn.disabled = false;
submitBtn.innerHTML = originalText;
});
};
// 添加错误处理函数
function handleFetchError(response) {
if (!response.ok) {
return response.text().then(text => {
try {
// 尝试解析错误响应为 JSON
const data = JSON.parse(text);
throw new Error(data.error || '请求失败');
} catch (e) {
// 如果不是 JSON返回原始错误文本
throw new Error(text || '请求失败');
}
});
}
return response.json();
}
function displayImportResult(apps, results) {
const tbody = document.getElementById('result-tbody');
tbody.innerHTML = '';
apps.forEach(app => {
const tr = document.createElement('tr');
const isSuccess = results.success.includes(app.name);
const failReason = results.failed[app.name];
tr.innerHTML = `
<td>
<div class="app-icon-small">
<img src="${app.icon_url}" alt="${app.name}" loading="lazy">
</div>
</td>
<td>${app.name}</td>
<td>${document.getElementById('batch-category').options[document.getElementById('batch-category').selectedIndex].text}</td>
<td>
${isSuccess ?
`<button onclick="deleteApp('${app.name}')" class="btn-delete" title="删除">
<i class="fas fa-trash"></i>
</button>` :
`<span class="error-text" title="${failReason}">导入失败</span>`
}
</td>
`;
tbody.appendChild(tr);
});
document.getElementById('import-result').style.display = 'block';
}
// 修改删除应用函<E794A8><E587BD>
function deleteApp(appName) {
if (confirm(`确定要删除应用 "${appName}" 吗?`)) {
fetch(`/delete_app_by_name/${encodeURIComponent(appName)}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
showNotification('删除成功', 'success');
// 找到并移除对应的表格行
const rows = document.querySelectorAll('#result-tbody tr');
for (let row of rows) {
if (row.querySelector('td:nth-child(2)').textContent === appName) {
row.remove();
break;
}
}
// 如果表格为空,隐藏结果区域
if (document.getElementById('result-tbody').children.length === 0) {
document.getElementById('import-result').style.display = 'none';
}
} else {
showNotification(data.error || '删除失败', 'error');
}
})
.catch(error => {
console.error('Error:', error);
showNotification('删除失败,请重试', 'error');
});
}
}
// 添加 contains 选择器的 polyfill
if (!HTMLElement.prototype.contains) {
HTMLElement.prototype.contains = function(node) {
return this.textContent.includes(node);
}
}
document.addEventListener('DOMContentLoaded', function() {
const platformButtons = document.querySelectorAll('.platform-btn');
const platformInput = document.getElementById('platform');
platformButtons.forEach(button => {
button.addEventListener('click', function() {
// 移除所有按钮的 active 类
platformButtons.forEach(btn => btn.classList.remove('active'));
// 添加当前按钮的 active 类
this.classList.add('active');
// 更新隐藏输入框的值
platformInput.value = this.dataset.value;
});
});
});
</script>
<style>
#batch-names {
width: 100%;
min-height: 100px;
padding: 10px;
border: 1px solid #d2d2d7;
border-radius: 8px;
font-size: 14px;
resize: vertical;
}
.preview-list {
margin-top: 20px;
}
.btn-primary {
margin-right: 10px;
}
.status-badge {
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
cursor: help;
}
.status-badge.success {
background: #34c759;
color: white;
}
.status-badge.error {
background: #ff3b30;
color: white;
}
/* 通知样式 */
.notification {
position: fixed;
top: 20px;
right: 20px;
padding: 15px 25px;
background: white;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 1000;
opacity: 0;
transform: translateY(-20px);
transition: all 0.3s ease;
}
.notification.show {
opacity: 1;
transform: translateY(0);
}
.notification.success {
background: #34c759;
color: white;
}
.notification.error {
background: #ff3b30;
color: white;
}
/* 添加失败原因样式 */
.fail-reason {
font-size: 12px;
color: #ff3b30;
margin-top: 4px;
}
.icon-type-options {
display: flex;
gap: 20px;
margin-top: 8px;
}
.icon-option {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
padding: 8px 16px;
border: 1px solid #d2d2d7;
border-radius: 6px;
transition: all 0.3s ease;
}
.icon-option:hover {
border-color: #0066cc;
}
.icon-option input[type="radio"] {
margin: 0;
}
.icon-option input[type="radio"]:checked + span {
color: #0066cc;
font-weight: 500;
}
.icon-option:has(input[type="radio"]:checked) {
border-color: #0066cc;
background-color: #f5f8ff;
}
/* 修改删除按钮样式 */
.btn-delete {
background: #ff3b30;
color: white;
border: none;
padding: 6px;
width: 28px;
height: 28px;
border-radius: 4px;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
transition: all 0.3s ease;
}
.btn-delete:hover {
background: #ff1a1a;
}
.btn-delete i {
font-size: 14px;
}
/* 添加错误文本样式 */
.error-text {
color: #ff3b30;
font-size: 12px;
cursor: help;
}
/* 修改表格样式 */
.admin-table td {
vertical-align: middle;
}
.app-icon-small {
width: 32px;
height: 32px;
border-radius: 6px;
overflow: hidden;
}
.app-icon-small img {
width: 100%;
height: 100%;
object-fit: cover;
}
.platform-buttons {
display: flex;
gap: 10px;
margin-top: 8px;
}
.platform-btn {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 10px;
border: 1px solid #d2d2d7;
border-radius: 8px;
background: white;
color: #333;
cursor: pointer;
transition: all 0.3s ease;
}
.platform-btn i {
font-size: 16px;
}
.platform-btn:hover {
border-color: #007AFF;
color: #007AFF;
}
.platform-btn.active {
background: #007AFF;
color: white;
border-color: #007AFF;
}
@media (max-width: 768px) {
.platform-buttons {
flex-direction: column;
}
.platform-btn {
width: 100%;
}
}
</style>
{% endblock %}

966
templates/admin_apps.html Executable file
View File

@@ -0,0 +1,966 @@
{% extends "base.html" %}
{% block content %}
{% include 'admin_nav.html' %}
<div class="admin-container">
<div class="admin-content">
<div class="admin-card full-width">
<div class="card-header">
<div class="header-left">
<i class="fas fa-th-large"></i>
<h3>应用管理 {% if search %}(搜索结果){% else %}(共{{ total_count }}个){% endif %}</h3>
</div>
<div class="header-actions">
<select id="category-filter" onchange="filterByCategory(this.value)" class="category-select">
<option value="">全部分类</option>
{% for category in categories %}
<option value="{{ category.id }}">{{ category.name }}</option>
{% endfor %}
</select>
<form class="search-form header-search" method="GET" action="{{ url_for('admin_apps') }}">
<div class="search-wrapper">
<i class="fas fa-search"></i>
<input type="text" name="search" placeholder="搜索应用..." value="{{ search }}">
</div>
</form>
</div>
</div>
<div class="table-responsive">
<table class="admin-table">
<thead>
<tr>
<th>图标</th>
<th>名称</th>
<th>分类</th>
<th>添加时间</th>
<th>操作</th>
</tr>
</thead>
<tbody id="apps-tbody">
<!-- 应用列表将通过JavaScript动态加载 -->
</tbody>
</table>
<!-- 加载指示器 -->
<div id="loading-indicator" style="display: none; text-align: center; padding: 20px;">
<div class="spinner"></div>
<p>加载中...</p>
</div>
<!-- 加载更多按钮 -->
<div id="load-more" style="text-align: center; padding: 20px;">
<button onclick="loadMoreApps()" class="btn-primary">
<i class="fas fa-sync"></i> 加载更多
</button>
</div>
</div>
</div>
</div>
</div>
<style>
.app-icon-container {
display: flex;
align-items: center;
gap: 10px;
width: 300px;
}
.app-icon-small {
width: 40px;
height: 40px;
flex-shrink: 0;
position: relative;
border-radius: 8px;
overflow: hidden;
}
.app-icon-small img {
width: 100%;
height: 100%;
object-fit: cover;
transition: all 0.3s ease;
}
.icon-edit-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: all 0.3s ease;
}
.app-icon-small:hover .icon-edit-overlay {
opacity: 1;
}
.icon-edit-btn {
color: white;
cursor: pointer;
padding: 5px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.2);
transition: all 0.3s ease;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
}
.icon-edit-btn:hover {
background: rgba(255, 255, 255, 0.3);
transform: scale(1.1);
}
.icon-url-input {
flex: 1;
min-width: 0;
padding: 4px 8px;
border: 1px solid #d2d2d7;
border-radius: 4px;
font-size: 12px;
color: #666;
transition: all 0.3s ease;
}
.icon-url-input:hover {
border-color: #b2b2b2;
}
.icon-url-input:focus {
border-color: #0066cc;
outline: none;
color: #333;
}
/* 调整表格第一列的宽度 */
.admin-table td:first-child {
width: 320px;
max-width: 320px;
}
.tablet-filter {
display: flex;
gap: 10px;
align-items: center;
}
.tablet-filter input {
padding: 8px 12px;
border: 1px solid #d2d2d7;
border-radius: 6px;
font-size: 14px;
min-width: 200px;
}
.tablet-filter input:focus {
border-color: #007AFF;
outline: none;
}
.tablet-filter button {
padding: 8px 16px;
background: #007AFF;
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
display: flex;
align-items: center;
gap: 6px;
transition: all 0.3s ease;
}
.tablet-filter button:hover {
background: #0056b3;
}
/* 添加成功提示样式 */
.filter-success {
position: fixed;
top: 20px;
right: 20px;
padding: 10px 20px;
background: #4CAF50;
color: white;
border-radius: 4px;
z-index: 1000;
animation: fadeInOut 3s forwards;
}
@keyframes fadeInOut {
0% { opacity: 0; transform: translateY(-20px); }
10% { opacity: 1; transform: translateY(0); }
90% { opacity: 1; transform: translateY(0); }
100% { opacity: 0; transform: translateY(-20px); }
}
.filter-card {
margin-bottom: 20px;
background: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.filter-content {
padding: 15px;
}
.filter-group {
display: flex;
gap: 10px;
align-items: center;
}
.filter-group input {
flex: 1;
padding: 8px 12px;
border: 1px solid #d2d2d7;
border-radius: 6px;
font-size: 14px;
min-width: 300px;
}
.filter-group input:focus {
border-color: #007AFF;
outline: none;
}
.filter-group button {
padding: 8px 16px;
background: #007AFF;
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
display: flex;
align-items: center;
gap: 6px;
transition: all 0.3s ease;
white-space: nowrap;
}
.filter-group button:hover {
background: #0056b3;
}
/* 批量筛选结果弹窗样式 */
.filter-result-modal {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
z-index: 1000;
}
.filter-result-content {
position: relative;
background: white;
margin: 10% auto;
padding: 20px;
width: 90%;
max-width: 500px;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0,0,0,0.2);
}
.filter-result-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
}
.filter-result-header h3 {
margin: 0;
font-size: 18px;
}
.filter-result-close {
font-size: 24px;
cursor: pointer;
color: #666;
}
.filter-result-list {
max-height: 300px;
overflow-y: auto;
}
.filter-result-item {
padding: 8px;
border-bottom: 1px solid #eee;
display: flex;
justify-content: space-between;
align-items: center;
}
.filter-result-item.success {
color: #4CAF50;
}
.filter-result-item.error {
color: #f44336;
}
</style>
<div id="filterResultModal" class="filter-result-modal">
<div class="filter-result-content">
<div class="filter-result-header">
<h3>筛选结果</h3>
<span class="filter-result-close" onclick="closeFilterResultModal()">&times;</span>
</div>
<div id="filterResultList" class="filter-result-list">
<!-- 结果将在这里动态显示 -->
</div>
</div>
</div>
<script>
let currentPage = 1;
const pageSize = 50;
let loading = false;
let hasMore = true;
let lastScrollPosition = 0;
// 初始加载
document.addEventListener('DOMContentLoaded', () => {
loadApps();
});
// 滚动加载
document.addEventListener('scroll', () => {
// 获取当前滚动位置
const currentScroll = window.scrollY;
// 只在向下滚动时检查是否需要加载更多
if (currentScroll > lastScrollPosition) {
const scrollThreshold = document.documentElement.scrollHeight - window.innerHeight - 200;
if (currentScroll > scrollThreshold && !loading && hasMore) {
loadMoreApps();
}
}
// 更新上次滚动位置
lastScrollPosition = currentScroll;
});
// 添加分类筛选函数
function filterByCategory(categoryId) {
resetLoadState();
const tbody = document.getElementById('apps-tbody');
tbody.innerHTML = '';
loadApps(1, categoryId);
}
// 修改loadApps函数以支持分类筛选
function loadApps(page = 1, categoryId = '') {
if (loading || !hasMore) return;
loading = true;
const loadingIndicator = document.getElementById('loading-indicator');
loadingIndicator.style.display = 'block';
let url = "{{ url_for('admin_get_apps') }}?page=" + page + "&size=" + pageSize;
if (categoryId) {
url += "&category_id=" + categoryId;
}
{% if search %}
url += "&search={{ search }}";
{% endif %}
fetch(url)
.then(response => response.json())
.then(data => {
const tbody = document.getElementById('apps-tbody');
if (page === 1) {
tbody.innerHTML = '';
}
if (data.success) {
data.apps.forEach(app => {
const tr = createAppRow(app);
tbody.appendChild(tr);
});
hasMore = data.has_more;
document.getElementById('load-more').style.display = hasMore ? 'block' : 'none';
} else {
showNotification(data.error || '加载失败', 'error');
}
})
.catch(error => {
console.error('Error:', error);
showNotification('加载失败,请重试', 'error');
})
.finally(() => {
loading = false;
loadingIndicator.style.display = 'none';
});
}
function loadMoreApps() {
if (loading || !hasMore) return;
loading = true;
const loadingIndicator = document.getElementById('loading-indicator');
loadingIndicator.style.display = 'block';
let url = "{{ url_for('admin_get_apps') }}?page=" + currentPage + "&size=" + pageSize;
const categoryId = document.getElementById('category-filter')?.value;
if (categoryId) {
url += "&category_id=" + categoryId;
}
{% if search %}
url += "&search={{ search }}";
{% endif %}
fetch(url)
.then(response => response.json())
.then(data => {
const tbody = document.getElementById('apps-tbody');
if (data.success) {
data.apps.forEach(app => {
const tr = createAppRow(app);
tbody.appendChild(tr);
});
hasMore = data.has_more;
if (hasMore) {
currentPage++;
}
document.getElementById('load-more').style.display = hasMore ? 'block' : 'none';
} else {
showNotification(data.error || '加载失败', 'error');
}
})
.catch(error => {
console.error('Error:', error);
showNotification('加载失败,请重试', 'error');
})
.finally(() => {
loading = false;
loadingIndicator.style.display = 'none';
});
}
function createAppRow(app) {
const tr = document.createElement('tr');
tr.dataset.appId = app.id;
tr.innerHTML = `
<td>
<div class="app-icon-container">
<div class="app-icon-small">
<img src="${app.icon_path.startsWith('http') ? app.icon_path : '/static/uploads/' + app.icon_path}"
alt="${app.name}"
loading="lazy">
<div class="icon-edit-overlay">
<label class="icon-edit-btn" title="上传图标">
<i class="fas fa-camera"></i>
<input type="file"
class="icon-input"
accept="image/*"
onchange="updateIcon(${app.id}, this)"
style="display: none;">
</label>
</div>
</div>
<input type="url"
value="${app.icon_path}"
placeholder="图标URL"
onchange="updateIconUrl(${app.id}, this.value)"
class="icon-url-input">
</div>
</td>
<td>
<form onsubmit="submitEditForm(event, this)" class="edit-form">
<input type="hidden" name="app_id" value="${app.id}">
<div class="edit-group">
<input type="text" name="name" value="${app.name}" required placeholder="应用名称">
<select name="category_id" required>
{% for category in categories %}
<option value="{{ category.id }}"
${app.category_id === {{ category.id }} ? 'selected' : ''}>
{{ category.name }}
</option>
{% endfor %}
</select>
<button type="submit" class="btn-edit" title="保存">
<i class="fas fa-save"></i>
</button>
</div>
</form>
</td>
<td>${app.category_name}</td>
<td>${app.created_at}</td>
<td>
<div class="action-buttons">
<a href="#" class="btn-delete" onclick="deleteApp(${app.id}); return false;">
<i class="fas fa-trash"></i>
</a>
</div>
</td>
`;
return tr;
}
function submitEditForm(event, form) {
event.preventDefault();
const formData = new FormData(form);
const appId = formData.get('app_id');
fetch('/edit_app/' + appId, {
method: 'POST',
body: formData
})
.then(response => response.json())
.then(data => {
if (data.success) {
// 显示成功消息
showNotification('修改成功', 'success');
// 更新分类名称显示
const row = form.closest('tr');
const categorySelect = form.querySelector('select[name="category_id"]');
const selectedOption = categorySelect.options[categorySelect.selectedIndex];
const categoryCell = row.querySelector('td:nth-child(3)'); // 第三列是分类显示列
categoryCell.textContent = selectedOption.textContent;
} else {
showNotification(data.error || '修改失败', 'error');
}
})
.catch(error => {
console.error('Error:', error);
showNotification('保存失败,请重试', 'error');
});
}
function deleteApp(appId) {
if (confirm('确定要删除这个应用吗?')) {
fetch(`{{ url_for('delete_app', app_id=0) }}`.replace('0', appId), {
method: 'POST'
})
.then(response => response.json())
.then(data => {
if (data.success) {
showNotification(data.message, 'success');
// 找到并移除对应的表格行
const row = document.querySelector(`tr[data-app-id="${appId}"]`);
if (row) {
row.remove();
}
// 保持当前滚动位置
const currentScroll = window.scrollY;
window.scrollTo(0, currentScroll);
} else {
showNotification(data.error, 'error');
}
})
.catch(error => {
showNotification('删除失败,请重试', 'error');
});
}
}
function updateIcon(appId, input) {
if (!input.files || !input.files[0]) {
showNotification('请选择图片文件', 'error');
return;
}
// 检查文件类型
const file = input.files[0];
const allowedTypes = ['image/jpeg', 'image/png', 'image/gif'];
if (!allowedTypes.includes(file.type)) {
showNotification('不支持的文件类型,请选择 jpg、png 或 gif 格式的图片', 'error');
return;
}
// 检查文件大小限制为5MB
if (file.size > 5 * 1024 * 1024) {
showNotification('文件太大请选择5MB以下的图片', 'error');
return;
}
const formData = new FormData();
formData.append('icon', file);
// 修改获取图片元素的方式
const parentTd = input.closest('td');
if (!parentTd) {
showNotification('找不到图片元素', 'error');
return;
}
const imgElement = parentTd.querySelector('.app-icon-small img');
if (!imgElement) {
showNotification('找不到图片元素', 'error');
return;
}
const originalSrc = imgElement.src;
imgElement.style.opacity = '0.5';
console.log('开始上传图标:', {
appId: appId,
fileName: file.name,
fileType: file.type,
fileSize: file.size
});
fetch(`/admin/update_app_icon/${appId}`, {
method: 'POST',
body: formData,
credentials: 'same-origin'
})
.then(response => {
console.log('服务器响应:', response.status);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
})
.then(data => {
console.log('服务器响应数据:', data);
if (data.success) {
showNotification(data.message || '图标更新成功', 'success');
const newSrc = '/static/uploads/' + data.icon_path;
console.log('更新图标路径:', newSrc);
imgElement.src = newSrc;
} else {
console.error('更新失败:', data.error);
showNotification(data.error || '更新失败', 'error');
imgElement.src = originalSrc;
}
})
.catch(error => {
console.error('请求错误:', error);
showNotification(`更新失败: ${error.message}`, 'error');
imgElement.src = originalSrc;
})
.finally(() => {
imgElement.style.opacity = '1';
input.value = '';
console.log('图标更新操作完成');
});
}
function showNotification(message, type = 'success') {
console.log(`显示通知: ${type} - ${message}`);
const notification = document.createElement('div');
notification.className = `notification ${type}`;
notification.textContent = message;
notification.style.position = 'fixed';
notification.style.bottom = '20px';
notification.style.right = '20px';
notification.style.padding = '10px 20px';
notification.style.borderRadius = '4px';
notification.style.backgroundColor = type === 'success' ? '#4CAF50' : '#f44336';
notification.style.color = 'white';
notification.style.zIndex = '1000';
notification.style.opacity = '0';
notification.style.transform = 'translateY(20px)';
notification.style.transition = 'all 0.3s ease';
document.body.appendChild(notification);
setTimeout(() => {
notification.style.opacity = '1';
notification.style.transform = 'translateY(0)';
}, 10);
setTimeout(() => {
notification.style.opacity = '0';
notification.style.transform = 'translateY(20px)';
setTimeout(() => notification.remove(), 300);
}, 3000);
}
// 添加样式
const style = document.createElement('style');
style.textContent = `
.app-icon-small {
position: relative;
width: 40px;
height: 40px;
border-radius: 8px;
overflow: hidden;
cursor: pointer;
}
.app-icon-small img {
width: 100%;
height: 100%;
object-fit: cover;
transition: all 0.3s ease;
}
.icon-edit-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: all 0.3s ease;
}
.app-icon-small:hover .icon-edit-overlay {
opacity: 1;
}
.app-icon-small:hover img {
transform: scale(1.1);
}
.icon-edit-btn {
color: white;
cursor: pointer;
padding: 5px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.2);
transition: all 0.3s ease;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
}
.icon-edit-btn:hover {
background: rgba(255, 255, 255, 0.3);
transform: scale(1.1);
}
.icon-edit-btn i {
font-size: 14px;
}
`;
document.head.appendChild(style);
// 添加样式
const filterStyle = document.createElement('style');
filterStyle.textContent = `
.header-actions {
display: flex;
gap: 15px;
align-items: center;
}
.category-select {
padding: 8px 12px;
border: 1px solid #d2d2d7;
border-radius: 6px;
font-size: 14px;
color: #1d1d1f;
background-color: white;
cursor: pointer;
transition: all 0.3s ease;
}
.category-select:hover {
border-color: #0066cc;
}
.category-select:focus {
outline: none;
border-color: #0066cc;
box-shadow: 0 0 0 2px rgba(0,102,204,0.1);
}
`;
document.head.appendChild(filterStyle);
function updateIconUrl(appId, newUrl) {
if (!newUrl) return;
if (!newUrl.startsWith('http://') && !newUrl.startsWith('https://')) {
showNotification('请输入有效的URL地址', 'error');
return;
}
// 显示加载状态
const tr = document.querySelector(`tr[data-app-id="${appId}"]`);
const input = tr ? tr.querySelector('.icon-url-input') : null;
const img = tr ? tr.querySelector('.app-icon-small img') : null;
const originalUrl = img ? img.src : '';
if (input) input.disabled = true;
if (img) img.style.opacity = '0.5';
// 添加错误处理和重试逻辑
const updateRequest = async (retryCount = 0) => {
try {
// 修改为使用 url_for 生成的完整路径
const response = await fetch("{{ url_for('update_app_icon_url', app_id=0) }}".replace('0', appId), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
},
body: JSON.stringify({ icon_url: newUrl }),
credentials: 'same-origin'
});
// 检查响应状态
if (!response.ok) {
// 如果是 401 未授权,重定向到登录页面
if (response.status === 401) {
window.location.href = "{{ url_for('login') }}";
return;
}
// 如果是 429 请求过多,等待后重试
if (response.status === 429 && retryCount < 3) {
await new Promise(resolve => setTimeout(resolve, 1000));
return updateRequest(retryCount + 1);
}
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
if (data.success) {
showNotification('图标链接更新成功', 'success');
if (img) {
img.src = newUrl;
img.style.opacity = '1';
}
} else {
throw new Error(data.error || '更新失败');
}
} catch (error) {
console.error('Error:', error);
showNotification(error.message || '更新失败,请重试', 'error');
// 恢复原始状态
if (img) {
img.src = originalUrl;
img.style.opacity = '1';
}
if (input) {
input.value = originalUrl;
}
} finally {
if (input) input.disabled = false;
if (img) img.style.opacity = '1';
}
};
// 开始更新请求
updateRequest();
}
// 修改防抖函数
const debounce = (func, wait) => {
let timeout;
return (...args) => {
clearTimeout(timeout);
return new Promise(resolve => {
timeout = setTimeout(() => {
resolve(func.apply(this, args));
}, wait);
});
};
};
// 使用防抖包装更新函数
const debouncedUpdateIconUrl = debounce(updateIconUrl, 500);
// 修改事件监听器
document.addEventListener('DOMContentLoaded', () => {
const tbody = document.getElementById('apps-tbody');
// 使事件委托处理图标URL输入
tbody.addEventListener('change', async (e) => {
if (e.target.classList.contains('icon-url-input')) {
const tr = e.target.closest('tr');
const appId = tr.dataset.appId;
if (appId) {
try {
await debouncedUpdateIconUrl(appId, e.target.value);
} catch (error) {
console.error('Error:', error);
showNotification('更新失败,请重试', 'error');
}
}
}
});
});
// 添加通知函数
function showNotification(message, type = 'success') {
const notification = document.createElement('div');
notification.className = `notification ${type}`;
notification.textContent = message;
Object.assign(notification.style, {
position: 'fixed',
bottom: '20px',
right: '20px',
padding: '10px 20px',
borderRadius: '4px',
backgroundColor: type === 'success' ? '#4CAF50' : '#f44336',
color: 'white',
zIndex: '1000',
opacity: '0',
transform: 'translateY(20px)',
transition: 'all 0.3s ease'
});
document.body.appendChild(notification);
// 触发重排以应用过渡效果
notification.offsetHeight;
notification.style.opacity = '1';
notification.style.transform = 'translateY(0)';
setTimeout(() => {
notification.style.opacity = '0';
notification.style.transform = 'translateY(20px)';
setTimeout(() => notification.remove(), 300);
}, 3000);
}
// 添加回车键触发筛选
document.getElementById('tabletAppFilter').addEventListener('keypress', function(e) {
if (e.key === 'Enter') {
e.preventDefault();
filterTabletApps();
}
});
// 点击弹窗外部关闭
window.onclick = function(event) {
const modal = document.getElementById('filterResultModal');
if (event.target == modal) {
modal.style.display = 'none';
}
}
// 添加重置加载状态的函数
function resetLoadState() {
currentPage = 1;
hasMore = true;
loading = false;
lastScrollPosition = 0;
}
</script>
{% endblock %}

177
templates/admin_batch_import.html Executable file
View File

@@ -0,0 +1,177 @@
{% extends "base.html" %}
{% block content %}
{% include 'admin_nav.html' %}
<div class="admin-container">
<div class="admin-content">
<div class="admin-card">
<div class="card-header">
<div class="header-left">
<i class="fas fa-cloud-upload-alt"></i>
<h3>批量导入应用</h3>
</div>
</div>
<form id="batch-import-form" class="admin-form">
<div class="form-group">
<label for="category">选择分类</label>
<select id="category" name="category_id" required>
{% for category in categories %}
<option value="{{ category.id }}">{{ category.name }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label for="icon-url">图标链接</label>
<input type="url" id="icon-url" name="icon_url" placeholder="http://example.com/icon.png" required>
</div>
<div class="form-group">
<label for="app-names">应用名称(用英文逗号分隔)</label>
<textarea id="app-names" name="app_names" rows="5" placeholder="输入应用名称,多个应用用英文逗号分隔" required></textarea>
</div>
<button type="submit" class="btn-primary">
<i class="fas fa-cloud-upload-alt"></i> 批量导入
</button>
</form>
</div>
<!-- 导入列表 -->
<div class="admin-card full-width">
<div class="card-header">
<div class="header-left">
<i class="fas fa-list"></i>
<h3>导入列表</h3>
</div>
</div>
<div class="table-responsive">
<table class="admin-table">
<thead>
<tr>
<th>图标</th>
<th>名称</th>
<th>分类</th>
<th>操作</th>
</tr>
</thead>
<tbody id="import-list">
<!-- 导入的应用将在这里显示 -->
</tbody>
</table>
</div>
</div>
</div>
</div>
<script>
document.getElementById('batch-import-form').addEventListener('submit', function(e) {
e.preventDefault();
const categoryId = document.getElementById('category').value;
const iconUrl = document.getElementById('icon-url').value;
const appNames = document.getElementById('app-names').value
.split(',')
.map(name => name.trim())
.filter(name => name);
if (!appNames.length) {
showNotification('请输入至少一个应用名称', 'error');
return;
}
// 清空现有列表
document.getElementById('import-list').innerHTML = '';
// 添加应用到列表
appNames.forEach(name => {
const tr = document.createElement('tr');
tr.innerHTML = `
<td>
<div class="app-icon-small">
<img src="${iconUrl}" alt="${name}" loading="lazy">
</div>
</td>
<td>${name}</td>
<td>${document.getElementById('category').options[document.getElementById('category').selectedIndex].text}</td>
<td>
<div class="action-buttons">
<a href="#" class="btn-delete" onclick="removeApp(this); return false;">
<i class="fas fa-trash"></i>
</a>
</div>
</td>
`;
document.getElementById('import-list').appendChild(tr);
});
});
function removeApp(button) {
const row = button.closest('tr');
row.remove();
}
function importApps() {
const rows = document.getElementById('import-list').getElementsByTagName('tr');
const apps = Array.from(rows).map(row => ({
name: row.cells[1].textContent,
icon_url: row.querySelector('img').src,
category_id: document.getElementById('category').value
}));
if (!apps.length) {
showNotification('没有要导入的应用', 'error');
return;
}
fetch('{{ url_for("admin_batch_import") }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ apps: apps })
})
.then(response => response.json())
.then(data => {
if (data.success) {
showNotification(data.message, 'success');
document.getElementById('batch-import-form').reset();
document.getElementById('import-list').innerHTML = '';
} else {
showNotification(data.error, 'error');
}
})
.catch(error => {
showNotification('导入失败,请重试', 'error');
console.error('Error:', error);
});
}
// 添加导入按钮
const importButton = document.createElement('button');
importButton.className = 'btn-primary';
importButton.style.marginTop = '20px';
importButton.innerHTML = '<i class="fas fa-cloud-upload-alt"></i> 确认导入';
importButton.onclick = importApps;
document.querySelector('.admin-card.full-width').appendChild(importButton);
</script>
<style>
#app-names {
width: 100%;
min-height: 100px;
padding: 10px;
border: 1px solid #d2d2d7;
border-radius: 8px;
font-size: 14px;
resize: vertical;
}
.import-list {
margin-top: 20px;
}
.btn-primary {
margin-right: 10px;
}
</style>
{% endblock %}

512
templates/admin_categories.html Executable file
View File

@@ -0,0 +1,512 @@
{% extends "base.html" %}
{% block content %}
{% include 'admin_nav.html' %}
<div class="admin-container">
<div class="admin-content">
<div class="admin-card">
<div class="card-header">
<div class="header-left">
<i class="fas fa-th-large"></i>
<h3>分类管理</h3>
</div>
<button onclick="showAddCategoryModal()" class="btn-primary">
<i class="fas fa-plus"></i> 添加分类
</button>
</div>
<div class="categories-list">
<div class="categories-header">
<div class="sort-handle-header"></div>
<div>分类名称</div>
<div>应用数量</div>
<div>操作</div>
</div>
<div id="sortableCategories">
{% for category in categories %}
<div class="category-item" data-id="{{ category.id }}">
<div class="sort-handle">
<i class="fas fa-grip-vertical"></i>
</div>
<div class="category-name">{{ category.name }}</div>
<div class="app-count">{{ category.app_count }}个应用</div>
<div class="category-actions">
<button onclick="editCategory({{ category.id }}, '{{ category.name }}')" class="btn-edit">
<i class="fas fa-edit"></i>
</button>
<button onclick="deleteCategory({{ category.id }})" class="btn-delete">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
{% endfor %}
</div>
</div>
</div>
</div>
</div>
<!-- 添加分类弹窗 -->
<div id="addCategoryModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h3>添加分类</h3>
<span class="close" onclick="closeAddCategoryModal()">&times;</span>
</div>
<form id="addCategoryForm" onsubmit="return addCategory(event)">
<div class="form-group">
<label for="categoryName">分类名称</label>
<input type="text" id="categoryName" name="name" required>
</div>
<div class="form-actions">
<button type="submit" class="btn-primary">确认添加</button>
<button type="button" onclick="closeAddCategoryModal()" class="btn-secondary">取消</button>
</div>
</form>
</div>
</div>
<!-- 编辑分类弹窗 -->
<div id="editCategoryModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h3>编辑分类</h3>
<span class="close" onclick="closeEditCategoryModal()">&times;</span>
</div>
<form id="editCategoryForm" onsubmit="return updateCategory(event)">
<input type="hidden" id="editCategoryId">
<div class="form-group">
<label for="editCategoryName">分类名称</label>
<input type="text" id="editCategoryName" name="name" required>
</div>
<div class="form-actions">
<button type="submit" class="btn-primary">保存修改</button>
<button type="button" onclick="closeEditCategoryModal()" class="btn-secondary">取消</button>
</div>
</form>
</div>
</div>
<style>
.categories-list {
margin-top: 20px;
}
.categories-header {
display: grid;
grid-template-columns: 50px 1fr 100px 120px;
padding: 10px;
background: #f5f5f7;
border-radius: 8px;
margin-bottom: 10px;
font-weight: 500;
}
.category-item {
display: grid;
grid-template-columns: 50px 1fr 100px 120px;
align-items: center;
padding: 12px;
background: white;
border-radius: 8px;
margin-bottom: 8px;
transition: all 0.3s ease;
cursor: move;
}
.category-item:hover {
background: #f8f9fa;
}
.sort-handle {
color: #999;
cursor: move;
display: flex;
align-items: center;
justify-content: center;
}
.sort-handle i {
font-size: 16px;
}
.category-name {
font-size: 14px;
}
.category-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
}
.btn-edit, .btn-delete {
padding: 6px 12px;
border: none;
border-radius: 4px;
cursor: pointer;
transition: all 0.3s ease;
}
.btn-edit {
background: #007AFF;
color: white;
}
.btn-delete {
background: #ff3b30;
color: white;
}
.btn-edit:hover {
background: #0056b3;
}
.btn-delete:hover {
background: #dc3545;
}
/* 拖拽时的样式 */
.category-item.dragging {
opacity: 0.5;
background: #f0f0f0;
}
.category-item.drag-over {
border-top: 2px solid #007AFF;
}
/* 添加弹窗样式 */
.modal {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
z-index: 1000;
}
.modal-content {
position: relative;
background-color: #fff;
margin: 10% auto;
padding: 20px;
width: 90%;
max-width: 500px;
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0px;
}
.modal-header h3 {
margin: 0;
font-size: 18px;
color: #333;
}
.close {
font-size: 24px;
color: #666;
cursor: pointer;
padding: 5px;
}
.close:hover {
color: #333;
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
margin-bottom: 8px;
color: #333;
font-size: 14px;
}
.form-group input {
width: 100%;
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 14px;
}
.form-group input:focus {
outline: none;
border-color: #007AFF;
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
}
.btn-secondary {
background: #f5f5f7;
color: #333;
border: none;
padding: 8px 16px;
border-radius: 6px;
cursor: pointer;
transition: all 0.3s ease;
}
.btn-secondary:hover {
background: #e5e5e7;
}
.app-count {
font-size: 13px;
color: #666;
text-align: center;
background: #f5f5f7;
padding: 4px 8px;
border-radius: 12px;
margin-right: 10px;
}
</style>
<script src="https://cdnjs.cloudflare.com/ajax/libs/Sortable/1.14.0/Sortable.min.js"></script>
<script>
// 初始化拖拽排序
document.addEventListener('DOMContentLoaded', function() {
var el = document.getElementById('sortableCategories');
var sortable = new Sortable(el, {
animation: 150,
handle: '.sort-handle',
ghostClass: 'dragging',
onEnd: function(evt) {
// 获取新的排序
var items = el.getElementsByClassName('category-item');
var newOrder = Array.from(items).map(item => parseInt(item.dataset.id));
console.log('Sending order:', newOrder); // 添加调试日志
// 发送到服务器
fetch('/admin/update_category_order', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
},
credentials: 'same-origin',
body: JSON.stringify({
order: newOrder
})
})
.then(response => {
console.log('Response status:', response.status); // 添加调试日志
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
})
.then(data => {
console.log('Response data:', data); // 添加调试日志
if (data.success) {
showNotification('排序已更新', 'success');
} else {
throw new Error(data.error || '更新失败');
}
})
.catch(error => {
console.error('Error:', error);
showNotification('更新失败,请重试', 'error');
// 如果更新失败,恢复原始顺序
setTimeout(() => {
location.reload();
}, 2000);
});
}
});
});
function showNotification(message, type = 'success') {
const notification = document.createElement('div');
notification.className = `notification ${type}`;
notification.textContent = message;
Object.assign(notification.style, {
position: 'fixed',
bottom: '20px',
right: '20px',
padding: '10px 20px',
borderRadius: '4px',
backgroundColor: type === 'success' ? '#4CAF50' : '#f44336',
color: 'white',
zIndex: '1000',
opacity: '0',
transform: 'translateY(20px)',
transition: 'all 0.3s ease',
boxShadow: '0 2px 5px rgba(0,0,0,0.2)'
});
document.body.appendChild(notification);
// 触发重排以应用过渡效果
notification.offsetHeight;
notification.style.opacity = '1';
notification.style.transform = 'translateY(0)';
setTimeout(() => {
notification.style.opacity = '0';
notification.style.transform = 'translateY(20px)';
setTimeout(() => notification.remove(), 300);
}, 3000);
}
function deleteCategory(categoryId) {
if (confirm('确定要删除这个分类吗?删除后将同时删除该分类下的所有应用')) {
fetch(`/delete_category/${categoryId}`, {
method: 'POST'
})
.then(response => response.json())
.then(data => {
if (data.success) {
showNotification('分类及其应用已删除', 'success');
// 延迟刷新页面,让用户看到通知
setTimeout(() => {
location.reload();
}, 1000);
} else {
showNotification(data.error || '删除失败', 'error');
}
})
.catch(error => {
console.error('Error:', error);
showNotification('删除失败,请重试', 'error');
});
}
}
// 添加分类弹窗相关函数
function showAddCategoryModal() {
document.getElementById('addCategoryModal').style.display = 'block';
document.getElementById('categoryName').value = '';
document.getElementById('categoryName').focus();
}
function closeAddCategoryModal() {
document.getElementById('addCategoryModal').style.display = 'none';
}
function addCategory(event) {
event.preventDefault();
const name = document.getElementById('categoryName').value.trim();
if (!name) {
showNotification('分类名称不能为空', 'error');
return false;
}
fetch('/add_category', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: `name=${encodeURIComponent(name)}`
})
.then(response => response.json())
.then(data => {
if (data.success) {
showNotification('分类添加成功', 'success');
closeAddCategoryModal();
setTimeout(() => location.reload(), 1000);
} else {
showNotification(data.error || '添加失败', 'error');
}
})
.catch(error => {
console.error('Error:', error);
showNotification('添加失败,请重试', 'error');
});
return false;
}
// 编辑分类弹窗相关函数
function showEditCategoryModal() {
document.getElementById('editCategoryModal').style.display = 'block';
}
function closeEditCategoryModal() {
document.getElementById('editCategoryModal').style.display = 'none';
}
function editCategory(id, name) {
document.getElementById('editCategoryId').value = id;
document.getElementById('editCategoryName').value = name;
showEditCategoryModal();
}
function updateCategory(event) {
event.preventDefault();
const id = document.getElementById('editCategoryId').value;
const name = document.getElementById('editCategoryName').value.trim();
if (!name) {
showNotification('分类名称不能为空', 'error');
return false;
}
fetch(`/edit_category/${id}`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: `name=${encodeURIComponent(name)}`
})
.then(response => response.json())
.then(data => {
if (data.success) {
showNotification('分类修改成功', 'success');
closeEditCategoryModal();
setTimeout(() => location.reload(), 1000);
} else {
showNotification(data.error || '修改失败', 'error');
}
})
.catch(error => {
console.error('Error:', error);
showNotification('修改失败,请重试', 'error');
});
return false;
}
// 点击弹窗外部关闭
window.onclick = function(event) {
const addModal = document.getElementById('addCategoryModal');
const editModal = document.getElementById('editCategoryModal');
if (event.target == addModal) {
closeAddCategoryModal();
}
if (event.target == editModal) {
closeEditCategoryModal();
}
}
// 添加 ESC 键关闭弹窗
document.addEventListener('keydown', function(event) {
if (event.key === 'Escape') {
closeAddCategoryModal();
closeEditCategoryModal();
}
});
</script>
{% endblock %}

680
templates/admin_coming.html Executable file
View File

@@ -0,0 +1,680 @@
{% extends "base.html" %}
{% block content %}
{% include 'admin_nav.html' %}
<div class="admin-container">
<div class="admin-content">
<div class="admin-card full-width">
<div class="card-header">
<div class="header-left">
<i class="fas fa-clock"></i>
<h3>即将上线应用管理</h3>
</div>
</div>
<form onsubmit="submitAddComingAppForm(event, this)" class="admin-form" enctype="multipart/form-data">
<div class="form-group">
<label for="app-name">应用名称</label>
<input type="text" id="app-name" name="name" required placeholder="请输入应用名称">
</div>
<div class="form-group">
<label>图标选择方式</label>
<div class="icon-method-selector">
<label>
<input type="radio" name="icon_method" value="upload" checked onchange="toggleIconMethod(this)">
上传图片
</label>
<label>
<input type="radio" name="icon_method" value="url" onchange="toggleIconMethod(this)">
图标链接
</label>
</div>
</div>
<div class="form-group" id="icon-upload-group">
<label for="app-icon">应用图标</label>
<div class="file-input-wrapper">
<input type="file" id="app-icon" name="icon" accept="image/*">
<label for="app-icon" class="file-input-label">
<i class="fas fa-cloud-upload-alt"></i>
<span>选择图标</span>
</label>
</div>
</div>
<div class="form-group" id="icon-url-group" style="display: none;">
<label for="icon-url">图标链接</label>
<input type="url" id="icon-url" name="icon_url" placeholder="请输入图标链接 (http:// 或 https://)">
</div>
<button type="submit" class="btn-primary">
<i class="fas fa-plus"></i> 添加到即将上线
</button>
</form>
<div class="apps-list">
<div class="apps-header">
<div class="sort-handle-header"></div>
<div>图标</div>
<div>名称</div>
<div>添加时间</div>
<div>操作</div>
</div>
<div id="sortableApps">
{% for app in coming_apps %}
<div class="app-item" data-id="{{ app.id }}">
<div class="sort-handle">
<i class="fas fa-grip-vertical"></i>
</div>
<div class="app-icon-cell">
<div class="app-icon-container">
<div class="app-icon-small">
{% if 'http' in app.icon_path %}
<img src="{{ app.icon_path }}" alt="{{ app.name }}" id="icon-{{ app.id }}">
{% else %}
<img src="{{ url_for('static', filename='uploads/' + app.icon_path) }}" alt="{{ app.name }}" id="icon-{{ app.id }}">
{% endif %}
<div class="icon-edit-overlay">
<label class="icon-edit-btn" title="上传图标">
<i class="fas fa-camera"></i>
<input type="file"
class="icon-input"
accept="image/*"
onchange="updateIcon({{ app.id }}, this)"
style="display: none;">
</label>
</div>
</div>
<input type="url"
value="{{ app.icon_path }}"
placeholder="图标URL"
onchange="updateIconUrl({{ app.id }}, this.value)"
class="icon-url-input">
</div>
</div>
<div class="app-name">{{ app.name }}</div>
<div class="app-time">{{ app.created_at }}</div>
<div class="app-actions">
<button onclick="removeFromComing({{ app.id }})" class="btn-delete">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
{% endfor %}
</div>
</div>
</div>
</div>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/Sortable/1.14.0/Sortable.min.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
// 初始化拖拽排序
const sortableContainer = document.getElementById('sortableApps');
if (sortableContainer) {
try {
new Sortable(sortableContainer, {
animation: 150,
handle: '.sort-handle',
ghostClass: 'dragging',
onEnd: function(evt) {
const items = sortableContainer.getElementsByClassName('app-item');
const newOrder = Array.from(items).map(item => parseInt(item.dataset.id));
fetch('/admin/coming/update_order', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
},
credentials: 'same-origin',
body: JSON.stringify({ order: newOrder })
})
.then(response => {
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
})
.then(data => {
if (data.success) {
showNotification('排序已更新', 'success');
} else {
throw new Error(data.error || '更新失败');
}
})
.catch(error => {
console.error('Error:', error);
showNotification('更新失败,请重试', 'error');
setTimeout(() => location.reload(), 2000);
});
}
});
} catch (error) {
console.error('Failed to initialize Sortable:', error);
}
}
// 修复图标URL更新函数
function updateIconUrl(appId, newUrl) {
if (!newUrl) return;
if (!newUrl.startsWith('http://') && !newUrl.startsWith('https://')) {
showNotification('请输入有效的URL地址', 'error');
return;
}
const appItem = document.querySelector(`.app-item[data-id="${appId}"]`);
if (!appItem) {
console.error('App item not found:', appId);
return;
}
const input = appItem.querySelector('.icon-url-input');
const img = appItem.querySelector('.app-icon-small img');
if (!input || !img) {
console.error('Required elements not found');
return;
}
const originalUrl = img.src;
input.disabled = true;
img.style.opacity = '0.5';
fetch('/admin/coming/update_icon_url', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
app_id: appId,
icon_url: newUrl
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
showNotification('图标链接更新成功', 'success');
img.src = newUrl;
} else {
throw new Error(data.error || '更新失败');
}
})
.catch(error => {
showNotification(error.message || '更新失败,请重试', 'error');
img.src = originalUrl;
input.value = originalUrl;
})
.finally(() => {
input.disabled = false;
img.style.opacity = '1';
});
}
// 将 updateIconUrl 函数添加到全局作用域
window.updateIconUrl = updateIconUrl;
});
// 更新图标
function updateIcon(appId, input) {
if (!input.files || !input.files[0]) {
showNotification('请选择图片文件', 'error');
return;
}
const file = input.files[0];
const allowedTypes = ['image/jpeg', 'image/png', 'image/gif'];
if (!allowedTypes.includes(file.type)) {
showNotification('不支持的文件类型,请选择 jpg、png 或 gif 格式的图片', 'error');
return;
}
if (file.size > 5 * 1024 * 1024) {
showNotification('文件太大请选择5MB以下的图片', 'error');
return;
}
const formData = new FormData();
formData.append('icon', file);
formData.append('app_id', appId);
const imgElement = input.closest('.app-icon-small').querySelector('img');
const originalSrc = imgElement.src;
imgElement.style.opacity = '0.5';
fetch('/admin/coming/update_icon', {
method: 'POST',
body: formData
})
.then(response => response.json())
.then(data => {
if (data.success) {
showNotification('图标更新成功', 'success');
imgElement.src = data.icon_path.startsWith('http') ?
data.icon_path :
`/static/uploads/${data.icon_path}`;
} else {
showNotification(data.error || '更新失败', 'error');
imgElement.src = originalSrc;
}
})
.catch(error => {
showNotification('更新失败,请重试', 'error');
imgElement.src = originalSrc;
})
.finally(() => {
imgElement.style.opacity = '1';
input.value = '';
});
}
// 显示通知
function showNotification(message, type = 'success') {
const notification = document.createElement('div');
notification.className = `notification ${type}`;
notification.textContent = message;
Object.assign(notification.style, {
position: 'fixed',
bottom: '20px',
right: '20px',
padding: '10px 20px',
borderRadius: '4px',
backgroundColor: type === 'success' ? '#4CAF50' : '#f44336',
color: 'white',
zIndex: '1000',
opacity: '0',
transform: 'translateY(20px)',
transition: 'all 0.3s ease'
});
document.body.appendChild(notification);
setTimeout(() => {
notification.style.opacity = '1';
notification.style.transform = 'translateY(0)';
}, 10);
setTimeout(() => {
notification.style.opacity = '0';
notification.style.transform = 'translateY(20px)';
setTimeout(() => notification.remove(), 300);
}, 3000);
}
// 保持原有的其他函数不变
function toggleIconMethod(radio) {
const uploadGroup = document.getElementById('icon-upload-group');
const urlGroup = document.getElementById('icon-url-group');
if (radio.value === 'upload') {
uploadGroup.style.display = 'block';
urlGroup.style.display = 'none';
document.getElementById('icon-url').value = '';
} else {
uploadGroup.style.display = 'none';
urlGroup.style.display = 'block';
document.getElementById('app-icon').value = '';
}
}
function submitAddComingAppForm(event, form) {
event.preventDefault();
const formData = new FormData(form);
// 根据选择的方式处理图标
const iconMethod = form.querySelector('input[name="icon_method"]:checked').value;
if (iconMethod === 'upload' && !formData.get('icon').size) {
alert('请选择图标文件');
return;
}
if (iconMethod === 'url' && !formData.get('icon_url')) {
alert('请输入图标链接');
return;
}
fetch('/admin/coming/add', {
method: 'POST',
body: formData
})
.then(response => response.json())
.then(data => {
if (data.success) {
location.reload();
} else {
alert(data.message || '添加失败');
}
});
}
function removeFromComing(appId) {
if (confirm('确定要从即将上线列表中移除这个应用吗?')) {
fetch(`/admin/coming/remove/${appId}`, {
method: 'POST',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json'
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
const appItem = document.querySelector(`.app-item[data-id="${appId}"]`);
if (appItem) {
appItem.remove();
showNotification('应用已移除', 'success');
}
} else {
throw new Error(data.message || '删除失败');
}
})
.catch(error => {
console.error('Error:', error);
showNotification(error.message || '删除失败,请重试', 'error');
});
}
}
</script>
<style>
/* 图标容器样式 */
.app-icon-container {
display: flex;
align-items: center;
gap: 10px;
width: 300px;
}
.app-icon-small {
width: 40px;
height: 40px;
flex-shrink: 0;
position: relative;
border-radius: 8px;
overflow: hidden;
}
.app-icon-small img {
width: 100%;
height: 100%;
object-fit: cover;
transition: all 0.3s ease;
}
.icon-edit-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: all 0.3s ease;
}
.app-icon-small:hover .icon-edit-overlay {
opacity: 1;
}
.icon-edit-btn {
color: white;
cursor: pointer;
padding: 5px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.2);
transition: all 0.3s ease;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
}
.icon-edit-btn:hover {
background: rgba(255, 255, 255, 0.3);
transform: scale(1.1);
}
.icon-url-input {
flex: 1;
min-width: 0;
padding: 4px 8px;
border: 1px solid #d2d2d7;
border-radius: 4px;
font-size: 12px;
color: #666;
transition: all 0.3s ease;
}
.icon-url-input:hover {
border-color: #b2b2b2;
}
.icon-url-input:focus {
border-color: #0066cc;
outline: none;
color: #333;
}
/* 保持原有的其他样式不变 */
.file-input-wrapper {
position: relative;
overflow: hidden;
display: inline-block;
}
.file-input-wrapper input[type=file] {
position: absolute;
left: 0;
top: 0;
opacity: 0;
cursor: pointer;
}
.file-input-label {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
background: #f5f5f5;
border-radius: 4px;
cursor: pointer;
transition: background 0.3s;
}
.file-input-label:hover {
background: #e5e5e5;
}
[data-theme="dark"] .file-input-label {
background: #2c2c2e;
}
[data-theme="dark"] .file-input-label:hover {
background: #3c3c3e;
}
.icon-method-selector {
display: flex;
gap: 20px;
margin-bottom: 10px;
}
.icon-method-selector label {
display: flex;
align-items: center;
gap: 5px;
cursor: pointer;
}
.icon-method-selector input[type="radio"] {
margin: 0;
}
.modal {
display: none;
position: fixed;
z-index: 1000;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: rgba(0,0,0,0.4);
}
.modal-content {
background-color: #fefefe;
margin: 15% auto;
padding: 20px;
border: 1px solid #888;
width: 80%;
max-width: 500px;
border-radius: 8px;
}
.close {
color: #aaa;
float: right;
font-size: 28px;
font-weight: bold;
cursor: pointer;
}
.close:hover {
color: black;
}
.icon-edit-tabs {
margin-bottom: 20px;
border-bottom: 1px solid #ddd;
}
.tab-btn {
padding: 10px 20px;
border: none;
background: none;
cursor: pointer;
}
.tab-btn.active {
border-bottom: 2px solid #007bff;
color: #007bff;
}
.tab-content {
padding: 20px 0;
}
.handle {
cursor: move;
color: #666;
}
.icon-actions {
margin-top: 5px;
}
.btn-icon {
background: none;
border: none;
color: #007bff;
cursor: pointer;
padding: 5px;
}
.btn-icon:hover {
color: #0056b3;
}
[data-theme="dark"] .modal-content {
background-color: #2c2c2e;
color: #fff;
}
[data-theme="dark"] .close {
color: #fff;
}
[data-theme="dark"] .tab-btn {
color: #fff;
}
[data-theme="dark"] .tab-btn.active {
border-bottom-color: #0a84ff;
color: #0a84ff;
}
.apps-list {
margin-top: 20px;
}
.apps-header {
display: grid;
grid-template-columns: 50px 300px 1fr 150px 100px;
padding: 10px;
background: #f5f5f7;
border-radius: 8px;
margin-bottom: 10px;
font-weight: 500;
}
.app-item {
display: grid;
grid-template-columns: 50px 300px 1fr 150px 100px;
align-items: center;
padding: 12px;
background: white;
border-radius: 8px;
margin-bottom: 8px;
transition: all 0.3s ease;
cursor: move;
}
.app-item:hover {
background: #f8f9fa;
}
.sort-handle {
color: #999;
cursor: move;
display: flex;
align-items: center;
justify-content: center;
}
.sort-handle i {
font-size: 16px;
}
.app-name {
font-size: 14px;
}
.app-time {
font-size: 13px;
color: #666;
}
.app-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
}
/* 拖拽时的样式 */
.app-item.dragging {
opacity: 0.5;
background: #f0f0f0;
}
.app-item.drag-over {
border-top: 2px solid #007AFF;
}
</style>
{% endblock %}

618
templates/admin_dashboard.html Executable file
View File

@@ -0,0 +1,618 @@
{% extends "base.html" %}
{% block content %}
{% include 'admin_nav.html' %}
<div class="admin-container">
<div class="admin-content">
<div class="dashboard-header">
<h2>网站概览</h2>
<div class="refresh-btn" onclick="location.reload()">
<i class="fas fa-sync-alt"></i>
刷新数据
</div>
</div>
<!-- 数据卡片 -->
<div class="stat-cards">
<div class="stat-card">
<div class="stat-icon">
<i class="fas fa-users"></i>
</div>
<div class="stat-info">
<div class="stat-title">总用户数</div>
<div class="stat-value">{{ stats.total_users }}</div>
<div class="stat-subtitle">
今日新增 {{ stats.today_users }}
<br>
今日邀请 {{ stats.today_inviters or 0 }} / 被邀请 {{ stats.today_invitees or 0 }}
</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon">
<i class="fas fa-heart"></i>
</div>
<div class="stat-info">
<div class="stat-title">心愿单总数</div>
<div class="stat-value">{{ stats.total_wishlists }}</div>
<div class="stat-subtitle">今日新增 {{ stats.today_wishlists }}</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon">
<i class="fas fa-bell"></i>
</div>
<div class="stat-info">
<div class="stat-title">通知状态</div>
<div class="stat-value">{{ stats.notified_wishlists }}</div>
<div class="stat-subtitle">
已通知 {{ stats.notified_wishlists }} / 未通知 {{ stats.unnotified_wishlists }}
<br>
今日通知 {{ stats.today_notified }}
</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon">
<i class="fas fa-mobile-alt"></i>
</div>
<div class="stat-info">
<div class="stat-title">应用总数</div>
<div class="stat-value">{{ stats.total_apps }}</div>
<div class="stat-subtitle">
手机端 {{ stats.mobile_apps }} / 平板端 {{ stats.tablet_apps }}
<br>
今日新增 {{ stats.today_apps }}
</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon">
<i class="fas fa-chart-line"></i>
</div>
<div class="stat-info">
<div class="stat-title">通知成功率</div>
{% set success_rate = (stats.notified_wishlists / stats.total_wishlists * 100)|round(2) if stats.total_wishlists > 0 else 0 %}
<div class="stat-value">{{ success_rate }}%</div>
<div class="stat-subtitle">
总通知数 {{ stats.total_wishlists }}
<br>
成功 {{ stats.notified_wishlists }}
</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon">
<i class="fas fa-user-check"></i>
</div>
<div class="stat-info">
<div class="stat-title">活跃用户</div>
<div class="stat-value">{{ stats.user_activity.active_users }}</div>
<div class="stat-subtitle">
活跃率 {{ stats.user_activity.active_rate }}%
<br>
总用户 {{ stats.total_users }}
</div>
</div>
</div>
</div>
<!-- 图表区域 -->
<div class="chart-section">
<div class="chart-card">
<h3>最近7天注册趋势</h3>
<canvas id="trendChart"></canvas>
</div>
<div class="chart-card">
<h3>热门应用 TOP10</h3>
<canvas id="popularChart"></canvas>
</div>
<div class="chart-card">
<h3>应用分类分布</h3>
<canvas id="categoryChart"></canvas>
</div>
<div class="chart-card">
<h3>最近7天心愿单趋势</h3>
<canvas id="wishlistTrendChart"></canvas>
</div>
<div class="chart-card">
<h3>最近30天应用增长趋势</h3>
<canvas id="appGrowthChart"></canvas>
</div>
<div class="chart-card">
<h3>用户活跃度分析</h3>
<canvas id="userActivityChart"></canvas>
</div>
</div>
</div>
</div>
<style>
.dashboard-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
}
.refresh-btn {
padding: 8px 16px;
background: #f5f5f5;
border-radius: 6px;
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
transition: all 0.3s ease;
}
.refresh-btn:hover {
background: #e8e8e8;
}
.stat-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 20px;
margin-bottom: 24px;
}
.stat-card {
background: white;
border-radius: 12px;
padding: 20px;
display: flex;
align-items: center;
gap: 20px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.stat-icon {
width: 48px;
height: 48px;
border-radius: 12px;
background: rgba(24, 144, 255, 0.1);
color: #1890ff;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
}
.stat-info {
flex: 1;
}
.stat-title {
color: #666;
font-size: 14px;
margin-bottom: 4px;
}
.stat-value {
font-size: 24px;
font-weight: 500;
color: #333;
margin-bottom: 4px;
}
.stat-subtitle {
color: #999;
font-size: 12px;
line-height: 1.5;
}
.stat-subtitle br {
content: '';
display: block;
margin: 2px 0;
}
.chart-section {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
gap: 20px;
}
.chart-card {
background: white;
border-radius: 12px;
padding: 20px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.chart-card h3 {
font-size: 16px;
color: #333;
}
/* 暗色模式适配 */
[data-theme="dark"] .stat-card,
[data-theme="dark"] .chart-card {
background: #242424;
}
[data-theme="dark"] .refresh-btn {
background: #333;
color: #fff;
}
[data-theme="dark"] .refresh-btn:hover {
background: #444;
}
[data-theme="dark"] .stat-title {
color: #999;
}
[data-theme="dark"] .stat-value {
color: #fff;
}
[data-theme="dark"] .chart-card h3 {
color: #fff;
}
</style>
<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/3.9.1/chart.min.js"></script>
<script>
// 全局统计数据
const stats = {{ stats|tojson|safe }};
// 趋势图
const trendCtx = document.getElementById('trendChart').getContext('2d');
const trendDates = {{ stats.daily_stats|map(attribute='date')|list|tojson|safe }}.reverse();
const trendCounts = {{ stats.daily_stats|map(attribute='count')|list|tojson|safe }}.reverse();
new Chart(trendCtx, {
type: 'line',
data: {
labels: trendDates,
datasets: [{
label: '每日注册',
data: trendCounts,
borderColor: '#1890ff',
backgroundColor: 'rgba(24, 144, 255, 0.1)',
fill: true,
tension: 0.1,
pointRadius: 4,
pointHoverRadius: 6
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: false
},
tooltip: {
callbacks: {
title: function(context) {
return '日期:' + context[0].label;
},
label: function(context) {
return '注册人数:' + context.raw;
}
}
}
},
scales: {
y: {
beginAtZero: true,
ticks: {
stepSize: 1
}
}
}
}
});
// 热门应用图
const popularCtx = document.getElementById('popularChart').getContext('2d');
new Chart(popularCtx, {
type: 'bar',
data: {
labels: {{ stats.popular_apps|map(attribute='app_name')|list|tojson|safe }},
datasets: [{
label: '关注人数',
data: {{ stats.popular_apps|map(attribute='count')|list|tojson|safe }},
backgroundColor: '#1890ff'
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
aspectRatio: 1,
plugins: {
legend: {
display: false
},
tooltip: {
callbacks: {
title: function(context) {
return context[0].label;
},
label: function(context) {
return `关注人数: ${context.raw}`;
}
}
}
},
scales: {
x: {
grid: {
display: false
}
},
y: {
beginAtZero: true,
grid: {
color: 'rgba(0, 0, 0, 0.1)'
},
ticks: {
stepSize: 1
}
}
}
}
});
// 分类分布图
const categoryCtx = document.getElementById('categoryChart').getContext('2d');
new Chart(categoryCtx, {
type: 'doughnut',
data: {
labels: {{ stats.category_stats|map(attribute='name')|list|tojson|safe }},
datasets: [{
data: {{ stats.category_stats|map(attribute='count')|list|tojson|safe }},
backgroundColor: [
'#1890ff', '#13c2c2', '#52c41a', '#faad14', '#f5222d',
'#722ed1', '#eb2f96', '#fa8c16', '#a0d911', '#fadb14'
]
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'right',
labels: {
boxWidth: 12,
padding: 10
}
},
tooltip: {
callbacks: {
label: function(context) {
const label = context.label || '';
const value = context.raw;
const total = context.dataset.data.reduce((a, b) => a + b, 0);
const percentage = Math.round((value / total) * 100);
return `${label}: ${value} (${percentage}%)`;
}
}
}
}
}
});
// 心愿单趋势图
const wishlistTrendCtx = document.getElementById('wishlistTrendChart').getContext('2d');
const wishlistDates = {{ stats.wishlist_stats|map(attribute='date')|list|tojson|safe }}.reverse();
const wishlistCounts = {{ stats.wishlist_stats|map(attribute='count')|list|tojson|safe }}.reverse();
new Chart(wishlistTrendCtx, {
type: 'line',
data: {
labels: wishlistDates,
datasets: [{
label: '新增心愿',
data: wishlistCounts,
borderColor: '#13c2c2',
backgroundColor: 'rgba(19, 194, 194, 0.1)',
fill: true,
tension: 0.1,
pointRadius: 4,
pointHoverRadius: 6
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: false
},
tooltip: {
callbacks: {
title: function(context) {
return '日期:' + context[0].label;
},
label: function(context) {
return '新增心愿:' + context.raw;
}
}
}
},
scales: {
y: {
beginAtZero: true,
ticks: {
stepSize: 1
}
}
}
}
});
// 应用增长趋势图
const appGrowthCtx = document.getElementById('appGrowthChart').getContext('2d');
new Chart(appGrowthCtx, {
type: 'line',
data: {
labels: {{ stats.app_growth|map(attribute='date')|list|tojson|safe }},
datasets: [{
label: '总数',
data: {{ stats.app_growth|map(attribute='count')|list|tojson|safe }},
borderColor: '#1890ff',
backgroundColor: 'rgba(24, 144, 255, 0.1)',
fill: true
}, {
label: '手机端',
data: {{ stats.app_growth|map(attribute='mobile_count')|list|tojson|safe }},
borderColor: '#52c41a',
backgroundColor: 'rgba(82, 196, 26, 0.1)',
fill: true
}, {
label: '平板端',
data: {{ stats.app_growth|map(attribute='tablet_count')|list|tojson|safe }},
borderColor: '#722ed1',
backgroundColor: 'rgba(114, 46, 209, 0.1)',
fill: true
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: true,
position: 'top'
},
tooltip: {
mode: 'index',
intersect: false
}
},
scales: {
y: {
beginAtZero: true,
stacked: false
}
}
}
});
// 用户活跃度分析图
const userActivityCtx = document.getElementById('userActivityChart').getContext('2d');
new Chart(userActivityCtx, {
type: 'bar',
data: {
labels: {{ stats.user_activity.details|map(attribute='activity_level')|list|tojson|safe }},
datasets: [{
label: '用户数',
data: {{ stats.user_activity.details|map(attribute='user_count')|list|tojson|safe }},
backgroundColor: [
'#ff4d4f', // 不活跃 - 红色
'#faad14', // 轻度活跃 - 橙色
'#52c41a', // 中度活跃 - 绿色
'#1890ff' // 高度活跃 - 蓝色
],
order: 1
}, {
label: '平均心愿单数',
data: {{ stats.user_activity.details|map(attribute='avg_total_wishlists')|list|tojson|safe }},
type: 'line',
borderColor: '#722ed1',
backgroundColor: 'rgba(114, 46, 209, 0.1)',
yAxisID: 'y1',
order: 0,
fill: false,
tension: 0.4
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: 'index',
intersect: false
},
plugins: {
legend: {
display: true,
position: 'top'
},
tooltip: {
callbacks: {
label: function(context) {
const details = {{ stats.user_activity.details|tojson|safe }};
const data = details[context.dataIndex];
if (context.dataset.label === '用户数') {
const total = {{ stats.total_users }};
const percentage = Math.round((context.raw / total) * 100);
return [
`用户数: ${context.raw} 人 (${percentage}%)`,
`心愿单范围: ${data.min_wishlists} - ${data.max_wishlists}`,
`平均每日添加: ${data.avg_daily_activity}`,
`平均活跃天数: ${data.avg_active_days}`,
`平均活跃周数: ${data.avg_active_weeks}`,
`最近7天活跃: ${data.active_users_last_week}`,
`最近24小时活跃: ${data.active_users_last_day}`,
`平均账龄: ${data.avg_user_age_days}`,
`上次活动: ${data.avg_days_since_last_activity || 'N/A'} 天前`
];
} else {
return `平均心愿单数: ${context.raw.toFixed(1)}`;
}
}
}
}
},
scales: {
y: {
beginAtZero: true,
title: {
display: true,
text: '用户数'
}
},
y1: {
beginAtZero: true,
position: 'right',
title: {
display: true,
text: '平均心愿单数'
},
grid: {
drawOnChartArea: false
}
}
}
}
});
// 设置图表容器高度
document.querySelectorAll('.chart-card').forEach(card => {
card.style.height = '400px';
});
// 检查暗色模式并更新图表
function updateChartsTheme() {
const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
Chart.defaults.color = isDark ? '#999' : '#666';
Chart.defaults.borderColor = isDark ? '#333' : '#eee';
}
updateChartsTheme();
document.addEventListener('themeChanged', updateChartsTheme);
</script>
{% endblock %}

334
templates/admin_nav.html Executable file
View File

@@ -0,0 +1,334 @@
<div class="mobile-header">
<button class="nav-toggle" onclick="toggleNav()">
<i class="fas fa-bars"></i>
</button>
<h2>NEXT Store</h2>
</div>
<nav class="admin-nav">
<div class="admin-nav-header">
<h2>NEXT Store</h2>
</div>
<ul class="admin-nav-menu">
<a href="{{ url_for('admin_dashboard') }}" class="admin-nav-item {% if request.endpoint == 'admin_dashboard' %}active{% endif %}">
<i class="fas fa-chart-line"></i>
<span>网站概览</span>
</a>
<div class="admin-nav-item dropdown {% if request.endpoint.startswith('wiki.admin_') %}active{% endif %}">
<i class="fas fa-book"></i>
<span>Wiki 管理</span>
<i class="fas fa-chevron-down dropdown-icon"></i>
<div class="dropdown-menu" style="{% if not request.endpoint.startswith('wiki.admin_') %}display: none;{% endif %}">
<a href="{{ url_for('wiki.admin_wiki') }}" class="dropdown-item {% if request.endpoint == 'wiki.admin_wiki' %}active{% endif %}">
<i class="fas fa-plus-circle"></i>
<span>添加条目</span>
</a>
<a href="{{ url_for('wiki.admin_wiki_list') }}" class="dropdown-item {% if request.endpoint == 'wiki.admin_wiki_list' %}active{% endif %}">
<i class="fas fa-list"></i>
<span>条目列表</span>
</a>
<a href="{{ url_for('wiki.admin_wiki_comments') }}" class="dropdown-item {% if request.endpoint == 'wiki.admin_wiki_comments' %}active{% endif %}">
<i class="fas fa-comment-dots"></i>
<span>评论审核</span>
</a>
<!-- 在现有的导航项中添加 -->
<a href="{{ url_for('wiki.wiki_categories') }}" class="dropdown-item">
<i class="fas fa-tags"></i>
<span>分类管理</span>
</a>
</div>
</div>
<a href="{{ url_for('admin') }}" class="admin-nav-item {% if request.endpoint == 'admin' %}active{% endif %}">
<i class="fas fa-plus-circle"></i>
<span>添加应用</span>
</a>
<a href="{{ url_for('admin_categories') }}" class="admin-nav-item {% if request.endpoint == 'admin_categories' %}active{% endif %}">
<i class="fas fa-folder-plus"></i>
<span>分类管理</span>
</a>
<a href="{{ url_for('admin_apps') }}" class="admin-nav-item {% if request.endpoint == 'admin_apps' %}active{% endif %}">
<i class="fas fa-th-large"></i>
<span>应用管理</span>
</a>
<a href="{{ url_for('auto_import_page') }}" class="admin-nav-item {% if request.endpoint == 'auto_import_page' %}active{% endif %}">
<i class="fas fa-cloud-download-alt"></i>
<span>自动导入</span>
</a>
<a href="{{ url_for('site_settings') }}" class="admin-nav-item {% if request.endpoint == 'site_settings' %}active{% endif %}">
<i class="fas fa-cog"></i>
<span>站点设置</span>
</a>
<a href="{{ url_for('admin_wishlist') }}" class="admin-nav-item {% if request.endpoint == 'admin_wishlist' %}active{% endif %}">
<i class="fas fa-heart"></i>
<span>心愿单管理</span>
</a>
<a href="{{ url_for('email_settings') }}" class="admin-nav-item {% if request.endpoint == 'email_settings' %}active{% endif %}">
<i class="fas fa-envelope"></i>
<span>邮件设置</span>
</a>
<a href="{{ url_for('admin_users') }}" class="admin-nav-item {% if request.endpoint == 'admin_users' %}active{% endif %}">
<i class="fas fa-users"></i>
<span>用户管理</span>
</a>
{% if is_superadmin() %}
<a href="{{ url_for('user_management') }}" class="admin-nav-item {% if request.endpoint == 'user_management' %}active{% endif %}">
<i class="fas fa-users"></i>
<span>员工管理</span>
</a>
<a href="{{ url_for('credits_management') }}" class="admin-nav-item {% if request.endpoint == 'credits_management' %}active{% endif %}">
<i class="fas fa-award"></i>
<span>鸣谢管理</span>
</a>
{% endif %}
<a href="{{ url_for('app_edit') }}" class="admin-nav-item {% if request.endpoint == 'app_edit' %}active{% endif %}">
<i class="fas fa-edit"></i>
<span>应用详情</span>
</a>
{% if is_superadmin() %}
<a href="{{ url_for('donate_settings_page') }}" class="admin-nav-item {% if request.endpoint == 'donate_settings_page' %}active{% endif %}">
<i class="fas fa-gift"></i>
赞赏管理
</a>
{% endif %}
<a href="{{ url_for('tablet_filter') }}" class="admin-nav-item {% if request.endpoint == 'tablet_filter' %}active{% endif %}">
<i class="fas fa-tablet-alt"></i>
<span>平板筛选</span>
</a>
<a href="{{ url_for('admin_coming') }}" class="admin-nav-item {% if request.endpoint == 'admin_coming' %}active{% endif %}">
<i class="fas fa-clock"></i>
<span>即将上线</span>
</a>
<a href="{{ url_for('logout') }}" class="admin-nav-item">
<i class="fas fa-sign-out-alt"></i>
<span>退出登录</span>
</a>
</ul>
</nav>
<div class="nav-overlay" onclick="toggleNav()"></div>
<script>
function toggleNav() {
document.querySelector('.admin-nav').classList.toggle('active');
document.querySelector('.nav-overlay').classList.toggle('active');
}
// 在移动端点击导航项后自动关闭导航栏
document.querySelectorAll('.admin-nav-item').forEach(item => {
item.addEventListener('click', () => {
if (window.innerWidth <= 768) {
toggleNav();
}
});
});
// 监听窗口大小变化
window.addEventListener('resize', () => {
if (window.innerWidth > 768) {
document.querySelector('.admin-nav').classList.remove('active');
document.querySelector('.nav-overlay').classList.remove('active');
}
});
// 添加下拉菜单交互
document.addEventListener('DOMContentLoaded', function() {
const dropdowns = document.querySelectorAll('.dropdown');
dropdowns.forEach(dropdown => {
dropdown.addEventListener('click', function(e) {
// 如果点击的是下拉项,不切换展开状态
if (e.target.closest('.dropdown-item')) return;
// 切换当前下拉菜单的展开状态
this.classList.toggle('active');
const menu = this.querySelector('.dropdown-menu');
menu.style.display = menu.style.display === 'block' ? 'none' : 'block';
// 关闭其他下拉菜单
dropdowns.forEach(other => {
if (other !== dropdown) {
other.classList.remove('active');
const otherMenu = other.querySelector('.dropdown-menu');
if (otherMenu) {
otherMenu.style.display = 'none';
}
}
});
});
});
});
</script>
<style>
.admin-nav {
position: fixed;
top: 0;
left: 0;
width: 250px;
height: 100vh;
background: #fff;
box-shadow: 2px 0 8px rgba(0,0,0,0.1);
z-index: 1000;
display: flex;
flex-direction: column;
transition: transform 0.3s ease;
}
.admin-nav-header {
padding: 20px;
border-bottom: 1px solid #eee;
flex-shrink: 0;
}
.admin-nav-menu {
flex: 1;
overflow-y: auto;
padding: 10px 0;
/* 添加滚动条样式 */
scrollbar-width: thin;
scrollbar-color: #888 #f5f5f5;
}
/* 自定义滚动条样式 */
.admin-nav-menu::-webkit-scrollbar {
width: 6px;
}
.admin-nav-menu::-webkit-scrollbar-track {
background: #f5f5f5;
}
.admin-nav-menu::-webkit-scrollbar-thumb {
background: #888;
border-radius: 3px;
}
.admin-nav-menu::-webkit-scrollbar-thumb:hover {
background: #666;
}
/* 暗色模式适配 */
[data-theme="dark"] .admin-nav {
background: #242424;
box-shadow: 2px 0 8px rgba(0,0,0,0.2);
}
[data-theme="dark"] .admin-nav-header {
border-color: #333;
}
[data-theme="dark"] .admin-nav-menu::-webkit-scrollbar-track {
background: #333;
}
[data-theme="dark"] .admin-nav-menu::-webkit-scrollbar-thumb {
background: #666;
}
[data-theme="dark"] .admin-nav-menu::-webkit-scrollbar-thumb:hover {
background: #888;
}
@media (max-width: 768px) {
.admin-nav {
transform: translateX(-100%);
}
.admin-nav.active {
transform: translateX(0);
}
}
/* 下拉菜单样式 */
.dropdown {
position: relative;
cursor: pointer;
}
.dropdown-icon {
margin-left: auto;
font-size: 12px;
transition: transform 0.3s ease;
}
.dropdown.active:not(.no-rotate) .dropdown-icon {
transform: rotate(180deg);
}
.dropdown-menu {
display: none;
position: relative;
margin-top: 5px;
overflow: hidden;
transition: all 0.3s ease;
z-index: 1000;
background-color: rgba(0, 0, 0, .3);
padding: 5px 0;
min-width: 100%;
}
.dropdown.active .dropdown-menu {
display: block;
}
.dropdown-item {
display: flex;
align-items: center;
padding: 0 20px 0 40px;
color: #333;
text-decoration: none;
transition: all 0.3s ease;
line-height: 36px;
white-space: nowrap;
color: #fff;
}
.dropdown-item i {
margin-right: 10px;
font-size: 14px;
}
.dropdown-item:hover {
background-color: rgba(255, 255, 255, .1);
}
.dropdown-item.active {
background-color: rgba(255, 255, 255, .1);
font-weight: 500;
}
/* 暗色模式适配 */
[data-theme="dark"] .dropdown-menu {
background-color: rgba(0, 0, 0, .5);
}
[data-theme="dark"] .dropdown-item {
color: #fff;
}
[data-theme="dark"] .dropdown-item:hover {
background-color: rgba(255, 255, 255, .1);
}
[data-theme="dark"] .dropdown-item.active {
background-color: rgba(255, 255, 255, .1);
}
/* 字体样式 */
.dropdown-item {
font-family: "Helvetica Neue", Helvetica, "PingFang SC", Tahoma, Arial, sans-serif;
font-size: 14px;
}
/* 移动端适配 */
@media (max-width: 768px) {
.dropdown-menu {
margin-left: 20px;
margin-right: 20px;
}
.dropdown-item {
padding: 0 15px 0 35px;
}
}
</style>

View File

@@ -0,0 +1,95 @@
{% extends "base.html" %}
{% block content %}
{% include 'admin_nav.html' %}
<div class="admin-container">
<div class="admin-content">
<div class="admin-card">
<div class="card-header">
<div class="header-left">
<i class="fas fa-shield-alt"></i>
<h3>权限管理</h3>
</div>
</div>
<form onsubmit="updatePermissions(event)" class="admin-form">
<div class="form-group">
<label>普通管理员权限</label>
<div class="permissions-grid">
<div class="permission-item">
<input type="checkbox" id="upload_permission" name="upload_permission"
{% if permissions.upload_permission %}checked{% endif %}>
<label for="upload_permission">上传图片权限</label>
</div>
<div class="permission-item">
<input type="checkbox" id="delete_permission" name="delete_permission"
{% if permissions.delete_permission %}checked{% endif %}>
<label for="delete_permission">删除应用权限</label>
</div>
</div>
</div>
<button type="submit" class="btn-primary">
<i class="fas fa-save"></i> 保存设置
</button>
</form>
</div>
</div>
</div>
<script>
function updatePermissions(event) {
event.preventDefault();
const formData = new FormData(event.target);
fetch('{{ url_for("admin_update_permissions") }}', {
method: 'POST',
body: JSON.stringify({
upload_permission: formData.get('upload_permission') === 'on',
delete_permission: formData.get('delete_permission') === 'on'
}),
headers: {
'Content-Type': 'application/json'
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
showNotification(data.message, 'success');
} else {
showNotification(data.error, 'error');
}
})
.catch(error => {
showNotification('更新权限失败,请重试', 'error');
console.error('Error:', error);
});
}
</script>
<style>
.permissions-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
margin-top: 10px;
}
.permission-item {
display: flex;
align-items: center;
gap: 10px;
}
.permission-item input[type="checkbox"] {
width: 18px;
height: 18px;
}
.permission-item label {
font-size: 14px;
color: #1d1d1f;
cursor: pointer;
}
</style>
{% endblock %}

680
templates/admin_users.html Executable file
View File

@@ -0,0 +1,680 @@
{% extends "base.html" %}
{% block content %}
{% include 'admin_nav.html' %}
<div class="admin-container">
<div class="admin-content">
<div class="admin-card">
<div class="card-header">
<div class="header-left">
<i class="fas fa-users"></i>
<h3>用户管理</h3>
</div>
<div class="filter-sort">
<div class="sort-box">
<i class="fas fa-sort"></i>
<select class="sort-select" onchange="sortUsers(this)">
<option value="created_desc">注册时间 ↓</option>
<option value="created_asc">注册时间 ↑</option>
<option value="login_desc">最近登录 ↓</option>
<option value="login_asc">最近登录 ↑</option>
<option value="invite_desc">邀请人数 ↓</option>
<option value="invite_asc">邀请人数 ↑</option>
</select>
</div>
<div class="search-box">
<input type="text"
class="search-input"
placeholder="搜索用户..."
onkeyup="filterUsers(this)">
<i class="fas fa-search"></i>
</div>
</div>
</div>
<div class="users-table-container">
<table class="users-table">
<thead>
<tr>
<th>头像</th>
<th>昵称</th>
<th>邮箱</th>
<th>华为ID</th>
<th>邀请人数</th>
<th>被邀请者</th>
<th>注册时间</th>
<th>最近登录</th>
<th>登录次数</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{% for user in users %}
<tr>
<td>
<img src="{{ user.avatar or '/static/images/default-avatar.png' }}"
alt="{{ user.name }}"
class="user-avatar">
</td>
<td title="{{ user.name }}">{{ user.name }}</td>
<td class="user-email">{{ user.email or '未设置' }}</td>
<td class="user-id">{{ user.huawei_id }}</td>
<td>
<div class="invite-count">
<span>{{ user.invite_count }}</span>
{% if user.invite_count > 0 %}
<button class="btn-view" onclick="viewInvitees('{{ user.id }}')">
<i class="fas fa-eye"></i>
</button>
{% endif %}
</div>
</td>
<td>
{% if user.inviter_name %}
<span class="inviter-tag">
<i class="fas fa-user-plus"></i>
{{ user.inviter_name }}
</span>
{% else %}
-
{% endif %}
</td>
<td>
<div class="time-info">
<span class="time-label">注册时间</span>
<span class="time-value">{{ user.created_at }}</span>
</div>
</td>
<td>
<div class="time-info">
<span class="time-label">最近登录</span>
<span class="time-value">{{ user.last_login or '未登录' }}</span>
</div>
</td>
<td>{{ user.login_count }}</td>
<td>
<div class="action-buttons">
<button class="btn-delete" onclick="deleteUser('{{ user.id }}', '{{ user.name }}')" title="删除">
<i class="fas fa-trash"></i>
</button>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- 被邀请用户列表弹窗 -->
<div id="inviteesModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h3>邀请的用户</h3>
<button class="close-btn" onclick="closeInviteesModal()">
<i class="fas fa-times"></i>
</button>
</div>
<div id="inviteesList" class="invitees-list"></div>
</div>
</div>
<!-- Toast 容器 -->
<div id="toast" class="toast"></div>
<style>
/* 容器样式优化 */
.admin-container {
padding: 0; /* 移除容器的内边距 */
}
.admin-content {
padding: 0; /* 移除内容区的内边距 */
max-width: none; /* 移除最大宽度限制 */
}
/* 卡片样式优化 */
.admin-card {
background: white;
border-radius: 0; /* 移除圆角 */
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
overflow: hidden;
margin: 0; /* 移除外边距 */
}
/* 表头样式优化 */
.card-header {
padding: 10px 12px; /* 减小表头内边距 */
border-bottom: 1px solid #eee;
display: flex;
align-items: center;
justify-content: space-between;
background: white;
position: sticky; /* 使表头固定 */
top: 0;
z-index: 10;
}
.header-left {
display: flex;
align-items: center;
gap: 8px; /* 减小间距 */
}
.header-left i {
font-size: 16px; /* 减小图标尺寸 */
color: #1890ff;
}
.header-left h3 {
margin: 0;
font-size: 16px; /* 减小标题字号 */
font-weight: 500;
}
/* 表格容器样式 */
.users-table-container {
padding: 0; /* 移除内边距 */
overflow-x: auto;
}
.users-table {
width: 100%;
border-collapse: collapse;
margin-top: 0;
border: none; /* 移除表格边框 */
}
.users-table th,
.users-table td {
padding: 8px 12px; /* 减小单元格内边距 */
text-align: left;
border-bottom: 1px solid #eee;
white-space: nowrap;
}
.users-table th {
background: #fafafa;
font-weight: 600;
color: #666;
font-size: 12px; /* 减小表头字号 */
text-transform: uppercase;
}
.users-table td {
font-size: 13px; /* 减小单元格字号 */
color: #333;
}
/* 昵称列宽度限制 */
.users-table td:nth-child(2) { /* 昵称是第二列 */
max-width: 120px; /* 限制最大宽度 */
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* 鼠标悬停时显示完整昵称 */
.users-table td:nth-child(2):hover {
position: relative;
}
.users-table td:nth-child(2):hover::after {
content: attr(title);
position: absolute;
left: 0;
top: 100%;
background: #fff;
padding: 4px 8px;
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
z-index: 1000;
white-space: normal;
max-width: 200px;
word-break: break-all;
}
/* 暗色模式适配 */
[data-theme="dark"] .users-table td:nth-child(2):hover::after {
background: #333;
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
}
/* 用户头像样式优化 */
.users-table td:first-child { /* 头像是第一列 */
width: 40px; /* 固定宽度 */
padding: 8px 4px; /* 减小内边距 */
}
.user-avatar {
width: 32px; /* 减小头像尺寸 */
height: 32px;
border-radius: 50%;
object-fit: cover;
border: 1px solid #fff; /* 减小边框 */
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
/* 邮箱和ID样式 */
.user-email,
.user-id {
color: #666;
font-size: 12px; /* 减小字号 */
}
/* 邀请数量样式 */
.invite-count {
display: flex;
align-items: center;
gap: 4px; /* 减小间距 */
}
.invite-count span {
font-weight: 500;
color: #1890ff;
}
.inviter-tag {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
background: rgba(24, 144, 255, 0.1);
color: #1890ff;
border-radius: 4px;
font-size: 12px;
transition: all 0.3s ease;
}
.inviter-tag:hover {
background: rgba(24, 144, 255, 0.2);
}
.inviter-tag i {
font-size: 12px;
}
/* 时间显示样式 */
.time-info {
display: flex;
flex-direction: column;
gap: 2px; /* 减小间距 */
}
.time-label {
font-size: 12px;
color: #999;
line-height: 1.2; /* 减小行高 */
}
.time-value {
font-size: 13px;
color: #333;
line-height: 1.2; /* 减小行高 */
}
/* 按钮样式优化 */
.btn-view,
.btn-delete {
padding: 4px; /* 减小按钮内边距 */
border: none;
border-radius: 4px;
cursor: pointer;
transition: all 0.3s ease;
width: 24px; /* 减小按钮尺寸 */
height: 24px;
display: flex;
align-items: center;
justify-content: center;
}
.btn-view {
background: #1890ff;
color: white;
}
.btn-delete {
background: #ff4d4f;
color: white;
}
.btn-view:hover {
background: #40a9ff;
}
.btn-delete:hover {
background: #ff7875;
}
/* 暗色模式适配 */
[data-theme="dark"] .admin-card {
background: #242424;
box-shadow: none;
}
[data-theme="dark"] .card-header {
background: #242424;
border-color: #333;
}
[data-theme="dark"] .users-table th {
background: #1f1f1f;
color: #999;
}
[data-theme="dark"] .users-table td {
color: #ccc;
}
[data-theme="dark"] .user-email,
[data-theme="dark"] .user-id {
color: #999;
}
[data-theme="dark"] .time-label {
color: #666;
}
[data-theme="dark"] .time-value {
color: #ccc;
}
.invitees-list {
max-height: 400px;
overflow-y: auto;
padding: 20px;
}
.invitee-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
border-bottom: 1px solid #eee;
}
.invitee-info {
flex: 1;
}
.invitee-name {
font-weight: 500;
font-size: 14px;
margin-bottom: 4px;
}
.invitee-email {
color: #666;
font-size: 12px;
margin-bottom: 4px;
}
.invitee-date {
color: #999;
font-size: 12px;
}
[data-theme="dark"] .invitee-item {
border-color: #333;
}
[data-theme="dark"] .invitee-email {
color: #999;
}
[data-theme="dark"] .invitee-date {
color: #666;
}
/* 搜索和排序区域样式 */
.filter-sort {
display: flex;
gap: 8px; /* 减小间距 */
align-items: center;
}
.sort-box {
position: relative;
}
.sort-box i {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
color: #666;
pointer-events: none;
}
.sort-select {
padding: 6px 10px 6px 30px; /* 减小内边距 */
border: 1px solid #ddd;
border-radius: 6px;
font-size: 13px; /* 减小字号 */
color: #333;
background: white;
cursor: pointer;
appearance: none;
min-width: 140px; /* 减小最小宽度 */
}
.search-box {
position: relative;
flex: 1;
max-width: 300px;
}
.search-input {
width: 100%;
padding: 6px 10px 6px 30px; /* 减小内边距 */
border: 1px solid #ddd;
border-radius: 6px;
font-size: 13px; /* 减小字号 */
}
.search-box i {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
color: #666;
}
/* 暗色模式适配 */
[data-theme="dark"] .sort-select,
[data-theme="dark"] .search-input {
background: #333;
border-color: #444;
color: #ccc;
}
[data-theme="dark"] .sort-box i,
[data-theme="dark"] .search-box i {
color: #999;
}
/* 修改滚动条样式 */
.users-table-container::-webkit-scrollbar {
height: 8px; /* 水平滚动条高度 */
background-color: #f5f5f5;
}
.users-table-container::-webkit-scrollbar-thumb {
background-color: #ddd;
border-radius: 4px;
}
.users-table-container::-webkit-scrollbar-thumb:hover {
background-color: #ccc;
}
[data-theme="dark"] .users-table-container::-webkit-scrollbar {
background-color: #1a1a1a;
}
[data-theme="dark"] .users-table-container::-webkit-scrollbar-thumb {
background-color: #333;
}
[data-theme="dark"] .users-table-container::-webkit-scrollbar-thumb:hover {
background-color: #444;
}
/* Modal 默认样式 */
.modal {
display: none; /* 默认隐藏 */
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
z-index: 1000;
align-items: center;
justify-content: center;
}
.modal-content {
background: white;
border-radius: 8px;
max-width: 500px;
width: 90%;
max-height: 80vh;
position: relative;
}
[data-theme="dark"] .modal-content {
background: #242424;
}
</style>
<script>
function showToast(message) {
const toast = document.getElementById('toast');
toast.textContent = message;
toast.style.display = 'block';
setTimeout(() => {
toast.style.display = 'none';
}, 3000);
}
function viewInvitees(userId) {
// 先清空内容
const inviteesList = document.getElementById('inviteesList');
inviteesList.innerHTML = '';
fetch(`/admin/users/${userId}/invitees`)
.then(response => response.json())
.then(data => {
if (data.success) {
inviteesList.innerHTML = data.invitees.map(invitee => `
<div class="invitee-item">
<img src="${invitee.avatar || '/static/images/default-avatar.png'}"
alt="${invitee.name}"
class="user-avatar">
<div class="invitee-info">
<div class="invitee-name">${invitee.name}</div>
<div class="invitee-email">${invitee.email || '未设置邮箱'}</div>
<div class="invitee-date">邀请时间:${invitee.invite_date}</div>
</div>
</div>
`).join('');
// 显示 modal
document.getElementById('inviteesModal').style.display = 'flex';
} else {
showToast(data.error || '获取邀请用户列表失败');
}
})
.catch(error => {
console.error('Failed to get invitees:', error);
showToast('获取邀请用户列表失败');
});
}
function closeInviteesModal() {
const modal = document.getElementById('inviteesModal');
modal.style.display = 'none';
// 清空弹窗内容,防止下次打开时显示旧数据
document.getElementById('inviteesList').innerHTML = '';
}
function deleteUser(userId, userName) {
if (!confirm(`确定要删除用户"${userName}"吗?此操作不可恢复。`)) return;
fetch(`/admin/users/${userId}/delete`, {
method: 'POST'
})
.then(response => response.json())
.then(data => {
if (data.success) {
location.reload();
} else {
showToast(data.error || '删除失败');
}
})
.catch(error => {
console.error('Delete failed:', error);
showToast('删除失败,请稍后重试');
});
}
function sortUsers(select) {
const [field, order] = select.value.split('_');
const tbody = document.querySelector('.users-table tbody');
const rows = Array.from(tbody.querySelectorAll('tr'));
rows.sort((a, b) => {
let aValue, bValue;
switch(field) {
case 'created':
aValue = new Date(a.cells[6].textContent);
bValue = new Date(b.cells[6].textContent);
break;
case 'login':
aValue = a.cells[7].textContent === '未登录' ? new Date(0) : new Date(a.cells[7].textContent);
bValue = b.cells[7].textContent === '未登录' ? new Date(0) : new Date(b.cells[7].textContent);
break;
case 'invite':
aValue = parseInt(a.cells[4].textContent);
bValue = parseInt(b.cells[4].textContent);
break;
}
return order === 'asc' ? aValue - bValue : bValue - aValue;
});
rows.forEach(row => tbody.appendChild(row));
}
function filterUsers(input) {
const filter = input.value.toLowerCase();
const rows = document.querySelectorAll('.users-table tbody tr');
rows.forEach(row => {
const name = row.cells[1].textContent.toLowerCase();
const email = row.cells[2].textContent.toLowerCase();
const huaweiId = row.cells[3].textContent.toLowerCase();
const matches = name.includes(filter) ||
email.includes(filter) ||
huaweiId.includes(filter);
row.style.display = matches ? '' : 'none';
});
}
// 点击遮罩层关闭弹窗时也清空内容
document.getElementById('inviteesModal').addEventListener('click', function(e) {
if (e.target === this) {
closeInviteesModal();
}
});
</script>
{% endblock %}

965
templates/admin_wiki.html Executable file
View File

@@ -0,0 +1,965 @@
{% extends "base.html" %}
{% block title %}Wiki 管理{% endblock %}
{% block head %}
<!-- 引入相关CSS和JS -->
<link href="{{ url_for('static', filename='libs/quill/quill.snow.css') }}" rel="stylesheet">
<script src="{{ url_for('static', filename='libs/highlight/highlight.min.js') }}"></script>
<script src="{{ url_for('static', filename='libs/highlight/languages/javascript.min.js') }}"></script>
<script src="{{ url_for('static', filename='libs/highlight/languages/python.min.js') }}"></script>
<script src="{{ url_for('static', filename='libs/highlight/languages/bash.min.js') }}"></script>
<script src="{{ url_for('static', filename='libs/highlight/languages/xml.min.js') }}"></script>
<script src="{{ url_for('static', filename='libs/quill/quill.min.js') }}"></script>
{% endblock %}
{% block content %}
{% include 'admin_nav.html' %}
<div class="admin-wiki-container">
<h1 class="page-title">添加 Wiki 条目</h1>
<div class="add-entry-section">
<h2>添加新条目</h2>
<form id="addEntryForm" class="entry-form" enctype="multipart/form-data">
<div class="wiki-editor-container">
<div class="wiki-editor-sidebar">
<div class="form-group">
<label for="title">标题</label>
<input type="text" id="title" name="title" required>
</div>
<div class="form-group">
<label for="version">版本</label>
<input type="text" id="version" name="version" required>
</div>
<div class="form-group">
<label>条目类型</label>
<select id="wiki-type" class="form-control" required>
<option value="">选择条目类型</option>
<!-- 动态加载的选项将在这里生成 -->
</select>
<div id="selected-category-display" class="mt-2 text-muted"></div>
</div>
</div>
<div class="wiki-editor-main">
<div class="form-group">
<label for="content">内容</label>
<div id="content-editor" class="editor"></div>
<input type="hidden" id="content" name="content">
</div>
</div>
</div>
<button type="submit" class="btn-primary">添加条目</button>
<!-- 隐藏的分类字段 -->
<input type="hidden" id="first-level" name="first_level">
<input type="hidden" id="second-level" name="second_level">
<input type="hidden" id="third-level" name="third_level">
</form>
</div>
</div>
<style>
/* 新增样式 */
.wiki-editor-container {
display: flex;
gap: 20px;
margin-bottom: 20px;
}
.wiki-editor-sidebar {
width: 250px;
background: #f9fafb;
padding: 20px;
border-radius: 8px;
border: 1px solid #e5e7eb;
}
.wiki-editor-main {
flex-grow: 1;
}
.wiki-editor-sidebar .form-group {
margin-bottom: 15px;
}
.wiki-editor-sidebar .form-group label {
margin-bottom: 6px;
}
.wiki-editor-sidebar .form-group input,
.wiki-editor-sidebar .form-group select {
width: 100%;
padding: 8px;
border: 1px solid #d1d5db;
border-radius: 4px;
}
/* 暗色模式适配 */
[data-theme="dark"] .wiki-editor-sidebar {
background: #1f2937;
border-color: #374151;
}
[data-theme="dark"] .wiki-editor-sidebar .form-group input,
[data-theme="dark"] .wiki-editor-sidebar .form-group select {
background: #111827;
border-color: #374151;
color: #f9fafb;
}
/* 移动端适配 */
@media (max-width: 768px) {
.wiki-editor-container {
flex-direction: column;
}
.wiki-editor-sidebar {
width: 100%;
}
}
/* 容器布局 */
.admin-wiki-container {
margin-left: 250px;
padding: 20px;
min-height: 100vh;
background: #f8f9fa;
}
@media (max-width: 768px) {
.admin-wiki-container {
margin-left: 0;
padding: 15px;
}
}
/* 二级导航栏样式 */
.wiki-header {
position: sticky;
top: 0;
background: rgba(255, 255, 255, 0.9);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
margin: -20px -20px 20px -20px;
padding: 15px 20px;
z-index: 100;
}
.header-content {
max-width: 1200px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 15px;
}
.admin-title {
margin: 0;
font-size: 24px;
font-weight: 600;
color: #333;
background: linear-gradient(120deg, #3b82f6, #2563eb);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.wiki-nav {
display: flex;
gap: 20px;
border-bottom: 1px solid #eee;
padding-bottom: 10px;
}
.nav-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
color: #666;
text-decoration: none;
font-weight: 500;
border-radius: 20px;
transition: all 0.3s ease;
}
.nav-item:hover {
color: #3b82f6;
background: rgba(59, 130, 246, 0.1);
}
.nav-item.active {
color: #3b82f6;
background: rgba(59, 130, 246, 0.1);
}
.nav-item i {
font-size: 16px;
}
/* 编辑器样式 */
.editor {
height: 400px;
margin-bottom: 20px;
background: #fff;
border-radius: 4px;
}
.ql-toolbar.ql-snow {
border-top-left-radius: 4px;
border-top-right-radius: 4px;
}
.ql-container.ql-snow {
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
}
/* 表单样式 */
.add-entry-section {
background: #fff;
padding: 30px;
border-radius: 10px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
margin-top: 20px;
}
.add-entry-section h2 {
margin: 0 0 20px 0;
font-size: 20px;
color: #333;
}
.entry-form {
margin: 0 auto;
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
margin-bottom: 8px;
font-weight: 500;
}
.form-group input[type="text"],
.form-group input[type="file"] {
width: 100%;
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
}
.admin-title {
margin: 0;
font-size: 24px;
color: #333;
}
.wiki-actions {
display: flex;
gap: 15px;
}
.btn-primary,
.btn-secondary {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 20px;
border: none;
border-radius: 5px;
cursor: pointer;
font-size: 14px;
text-decoration: none;
transition: all 0.3s ease;
}
.btn-primary {
background: #007AFF;
color: #fff;
}
.btn-secondary {
background: #f8f9fa;
color: #333;
border: 1px solid #ddd;
}
.btn-primary:hover {
background: #0056b3;
}
.btn-secondary:hover {
background: #e9ecef;
}
/* 暗色模式适配 */
[data-theme="dark"] .wiki-header {
background: rgba(26, 26, 26, 0.9);
border-bottom-color: rgba(255, 255, 255, 0.1);
}
[data-theme="dark"] .wiki-nav {
border-bottom-color: #333;
}
[data-theme="dark"] .nav-item {
color: #999;
}
[data-theme="dark"] .nav-item:hover {
color: #3b82f6;
background: rgba(59, 130, 246, 0.15);
}
[data-theme="dark"] .nav-item.active {
color: #3b82f6;
background: rgba(59, 130, 246, 0.15);
}
/* 移动端适配 */
@media (max-width: 768px) {
.wiki-header {
position: static;
margin: -15px -15px 15px -15px;
padding: 15px;
}
.header-content {
gap: 10px;
}
.admin-title {
font-size: 20px;
}
.wiki-nav {
gap: 10px;
}
.nav-item {
padding: 6px 12px;
font-size: 14px;
}
}
.page-title {
margin: 0 0 30px 0;
font-size: 24px;
font-weight: 600;
color: #333;
}
.entry-count {
font-size: 14px;
color: #666;
font-weight: normal;
}
[data-theme="dark"] .page-title {
color: #fff;
}
[data-theme="dark"] .entry-count {
color: #999;
}
.image-upload-container {
margin-top: 10px;
}
.image-upload-area {
border: 2px dashed #e5e7eb;
border-radius: 8px;
padding: 20px;
text-align: center;
cursor: pointer;
position: relative;
transition: all 0.3s ease;
}
.image-upload-area:hover {
border-color: #3b82f6;
background: #f8fafc;
}
.image-input {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
opacity: 0;
cursor: pointer;
}
.upload-placeholder {
color: #6b7280;
}
.upload-placeholder i {
font-size: 32px;
margin-bottom: 10px;
color: #9ca3af;
}
.upload-placeholder p {
margin: 5px 0;
font-size: 16px;
}
.upload-hint {
font-size: 14px;
color: #9ca3af;
}
.image-preview-container {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 16px;
margin-top: 20px;
}
.preview-item {
position: relative;
border-radius: 8px;
overflow: hidden;
aspect-ratio: 1;
background: #f8fafc;
border: 1px solid #e5e7eb;
}
.preview-item img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.preview-remove {
position: absolute;
top: 8px;
right: 8px;
width: 24px;
height: 24px;
border-radius: 12px;
background: rgba(0, 0, 0, 0.5);
color: white;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s ease;
z-index: 10;
}
.preview-remove:hover {
background: rgba(0, 0, 0, 0.7);
transform: scale(1.1);
}
/* 暗色模式适配 */
[data-theme="dark"] .image-upload-area {
border-color: #374151;
}
[data-theme="dark"] .image-upload-area:hover {
border-color: #3b82f6;
background: #1f2937;
}
[data-theme="dark"] .preview-item {
background: #1f2937;
border-color: #374151;
}
[data-theme="dark"] .upload-placeholder {
color: #9ca3af;
}
[data-theme="dark"] .upload-placeholder i {
color: #6b7280;
}
.custom-modal {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
z-index: 1000;
max-width: 400px;
width: 90%;
text-align: center;
}
.custom-modal-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0,0,0,0.5);
z-index: 999;
display: none;
}
.custom-modal-content {
margin-bottom: 20px;
}
.custom-modal-actions {
display: flex;
justify-content: center;
gap: 10px;
}
[data-theme="dark"] .custom-modal {
background: #2c2c2c;
color: white;
}
</style>
<div id="customModalOverlay" class="custom-modal-overlay">
<div class="custom-modal">
<div id="customModalContent" class="custom-modal-content"></div>
<div class="custom-modal-actions">
<button id="customModalConfirm" class="btn-primary">确定</button>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
console.log('=== 页面加载完成,开始初始化分类选择 ===');
// 获取关键元素
const wikiTypeSelect = document.getElementById('wiki-type');
const selectedCategoryDisplay = document.getElementById('selected-category-display');
const firstLevelInput = document.getElementById('first-level');
const secondLevelInput = document.getElementById('second-level');
const thirdLevelInput = document.getElementById('third-level');
// 详细的元素存在性检查
console.log('元素检查:', {
wikiTypeSelect: !!wikiTypeSelect,
selectedCategoryDisplay: !!selectedCategoryDisplay,
firstLevelInput: !!firstLevelInput,
secondLevelInput: !!secondLevelInput,
thirdLevelInput: !!thirdLevelInput
});
// 如果任何关键元素缺失,立即退出
if (!wikiTypeSelect || !selectedCategoryDisplay ||
!firstLevelInput || !secondLevelInput || !thirdLevelInput) {
console.error('未找到必要的页面元素,分类加载终止');
return;
}
// 分类加载函数
async function loadWikiCategories() {
try {
const response = await fetch('/admin/wiki/categories', {
headers: {
'X-Requested-With': 'XMLHttpRequest'
}
});
if (!response.ok) {
console.error('获取分类失败:', response.statusText);
return;
}
const categories = await response.json();
console.log('获取到的分类数据:', categories);
// 按一级分类分组
const groupedCategories = {};
categories.forEach(category => {
const { first_level, second_level, third_level } = category;
if (!groupedCategories[first_level]) {
groupedCategories[first_level] = {};
}
if (!groupedCategories[first_level][second_level]) {
groupedCategories[first_level][second_level] = [];
}
groupedCategories[first_level][second_level].push(third_level);
});
const wikiTypeSelect = document.getElementById('wiki-type');
wikiTypeSelect.innerHTML = ''; // 清空现有选项
// 动态生成分类选项
Object.entries(groupedCategories).forEach(([firstLevel, secondLevels]) => {
const firstLevelOptgroup = document.createElement('optgroup');
firstLevelOptgroup.label = firstLevel;
Object.entries(secondLevels).forEach(([secondLevel, thirdLevels]) => {
thirdLevels.forEach(thirdLevel => {
const option = document.createElement('option');
option.value = `${firstLevel} - ${secondLevel} - ${thirdLevel}`;
option.textContent = `${firstLevel} - ${secondLevel} - ${thirdLevel}`;
firstLevelOptgroup.appendChild(option);
});
});
wikiTypeSelect.appendChild(firstLevelOptgroup);
});
console.log('分类数据处理完成');
} catch (error) {
console.error('加载分类时发生错误:', error);
}
}
// 处理分类选择的函数
function handleCategorySelection() {
console.log('handleCategorySelection 被调用');
// 确保选择了有效选项
if (wikiTypeSelect.selectedIndex < 0) {
console.warn('未选择有效选项');
selectedCategoryDisplay.textContent = '请选择分类';
// 清空隐藏字段
firstLevelInput.value = '';
secondLevelInput.value = '';
thirdLevelInput.value = '';
selectedCategoryDisplay.style.color = '#6b7280';
return;
}
const selectedOption = wikiTypeSelect.options[wikiTypeSelect.selectedIndex];
console.log('选中的选项:', selectedOption);
// 安全地解析选择的分类
try {
// 直接使用 value 属性,不再假设使用下划线分隔
const categoryParts = selectedOption.value.split(' - ');
// 检查分类是否完整
if (categoryParts.length !== 3) {
throw new Error('分类格式不正确');
}
const [firstLevel, secondLevel, thirdLevel] = categoryParts;
console.log('解析后的分类:', { firstLevel, secondLevel, thirdLevel });
// 验证分类不为空
if (!firstLevel || !secondLevel || !thirdLevel) {
throw new Error('分类不能为空');
}
// 更新隐藏字段
firstLevelInput.value = firstLevel;
secondLevelInput.value = secondLevel;
thirdLevelInput.value = thirdLevel;
// 更新显示的分类信息
const displayText = `${firstLevel} - ${secondLevel} - ${thirdLevel}`;
selectedCategoryDisplay.textContent = displayText;
// 样式增强
selectedCategoryDisplay.style.color = '#3b82f6';
selectedCategoryDisplay.style.fontWeight = '600';
selectedCategoryDisplay.style.fontSize = '0.875rem';
} catch (error) {
console.error('分类选择错误:', error);
// 重置显示
selectedCategoryDisplay.textContent = '分类选择错误';
selectedCategoryDisplay.style.color = '#dc3545';
// 清空隐藏字段
firstLevelInput.value = '';
secondLevelInput.value = '';
thirdLevelInput.value = '';
}
}
// 添加 change 事件监听器
wikiTypeSelect.addEventListener('change', handleCategorySelection);
// 调用分类加载函数
loadWikiCategories();
// 额外的调试函数
function debugCategoryLoading() {
console.log('=== 分类加载调试 ===');
fetch('/admin/wiki/categories', {
method: 'GET',
headers: {
'X-Requested-With': 'XMLHttpRequest',
'Accept': 'application/json'
}
})
.then(response => {
console.log('完整响应对象:', response);
console.log('响应状态:', response.status);
console.log('响应状态文本:', response.statusText);
// 记录所有响应头
console.log('响应头:');
for (let [key, value] of response.headers.entries()) {
console.log(`${key}: ${value}`);
}
return response.json();
})
.then(data => {
console.log('=== 分类数据详细信息 ===');
console.log('是否成功:', data.success);
console.log('分类总数:', data.total_count);
console.log('分类详情:', data.categories);
})
.catch(error => {
console.error('分类加载调试失败:', error);
});
}
// 立即执行调试
debugCategoryLoading();
});
// 在页面加载时,默认选择第一个选项
document.addEventListener('DOMContentLoaded', function() {
const wikiTypeSelect = document.getElementById('wiki-type');
if (wikiTypeSelect && wikiTypeSelect.options.length > 0) {
// 选择第一个 option
wikiTypeSelect.selectedIndex = 0;
// 触发 change 事件以更新分类显示
wikiTypeSelect.dispatchEvent(new Event('change'));
}
});
// 自定义弹窗函数
function showCustomModal(options) {
const overlay = document.getElementById('customModalOverlay');
const content = document.getElementById('customModalContent');
const confirmBtn = document.getElementById('customModalConfirm');
// 设置内容
content.innerHTML = `
<h3>${options.title || '提示'}</h3>
<p>${options.text || ''}</p>
`;
// 设置样式
content.parentElement.style.backgroundColor = options.type === 'success' ? '#e6f3e6' : '#f8d7da';
confirmBtn.style.backgroundColor = options.type === 'success' ? '#28a745' : '#dc3545';
// 显示弹窗
overlay.style.display = 'block';
// 确认按钮事件
const handleConfirm = () => {
overlay.style.display = 'none';
confirmBtn.removeEventListener('click', handleConfirm);
// 执行关闭回调
if (options.didClose) {
options.didClose();
}
};
confirmBtn.addEventListener('click', handleConfirm);
}
document.addEventListener('DOMContentLoaded', function() {
// Quill 工具栏配置
const toolbarOptions = [
['bold', 'italic', 'underline', 'strike'],
['blockquote', 'code-block'],
[{ 'header': [1, 2, 3, 4, 5, 6, false] }],
[{ 'list': 'ordered'}, { 'list': 'bullet' }],
[{ 'script': 'sub'}, { 'script': 'super' }],
[{ 'indent': '-1'}, { 'indent': '+1' }],
[{ 'direction': 'rtl' }],
[{ 'color': [] }, { 'background': [] }],
[{ 'font': [] }],
[{ 'align': [] }],
['clean'],
['link', 'image', 'video']
];
// 确保编辑器容器存在
const editorContainer = document.getElementById('content-editor');
const hiddenContentInput = document.getElementById('content');
if (!editorContainer || !hiddenContentInput) {
console.error('富文本编辑器的必要元素未找到');
return;
}
// 初始化 Quill 编辑器
const editor = new Quill('#content-editor', {
modules: {
toolbar: {
container: toolbarOptions,
handlers: {
// 自定义链接处理
link: function(value) {
if (value) {
const range = this.quill.getSelection();
if (range) {
let url = prompt('请输入链接URL:');
if (url) {
if (!/^https?:\/\//i.test(url)) {
url = 'http://' + url;
}
this.quill.format('link', url);
}
}
} else {
this.quill.format('link', false);
}
}
}
},
syntax: {
highlight: (text) => hljs.highlightAuto(text).value
}
},
theme: 'snow'
});
// 同步编辑器内容到隐藏输入框
editor.on('text-change', function() {
// 将编辑器内容转换为 JSON 格式
const content = editor.getContents();
hiddenContentInput.value = JSON.stringify(content);
});
// 调试:检查编辑器是否正确初始化
console.log('Quill 编辑器初始化:', {
editor: !!editor,
container: editorContainer,
hiddenInput: hiddenContentInput
});
// 可选:如果需要预填充内容
try {
// 尝试从隐藏输入框读取初始内容
const initialContent = hiddenContentInput.value;
if (initialContent) {
const parsedContent = JSON.parse(initialContent);
editor.setContents(parsedContent);
}
} catch (error) {
console.warn('初始内容解析失败:', error);
}
});
document.addEventListener('DOMContentLoaded', function() {
// 表单提交处理
const addEntryForm = document.getElementById('addEntryForm');
if (!addEntryForm) {
console.error('未找到表单元素 #addEntryForm');
return;
}
addEntryForm.addEventListener('submit', async function(e) {
e.preventDefault();
// 获取表单元素
const titleInput = document.getElementById('title');
const versionInput = document.getElementById('version');
const contentInput = document.getElementById('content');
const firstLevelInput = document.getElementById('first-level');
const secondLevelInput = document.getElementById('second-level');
const thirdLevelInput = document.getElementById('third-level');
// 验证表单数据
if (!titleInput || !versionInput || !contentInput ||
!firstLevelInput || !secondLevelInput || !thirdLevelInput) {
console.error('表单元素缺失');
showCustomModal({
title: '错误',
text: '表单元素未正确加载,请刷新页面',
type: 'error'
});
return;
}
// 获取表单数据
const formData = {
title: titleInput.value.trim(),
version: versionInput.value.trim(),
content: contentInput.value,
first_level: firstLevelInput.value,
second_level: secondLevelInput.value,
third_level: thirdLevelInput.value
};
// 数据验证
const validationErrors = [];
if (!formData.title) validationErrors.push('标题不能为空');
if (!formData.version) validationErrors.push('版本不能为空');
if (!formData.content) validationErrors.push('内容不能为空');
if (!formData.first_level || !formData.second_level || !formData.third_level) {
validationErrors.push('请选择完整的分类');
}
if (validationErrors.length > 0) {
showCustomModal({
title: '表单验证错误',
text: validationErrors.join('<br>'),
type: 'error'
});
return;
}
try {
const response = await fetch('/admin/wiki/add', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(formData)
});
const result = await response.json();
if (result.success) {
showCustomModal({
title: '成功',
text: 'Wiki 条目添加成功',
type: 'success',
didClose: () => {
// 可选:重置表单或跳转
addEntryForm.reset();
window.location.href = '/admin/wiki';
}
});
} else {
showCustomModal({
title: '错误',
text: result.error || '添加 Wiki 条目失败',
type: 'error'
});
}
} catch (error) {
console.error('提交表单时发生错误:', error);
showCustomModal({
title: '网络错误',
text: '无法提交表单,请检查网络连接',
type: 'error'
});
}
});
});
</script>
{% endblock %}

View File

@@ -0,0 +1,541 @@
{% extends "base.html" %}
{% block title %}Wiki 分类管理{% endblock %}
{% block head %}
<style>
.container {
margin-left: 250px;
padding: 20px;
min-height: 100vh;
background: #f8f9fa;
}
@media (max-width: 768px) {
.container {
margin-left: 0;
padding: 15px;
}
}
.category-table {
width: 100%;
border-collapse: separate;
border-spacing: 0;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
border-radius: 8px;
overflow: hidden;
}
.category-table th,
.category-table td {
border: 1px solid #e0e0e0;
padding: 12px;
text-align: left;
transition: background-color 0.3s ease;
}
.category-table th {
background-color: #f1f3f5;
font-weight: 600;
color: #495057;
text-transform: uppercase;
}
.category-table tr:nth-child(even) {
background-color: #f8f9fa;
}
.category-table tr:hover {
background-color: #e9ecef;
}
.category-actions {
display: flex;
gap: 10px;
justify-content: center;
}
.btn-edit,
.btn-delete {
padding: 6px 12px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.9em;
transition: opacity 0.3s ease;
}
.btn-edit {
background-color: #28a745;
color: white;
}
.btn-delete {
background-color: #dc3545;
color: white;
}
.btn-edit:hover {
opacity: 0.8;
}
.btn-delete:hover {
opacity: 0.8;
}
.add-category-form {
margin-bottom: 20px;
display: flex;
flex-wrap: wrap;
gap: 10px;
background-color: #f8f9fa;
padding: 15px;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
.add-category-form input,
.add-category-form select {
flex: 1;
padding: 8px;
border: 1px solid #ddd;
border-radius: 4px;
min-width: 100px;
}
@media (max-width: 768px) {
.add-category-form {
flex-direction: column;
}
.category-table {
font-size: 0.9em;
}
.category-table td,
.category-table th {
padding: 8px;
}
}
.category-level-indicator {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 50%;
margin-right: 8px;
}
.category-level-1 {
background-color: #007bff;
}
.category-level-2 {
background-color: #28a745;
}
.category-level-3 {
background-color: #dc3545;
}
</style>
{% endblock %}
{% block content %}
{% include 'admin_nav.html' %}
<div class="container">
<h1>Wiki 分类管理</h1>
{% if error %}
<div class="alert alert-danger">
{{ error }}
</div>
{% endif %}
<div class="add-category-section">
<form id="addCategoryForm" class="add-category-form">
<select id="first-level" required>
<option value="">选择一级分类</option>
<option value="版本号">版本号</option>
<option value="资讯">资讯</option>
</select>
<input type="text" id="second-level" placeholder="二级分类" required>
<input type="text" id="third-level" placeholder="三级分类" required>
<input type="text" id="description" placeholder="描述(可选)">
<input type="number" id="sort-order" placeholder="排序默认999" value="999">
<button type="submit" class="btn btn-primary">添加分类</button>
</form>
</div>
<table class="category-table">
<thead>
<tr>
<th>ID</th>
<th>一级分类</th>
<th>二级分类</th>
<th>三级分类</th>
<th>描述</th>
<th>排序</th>
<th>操作</th>
</tr>
</thead>
<tbody id="categoriesTableBody">
{% for category in categories %}
<tr data-id="{{ category.id }}">
<td>{{ category.id }}</td>
<td>
<span class="category-level-indicator category-level-1"></span>
{{ category.first_level }}
</td>
<td>
<span class="category-level-indicator category-level-2"></span>
{{ category.second_level }}
</td>
<td>
<span class="category-level-indicator category-level-3"></span>
{{ category.third_level }}
</td>
<td>{{ category.description or '' }}</td>
<td>{{ category.sort_order }}</td>
<td class="category-actions">
<button class="btn-edit" onclick="editCategory({{ category.id }})">编辑</button>
<button class="btn-delete" onclick="deleteCategory({{ category.id }})">删除</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
// 获取分类列表
async function fetchCategories() {
try {
const response = await fetch('/admin/wiki/categories', {
headers: {
'X-Requested-With': 'XMLHttpRequest'
}
});
if (!response.ok) {
console.error('获取分类失败:', response.statusText);
return [];
}
const categories = await response.json();
console.log('获取到的分类:', categories);
console.log('分类总数:', categories.length);
return categories;
} catch (error) {
console.error('获取分类时发生错误:', error);
// 显示错误消息
const errorDiv = document.createElement('div');
errorDiv.className = 'alert alert-danger';
errorDiv.textContent = `获取分类失败: ${error.message}`;
document.querySelector('.container').insertBefore(errorDiv, document.querySelector('.category-table'));
return [];
}
}
// 渲染分类列表
async function renderCategories() {
const categories = await fetchCategories();
const tableBody = document.getElementById('categoriesTableBody');
// 清空现有行
tableBody.innerHTML = '';
// 如果没有分类,显示提示
if (categories.length === 0) {
const noDataRow = `
<tr>
<td colspan="7" style="text-align: center; color: #888;">
暂无分类,请添加新分类
</td>
</tr>
`;
tableBody.innerHTML = noDataRow;
return;
}
// 按一级分类分组
const groupedCategories = {};
categories.forEach(category => {
if (!groupedCategories[category.first_level]) {
groupedCategories[category.first_level] = [];
}
groupedCategories[category.first_level].push(category);
});
// 渲染分类列表
Object.keys(groupedCategories).forEach(firstLevel => {
const firstLevelCategories = groupedCategories[firstLevel];
firstLevelCategories.forEach(category => {
const row = `
<tr data-id="${category.id}">
<td>${category.id}</td>
<td>
<span class="category-level-indicator category-level-1"></span>
${category.first_level}
</td>
<td>
<span class="category-level-indicator category-level-2"></span>
${category.second_level}
</td>
<td>
<span class="category-level-indicator category-level-3"></span>
${category.third_level}
</td>
<td>${category.description || ''}</td>
<td>${category.sort_order}</td>
<td class="category-actions">
<button class="btn-edit" onclick="editCategory(${category.id})">编辑</button>
<button class="btn-delete" onclick="deleteCategory(${category.id})">删除</button>
</td>
</tr>
`;
tableBody.innerHTML += row;
});
});
}
// 初始化页面
if (document.getElementById('categoriesTableBody')) {
renderCategories();
}
// 添加分类表单提交处理
document.getElementById('addCategoryForm').addEventListener('submit', async function(e) {
e.preventDefault();
const data = {
first_level: document.getElementById('first-level').value,
second_level: document.getElementById('second-level').value,
third_level: document.getElementById('third-level').value,
description: document.getElementById('description').value,
sort_order: document.getElementById('sort-order').value
};
try {
const response = await fetch('/admin/wiki/category/add', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(data)
});
const result = await response.json();
if (result.success) {
// 重新加载分类列表
await renderCategories();
// 清空表单
document.getElementById('first-level').value = '';
document.getElementById('second-level').value = '';
document.getElementById('third-level').value = '';
document.getElementById('description').value = '';
document.getElementById('sort-order').value = '999';
// 显示成功消息
const successDiv = document.createElement('div');
successDiv.className = 'alert alert-success';
successDiv.textContent = '分类添加成功';
document.querySelector('.container').insertBefore(successDiv, document.querySelector('.category-table'));
// 3秒后移除成功消息
setTimeout(() => successDiv.remove(), 3000);
} else {
// 显示错误消息
const errorDiv = document.createElement('div');
errorDiv.className = 'alert alert-danger';
errorDiv.textContent = result.error || '添加分类失败';
document.querySelector('.container').insertBefore(errorDiv, document.querySelector('.category-table'));
}
} catch (error) {
console.error('添加分类时发生错误:', error);
// 显示错误消息
const errorDiv = document.createElement('div');
errorDiv.className = 'alert alert-danger';
errorDiv.textContent = `添加分类时发生错误: ${error.message}`;
document.querySelector('.container').insertBefore(errorDiv, document.querySelector('.category-table'));
}
});
// 删除分类
window.deleteCategory = async function(categoryId) {
if (!confirm('确定要删除这个分类吗?')) return;
try {
const response = await fetch(`/admin/wiki/category/delete/${categoryId}`, {
method: 'POST'
});
const result = await response.json();
if (result.success) {
// 重新加载分类列表
await renderCategories();
// 显示成功消息
const successDiv = document.createElement('div');
successDiv.className = 'alert alert-success';
successDiv.textContent = '分类删除成功';
document.querySelector('.container').insertBefore(successDiv, document.querySelector('.category-table'));
// 3秒后移除成功消息
setTimeout(() => successDiv.remove(), 3000);
} else {
// 显示错误消息
const errorDiv = document.createElement('div');
errorDiv.className = 'alert alert-danger';
errorDiv.textContent = result.error || '删除分类失败';
document.querySelector('.container').insertBefore(errorDiv, document.querySelector('.category-table'));
}
} catch (error) {
console.error('删除分类时发生错误:', error);
// 显示错误消息
const errorDiv = document.createElement('div');
errorDiv.className = 'alert alert-danger';
errorDiv.textContent = `删除分类时发生错误: ${error.message}`;
document.querySelector('.container').insertBefore(errorDiv, document.querySelector('.category-table'));
}
};
// 编辑分类
window.editCategory = function(categoryId) {
// 获取当前行的数据
const row = document.querySelector(`tr[data-id="${categoryId}"]`);
const firstLevel = row.querySelector('td:nth-child(2)').textContent.trim();
const secondLevel = row.querySelector('td:nth-child(3)').textContent.trim();
const thirdLevel = row.querySelector('td:nth-child(4)').textContent.trim();
const description = row.querySelector('td:nth-child(5)').textContent.trim();
const sortOrder = row.querySelector('td:nth-child(6)').textContent.trim();
// 弹出编辑模态框
const editModal = `
<div class="modal" id="editCategoryModal">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">编辑分类</h5>
<button type="button" class="close" onclick="closeEditModal()">&times;</button>
</div>
<div class="modal-body">
<form id="editCategoryForm">
<input type="hidden" id="edit-category-id" value="${categoryId}">
<div class="form-group">
<label>一级分类</label>
<select id="edit-first-level" class="form-control">
<option value="版本号" ${firstLevel === '版本号' ? 'selected' : ''}>版本号</option>
<option value="资讯" ${firstLevel === '资讯' ? 'selected' : ''}>资讯</option>
</select>
</div>
<div class="form-group">
<label>二级分类</label>
<input type="text" id="edit-second-level" class="form-control" value="${secondLevel}">
</div>
<div class="form-group">
<label>三级分类</label>
<input type="text" id="edit-third-level" class="form-control" value="${thirdLevel}">
</div>
<div class="form-group">
<label>描述</label>
<input type="text" id="edit-description" class="form-control" value="${description}">
</div>
<div class="form-group">
<label>排序</label>
<input type="number" id="edit-sort-order" class="form-control" value="${sortOrder}">
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="closeEditModal()">取消</button>
<button type="button" class="btn btn-primary" onclick="saveEditCategory()">保存</button>
</div>
</div>
</div>
</div>
`;
// 插入模态框
document.body.insertAdjacentHTML('beforeend', editModal);
};
// 关闭编辑模态框
window.closeEditModal = function() {
const modal = document.getElementById('editCategoryModal');
if (modal) modal.remove();
};
// 保存编辑分类
window.saveEditCategory = async function() {
const categoryId = document.getElementById('edit-category-id').value;
const firstLevel = document.getElementById('edit-first-level').value;
const secondLevel = document.getElementById('edit-second-level').value;
const thirdLevel = document.getElementById('edit-third-level').value;
const description = document.getElementById('edit-description').value;
const sortOrder = document.getElementById('edit-sort-order').value;
try {
const response = await fetch('/admin/wiki/category/update', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
id: categoryId,
first_level: firstLevel,
second_level: secondLevel,
third_level: thirdLevel,
description: description,
sort_order: sortOrder
})
});
const result = await response.json();
if (result.success) {
// 关闭模态框
closeEditModal();
// 重新加载分类列表
await renderCategories();
// 显示成功消息
const successDiv = document.createElement('div');
successDiv.className = 'alert alert-success';
successDiv.textContent = '分类更新成功';
document.querySelector('.container').insertBefore(successDiv, document.querySelector('.category-table'));
// 3秒后移除成功消息
setTimeout(() => successDiv.remove(), 3000);
} else {
// 显示错误消息
const errorDiv = document.createElement('div');
errorDiv.className = 'alert alert-danger';
errorDiv.textContent = result.error || '更新分类失败';
document.querySelector('.container').insertBefore(errorDiv, document.querySelector('.category-table'));
}
} catch (error) {
console.error('更新分类时发生错误:', error);
// 显示错误消息
const errorDiv = document.createElement('div');
errorDiv.className = 'alert alert-danger';
errorDiv.textContent = `更新分类时发生错误: ${error.message}`;
document.querySelector('.container').insertBefore(errorDiv, document.querySelector('.category-table'));
}
};
});
</script>
{% endblock %}

View File

@@ -0,0 +1,577 @@
{% extends "base.html" %}
{% block content %}
{% include 'admin_nav.html' %}
<div class="admin-container wiki-comments-review">
<div class="admin-header">
<h1>Wiki 评论审核</h1>
<div class="header-actions">
<div class="comments-filter">
<select id="status-filter" class="form-select">
<option value="all">全部状态</option>
<option value="pending">待审核</option>
<option value="approved">已通过</option>
<option value="rejected">已拒绝</option>
</select>
<select id="type-filter" class="form-select">
<option value="all">全部类型</option>
<option value="feature_request">功能建议</option>
<option value="bug_report">问题报告</option>
<option value="experience_share">新增功能</option>
</select>
</div>
</div>
</div>
<!-- <div class="debug-info">
<p>总评论数: {{ comments|length }}</p>
{% if comments %}
<p>第一条评论详情:</p>
<pre>{{ comments[0]|tojson(indent=2) }}</pre>
{% endif %}
</div> -->
<div class="comments-table-container">
<table class="admin-table comments-table">
<thead>
<tr>
<th class="text-center">ID</th>
<th>Wiki 标题</th>
<th>用户</th>
<th>评论类型</th>
<th class="content-column">内容</th>
<th>状态</th>
<th>创建时间</th>
<th>操作</th>
</tr>
</thead>
<tbody id="comments-list">
{% for comment in comments %}
<tr data-comment-id="{{ comment.id }}"
data-status="{{ comment.status }}"
data-type="{{ comment.comment_type }}"
class="comment-row {% if comment.status == 'pending' %}pending-review{% endif %}">
<td class="text-center">{{ comment.id }}</td>
<td>
<div class="wiki-title-cell">
<span>{{ comment.wiki_title }}</span>
</div>
</td>
<td>
<div class="user-cell">
{% if comment.user_avatar %}
<div class="user-avatar">
<img src="{{ comment.user_avatar }}" alt="{{ comment.user_name }}">
</div>
{% endif %}
<span class="user-name">{{ comment.user_name }}</span>
</div>
</td>
<td>
<span class="comment-type-badge
{% if comment.comment_type == 'feature_request' %}badge-feature
{% elif comment.comment_type == 'bug_report' %}badge-bug
{% else %}badge-experience{% endif %}">
{% if comment.comment_type == 'feature_request' %}功能建议
{% elif comment.comment_type == 'bug_report' %}问题报告
{% else %}新增功能{% endif %}
</span>
</td>
<td class="content-column">
<div class="content-wrapper">
<div class="comment-content-preview" title="{{ comment.content }}">
{{ comment.content }}
</div>
<span class="comment-expand-btn" style="display: none;">
<i class="fas fa-chevron-right"></i>
</span>
</div>
</td>
<td class="status-cell">
{% if comment.status == 'pending' %}
<span class="badge badge-warning">待审核</span>
{% elif comment.status == 'approved' %}
<span class="badge badge-success">已通过</span>
{% else %}
<span class="badge badge-danger">已拒绝</span>
{% endif %}
</td>
<td>{{ comment.created_at }}</td>
<td>
<div class="action-buttons">
{% if comment.status == 'pending' %}
<button class="btn btn-success approve-btn" title="通过">
<i class="fas fa-check"></i>
</button>
<button class="btn btn-danger reject-btn" title="拒绝">
<i class="fas fa-times"></i>
</button>
{% endif %}
<button class="btn btn-secondary delete-btn" title="删除">
<i class="fas fa-trash"></i>
</button>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<style>
.comment-content-preview {
max-width: 300px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: inline-flex;
align-items: center;
vertical-align: middle;
line-height: 1.4;
position: relative;
}
.comment-expand-btn {
cursor: pointer;
margin-left: 8px;
color: var(--text-secondary);
transition: transform 0.3s ease, color 0.3s ease;
}
.comment-expand-btn:hover {
color: var(--primary-color);
transform: rotate(90deg);
}
.comment-content-full {
display: none;
width: 100%;
white-space: normal;
word-break: break-all;
margin-top: 8px;
padding: 8px;
background-color: var(--background-secondary);
border-radius: 6px;
}
.comment-content-full.show {
display: block;
}
/* 展开模式 */
.comment-content-preview.expanded {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 1000;
background-color: var(--background-primary);
border: 1px solid var(--border-color);
border-radius: 12px;
box-shadow: 0 10px 30px rgba(0,0,0,0.1);
max-width: 80vw;
max-height: 70vh;
width: 600px;
padding: 20px;
overflow-y: auto;
white-space: normal;
word-break: break-all;
}
/* 遮罩层 */
.comment-preview-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0,0,0,0.5);
z-index: 999;
display: none;
opacity: 0;
transition: opacity 0.3s ease;
}
.comment-preview-overlay.show {
display: block;
opacity: 1;
}
.expanded {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 1000;
background-color: var(--background-primary);
border: 1px solid var(--border-color);
border-radius: 12px;
box-shadow: 0 10px 30px rgba(0,0,0,0.1);
max-width: 80vw;
max-height: 70vh;
width: 600px;
padding: 20px;
overflow-y: auto;
opacity: 0;
transition: opacity 0.3s ease;
}
.expanded.show {
opacity: 1;
}
.expanded-comment-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
border-bottom: 1px solid var(--border-color);
padding-bottom: 10px;
}
.expanded-comment-close {
background: none;
border: none;
font-size: 20px;
cursor: pointer;
color: var(--text-secondary);
transition: color 0.3s ease;
}
.expanded-comment-close:hover {
color: var(--text-primary);
}
.expanded-comment-content {
line-height: 1.6;
font-size: 16px;
color: var(--text-primary);
white-space: pre-wrap;
word-wrap: break-word;
}
/* 深色模式适配 */
[data-theme="dark"] .comment-content-preview:hover {
background-color: rgba(255,255,255,0.1);
}
[data-theme="dark"] .comment-content-preview.expanded {
background-color: var(--background-secondary);
}
.user-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
overflow: hidden;
margin-right: 8px;
}
.user-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.user-cell {
display: flex;
align-items: center;
}
.user-name {
font-size: 0.9em;
}
.wiki-comments-review {
background-color: var(--background-primary);
border-radius: 12px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05);
padding: 1.5rem;
}
.admin-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--border-color);
}
.header-actions {
display: flex;
align-items: center;
gap: 1rem;
}
.form-select {
padding: 0.5rem 1rem;
border: 1px solid var(--border-color);
border-radius: 6px;
background-color: var(--background-secondary);
color: var(--text-primary);
}
.comments-table {
width: 100%;
border-collapse: separate;
border-spacing: 0 0.5rem;
}
.comments-table thead th {
background-color: var(--background-secondary);
padding: 0.75rem;
text-align: left;
font-weight: 600;
}
.comment-row {
background-color: var(--background-secondary);
transition: all 0.3s ease;
}
.comment-row:hover {
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
transform: translateY(-2px);
}
.pending-review {
border-left: 4px solid #ffc107;
}
.content-column {
max-width: 200px;
position: relative;
}
.content-wrapper {
display: flex;
align-items: center;
width: 100%;
}
.comment-content-preview {
flex-grow: 1;
max-width: calc(100% - 30px); /* 为箭头预留空间 */
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.comment-expand-btn {
cursor: pointer;
margin-left: 8px;
color: var(--text-secondary);
transition: transform 0.3s ease, color 0.3s ease;
display: none; /* 默认隐藏 */
}
.comment-expand-btn:hover {
color: var(--primary-color);
transform: rotate(90deg);
}
.comment-content-full {
display: none;
width: 100%;
white-space: normal;
word-break: break-all;
margin-top: 8px;
padding: 8px;
background-color: var(--background-secondary);
border-radius: 6px;
}
.comment-content-full.show {
display: block;
}
.comment-type-badge {
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 500;
}
.badge-feature {
background-color: rgba(59, 130, 246, 0.1);
color: #3b82f6;
}
.badge-bug {
background-color: rgba(239, 68, 68, 0.1);
color: #ef4444;
}
.action-buttons {
display: flex;
gap: 0.5rem;
}
.action-buttons .btn {
width: 35px;
height: 35px;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
border-radius: 6px;
}
.action-buttons .btn i {
font-size: 1rem;
}
[data-theme="dark"] .wiki-comments-review {
background-color: var(--background-secondary);
box-shadow: 0 4px 6px rgba(255, 255, 255, 0.05);
}
.badge-experience {
background-color: rgba(52, 211, 153, 0.1);
color: #10b981;
}
.comments-filter {
display: flex;
gap: 10px;
}
/* 删除按钮样式 */
.action-buttons .delete-btn {
background-color: rgba(220, 53, 69, 0.1);
color: #dc3545;
margin-left: 0.5rem;
}
.action-buttons .delete-btn:hover {
background-color: rgba(220, 53, 69, 0.2);
}
[data-theme="dark"] .action-buttons .delete-btn {
background-color: rgba(220, 53, 69, 0.2);
color: #ff6b6b;
}
[data-theme="dark"] .action-buttons .delete-btn:hover {
background-color: rgba(220, 53, 69, 0.3);
}
</style>
<script>
document.addEventListener('DOMContentLoaded', function() {
const commentsList = document.getElementById('comments-list');
// 审核和删除操作
commentsList.addEventListener('click', function(event) {
const approveBtn = event.target.closest('.approve-btn');
const rejectBtn = event.target.closest('.reject-btn');
const deleteBtn = event.target.closest('.delete-btn');
if (approveBtn || rejectBtn || deleteBtn) {
// 找到最近的 tr 行
const row = event.target.closest('tr');
// 获取评论ID
const commentId = row.getAttribute('data-comment-id');
let url, method;
if (approveBtn) {
url = `/admin/wiki/comment/${commentId}/approve`;
method = '通过';
} else if (rejectBtn) {
url = `/admin/wiki/comment/${commentId}/reject`;
method = '拒绝';
} else if (deleteBtn) {
url = `/admin/wiki/comment/${commentId}/delete`;
method = '删除';
}
// 确认删除操作
if (deleteBtn && !confirm(`确定要${method}这条评论吗?`)) {
return;
}
// 发送请求
fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
})
.then(response => {
if (!response.ok) {
throw new Error('Network response was not ok');
}
return response.json();
})
.then(result => {
if (result.success) {
if (approveBtn) {
const statusCell = row.querySelector('.status-cell');
statusCell.innerHTML = '<span class="badge badge-success">已通过</span>';
} else if (rejectBtn) {
const statusCell = row.querySelector('.status-cell');
statusCell.innerHTML = '<span class="badge badge-danger">已拒绝</span>';
} else if (deleteBtn) {
// 删除整行
row.remove();
}
// 移除操作按钮
const actionButtons = row.querySelector('.action-buttons');
if (actionButtons && (approveBtn || rejectBtn)) {
actionButtons.remove();
}
} else {
alert(result.error || `${method}操作失败`);
}
})
.catch(error => {
console.error('Error:', error);
alert(`${method}操作失败,请重试`);
});
}
});
// 原有的内容预览和筛选代码保持不变
const contentPreviews = document.querySelectorAll('.content-wrapper');
contentPreviews.forEach(wrapper => {
const preview = wrapper.querySelector('.comment-content-preview');
const expandBtn = wrapper.querySelector('.comment-expand-btn');
const fullContent = preview.textContent.trim();
// 如果文本超过单元格宽度,显示展开箭头
if (preview.scrollWidth > preview.clientWidth) {
expandBtn.style.display = 'inline-block';
}
const previewText = fullContent.length > 50
? fullContent.substring(0, 50) + '...'
: fullContent;
// 创建完整内容容器
const fullContentDiv = document.createElement('div');
fullContentDiv.classList.add('comment-content-full');
fullContentDiv.textContent = fullContent;
wrapper.parentNode.appendChild(fullContentDiv);
// 添加展开/收起事件
expandBtn.addEventListener('click', function(e) {
e.stopPropagation();
fullContentDiv.classList.toggle('show');
// 旋转箭头
expandBtn.querySelector('i').style.transform =
fullContentDiv.classList.contains('show')
? 'rotate(90deg)'
: 'rotate(0deg)';
});
});
});
</script>
{% endblock %}

193
templates/admin_wiki_edit.html Executable file
View File

@@ -0,0 +1,193 @@
{% extends "base.html" %}
{% block title %}编辑 Wiki 条目{% endblock %}
{% block head %}
<!-- 引入相关CSS和JS -->
<link href="{{ url_for('static', filename='libs/quill/quill.snow.css') }}" rel="stylesheet">
<script src="{{ url_for('static', filename='libs/highlight/highlight.min.js') }}"></script>
<script src="{{ url_for('static', filename='libs/highlight/languages/javascript.min.js') }}"></script>
<script src="{{ url_for('static', filename='libs/highlight/languages/python.min.js') }}"></script>
<script src="{{ url_for('static', filename='libs/highlight/languages/bash.min.js') }}"></script>
<script src="{{ url_for('static', filename='libs/highlight/languages/xml.min.js') }}"></script>
<script src="{{ url_for('static', filename='libs/quill/quill.min.js') }}"></script>
{% endblock %}
{% block content %}
{% include 'admin_nav.html' %}
<div class="admin-wiki-container">
<h1 class="page-title">编辑 Wiki 条目</h1>
<div class="edit-entry-section">
<h2>编辑条目</h2>
<form id="editEntryForm" class="entry-form" enctype="multipart/form-data">
<input type="hidden" id="entry-id" value="{{ entry.id }}">
<div class="form-group">
<label for="title">标题</label>
<input type="text" id="title" name="title" value="{{ entry.title }}" required>
</div>
<div class="form-group">
<label for="version">版本</label>
<input type="text" id="version" name="version" value="{{ entry.version }}" required>
</div>
<div class="form-group">
<label for="wiki-type">条目类型</label>
<select id="wiki-type" name="wiki_type" required>
<option value="version" {% if entry.wiki_type == 'version' %}selected{% endif %}>版本号</option>
<option value="news" {% if entry.wiki_type == 'news' %}selected{% endif %}>资讯</option>
</select>
</div>
<div class="form-group">
<label for="content">内容</label>
<div id="content-editor" class="editor"></div>
<input type="hidden" id="content" name="content">
</div>
<div class="form-group">
<label for="images">图片上传</label>
<div class="image-upload-container">
<div class="image-upload-area" id="imageUploadArea">
<input type="file" id="images" name="images[]" accept="image/*" multiple class="image-input">
<div class="upload-placeholder">
<i class="fas fa-cloud-upload-alt"></i>
<p>点击或拖拽图片到此处上传</p>
<span class="upload-hint">支持多张图片上传</span>
</div>
</div>
<div class="image-preview-container" id="imagePreviewContainer">
{% for image in entry.images %}
<div class="preview-item" data-path="{{ image }}">
<img src="{{ url_for('static', filename=image) }}" alt="预览图">
<span class="preview-remove" onclick="removePreviewItem(this)">×</span>
</div>
{% endfor %}
</div>
</div>
</div>
<button type="submit" class="btn-primary">更新条目</button>
</form>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
// Quill 工具栏配置(与添加页面相同)
const toolbarOptions = [
// ... 工具栏配置保持不变
];
// 初始化编辑器
const editor = new Quill('#content-editor', {
modules: {
toolbar: {
container: toolbarOptions,
handlers: {
// ... 工具栏处理器保持不变
}
},
syntax: {
highlight: (text) => hljs.highlightAuto(text).value
}
},
theme: 'snow'
});
// 设置初始内容
const initialContent = {{ entry.content | tojson | safe }};
editor.setContents(initialContent);
// 表单提交
document.getElementById('editEntryForm').addEventListener('submit', async function(e) {
e.preventDefault();
try {
// 获取编辑器内容
const content = editor.getContents();
const title = document.getElementById('title').value;
const version = document.getElementById('version').value;
const wikiType = document.getElementById('wiki-type').value;
const entryId = document.getElementById('entry-id').value;
// 获取所有预览项的图片文件
const previewContainer = document.getElementById('imagePreviewContainer');
const previewItems = previewContainer.getElementsByClassName('preview-item');
const imagePromises = Array.from(previewItems).map(item => {
return new Promise((resolve) => {
const existingPath = item.getAttribute('data-path');
const file = item._file;
if (existingPath && !file) {
// 如果是已存在的图片,直接返回路径
resolve({
name: existingPath.split('/').pop(),
data: null,
path: existingPath
});
} else if (file) {
// 如果是新上传的图片
const reader = new FileReader();
reader.onload = function(e) {
const timestamp = Date.now();
const safeName = file.name.replace(/[^a-zA-Z0-9.]/g, '_');
const uniqueName = `${timestamp}_${safeName}`;
resolve({
name: uniqueName,
data: e.target.result.split(',')[1],
path: 'uploads/wiki/' + uniqueName
});
};
reader.readAsDataURL(file);
}
});
});
// 等待所有图片处理完成
const imageDataUrls = await Promise.all(imagePromises);
// 创建 FormData 对象
const formData = new FormData();
formData.append('title', title);
formData.append('version', version);
formData.append('wiki_type', wikiType);
formData.append('content', JSON.stringify(content));
formData.append('entry_id', entryId);
// 添加图片数据
imageDataUrls.forEach((img, index) => {
formData.append(`images[${index}][name]`, img.name);
formData.append(`images[${index}][data]`, img.data || '');
formData.append(`images[${index}][path]`, img.path);
});
const response = await fetch(`/admin/wiki/edit/${entryId}`, {
method: 'POST',
body: formData
// 注意:不要设置 Content-Type让浏览器自动设置
});
const responseData = await response.json();
if (responseData.success) {
alert('更新成功');
window.location.href = '/admin/wiki';
} else {
alert('更新失败:' + responseData.error);
}
} catch (error) {
console.error('Error:', error);
alert('更新失败:' + error.message);
}
});
// 移除预览项的函数
window.removePreviewItem = function(element) {
const previewItem = element.closest('.preview-item');
previewItem.remove();
};
});
</script>
<style>
/* 样式与添加页面保持一致 */
</style>
{% endblock %}

723
templates/admin_wiki_list.html Executable file
View File

@@ -0,0 +1,723 @@
{% extends "base.html" %}
{% block title %}Wiki 条目管理{% endblock %}
{% block head %}
<link href="{{ url_for('static', filename='libs/quill/quill.snow.css') }}" rel="stylesheet">
<script src="{{ url_for('static', filename='libs/highlight/highlight.min.js') }}"></script>
<script src="{{ url_for('static', filename='libs/highlight/languages/javascript.min.js') }}"></script>
<script src="{{ url_for('static', filename='libs/highlight/languages/python.min.js') }}"></script>
<script src="{{ url_for('static', filename='libs/highlight/languages/bash.min.js') }}"></script>
<script src="{{ url_for('static', filename='libs/highlight/languages/xml.min.js') }}"></script>
<script src="{{ url_for('static', filename='libs/quill/quill.min.js') }}"></script>
{% endblock %}
{% block content %}
{% include 'admin_nav.html' %}
<div class="admin-wiki-container">
<h1 class="page-title">Wiki 条目列表 <span class="entry-count">({{ entries|length }})</span></h1>
<div class="search-section">
<div class="search-box">
<i class="fas fa-search"></i>
<input type="text" id="searchInput" placeholder="搜索条目...">
</div>
</div>
<div class="entries-grid">
{% for entry in entries %}
<div class="entry-card" data-id="{{ entry.id }}">
{% if entry.image_path %}
<div class="entry-image">
<img src="{{ url_for('static', filename=entry.image_path) }}" alt="{{ entry.title }}">
</div>
{% endif %}
<div class="entry-info">
<h3>{{ entry.title }}</h3>
<div class="entry-meta">
<span class="version">{{ entry.version }}</span>
<span class="date">{{ entry.created_at }}</span>
</div>
<div class="entry-content" style="display: none;">{{ entry.content }}</div>
</div>
<div class="entry-actions">
<button class="btn-edit" onclick="editEntry({{ entry.id }})">
<i class="fas fa-edit"></i>
</button>
<button class="btn-delete" onclick="deleteEntry({{ entry.id }})">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
{% endfor %}
</div>
</div>
<div id="editModal" class="modal" style="display: none;">
<div class="modal-content">
<span class="close" onclick="closeEditModal()">&times;</span>
<h2>编辑条目</h2>
<form id="editEntryForm" class="entry-form" enctype="multipart/form-data">
<input type="hidden" id="editEntryId" name="entry_id">
<div class="form-group">
<label for="editTitle">标题</label>
<input type="text" id="editTitle" name="title" required>
</div>
<div class="form-group">
<label for="editVersion">版本</label>
<input type="text" id="editVersion" name="version" required>
</div>
<div class="form-group">
<label for="editWikiType">条目类型</label>
<select id="editWikiType" name="wiki_type" class="form-control">
<option value="version">版本号</option>
<option value="news">资讯</option>
</select>
</div>
<div class="form-group">
<label for="editContent">内容</label>
<div id="editContent-editor" class="editor"></div>
<input type="hidden" id="editContent" name="content">
</div>
<button type="submit" class="btn-primary">保存更改</button>
</form>
</div>
</div>
<style>
/* 容器布局 */
.admin-wiki-container {
margin-left: 250px;
padding: 20px;
min-height: 100vh;
background: #f8f9fa;
}
/* 模态框样式 */
.modal {
display: flex;
justify-content: center;
align-items: center;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
z-index: 2000;
padding: 20px;
box-sizing: border-box;
}
.modal-content {
background: #fff;
padding: 30px;
border-radius: 8px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
max-width: 800px;
width: 90%;
position: relative;
max-height: 90vh;
overflow-y: auto;
margin: auto;
animation: modalFadeIn 0.3s ease;
}
@keyframes modalFadeIn {
from {
opacity: 0;
transform: translateY(-20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.close {
position: absolute;
top: 10px;
right: 10px;
font-size: 24px;
cursor: pointer;
color: #333;
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
transition: all 0.3s ease;
z-index: 1;
}
.close:hover {
background: rgba(0, 0, 0, 0.1);
}
.modal-content h2 {
margin: 0 0 20px 0;
font-size: 20px;
font-weight: 600;
color: #333;
padding-right: 30px;
}
.modal-content .form-group {
margin-bottom: 20px;
}
.modal-content .form-group label {
display: block;
margin-bottom: 8px;
font-weight: 500;
color: #333;
}
.modal-content .form-group input {
width: 100%;
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
box-sizing: border-box;
}
.modal-content .editor {
height: 300px;
margin-bottom: 20px;
border-radius: 4px;
overflow: hidden;
}
.modal-content .btn-primary {
width: 100%;
margin-top: 20px;
padding: 12px;
font-size: 16px;
border-radius: 4px;
}
[data-theme="dark"] .modal-content {
background: #1a1a1a;
color: #fff;
}
[data-theme="dark"] .close {
color: #fff;
}
[data-theme="dark"] .close:hover {
background: rgba(255, 255, 255, 0.1);
}
[data-theme="dark"] .modal-content h2 {
color: #fff;
}
[data-theme="dark"] .modal-content .form-group label {
color: #ccc;
}
[data-theme="dark"] .modal-content .form-group input {
background: #333;
border-color: #444;
color: #fff;
}
/* 搜索框样式 */
.search-section {
margin-bottom: 30px;
}
.search-box {
position: relative;
max-width: 500px;
margin: 0 auto;
}
.search-box i {
position: absolute;
left: 15px;
top: 50%;
transform: translateY(-50%);
color: #666;
}
.search-box input {
width: 100%;
padding: 12px 20px 12px 45px;
border: 1px solid #ddd;
border-radius: 25px;
font-size: 16px;
transition: all 0.3s ease;
}
/* 条目网格样式 */
.entries-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 20px;
padding: 20px;
}
.entry-card {
background: #fff;
border-radius: 10px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
overflow: hidden;
transition: transform 0.3s ease;
}
.entry-card:hover {
transform: translateY(-5px);
}
.entry-image {
height: 150px;
overflow: hidden;
}
.entry-image img {
width: 100%;
height: 100%;
object-fit: cover;
}
.entry-info {
padding: 15px;
}
.entry-info h3 {
margin: 0 0 10px 0;
font-size: 18px;
color: #333;
}
.entry-meta {
display: flex;
justify-content: space-between;
color: #666;
font-size: 14px;
}
.entry-actions {
display: flex;
justify-content: flex-end;
padding: 10px;
background: #f8f9fa;
gap: 10px;
}
.btn-edit,
.btn-delete {
border: none;
padding: 8px;
border-radius: 4px;
cursor: pointer;
transition: all 0.3s ease;
}
.btn-edit {
background: #28a745;
color: #fff;
}
.btn-delete {
background: #dc3545;
color: #fff;
}
.btn-edit:hover {
background: #218838;
}
.btn-delete:hover {
background: #c82333;
}
/* 暗色模式适配 */
[data-theme="dark"] .admin-wiki-container {
background: #121212;
}
[data-theme="dark"] .entry-card {
background: #1a1a1a;
}
[data-theme="dark"] .entry-info h3 {
color: #fff;
}
[data-theme="dark"] .entry-meta {
color: #999;
}
[data-theme="dark"] .entry-actions {
background: #2d2d2d;
}
[data-theme="dark"] .search-box input {
background: #2d2d2d;
border-color: #444;
color: #fff;
}
[data-theme="dark"] .search-box i {
color: #999;
}
/* 移动端适配 */
@media (max-width: 768px) {
.admin-wiki-container {
margin-left: 0;
padding: 15px;
}
.search-box input {
font-size: 14px;
padding: 10px 15px 10px 40px;
}
.entries-grid {
grid-template-columns: 1fr;
padding: 10px;
}
.entry-card {
margin-bottom: 15px;
}
.entry-info h3 {
font-size: 16px;
}
.modal-content {
padding: 20px;
width: 95%;
}
.modal-content .editor {
height: 250px;
}
}
.wiki-entry-type-table {
width: 100%;
border-collapse: collapse;
margin-top: 10px;
}
.wiki-entry-type-table th,
.wiki-entry-type-table td {
border: 1px solid #eee;
padding: 8px;
text-align: left;
}
.entry-type-select {
width: 100%;
padding: 5px;
border: 1px solid #ddd;
border-radius: 4px;
}
.badge {
padding: 4px 8px;
border-radius: 4px;
font-size: 0.8rem;
}
.version-badge {
background-color: #e6f3ff;
color: #3b82f6;
}
.news-badge {
background-color: #e6fff0;
color: #10b981;
}
</style>
<script>
document.addEventListener('DOMContentLoaded', function() {
const toolbarOptions = [
['bold', 'italic', 'underline', 'strike'],
['blockquote', 'code-block'],
[{ 'header': 1 }, { 'header': 2 }],
[{ 'list': 'ordered'}, { 'list': 'bullet' }],
[{ 'script': 'sub'}, { 'script': 'super' }],
[{ 'indent': '-1'}, { 'indent': '+1' }],
[{ 'direction': 'rtl' }],
[{ 'size': ['small', false, 'large', 'huge'] }],
[{ 'header': [1, 2, 3, 4, 5, 6, false] }],
[{ 'color': [] }, { 'background': [] }],
[{ 'font': [] }],
[{ 'align': [] }],
['clean'],
['link', 'image', 'video']
];
const editEditor = new Quill('#editContent-editor', {
modules: {
toolbar: {
container: toolbarOptions,
handlers: {
link: function(value) {
if (value) {
const range = this.quill.getSelection();
if (range) {
let url = prompt('请输入链接URL:');
if (url) {
if (!/^https?:\/\//i.test(url)) {
url = 'http://' + url;
}
this.quill.format('link', url);
}
}
} else {
this.quill.format('link', false);
}
}
}
},
syntax: {
highlight: (text) => hljs.highlightAuto(text).value
}
},
theme: 'snow'
});
// 安全地解析内容
function safeParseContent(content) {
console.log('原始内容:', content);
console.log('内容类型:', typeof content);
if (!content) return { ops: [{ insert: '' }] };
try {
// 处理可能的多层转义
let parsed;
if (typeof content === 'string') {
// 尝试多次解析
let currentContent = content;
for (let i = 0; i < 3; i++) {
try {
currentContent = JSON.parse(currentContent);
} catch (e) {
break;
}
}
parsed = currentContent;
} else {
parsed = content;
}
console.log('解析后的内容:', parsed);
// 验证是否为有效的 Quill Delta
if (parsed && parsed.ops && Array.isArray(parsed.ops)) {
return parsed;
}
// 如果不是标准格式,尝试转换
return {
ops: [{
insert: typeof parsed === 'string' ? parsed : JSON.stringify(parsed)
}]
};
} catch (e) {
console.error('内容解析错误:', e);
return {
ops: [{
insert: '无法解析的内容:' + (typeof content === 'string' ? content : JSON.stringify(content))
}]
};
}
}
window.editEntry = function(entryId) {
const entryCard = document.querySelector(`.entry-card[data-id="${entryId}"]`);
if (!entryCard) return;
const title = entryCard.querySelector('h3')?.textContent || '';
const version = entryCard.querySelector('.version')?.textContent || '';
const contentElement = entryCard.querySelector('.entry-content');
const content = contentElement?.textContent || '';
console.log('编辑条目详情:', { entryId, title, version, content });
document.getElementById('editEntryId').value = entryId;
document.getElementById('editTitle').value = title;
document.getElementById('editVersion').value = version;
try {
// 安全地解析内容
const contentObj = safeParseContent(content);
console.log('解析后的内容:', contentObj);
// 设置编辑器内容
editEditor.setContents(contentObj);
document.getElementById('editModal').style.display = 'block';
} catch (e) {
console.error('设置内容时出错:', e);
editEditor.setText(content || '');
document.getElementById('editModal').style.display = 'block';
}
};
document.getElementById('editEntryForm').addEventListener('submit', function(event) {
event.preventDefault();
// 获取富文本编辑器内容
const content = JSON.stringify(editEditor.getContents());
document.getElementById('editContent').value = content;
const formData = new FormData(this);
const entryId = document.getElementById('editEntryId').value;
fetch(`/admin/wiki/edit/${entryId}`, {
method: 'POST',
body: formData,
headers: {
// 明确指定不使用默认的 Content-Type
// 让浏览器自动设置 multipart/form-data
'X-Requested-With': 'XMLHttpRequest'
}
})
.then(response => {
console.log('响应状态:', response.status);
console.log('响应头:', response.headers);
// 检查响应状态
if (!response.ok) {
return response.text().then(text => {
console.error('错误响应内容:', text);
throw new Error(`HTTP error! status: ${response.status}, content: ${text}`);
});
}
return response.json();
})
.then(data => {
if (data.success) {
window.location.reload();
} else {
console.error('详细错误信息:', data);
alert('保存失败: ' + (data.error || '未知错误'));
}
})
.catch(error => {
console.error('保存错误:', error);
alert('保存失败: ' + error);
});
});
// 其他现有的函数保持不变
window.deleteEntry = function(entryId) {
if (!confirm('确定要删除这个条目吗?')) return;
fetch(`/admin/wiki/delete/${entryId}`, {
method: 'POST'
})
.then(response => response.json())
.then(data => {
if (data.success) {
const entryCard = document.querySelector(`.entry-card[data-id="${entryId}"]`);
if (entryCard) {
entryCard.remove();
}
} else {
alert('删除失败: ' + data.error);
}
})
.catch(error => {
alert('删除失败: ' + error);
});
};
document.getElementById('searchInput').addEventListener('input', function() {
const searchTerm = this.value.toLowerCase();
document.querySelectorAll('.entry-card').forEach(card => {
const title = card.querySelector('h3').textContent.toLowerCase();
const version = card.querySelector('.version').textContent.toLowerCase();
card.style.display = title.includes(searchTerm) || version.includes(searchTerm) ? '' : 'none';
});
});
window.closeEditModal = function() {
document.getElementById('editModal').style.display = 'none';
};
function saveWikiDisplaySettings() {
const showVersion = document.getElementById('show-version').checked;
const showNews = document.getElementById('show-news').checked;
const sortOrder = document.getElementById('sort-order').value;
const itemsPerPage = document.getElementById('items-per-page').value;
const previewLength = document.getElementById('preview-length').value;
// 收集单个 Wiki 条目的类型变更
const entryTypeChanges = [];
document.querySelectorAll('.entry-type-select').forEach(select => {
entryTypeChanges.push({
id: select.dataset.entryId,
type: select.value
});
});
// 发送设置到后端保存
fetch('/wiki/admin/save-display-settings', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
show_version: showVersion,
show_news: showNews,
sort_order: sortOrder,
items_per_page: itemsPerPage,
preview_length: previewLength,
entry_type_changes: entryTypeChanges
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
// 刷新页面或局部更新
location.reload();
} else {
alert('保存失败:' + data.error);
}
})
.catch(error => {
console.error('Error:', error);
alert('保存设置时发生错误');
});
closeWikiDisplaySettingsModal();
}
function openEditModal(entryId, title, version, wikiType, content) {
document.getElementById('editEntryId').value = entryId;
document.getElementById('editTitle').value = title;
document.getElementById('editVersion').value = version;
document.getElementById('editWikiType').value = wikiType;
// 如果使用富文本编辑器,更新编辑器内容
if (window.editContentEditor) {
window.editContentEditor.root.innerHTML = content;
}
document.getElementById('editModal').style.display = 'block';
}
});
</script>
{% endblock %}

694
templates/admin_wishlist.html Executable file
View File

@@ -0,0 +1,694 @@
{% extends "base.html" %}
{% block content %}
{% include 'admin_nav.html' %}
<div class="admin-container">
<div class="admin-content">
<!-- 标签页导航 -->
<div class="tab-nav">
<button class="tab-btn active" onclick="switchTab('unnotified')">
<i class="fas fa-bell-slash"></i>
未通知应用
</button>
<button class="tab-btn" onclick="switchTab('notified')">
<i class="fas fa-bell"></i>
已通知应用
</button>
</div>
<!-- 未通知应用 -->
<div class="admin-card" id="unnotified-tab">
<div class="card-header">
<div class="header-left">
<i class="fas fa-bell-slash"></i>
<h3>未通知应用</h3>
</div>
<div class="filter-sort">
<select class="sort-select" onchange="sortTable(this, 'unnotified')">
<option value="last_added_desc">最近添加时间 ↓</option>
<option value="last_added_asc">最近添加时间 ↑</option>
<option value="count_desc">关注人数 ↓</option>
<option value="count_asc">关注人数 ↑</option>
</select>
<div class="search-box">
<input type="text" class="search-input"
placeholder="搜索未通知应用..."
onkeyup="filterTable(this, 'unnotified')">
<i class="fas fa-search"></i>
</div>
</div>
</div>
<div class="wishlist-stats">
<table class="wishlist-table">
<thead>
<tr>
<th>应用名称</th>
<th>关注人数</th>
<th>最早添加时间</th>
<th>最近添加时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{% for item in wishlist_items %}
{% if not item.notified %}
<tr class="wishlist-row">
<td class="app-name">{{ item.app_name }}</td>
<td>{{ item.count }}</td>
<td>{{ item.first_added }}</td>
<td>{{ item.last_added }}</td>
<td>
<div class="action-buttons">
<button class="btn-view" onclick="viewUsers('{{ item.app_name }}')" title="查看用户">
<i class="fas fa-users"></i>
</button>
<button class="btn-notify large" onclick="notifyUsers('{{ item.app_name }}')" title="发送通知">
<i class="fas fa-envelope"></i>
<span>发送通知</span>
</button>
<button class="btn-delete" onclick="deleteWishlistItem('{{ item.app_name }}')" title="删除">
<i class="fas fa-trash"></i>
</button>
</div>
</td>
</tr>
{% endif %}
{% endfor %}
</tbody>
</table>
</div>
</div>
<!-- 已通知应用 -->
<div class="admin-card" id="notified-tab" style="display: none;">
<div class="card-header">
<div class="header-left">
<i class="fas fa-bell"></i>
<h3>已通知应用</h3>
</div>
<div class="filter-sort">
<select class="sort-select" onchange="sortTable(this, 'notified')">
<option value="last_added_desc">最近添加时间 ↓</option>
<option value="last_added_asc">最近添加时间 ↑</option>
<option value="count_desc">关注人数 ↓</option>
<option value="count_asc">关注人数 ↑</option>
</select>
<div class="search-box">
<input type="text" class="search-input"
placeholder="搜索已通知应用..."
onkeyup="filterTable(this, 'notified')">
<i class="fas fa-search"></i>
</div>
</div>
</div>
<div class="wishlist-stats">
<table class="wishlist-table notified">
<thead>
<tr>
<th>应用名称</th>
<th>关注人数</th>
<th>最早添加时间</th>
<th>最近添加时间</th>
<th>通知时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{% for item in wishlist_items %}
{% if item.notified %}
<tr class="wishlist-row">
<td class="app-name">{{ item.app_name }}</td>
<td>{{ item.count }}</td>
<td>{{ item.first_added }}</td>
<td>{{ item.last_added }}</td>
<td>{{ item.notified_at }}</td>
<td>
<div class="action-buttons">
<button class="btn-view" onclick="viewUsers('{{ item.app_name }}')" title="查看用户">
<i class="fas fa-users"></i>
</button>
<button class="btn-notify" onclick="notifyUsers('{{ item.app_name }}')" title="重新发送通知">
<i class="fas fa-paper-plane"></i>
<span>重新发送</span>
</button>
<button class="btn-delete" onclick="deleteWishlistItem('{{ item.app_name }}')" title="删除">
<i class="fas fa-trash"></i>
</button>
</div>
</td>
</tr>
{% endif %}
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- 用户列表弹窗 -->
<div id="usersModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h3>关注用户列表</h3>
<button class="close-btn" onclick="closeUsersModal()">
<i class="fas fa-times"></i>
</button>
</div>
<div id="usersList" class="users-list"></div>
</div>
</div>
<!-- Toast 容器 -->
<div id="toast" class="toast"></div>
<style>
.wishlist-table {
width: 100%;
border-collapse: collapse;
margin-top: 20px;
}
.wishlist-table th,
.wishlist-table td {
padding: 12px;
text-align: left;
border-bottom: 1px solid #eee;
}
.wishlist-table th {
background: #f5f5f5;
font-weight: 600;
}
.action-buttons {
display: flex;
gap: 8px;
}
.btn-view,
.btn-notify,
.btn-delete {
padding: 6px;
border: none;
border-radius: 4px;
cursor: pointer;
transition: all 0.3s ease;
}
.btn-view {
background: #1890ff;
color: white;
}
.btn-notify {
background: #52c41a;
color: white;
}
.btn-notify:hover {
background: #73d13d;
}
.btn-delete {
background: #ff4d4f;
color: white;
}
.btn-view:hover {
background: #40a9ff;
}
.btn-delete:hover {
background: #ff7875;
}
.users-list {
max-height: 400px;
overflow-y: auto;
padding: 20px;
}
.user-item {
display: flex;
align-items: center;
gap: 12px;
padding: 10px;
border-bottom: 1px solid #eee;
}
.user-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
object-fit: cover;
}
.user-info {
flex: 1;
}
.user-name {
font-weight: 500;
margin-bottom: 4px;
}
.user-email {
font-size: 12px;
color: #666;
}
/* 暗色模式适配 */
[data-theme="dark"] .wishlist-table th {
background: #1f1f1f;
}
[data-theme="dark"] .wishlist-table td {
border-color: #333;
}
[data-theme="dark"] .user-item {
border-color: #333;
}
[data-theme="dark"] .user-email {
color: #999;
}
.modal {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
z-index: 1000;
justify-content: center;
align-items: center;
}
.modal-content {
background: white;
border-radius: 8px;
width: 90%;
max-width: 600px;
max-height: 80vh;
overflow: hidden;
position: relative;
}
[data-theme="dark"] .modal-content {
background: #242424;
}
.admin-card {
background: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
margin-bottom: 20px;
}
.card-header {
padding: 16px 20px;
border-bottom: 1px solid #eee;
display: flex;
align-items: center;
justify-content: space-between;
}
.header-left {
display: flex;
align-items: center;
gap: 10px;
}
.search-box {
position: relative;
width: 300px;
}
.search-input {
width: 100%;
padding: 8px 12px;
padding-right: 35px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
transition: all 0.3s ease;
}
.search-input:focus {
border-color: #1890ff;
box-shadow: 0 0 0 2px rgba(24,144,255,0.2);
outline: none;
}
.search-box i {
position: absolute;
right: 12px;
top: 50%;
transform: translateY(-50%);
color: #999;
}
/* 暗色模式适配 */
[data-theme="dark"] .admin-card {
background: #242424;
}
[data-theme="dark"] .card-header {
border-color: #333;
}
[data-theme="dark"] .search-input {
background: #333;
border-color: #444;
color: #fff;
}
[data-theme="dark"] .search-input:focus {
border-color: #177ddc;
box-shadow: 0 0 0 2px rgba(23,125,220,0.2);
}
[data-theme="dark"] .search-box i {
color: #666;
}
/* 标签页样式 */
.tab-nav {
display: flex;
gap: 10px;
margin-bottom: 20px;
}
.tab-btn {
padding: 12px 24px;
border: none;
border-radius: 8px;
background: white;
color: #666;
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
font-size: 15px;
transition: all 0.3s ease;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.tab-btn.active {
background: #1890ff !important;
color: white !important;
}
.tab-btn:hover:not(.active) {
background: #f5f5f5;
}
/* 暗色模式适配 */
[data-theme="dark"] .tab-btn {
background: #242424;
color: #999;
}
[data-theme="dark"] .tab-btn.active {
background: #177ddc !important;
color: white !important;
}
[data-theme="dark"] .tab-btn:hover:not(.active) {
background: #333;
}
/* Toast 样式 */
.toast {
position: fixed;
top: 20px;
right: 20px;
padding: 12px 24px;
background: rgba(0, 0, 0, 0.8);
color: white;
border-radius: 4px;
z-index: 1000;
display: none;
animation: fadeInOut 3s ease;
}
@keyframes fadeInOut {
0% { opacity: 0; transform: translateY(-20px); }
10% { opacity: 1; transform: translateY(0); }
90% { opacity: 1; transform: translateY(0); }
100% { opacity: 0; transform: translateY(-20px); }
}
/* 暗色模式适配 */
[data-theme="dark"] .toast {
background: rgba(255, 255, 255, 0.9);
color: #000;
}
/* Add styles for filter and sort */
.filter-sort {
display: flex;
gap: 12px;
align-items: center;
}
.filter-select,
.sort-select {
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
background: white;
color: #666;
font-size: 14px;
cursor: pointer;
transition: all 0.3s ease;
}
.filter-select:hover,
.sort-select:hover {
border-color: #40a9ff;
}
.filter-select:focus,
.sort-select:focus {
border-color: #1890ff;
box-shadow: 0 0 0 2px rgba(24,144,255,0.2);
outline: none;
}
/* Dark mode styles for filter and sort */
[data-theme="dark"] .filter-select,
[data-theme="dark"] .sort-select {
background: #333;
border-color: #444;
color: #fff;
}
[data-theme="dark"] .filter-select:hover,
[data-theme="dark"] .sort-select:hover {
border-color: #177ddc;
}
[data-theme="dark"] .filter-select:focus,
[data-theme="dark"] .sort-select:focus {
border-color: #177ddc;
box-shadow: 0 0 0 2px rgba(23,125,220,0.2);
}
</style>
<script>
function viewUsers(appName) {
fetch(`/admin/wishlist/users/${encodeURIComponent(appName)}`)
.then(response => response.json())
.then(data => {
if (data.success) {
const usersList = document.getElementById('usersList');
usersList.innerHTML = data.users.map(user => `
<div class="user-item">
<img src="${user.avatar || '/static/images/default-avatar.png'}"
alt="${user.name}"
class="user-avatar">
<div class="user-info">
<div class="user-name">${user.name}</div>
<div class="user-email">${user.email || '未设置邮箱'}</div>
</div>
</div>
`).join('');
document.getElementById('usersModal').style.display = 'flex';
} else {
showToast(data.error || '获取用户列表失败');
}
})
.catch(error => {
console.error('Failed to get users:', error);
showToast('获取用户列表失败');
});
}
function closeUsersModal() {
document.getElementById('usersModal').style.display = 'none';
}
function deleteWishlistItem(appName) {
if (!confirm(`确定要删除应用"${appName}"的所有心愿记录吗?`)) return;
fetch(`/admin/wishlist/delete/${encodeURIComponent(appName)}`, {
method: 'POST'
})
.then(response => response.json())
.then(data => {
if (data.success) {
location.reload();
} else {
showToast(data.error || '删除失败');
}
})
.catch(error => {
console.error('Delete failed:', error);
showToast('删除失败,请稍后重试');
});
}
function notifyUsers(appName) {
const row = event.target.closest('tr');
const isNotified = row.closest('table').classList.contains('notified');
const message = isNotified ?
`确定要重新发送通知给所有关注"${appName}"的用户吗?` :
`确定要通知所有关注"${appName}"的用户吗?`;
if (!confirm(message)) return;
fetch(`/admin/wishlist/notify/${encodeURIComponent(appName)}`, {
method: 'POST'
})
.then(response => response.json())
.then(data => {
if (data.success) {
showToast(data.message || '通知发送成功');
// 移动行到已通知表格
if (!isNotified) {
const notifiedTable = document.querySelector('.wishlist-table.notified tbody');
const newRow = row.cloneNode(true);
// 修改按钮样式和文本
const notifyBtn = newRow.querySelector('.btn-notify');
notifyBtn.classList.remove('large');
notifyBtn.innerHTML = '<i class="fas fa-paper-plane"></i><span>重新发送</span>';
// 添加通知时间列
const timeCell = document.createElement('td');
timeCell.textContent = new Date().toLocaleString();
newRow.insertBefore(timeCell, newRow.lastElementChild);
notifiedTable.insertBefore(newRow, notifiedTable.firstChild);
row.remove();
// 如果未通知列表为空,自动切换到已通知标签页
const unnotifiedRows = document.querySelector('.wishlist-table:not(.notified) tbody').children.length;
if (unnotifiedRows === 0) {
switchTab('notified');
}
}
} else {
showToast(data.error || '发送失败');
}
})
.catch(error => {
console.error('Notification failed:', error);
showToast('发送失败,请稍后重试');
});
}
// 点击遮罩层关闭弹窗
document.getElementById('usersModal').addEventListener('click', function(e) {
if (e.target === this) {
closeUsersModal();
}
});
function filterTable(input, type) {
const searchText = input.value.toLowerCase();
const table = type === 'notified' ?
document.querySelector('.wishlist-table.notified') :
document.querySelector('.wishlist-table:not(.notified)');
const rows = table.querySelectorAll('tbody tr');
rows.forEach(row => {
const appName = row.querySelector('.app-name').textContent.toLowerCase();
const matchesSearch = appName.includes(searchText);
row.style.display = matchesSearch ? '' : 'none';
});
}
function switchTab(tabId) {
// 更新标签按钮状态
document.querySelectorAll('.tab-btn').forEach(btn => {
btn.classList.remove('active');
});
// 获取当前点击的按钮
const activeBtn = document.querySelector(`.tab-btn[onclick*="${tabId}"]`);
activeBtn.classList.add('active');
// 切换内容显示
document.getElementById('unnotified-tab').style.display = tabId === 'unnotified' ? 'block' : 'none';
document.getElementById('notified-tab').style.display = tabId === 'notified' ? 'block' : 'none';
}
// Toast 通知函数
function showToast(message, duration = 3000) {
const toast = document.getElementById('toast');
toast.textContent = message;
toast.style.display = 'block';
// 重置动画
toast.style.animation = 'none';
toast.offsetHeight; // 触发重排
toast.style.animation = 'fadeInOut 3s ease';
setTimeout(() => {
toast.style.display = 'none';
}, duration);
}
function sortTable(select, type) {
const sortBy = select.value;
const table = type === 'notified' ?
document.querySelector('.wishlist-table.notified') :
document.querySelector('.wishlist-table:not(.notified)');
const tbody = table.querySelector('tbody');
const rows = Array.from(tbody.querySelectorAll('tr'));
rows.sort((a, b) => {
let aValue, bValue;
if (sortBy.startsWith('count')) {
aValue = parseInt(a.querySelectorAll('td')[1].textContent);
bValue = parseInt(b.querySelectorAll('td')[1].textContent);
} else if (sortBy.startsWith('last_added')) {
const aIndex = type === 'notified' ? 3 : 3;
const bIndex = type === 'notified' ? 3 : 3;
aValue = new Date(a.querySelectorAll('td')[aIndex].textContent);
bValue = new Date(b.querySelectorAll('td')[bIndex].textContent);
}
if (sortBy.endsWith('asc')) {
return aValue - bValue;
} else {
return bValue - aValue;
}
});
rows.forEach(row => tbody.appendChild(row));
}
</script>
{% endblock %}

342
templates/all_apps.html Executable file
View File

@@ -0,0 +1,342 @@
{% extends "base.html" %}
{% block content %}
<div class="category-page">
<div class="header">
<a href="{{ url_for('index') }}" class="back-link" title="返回首页">
<i class="fas fa-arrow-left"></i>
</a>
<div class="header-title">
<h1>全部应用 ({{ apps|length }}个)</h1>
</div>
</div>
<div class="apps-grid" style="margin: 0; padding: 0;">
{% for app in apps %}
<div class="app-card" onclick="window.location.href='/app/{{ app.id }}'">
<div class="app-icon">
{% if 'http' in app.icon_path %}
<img data-src="{{ app.icon_path }}" alt="{{ app.name }}" src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7">
{% else %}
<img data-src="{{ url_for('static', filename='uploads/' + app.icon_path) }}" alt="{{ app.name }}" src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7">
{% endif %}
</div>
<div class="app-info">
<h3>{{ app.name }}</h3>
</div>
</div>
{% endfor %}
</div>
</div>
<style>
.category-page {
max-width: 1200px;
margin: 60px auto 20px;
padding: 0 15px;
}
.header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 20px;
background: white;
padding: 5px 20px 5px 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;
transition: transform 0.3s ease;
flex-shrink: 0;
}
.back-link:hover {
transform: translateY(-2px);
}
.header-title {
flex: 1;
}
.header h1 {
margin: 0;
font-size: 20px;
color: #333;
font-weight: 500;
font-family: "SimHei", "黑体", sans-serif;
text-align: left;
padding-right: auto;
margin-right: auto;
}
/* PC端网格布局 */
.apps-grid {
display: flex;
flex-wrap: wrap;
gap: 0;
justify-content: flex-start;
background: white;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
padding: 10px;
}
.app-card {
width: 80px; /* PC端固定宽度 */
padding: 6px;
box-sizing: border-box;
text-align: center;
}
.app-icon {
width: 48px;
height: 48px;
margin: 0 auto 4px;
}
.app-icon img {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 12px;
opacity: 0;
transition: opacity 0.3s ease;
}
.app-icon img.loaded {
opacity: 1;
}
.app-info h3 {
font-size: 11px;
margin: 0;
color: #333;
text-align: center;
line-height: 1.2;
height: 2.4em;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
/* 移动端适配 */
@media (max-width: 768px) {
.category-page {
padding: 0 10px;
}
.header {
margin-bottom: 15px;
}
.apps-grid {
padding: 6px;
justify-content: center; /* 移动端居中显示 */
}
.app-card {
width: 25%; /* 移动端四列布局 */
padding: 4px;
}
.app-icon {
width: 42px;
height: 42px;
margin-bottom: 3px;
}
.app-info h3 {
font-size: 10px;
}
}
/* 超小屏幕适配 */
@media (max-width: 320px) {
.apps-grid {
padding: 4px;
}
.app-card {
padding: 3px;
}
.app-icon {
width: 38px;
height: 38px;
}
.app-info h3 {
font-size: 9px;
}
}
/* 暗色模式样式 */
[data-theme="dark"] .category-page {
background: #1a1a1a;
}
[data-theme="dark"] .header {
background: #242424;
}
[data-theme="dark"] .header h1 {
color: #fff;
}
[data-theme="dark"] .back-link {
color: #ccc;
background: #333;
}
[data-theme="dark"] .back-link:hover {
background: #444;
color: #fff;
}
[data-theme="dark"] .apps-grid {
background: #242424;
}
[data-theme="dark"] .app-card {
background: #242424;
}
[data-theme="dark"] .app-info h3 {
color: #fff;
}
/* 添加全局过渡效果 */
* {
transition: background-color 0.3s ease, color 0.3s ease;
}
/* 确保 apps-grid 也有过渡效果 */
.apps-grid {
transition: background-color 0.3s ease;
background: #fff;
}
[data-theme="dark"] .apps-grid {
background: #242424;
}
/* 修改 app-card 的悬停效果 */
.app-card {
cursor: pointer;
transition: transform 0.3s ease, background-color 0.3s ease;
}
.app-card:hover {
transform: translateY(-2px);
}
[data-theme="dark"] .app-card:hover {
background: #333;
}
/* 优化返回按钮的触摸反馈 */
.back-link {
-webkit-tap-highlight-color: rgba(0,0,0,0);
cursor: pointer;
transition: all 0.3s ease;
}
.back-link:active {
opacity: 0.7;
}
@media (hover: hover) {
.back-link:hover {
opacity: 0.8;
}
}
/* 添加到现有样式的末尾 */
.floating-buttons {
position: fixed;
bottom: 40px;
right: 20px;
z-index: 1000;
display: flex;
flex-direction: column;
gap: 10px;
}
.theme-toggle {
width: 40px;
height: 40px;
border-radius: 50%;
border: none;
background: #fff;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s ease;
padding: 0;
}
.theme-toggle:hover {
transform: scale(1.1);
}
/* 暗色模式样式 */
[data-theme="dark"] .theme-toggle {
background: #333;
color: #fff;
}
/* 响应式调整 */
@media (max-width: 768px) {
.floating-buttons {
bottom: 30px;
right: 15px;
gap: 8px;
}
.theme-toggle {
width: 36px;
height: 36px;
}
}
</style>
<script>
const imageObserver = new IntersectionObserver((entries, observer) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target;
img.src = img.dataset.src;
img.onload = () => {
img.classList.add('loaded');
};
observer.unobserve(img);
}
});
}, {
rootMargin: '50px 0px',
threshold: 0.01
});
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('img[data-src]').forEach(img => {
imageObserver.observe(img);
});
});
</script>
{% endblock %}

492
templates/app_detail.html Executable file
View File

@@ -0,0 +1,492 @@
{% extends "base.html" %}
{% block content %}
<div class="app-detail-container">
<div class="app-header">
<div style="display: flex; justify-content: flex-start; margin-bottom: 15px;">
<a href="{{ request.referrer or url_for('index') }}" class="back-link">
<i class="fas fa-arrow-left"></i>
返回
</a>
</div>
<div class="app-basic-info">
<div class="app-icon">
{% if 'http' in app.icon_path %}
<img src="{{ app.icon_path }}" alt="{{ app.name }}">
{% else %}
<img src="{{ url_for('static', filename='uploads/' + app.icon_path) }}" alt="{{ app.name }}">
{% endif %}
</div>
<div class="app-title">
<div class="title-row">
<div class="title-info">
<h1>{{ app.name }}</h1>
<span class="category-tag">{{ category.name }}</span>
</div>
<div class="action-buttons">
{% if app.visit_url %}
<a href="{{ app.visit_url }}" class="visit-button" target="_blank">
<i class="fas fa-external-link-alt"></i>
访问
</a>
{% endif %}
{% if app.download_url %}
<a href="{{ app.download_url }}" class="download-button" target="_blank">
<i class="fas fa-download"></i>
下载
</a>
{% endif %}
</div>
</div>
</div>
</div>
</div>
<div class="app-content">
<div class="app-section">
<h2>应用描述</h2>
<div class="description">{{ app.description or '暂无描述' }}</div>
</div>
<div class="app-section">
<h2>应用信息</h2>
<div class="info-list">
<div class="info-item">
<label>支持平台</label>
<span class="platform-support">
{% if not app.platform %}
<span class="platform-tag mobile">
<i class="fas fa-mobile-alt"></i> 手机端
</span>
{% else %}
{% if 'mobile' in app.platform.split(',') %}
<span class="platform-tag mobile">
<i class="fas fa-mobile-alt"></i> 手机端
</span>
{% endif %}
{% if 'tablet' in app.platform.split(',') %}
<span class="platform-tag tablet">
<i class="fas fa-tablet-alt"></i> 平板端
</span>
{% endif %}
{% endif %}
</span>
</div>
<div class="info-item">
<label>版本</label>
<span>{{ app.version or '暂无' }}</span>
</div>
<div class="info-item">
<label>更新日期</label>
<span>
{% if app.update_date %}
{% if app.update_date is string %}
{{ app.update_date[:10] }}
{% else %}
{{ app.update_date.strftime('%Y-%m-%d') }}
{% endif %}
{% elif app.created_at %}
{% if app.created_at is string %}
{{ app.created_at[:10] }}
{% else %}
{{ app.created_at.strftime('%Y-%m-%d') }}
{% endif %}
{% else %}
暂无
{% endif %}
</span>
</div>
<div class="info-item">
<label>开发者</label>
<span>{{ app.developer or '暂无' }}</span>
</div>
</div>
</div>
<div class="download-section">
<a href="{{ app.download_url }}" class="download-button" target="_blank">
<i class="fas fa-download"></i>
下载
</a>
</div>
</div>
</div>
<style>
.app-detail-container {
max-width: 800px;
margin: 0 auto;
padding: 15px;
}
.app-header {
background: #fff;
padding: 15px;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
margin-bottom: 15px;
}
.back-link {
display: inline-flex;
align-items: center;
color: #666;
text-decoration: none;
padding: 6px 12px;
border-radius: 6px;
background: #fff;
transition: all 0.3s ease;
margin: 0;
}
.back-link i {
margin-right: 8px;
}
.back-link:hover {
background: #eee;
color: #333;
}
.app-basic-info {
display: flex;
align-items: center;
gap: 15px;
}
.app-icon {
width: 80px;
height: 80px;
}
.app-icon img {
width: 100%;
height: 100%;
border-radius: 16px;
object-fit: cover;
}
.app-title h1 {
margin: 0 0 6px 0;
font-size: 22px;
}
.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;
min-width: min-content;
max-width: max-content;
}
.app-content {
background: #fff;
padding: 15px;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.app-section {
margin-bottom: 20px;
}
.app-section h2 {
font-size: 16px;
margin: 0 0 8px 0;
color: #333;
padding: 0;
}
.info-list {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 8px;
}
.info-item {
background: #f8f9fa;
padding: 10px;
border-radius: 6px;
display: flex;
justify-content: space-between;
align-items: center;
}
.info-item label {
font-size: 12px;
color: #666;
}
.info-item span {
font-size: 14px;
color: #333;
}
.description {
line-height: 1.5;
color: #444;
white-space: pre-wrap;
font-size: 14px;
text-align: left;
padding: 0;
margin: 0;
word-break: break-word;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
}
.download-section {
display: none;
}
.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;
flex-shrink: 0;
margin-left: auto;
}
.download-button:hover {
background: #0056b3;
}
.download-button i {
font-size: 14px;
}
.title-row {
display: flex;
justify-content: space-between;
align-items: flex-start;
width: 100%;
gap: 15px;
}
.title-info {
display: flex;
flex-direction: column;
gap: 6px;
flex: 1;
min-width: 0;
}
.app-title {
flex: 1;
min-width: 0;
}
.action-buttons {
display: flex;
gap: 10px;
margin-left: auto;
}
.visit-button {
display: inline-flex;
align-items: center;
gap: 8px;
background: #34C759; /* 使用不同的颜色区分 */
color: white;
text-decoration: none;
padding: 8px 16px;
border-radius: 20px;
font-size: 14px;
transition: all 0.3s ease;
white-space: nowrap;
flex-shrink: 0;
}
.visit-button:hover {
background: #2FB350;
}
.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;
flex-shrink: 0;
}
.download-button:hover {
background: #0056b3;
}
.visit-button i,
.download-button i {
font-size: 14px;
}
.platform-support {
display: flex;
gap: 10px;
}
.platform-tag {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border-radius: 15px;
font-size: 12px;
background: #f5f5f7;
}
.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 i {
font-size: 12px;
}
/* 暗色模式样式 */
[data-theme="dark"] .app-detail-container {
background: #1a1a1a;
}
[data-theme="dark"] .app-header {
background: #242424;
}
[data-theme="dark"] .back-link {
color: #ccc;
background: #333;
}
[data-theme="dark"] .back-link:hover {
background: #444;
color: #fff;
}
[data-theme="dark"] .app-title h1 {
color: #fff;
}
[data-theme="dark"] .category-tag {
background: #333;
color: #ccc;
}
[data-theme="dark"] .app-content {
background: #242424;
}
[data-theme="dark"] .app-section h2 {
color: #fff;
}
[data-theme="dark"] .description {
color: #ccc;
}
[data-theme="dark"] .info-item {
background: #333;
}
[data-theme="dark"] .info-item label {
color: #999;
}
[data-theme="dark"] .info-item span {
color: #fff;
}
[data-theme="dark"] .platform-tag {
background: rgba(255, 255, 255, 0.1);
}
[data-theme="dark"] .platform-tag.mobile {
background: rgba(0, 122, 255, 0.2);
}
[data-theme="dark"] .platform-tag.tablet {
background: rgba(88, 86, 214, 0.2);
}
.floating-buttons {
position: fixed;
bottom: 40px;
right: 20px;
z-index: 1000;
display: flex;
flex-direction: column;
gap: 10px;
}
.theme-toggle {
width: 40px;
height: 40px;
border-radius: 50%;
border: none;
background: #fff;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s ease;
padding: 0;
}
.theme-toggle:hover {
transform: scale(1.1);
}
/* 暗色模式样式 */
[data-theme="dark"] .theme-toggle {
background: #333;
color: #fff;
}
/* 响应式调整 */
@media (max-width: 768px) {
.floating-buttons {
bottom: 30px;
right: 15px;
gap: 8px;
}
.theme-toggle {
width: 36px;
height: 36px;
}
}
</style>
<script>
document.querySelector('.back-link').addEventListener('click', function(e) {
e.preventDefault();
window.history.back();
});
</script>
{% endblock %}

423
templates/app_edit.html Executable file
View File

@@ -0,0 +1,423 @@
{% extends "base.html" %}
{% block content %}
{% include 'admin_nav.html' %}
<div class="admin-container">
<div class="admin-content">
<div class="admin-card">
<div class="card-header">
<div class="header-left">
<i class="fas fa-edit"></i>
<h3>应用详情 (Top50热门应用)</h3>
</div>
</div>
<!-- 应用选择部分 -->
<div class="app-selector">
<form class="search-form" method="GET" action="/admin/app/edit">
<div class="search-wrapper">
<i class="fas fa-search"></i>
<input type="text" name="search" placeholder="搜索应用..." value="{{ search }}">
</div>
</form>
<div class="apps-list">
{% for app in apps %}
<div class="app-item {% if selected_app and selected_app.id == app.id %}selected{% endif %}"
onclick="window.location.href='/admin/app/edit/{{ app.id }}'">
<div class="app-icon">
{% if 'http' in app.icon_path %}
<img src="{{ app.icon_path }}" alt="{{ app.name }}">
{% else %}
<img src="{{ url_for('static', filename='uploads/' + app.icon_path) }}" alt="{{ app.name }}">
{% endif %}
</div>
<div class="app-info">
<div class="app-name-category">
<h4>{{ app.name }}</h4>
<div class="app-meta">
<span class="category">{{ app.category_name }}</span>
</div>
</div>
<span class="search-count">搜索 {{ app.search_count }} 次</span>
</div>
</div>
{% endfor %}
</div>
</div>
{% if selected_app %}
<form method="POST" class="app-edit-form" action="/admin/app/edit/{{ selected_app.id }}">
<div class="basic-info">
<div class="current-icon">
{% if 'http' in selected_app.icon_path %}
<img src="{{ selected_app.icon_path }}" alt="{{ selected_app.name }}">
{% else %}
<img src="{{ url_for('static', filename='uploads/' + selected_app.icon_path) }}" alt="{{ selected_app.name }}">
{% endif %}
</div>
<div class="basic-fields">
<div class="form-group">
<label for="name">应用名称</label>
<input type="text" id="name" name="name" value="{{ selected_app.name }}" required>
</div>
<div class="form-group">
<label for="category">分类</label>
<select id="category" name="category_id" required>
{% for category in categories %}
<option value="{{ category.id }}" {% if selected_app.category_id == category.id %}selected{% endif %}>
{{ category.name }}
</option>
{% endfor %}
</select>
</div>
</div>
</div>
<div class="form-group">
<label for="description">应用描述</label>
<textarea id="description" name="description">{{ selected_app.description or '' }}</textarea>
</div>
<div class="form-group">
<label for="version">版本号</label>
<input type="text" id="version" name="version" value="{{ selected_app.version or '' }}">
</div>
<div class="form-group">
<label for="update_date">更新日期</label>
<input type="date" id="update_date" name="update_date" value="{{ selected_app.update_date or '' }}">
</div>
<div class="form-group">
<label for="developer">开发者</label>
<input type="text" id="developer" name="developer" value="{{ selected_app.developer or '' }}">
</div>
<div class="form-group">
<label for="visit_url">访问链接 (选填)</label>
<input type="url"
id="visit_url"
name="visit_url"
value="{{ selected_app.visit_url or '' }}"
placeholder="http://example.com">
</div>
<div class="form-group">
<label for="download_url">下载链接 (选填)</label>
<input type="url"
id="download_url"
name="download_url"
value="{{ selected_app.download_url or '' }}"
placeholder="http://example.com/download">
</div>
<button type="submit" class="btn-primary">
<i class="fas fa-save"></i> 保存更改
</button>
</form>
{% endif %}
</div>
</div>
</div>
<style>
.app-selector {
margin-bottom: 20px;
}
.apps-list {
max-height: 200px;
overflow-y: auto;
border: 1px solid #eee;
border-radius: 8px;
margin-top: 10px;
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1px;
background-color: #eee;
}
.app-item {
display: flex;
align-items: center;
padding: 8px 10px;
cursor: pointer;
transition: background-color 0.3s;
min-width: 0;
background-color: white;
}
.app-item:hover {
background-color: #f5f5f5;
}
.app-item.selected {
background-color: #e3f2fd;
}
.app-item .app-icon {
width: 32px;
height: 32px;
margin-right: 8px;
flex-shrink: 0;
}
.app-item .app-icon img {
width: 100%;
height: 100%;
border-radius: 6px;
object-fit: cover;
}
.app-item .app-info {
flex: 1;
min-width: 0;
display: flex;
justify-content: space-between;
align-items: center;
}
.app-item .app-info .app-name-category {
min-width: 0;
}
.app-item .app-info h4 {
margin: 0;
font-size: 12px;
color: #333;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.app-item .app-meta {
display: flex;
align-items: center;
gap: 4px;
margin-top: 2px;
}
.search-count {
font-size: 10px;
color: #007AFF;
background-color: #E3F2FD;
padding: 1px 4px;
border-radius: 3px;
white-space: nowrap;
margin-left: auto;
flex-shrink: 0;
}
.category {
font-size: 10px;
color: #666;
white-space: nowrap;
}
.current-icon {
width: 80px;
height: 80px;
flex-shrink: 0;
}
.current-icon img {
width: 100%;
height: 100%;
border-radius: 12px;
object-fit: cover;
}
.basic-info {
display: flex;
gap: 20px;
margin-bottom: 20px;
}
.basic-fields {
flex: 1;
display: flex;
flex-direction: column;
gap: 10px;
}
.basic-fields .form-group {
margin: 0; /* 移除默认的外边距 */
}
.basic-fields input,
.basic-fields select {
margin-top: 4px; /* 减小标签和输入框的间距 */
}
.app-edit-form {
margin-top: 20px;
padding-top: 20px;
border-top: 1px solid #eee;
}
.form-group {
margin-bottom: 15px;
}
.form-group label {
display: block;
margin-bottom: 5px;
color: #333;
font-size: 14px;
}
.form-group input,
.form-group select,
.form-group textarea {
width: 100%;
padding: 8px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
}
.form-group textarea {
resize: vertical;
}
.apps-list::-webkit-scrollbar {
width: 4px;
}
.apps-list::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 2px;
}
.apps-list::-webkit-scrollbar-thumb {
background: #ccc;
border-radius: 2px;
}
.apps-list::-webkit-scrollbar-thumb:hover {
background: #999;
}
@media (max-width: 768px) {
.apps-list {
grid-template-columns: 1fr;
}
}
.filter-bar {
display: flex;
gap: 15px;
margin-bottom: 15px;
align-items: center;
}
.category-select {
padding: 8px 12px;
border: 1px solid #d2d2d7;
border-radius: 6px;
font-size: 14px;
color: #1d1d1f;
background-color: white;
cursor: pointer;
transition: all 0.3s ease;
min-width: 120px;
}
.category-select:hover {
border-color: #0066cc;
}
.category-select:focus {
outline: none;
border-color: #0066cc;
box-shadow: 0 0 0 2px rgba(0,102,204,0.1);
}
.search-form {
flex: 1;
}
</style>
<script>
// 添加通知显示函数
function showNotification(message, type = 'success') {
const notification = document.createElement('div');
notification.className = `notification ${type}`;
notification.textContent = message;
notification.style.position = 'fixed';
notification.style.bottom = '20px';
notification.style.right = '20px';
notification.style.padding = '10px 20px';
notification.style.borderRadius = '4px';
notification.style.backgroundColor = type === 'success' ? '#4CAF50' : '#f44336';
notification.style.color = 'white';
notification.style.zIndex = '1000';
notification.style.opacity = '0';
notification.style.transform = 'translateY(20px)';
notification.style.transition = 'all 0.3s ease';
document.body.appendChild(notification);
setTimeout(() => {
notification.style.opacity = '1';
notification.style.transform = 'translateY(0)';
}, 10);
setTimeout(() => {
notification.style.opacity = '0';
notification.style.transform = 'translateY(20px)';
setTimeout(() => notification.remove(), 300);
}, 3000);
}
// 修改表单提交处理函数
document.querySelector('.app-edit-form').addEventListener('submit', function(e) {
e.preventDefault();
const formData = new FormData(this);
fetch(this.action, {
method: 'POST',
body: formData
})
.then(response => response.json())
.then(data => {
if (data.success) {
showNotification('保存成功', 'success');
} else {
showNotification(data.error || '保存失败', 'error');
}
})
.catch(error => {
showNotification('保存失败,请重试', 'error');
console.error('Error:', error);
});
});
// 添加动画样式
const style = document.createElement('style');
style.textContent = `
@keyframes fadeInOut {
0% { opacity: 0; transform: translateY(20px); }
10% { opacity: 1; transform: translateY(0); }
90% { opacity: 1; transform: translateY(0); }
100% { opacity: 0; transform: translateY(-20px); }
}
`;
document.head.appendChild(style);
// 添加分类筛选函数
function filterByCategory(categoryId) {
const searchParams = new URLSearchParams(window.location.search);
if (categoryId) {
searchParams.set('category', categoryId);
} else {
searchParams.delete('category');
}
window.location.href = `${window.location.pathname}?${searchParams.toString()}`;
}
</script>
{% endblock %}

1026
templates/auto_import.html Executable file

File diff suppressed because it is too large Load Diff

1494
templates/base.html Executable file

File diff suppressed because it is too large Load Diff

278
templates/category.html Executable file
View File

@@ -0,0 +1,278 @@
{% extends "base.html" %}
{% block content %}
<div class="category-page">
<div class="header">
<a href="{{ url_for('index') }}" class="back-link" title="返回首页">
<i class="fas fa-arrow-left"></i>
</a>
<div class="header-title">
<h1>{{ category.name }} ({{ apps|length }}个)</h1>
{% if platform == 'tablet' %}
<span class="platform-badge">
<i class="fas fa-tablet-alt"></i> 平板应用
</span>
{% endif %}
</div>
</div>
<div class="apps-grid" style="margin: 0; padding: 0;">
{% for app in apps %}
<div class="app-card" onclick="window.location.href='/app/{{ app.id }}'">
<div class="app-icon">
{% if 'http' in app.icon_path %}
<img data-src="{{ app.icon_path }}" alt="{{ app.name }}" src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7">
{% else %}
<img data-src="{{ url_for('static', filename='uploads/' + app.icon_path) }}" alt="{{ app.name }}" src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7">
{% endif %}
</div>
<div class="app-info">
<h3>{{ app.name }}</h3>
</div>
</div>
{% endfor %}
</div>
</div>
<style>
.category-page {
max-width: 1200px;
margin: 0 auto;
padding: 60px 15px 20px 15px;
transform: none !important;
transition: background-color 0.3s ease;
}
.header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 30px;
background: white;
padding: 5px 20px;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
transform: none !important;
transition: background-color 0.3s ease, color 0.3s ease;
}
.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;
flex-shrink: 0;
transition: background-color 0.3s ease, color 0.3s ease;
}
.back-link:hover {
background: #e5e5e7;
}
.header-title {
flex: 1;
transform: none !important;
transition: none !important;
}
.header h1 {
margin: 0;
font-size: 20px;
color: #333;
font-weight: 500;
font-family: "SimHei", "黑体", sans-serif;
text-align: left;
padding-right: auto;
margin-right: auto;
transform: none !important;
transition: none !important;
}
.platform-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
background: #007AFF;
color: white;
border-radius: 12px;
font-size: 12px;
margin-left: 10px;
transform: none !important;
transition: none !important;
}
.header-title {
display: flex;
align-items: center;
transform: none !important;
transition: none !important;
}
/* 暗色模式样式 */
[data-theme="dark"] .category-page {
background: #1a1a1a;
}
[data-theme="dark"] .header {
background: #242424;
}
[data-theme="dark"] .header h1 {
color: #fff;
}
[data-theme="dark"] .back-link {
color: #ccc;
background: #333;
}
[data-theme="dark"] .back-link:hover {
background: #444;
color: #fff;
}
[data-theme="dark"] .app-card {
background: #242424;
}
[data-theme="dark"] .app-info h3 {
color: #fff;
}
/* 添加全局过渡效果 */
* {
transition: background-color 0.3s ease, color 0.3s ease;
}
/* 确保 apps-grid 也有过渡效果 */
.apps-grid {
transition: background-color 0.3s ease;
background: #fff;
}
[data-theme="dark"] .apps-grid {
background: #242424;
}
.floating-buttons {
position: fixed;
bottom: 40px;
right: 20px;
z-index: 1000;
display: flex;
flex-direction: column;
gap: 10px;
}
.theme-toggle {
width: 40px;
height: 40px;
border-radius: 50%;
border: none;
background: #fff;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s ease;
padding: 0;
}
.theme-toggle:hover {
transform: scale(1.1);
}
/* 暗色模式样式 */
[data-theme="dark"] .theme-toggle {
background: #333;
color: #fff;
}
/* 响应式调整 */
@media (max-width: 768px) {
.floating-buttons {
bottom: 30px;
right: 15px;
gap: 8px;
}
.theme-toggle {
width: 36px;
height: 36px;
}
}
/* 添加图片渐显效果 */
.app-icon img {
opacity: 0;
transition: opacity 0.3s ease;
}
.app-icon img.loaded {
opacity: 1;
}
</style>
<script>
// 保存滚动位置和来源页面
window.addEventListener('beforeunload', () => {
sessionStorage.setItem('categoryScrollPosition', window.scrollY);
// 保存来源页面的路径
if (document.referrer) {
sessionStorage.setItem('categoryReferer', document.referrer);
}
});
// 恢复滚动位置
document.addEventListener('DOMContentLoaded', () => {
// 检查是否是从应用详情页返回
if (document.referrer.includes('/app/')) {
const scrollPosition = sessionStorage.getItem('categoryScrollPosition');
if (scrollPosition) {
window.scrollTo(0, parseInt(scrollPosition));
sessionStorage.removeItem('categoryScrollPosition');
}
}
// 优化图片懒加载
const imageObserver = new IntersectionObserver((entries, observer) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target;
// 预加载图片
const tempImage = new Image();
tempImage.onload = () => {
img.src = tempImage.src;
img.classList.add('loaded');
};
tempImage.src = img.dataset.src;
observer.unobserve(img);
}
});
}, {
rootMargin: '100px 0px', // 增加预加载距离
threshold: 0.01
});
// 批量处理图片
const images = document.querySelectorAll('img[data-src]');
if (images.length > 0) {
images.forEach(img => imageObserver.observe(img));
}
});
// 修改返回链接的处理
document.querySelector('.back-link').addEventListener('click', function(e) {
e.preventDefault();
window.history.back();
});
</script>
{% endblock %}

475
templates/coming.html Executable file
View File

@@ -0,0 +1,475 @@
{% extends "base.html" %}
{% block content %}
<div class="coming-apps-page">
<div id="toast" class="developing-tip">
<span id="toastMessage"></span>
</div>
<div class="header">
<a href="{{ url_for('explore') }}" class="back-link" title="返回探索">
<i class="fas fa-arrow-left"></i>
</a>
<h1>即将上线</h1>
</div>
<div class="apps-grid">
{% if coming_apps %}
{% for app in coming_apps %}
<div class="app-tile">
<div class="app-tile-content">
<div class="app-tile-icon">
{% if 'http' in app.icon_path %}
<img src="{{ app.icon_path }}" alt="{{ app.name }}">
{% else %}
<img src="{{ url_for('static', filename='uploads/' + app.icon_path) }}" alt="{{ app.name }}">
{% endif %}
</div>
<div class="app-tile-info">
<div class="app-tile-header">
<h3>{{ app.name }}</h3>
<button class="add-reminder-btn" onclick="toggleWishlist('{{ app.name }}')" title="添加提醒" data-app-name="{{ app.name }}">
<i class="fas fa-bell"></i>
</button>
</div>
<div class="app-tile-meta">
<span class="coming-soon-tag">即将上线</span>
</div>
</div>
</div>
</div>
{% endfor %}
{% else %}
<div class="no-apps-message">暂无即将上线应用</div>
{% endif %}
</div>
</div>
<style>
.coming-apps-page {
max-width: 1200px;
margin: 0 auto;
padding: 60px 15px 20px 15px;
transform: none !important;
transition: none !important;
}
.header {
display: flex;
align-items: center;
gap: 15px;
margin-bottom: 30px;
background: white;
padding: 10px 15px;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
transform: none !important;
transition: none !important;
}
.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;
transform: none !important;
transition: none !important;
}
.back-link:hover {
background: #e5e5e7;
color: #333;
}
.header h1 {
margin: 0;
font-size: 20px;
color: #333;
font-weight: 500;
font-family: "SimHei", "黑体", sans-serif;
transform: none !important;
transition: none !important;
}
.apps-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 15px;
}
.app-tile {
background: #fff;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.app-tile-content {
padding: 15px;
display: flex;
gap: 12px;
position: relative;
}
.app-tile-icon {
width: 64px;
height: 64px;
flex-shrink: 0;
}
.app-tile-icon img {
width: 100%;
height: 100%;
border-radius: 16px;
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;
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.app-tile-header h3 {
margin: 0 0 6px 0;
font-size: 16px;
color: #333;
font-weight: 500;
}
.coming-soon-tag {
display: inline-block;
font-size: 12px;
color: #0066cc;
background: rgba(0, 102, 204, 0.1);
padding: 2px 8px;
border-radius: 12px;
}
.no-apps-message {
grid-column: 1 / -1;
text-align: center;
padding: 40px;
color: #666;
background: #fff;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.app-tile-meta {
display: flex;
align-items: center;
gap: 10px;
}
.add-reminder-btn {
border: none;
background: rgba(0, 102, 204, 0.1);
color: #0066cc;
width: 32px;
height: 32px;
border-radius: 50%;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s ease;
margin-left: 8px;
flex-shrink: 0;
}
.add-reminder-btn:hover {
background: rgba(0, 102, 204, 0.2);
color: #0066cc;
transform: scale(1.05);
}
.add-reminder-btn.active {
background: #0066cc;
color: white;
}
.add-reminder-btn.active:hover {
background: #0052a3;
color: white;
}
.add-reminder-btn i {
font-size: 14px;
}
.developing-tip {
position: fixed;
bottom: 100px;
left: 50%;
transform: translateX(-50%);
background: rgba(0, 0, 0, 0.8);
color: white;
padding: 10px 20px;
border-radius: 25px;
font-size: 14px;
z-index: 1000;
opacity: 0;
transition: opacity 0.3s ease, transform 0.3s ease;
pointer-events: none;
transform: translate(-50%, 20px);
}
[data-theme="dark"] .developing-tip {
background: rgba(255, 255, 255, 0.9);
color: black;
}
.developing-tip.show {
opacity: 1;
transform: translate(-50%, 0);
}
@keyframes tipAppear {
0% {
opacity: 0;
transform: translate(-50%, 20px);
}
20% {
opacity: 1;
transform: translate(-50%, 0);
}
80% {
opacity: 1;
transform: translate(-50%, 0);
}
100% {
opacity: 0;
transform: translate(-50%, -20px);
}
}
.developing-tip.show {
animation: tipAppear 2s ease forwards;
}
/* 暗色模式样式 */
[data-theme="dark"] .add-reminder-btn {
background: rgba(94, 158, 255, 0.1);
color: #5E9EFF;
}
[data-theme="dark"] .add-reminder-btn:hover {
background: rgba(94, 158, 255, 0.2);
color: #5E9EFF;
}
[data-theme="dark"] .add-reminder-btn.active {
background: #5E9EFF;
color: white;
}
[data-theme="dark"] .add-reminder-btn.active:hover {
background: #4b8fef;
color: white;
}
[data-theme="dark"] .coming-apps-page {
background: #1a1a1a;
}
[data-theme="dark"] .header {
background: #242424;
}
[data-theme="dark"] .header h1 {
color: #fff;
}
[data-theme="dark"] .back-link {
color: #ccc;
background: #333;
}
[data-theme="dark"] .back-link:hover {
background: #444;
color: #fff;
}
[data-theme="dark"] .app-tile {
background: #242424;
}
[data-theme="dark"] .app-tile-header h3 {
color: #fff;
}
[data-theme="dark"] .no-apps-message {
background: #242424;
color: #ccc;
}
[data-theme="dark"] .coming-soon-tag {
background: rgba(94, 158, 255, 0.1);
color: #5E9EFF;
}
@media (max-width: 768px) {
.apps-grid {
grid-template-columns: 1fr;
}
}
</style>
<script>
// 存储心愿单应用列表
let wishlistApps = new Set();
// 加载心愿单
function loadWishlist() {
fetch('/user/wishlist/list')
.then(response => response.json())
.then(data => {
if (data.success) {
// 清空并重新填充心愿单集合
wishlistApps.clear();
data.items.forEach(item => wishlistApps.add(item.app_name));
// 更新所有按钮状态
updateAllButtonStates();
}
})
.catch(error => {
console.error('Load wishlist failed:', error);
});
}
// 更新所有按钮状态
function updateAllButtonStates() {
document.querySelectorAll('.add-reminder-btn').forEach(btn => {
const appName = btn.dataset.appName;
updateButtonState(btn, wishlistApps.has(appName));
});
}
// 更新单个按钮状态
function updateButtonState(button, isActive) {
const icon = button.querySelector('i');
if (isActive) {
button.classList.add('active');
button.title = '取消提醒';
icon.className = 'fas fa-check-circle';
} else {
button.classList.remove('active');
button.title = '添加提醒';
icon.className = 'fas fa-bell';
}
}
// 切换心愿单状态
function toggleWishlist(appName) {
const button = document.querySelector(`.add-reminder-btn[data-app-name="${appName}"]`);
const isRemoving = wishlistApps.has(appName);
if (isRemoving) {
// 获取项目ID并删除
fetch('/user/wishlist/list')
.then(response => response.json())
.then(data => {
if (data.success) {
const item = data.items.find(item => item.app_name === appName);
if (item) {
deleteFromWishlist(item.id);
}
}
});
} else {
addToWishlist(appName);
}
}
// 添加到心愿单
function addToWishlist(appName) {
fetch('/user/wishlist/add', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
app_name: appName
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
wishlistApps.add(appName);
updateAllButtonStates();
showToast('添加成功');
} else {
if (data.error.includes('邀请3位好友')) {
showToast('当前最多添加5个应用邀请3位好友可提升至20个');
} else if (data.error.includes('已达到心愿单上限')) {
showToast('已达到心愿单上限20个');
} else {
showToast(data.error || '添加失败');
}
}
})
.catch(error => {
console.error('Add to wishlist failed:', error);
showToast('添加失败,请稍后重试');
});
}
// 从心愿单中删除
function deleteFromWishlist(itemId) {
fetch(`/user/wishlist/delete/${itemId}`, {
method: 'POST'
})
.then(response => response.json())
.then(data => {
if (data.success) {
loadWishlist(); // 重新加载心愿单以更新状态
showToast('已取消提醒');
} else {
showToast(data.error || '取消失败');
}
})
.catch(error => {
console.error('Delete from wishlist failed:', error);
showToast('取消失败,请稍后重试');
});
}
// Toast 提示函数
function showToast(message) {
const toast = document.getElementById('toast');
const toastMessage = document.getElementById('toastMessage');
toastMessage.textContent = message;
toast.classList.add('show');
setTimeout(() => {
toast.classList.remove('show');
}, 2000);
}
// 返回按钮处理
document.querySelector('.back-link').addEventListener('click', function(e) {
e.preventDefault();
window.history.back();
});
// 页面加载时获取心愿单
document.addEventListener('DOMContentLoaded', loadWishlist);
</script>
{% endblock %}

187
templates/cookie_policy.html Executable file
View File

@@ -0,0 +1,187 @@
{% extends "policy_base.html" %}
{% block policy_header %}Cookie 政策{% endblock %}
{% block policy_icon %}fas fa-cookie-bite{% endblock %}
{% block policy_title %}Cookie 政策{% endblock %}
{% block policy_content %}
<div class="policy-update-date">
<p>最近更新2025年1月15日</p>
</div>
<section>
<h2>我们如何使用 Cookie</h2>
<p>我们启用 Cookie 的目的在于改善用户体验,具体包括:</p>
<ul>
<li>存储偏好设置:保存您的主题模式(明亮/暗黑)等浏览偏好</li>
<li>提供基本功能:确保网站的核心功能正常运行,如应用展示和搜索</li>
<li>统计分析:收集匿名的使用数据,如访问次数和浏览习惯,帮助我们改进网站</li>
<li>性能优化:记录您的浏览偏好,提供更流畅的访问体验</li>
<li>保护安全:帮助识别异常访问行为,保护网站安全</li>
</ul>
</section>
<section>
<h2>Cookie 类型</h2>
<h3>必要的 Cookie</h3>
<p>这些 Cookie 对于网站的正常运行是必需的,不能在我们的系统中关闭。它们通常用于维持网站的基本功能,如页面展示和主题切换等。</p>
<h3>分析 Cookie</h3>
<p>这些 Cookie 帮助我们了解访问者如何使用网站,收集匿名统计数据。这些信息帮助我们:</p>
<ul>
<li>了解访问者如何到达我们的网站</li>
<li>监控网站性能和响应时间</li>
<li>了解最受欢迎的应用和功能</li>
<li>发现并解决可能的问题</li>
</ul>
</section>
<section>
<h2>其他追踪技术</h2>
<h3>网站信标/像素标签</h3>
<p>除 Cookie 外,我们还可能在网站上使用网站信标或像素标签。这些通常是嵌入到网站或电子邮件中的电子图像,可用于:</p>
<ul>
<li>在您查看网页或电子邮件时识别您的设备</li>
<li>分析服务使用情况(与 Cookie 配合使用)</li>
<li>提供更符合您需求的内容</li>
<li>了解电子邮件是否被打开</li>
</ul>
<h3>本地存储</h3>
<p>我们可能在某些服务中使用其他本地存储技术,例如:</p>
<ul>
<li>本地共享对象Flash Cookie</li>
<li>HTML5 本地存储</li>
</ul>
<p>这些技术与 Cookie 类似,但可能使用不同的存储机制。</p>
</section>
<section>
<h2>什么是 Cookie</h2>
<p>Cookie 是一种网络服务器存储在计算机或移动设备上的小型文本文件。它们被广泛用于使网站能够更高效地运行并为您提供更好的用户体验。Cookie 可能来自我们的网站(第一方 Cookie或来自其他网站第三方 Cookie</p>
<h3>Cookie 的类型</h3>
<ul>
<li>会话 Cookie临时性的在您关闭浏览器后会被删除</li>
<li>持久性 Cookie保存在您的设备上直到过期或被您删除</li>
<li>必要性 Cookie网站基本功能所必需的</li>
<li>功能性 Cookie用于记住您的偏好设置</li>
<li>分析性 Cookie用于改进网站性能和用户体验</li>
</ul>
</section>
<section>
<h2>如何管理 Cookie</h2>
<p>您可以通过浏览器设置来管理 Cookie。常见的控制方式包括</p>
<ul>
<li>查看已存储的 Cookie</li>
<li>删除特定的 Cookie</li>
<li>阻止网站设置 Cookie</li>
<li>退出特定类型的 Cookie</li>
</ul>
<h3>主流浏览器的 Cookie 设置方法:</h3>
<ul>
<li>Chrome设置 → 隐私设置和安全性 → Cookie 和其他网站数据</li>
<li>Firefox选项 → 隐私与安全 → Cookie 和网站数据</li>
<li>Safari偏好设置 → 隐私 → Cookie 和网站数据</li>
<li>Edge设置 → Cookie 和网站权限</li>
</ul>
</section>
<section>
<h2>Cookie 的安全性</h2>
<p>我们采取以下措施来确保 Cookie 的安全使用:</p>
<ul>
<li>仅收集必要的信息,不包含个人敏感数据</li>
<li>采用加密技术保护 Cookie 数据</li>
<li>定期审查和更新 Cookie 政策</li>
<li>提供清晰的 Cookie 管理选项</li>
<li>遵守相关的数据保护法规</li>
</ul>
<h3>第三方 Cookie</h3>
<p>我们的网站可能使用第三方服务,这些服务可能会设置它们自己的 Cookie。这些第三方 Cookie 主要用于:</p>
<ul>
<li>网站访问统计和分析</li>
<li>改善用户体验</li>
<li>社交媒体功能</li>
</ul>
<p>我们无法直接控制第三方 Cookie建议您查看这些第三方的隐私政策了解更多信息。</p>
</section>
<section>
<h2>Do Not Track请勿追踪</h2>
<p>很多网络浏览器均设有 Do Not Track 功能,该功能可向网站发布 Do Not Track 请求。目前,主要互联网标准组织尚未设立相关政策来规定网站应如何应对此类请求。</p>
<p>我们目前没有根据"请勿跟踪"设置改变数据收集和使用方式,但我们保留在今后修改数据处理方式的权利,如有变更,我们会及时更新本政策。</p>
</section>
<section>
<h2>Cookie 使用声明</h2>
<p>NEXT Store 使用 Cookie 和类似技术的目的是提供更安全、更个性化的用户体验。我们致力于通过先进的技术手段,在保护用户隐私的同时,提供更优质的服务。</p>
<h3>Cookie 管理原则</h3>
<ul>
<li>最小必要:仅收集必要信息</li>
<li>用户选择:提供灵活的 Cookie 控制</li>
<li>安全加密:保护 Cookie 数据安全</li>
<li>透明公开:清晰说明 Cookie 使用方式</li>
</ul>
</section>
<section>
<h2>Cookie 安全与隐私保护</h2>
<p>我们采取以下措施保护通过 Cookie 收集的信息:</p>
<ul>
<li>使用行业标准加密技术</li>
<li>定期安全审计</li>
<li>限制 Cookie 数据访问权限</li>
<li>及时更新安全策略</li>
</ul>
</section>
<section>
<h2>更新和联系</h2>
<p>我们可能会不时更新此 Cookie 政策以反映我们的实践变化和法律要求。任何更改都将在此页面上发布,重大更改时我们会通过适当方式通知您。</p>
<p>如果您对我们的 Cookie 使用有任何疑问,请通过网站提供的联系方式与我们联系。</p>
</section>
<style>
/* 美化标题和图标 */
section h2 {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 20px;
}
section h2 i {
font-size: 1.2em;
color: #007AFF;
}
section h3 {
display: flex;
align-items: center;
gap: 8px;
margin: 20px 0 12px;
}
section h3 i {
font-size: 1em;
color: #666;
}
/* 暗色模式适配 */
[data-theme="dark"] section h2 i {
color: #3a9fff;
}
[data-theme="dark"] section h3 i {
color: #999;
}
</style>
{% endblock %}

246
templates/credits_management.html Executable file
View File

@@ -0,0 +1,246 @@
{% extends "base.html" %}
{% block content %}
{% include 'admin_nav.html' %}
<div class="admin-container">
<div class="admin-content">
<div class="admin-card">
<div class="card-header">
<div class="header-left">
<i class="fas fa-award"></i>
<h3>鸣谢管理</h3>
</div>
</div>
<form onsubmit="submitAddCreditForm(event, this)" class="admin-form" enctype="multipart/form-data">
<div class="form-group">
<label for="credit-name">姓名</label>
<input type="text" id="credit-name" name="name" placeholder="请输入姓名" required>
</div>
<div class="form-group">
<label for="credit-avatar">头像</label>
<div class="file-input-wrapper">
<input type="file" id="credit-avatar" name="avatar" accept="image/*">
<label for="credit-avatar" class="file-input-label">
<i class="fas fa-cloud-upload-alt"></i>
<span>选择文件</span>
</label>
</div>
<div class="form-group">
<label for="avatar-url">或输入头像URL</label>
<input type="url" id="avatar-url" name="avatar_url" placeholder="http://example.com/avatar.png">
</div>
</div>
<div class="form-group">
<label for="credit-link">链接</label>
<input type="url" id="credit-link" name="link" placeholder="http://example.com">
</div>
<button type="submit" class="btn-primary">
<i class="fas fa-plus"></i> 添加鸣谢
</button>
</form>
<div class="credits-list">
{% for credit in credits %}
<div class="credit-item">
<img src="{{ credit.avatar_url }}" alt="{{ credit.name }}" class="credit-avatar">
<div class="credit-info">
<h4>{{ credit.name }}</h4>
<a href="{{ credit.link }}" target="_blank">{{ credit.link }}</a>
</div>
<div class="credit-controls">
<button onclick="editCredit({{ credit.id }}, '{{ credit.name }}', '{{ credit.avatar_url }}', '{{ credit.link }}')" class="btn-edit">
<i class="fas fa-edit"></i>
</button>
<button onclick="deleteCredit({{ credit.id }})" class="btn-delete">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
{% endfor %}
</div>
</div>
</div>
</div>
<script>
function submitAddCreditForm(event, form) {
event.preventDefault();
const formData = new FormData(form);
fetch('{{ url_for("add_credit") }}', {
method: 'POST',
body: formData
})
.then(response => response.json())
.then(data => {
if (data.success) {
showNotification(data.message, 'success');
form.reset();
location.reload();
} else {
showNotification(data.error, 'error');
}
})
.catch(error => {
showNotification('操作失败,请重试', 'error');
console.error('Error:', error);
});
}
function editCredit(id, name, avatarUrl, link) {
const form = document.querySelector('.admin-form');
form.querySelector('#credit-name').value = name;
form.querySelector('#credit-link').value = link;
form.querySelector('#avatar-url').value = avatarUrl;
form.querySelector('#credit-avatar').value = ''; // 清空文件输入
form.onsubmit = function(event) {
event.preventDefault();
const formData = new FormData(form);
formData.append('id', id);
fetch('{{ url_for("edit_credit") }}', {
method: 'POST',
body: formData
})
.then(response => response.json())
.then(data => {
if (data.success) {
showNotification(data.message, 'success');
location.reload();
} else {
showNotification(data.error, 'error');
}
})
.catch(error => {
showNotification('操作失败,请重试', 'error');
console.error('Error:', error);
});
};
}
function deleteCredit(id) {
if (confirm('确定要删除这个鸣谢吗?')) {
fetch(`{{ url_for('delete_credit', credit_id=0) }}`.replace('0', id), {
method: 'POST'
})
.then(response => response.json())
.then(data => {
if (data.success) {
showNotification(data.message, 'success');
location.reload();
} else {
showNotification(data.error, 'error');
}
})
.catch(error => {
showNotification('删除失败,请重试', 'error');
console.error('Error:', error);
});
}
}
function showNotification(message, type = 'success') {
const notification = document.createElement('div');
notification.className = `notification ${type}`;
notification.textContent = message;
Object.assign(notification.style, {
position: 'fixed',
bottom: '20px',
right: '20px',
padding: '10px 20px',
borderRadius: '4px',
backgroundColor: type === 'success' ? '#4CAF50' : '#f44336',
color: 'white',
zIndex: '1000',
opacity: '0',
transform: 'translateY(20px)',
transition: 'all 0.3s ease'
});
document.body.appendChild(notification);
notification.offsetHeight;
notification.style.opacity = '1';
notification.style.transform = 'translateY(0)';
setTimeout(() => {
notification.style.opacity = '0';
notification.style.transform = 'translateY(20px)';
setTimeout(() => notification.remove(), 300);
}, 3000);
}
</script>
<style>
.credits-list {
margin-top: 20px;
}
.credit-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 20px;
border: 1px solid #e0e0e0;
border-radius: 8px;
margin-bottom: 10px;
background: #fff;
width: 100%;
box-sizing: border-box;
}
.credit-avatar {
width: 60px;
height: 60px;
border-radius: 50%;
object-fit: cover;
margin-right: 20px;
}
.credit-info {
flex: 1;
margin-right: 20px;
}
.credit-info h4 {
margin: 0;
font-size: 18px;
color: #333;
}
.credit-info a {
font-size: 16px;
color: #007AFF;
text-decoration: none;
word-break: break-all;
}
.credit-info a:hover {
text-decoration: underline;
}
.credit-controls {
display: flex;
gap: 15px;
}
.btn-edit, .btn-delete {
background: none;
border: none;
cursor: pointer;
color: #666;
transition: color 0.3s ease;
}
.btn-edit:hover {
color: #007AFF;
}
.btn-delete:hover {
color: #ff3b30;
}
</style>
{% endblock %}

907
templates/donate_settings.html Executable file
View File

@@ -0,0 +1,907 @@
{% extends "base.html" %}
{% block content %}
{% include 'admin_nav.html' %}
<div class="admin-container">
<div class="admin-content">
<div class="admin-card">
<div class="card-header">
<div class="header-left">
<i class="fas fa-cog"></i>
<h3>赞赏设置</h3>
</div>
</div>
<form onsubmit="submitDonateSettings(event)" class="admin-form" enctype="multipart/form-data">
<div class="form-group">
<label class="switch-label">
<span>赞赏功能</span>
<label class="switch">
<input type="checkbox" id="donate-enabled" name="donate_enabled"
{% if settings.donate_enabled == '1' %}checked{% endif %}>
<span class="slider round"></span>
</label>
</label>
</div>
<div class="form-group">
<label for="donate-text">赞赏文字</label>
<input type="text" id="donate-text" name="donate_text"
value="{{ settings.donate_text }}" required>
</div>
<div class="form-group">
<label for="donate-image">赞赏二维码</label>
<div class="file-input-wrapper">
<input type="file" id="donate-image" name="donate_image" accept="image/*">
<label for="donate-image" class="file-input-label">
<i class="fas fa-cloud-upload-alt"></i>
<span>选择文件</span>
</label>
</div>
{% if settings.donate_image %}
<div class="current-image">
<img src="{{ settings.donate_image }}" alt="当前赞赏二维码">
</div>
{% endif %}
</div>
<div class="form-group">
<label for="donate-note">赞赏说明</label>
<input type="text" id="donate-note" name="donate_note"
value="{{ settings.donate_note }}" placeholder="显示在二维码下方的说明文字">
</div>
<button type="submit" class="btn-primary">
<i class="fas fa-save"></i> 保存设置
</button>
</form>
</div>
<!-- 添加赞赏人卡片 -->
<div class="admin-card">
<div class="card-header">
<div class="header-left">
<i class="fas fa-user-plus"></i>
<h3>添加赞赏人</h3>
</div>
</div>
<form onsubmit="submitDonor(event)" class="admin-form">
<div class="form-group">
<label for="donor-name">赞赏人</label>
<input type="text" id="donor-name" name="name" required>
</div>
<div class="form-group">
<label for="donor-amount">赞赏金额</label>
<input type="text" id="donor-amount" name="amount" required>
</div>
<div class="form-group">
<label for="donor-link">个人链接</label>
<input type="url" id="donor-link" name="link" placeholder="http://...">
</div>
<div class="form-group">
<label for="donor-message">留言</label>
<textarea id="donor-message" name="message" rows="3" placeholder="赞赏留言..."></textarea>
</div>
<button type="submit" class="btn-primary">
<i class="fas fa-plus"></i> 添加赞赏人
</button>
</form>
</div>
<!-- 赞赏人列表卡片 -->
<div class="admin-card">
<div class="card-header">
<div class="header-left">
<i class="fas fa-list"></i>
<h3>赞赏人列表</h3>
<div class="search-box">
<input type="text" id="donor-search" placeholder="搜索赞赏人...">
</div>
</div>
<div class="header-right">
<div class="total-amount">
总金额:<span>¥{{ total_amount|default('0.00') }}</span>
</div>
</div>
</div>
<div class="donors-list">
{% for donor in donors %}
<div class="donor-item" data-donor-id="{{ donor.id }}">
<div class="donor-info">
<span class="donor-name">{{ donor.name }}</span>
<span class="donor-amount">{{ donor.amount }}</span>
{% if donor.message %}
<span class="donor-message">{{ donor.message }}</span>
{% endif %}
{% if donor.link %}
<span class="donor-link">
<a href="{{ donor.link }}" target="_blank">
<i class="fas fa-link"></i>
</a>
</span>
{% endif %}
</div>
<div class="donor-controls">
<button onclick="editDonor({{ donor.id }})" class="btn-edit">
<i class="fas fa-edit"></i>
</button>
<button onclick="deleteDonor({{ donor.id }})" class="btn-delete">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
{% endfor %}
</div>
</div>
</div>
</div>
<!-- 添加编辑模态框 -->
<div id="edit-donor-modal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h3>编辑赞赏人</h3>
<span class="close">&times;</span>
</div>
<form id="edit-donor-form" class="admin-form">
<input type="hidden" id="edit-donor-id">
<div class="form-group">
<label for="edit-donor-name">赞赏人</label>
<input type="text" id="edit-donor-name" name="name" required>
</div>
<div class="form-group">
<label for="edit-donor-amount">赞赏金额</label>
<input type="text" id="edit-donor-amount" name="amount" required>
</div>
<div class="form-group">
<label for="edit-donor-link">个人链接</label>
<input type="url" id="edit-donor-link" name="link" placeholder="http://...">
</div>
<div class="form-group">
<label for="edit-donor-message">留言</label>
<textarea id="edit-donor-message" name="message" rows="3"></textarea>
</div>
<button type="submit" class="btn-primary">
<i class="fas fa-save"></i> 保存修改
</button>
</form>
</div>
</div>
<script>
function submitDonateSettings(event) {
event.preventDefault();
const form = event.target;
const formData = new FormData(form);
// 添加开关状态
formData.set('donate_enabled', form.querySelector('#donate-enabled').checked ? '1' : '0');
fetch('{{ url_for("update_donate_settings") }}', {
method: 'POST',
body: formData
})
.then(response => response.json())
.then(data => {
if (data.success) {
showNotification('设置已更新', 'success');
} else {
showNotification(data.error || '更新失败', 'error');
}
})
.catch(error => {
console.error('Error:', error);
showNotification('更新失败,请重试', 'error');
});
}
function updatePreview(text, imageUrl) {
const preview = document.querySelector('.donate-preview');
preview.querySelector('.donate-text').textContent = text;
if (imageUrl) {
let img = preview.querySelector('.donate-qr');
if (!img) {
img = document.createElement('img');
img.className = 'donate-qr';
preview.appendChild(img);
}
img.src = imageUrl;
img.alt = '赞赏二维码';
}
}
function showNotification(message, type = 'success') {
const notification = document.createElement('div');
notification.className = `notification ${type}`;
// 添加图标
const icon = document.createElement('i');
icon.className = type === 'success' ? 'fas fa-check-circle' : 'fas fa-exclamation-circle';
notification.appendChild(icon);
// 添加消息文本
const text = document.createElement('span');
text.textContent = message;
notification.appendChild(text);
// 添加关闭按钮
const closeBtn = document.createElement('i');
closeBtn.className = 'fas fa-times close-notification';
closeBtn.onclick = () => closeNotification(notification);
notification.appendChild(closeBtn);
document.body.appendChild(notification);
// 确保动画正常执行
notification.style.opacity = '0';
notification.style.transform = 'translateY(20px)';
// 强制重排,确保动画生效
notification.offsetHeight;
// 显示通知
notification.style.opacity = '1';
notification.style.transform = 'translateY(0)';
// 延迟关闭通知
const timer = setTimeout(() => {
closeNotification(notification);
}, 2000);
// 鼠标悬停时暂停关闭计时
notification.addEventListener('mouseenter', () => {
clearTimeout(timer);
});
// 鼠标离开时重新开始计时
notification.addEventListener('mouseleave', () => {
setTimeout(() => {
closeNotification(notification);
}, 1000);
});
}
// 修改关闭通知函数
function closeNotification(notification) {
// 添加关闭动画
notification.style.opacity = '0';
notification.style.transform = 'translateY(20px)';
// 等待动画完成后移除元素
setTimeout(() => {
if (notification && notification.parentElement) {
notification.remove();
}
}, 300);
}
// 添加赞赏人
function submitDonor(event) {
event.preventDefault();
const form = event.target;
const formData = new FormData(form);
fetch('{{ url_for("add_donor") }}', {
method: 'POST',
body: formData
})
.then(response => response.json())
.then(data => {
if (data.success) {
showNotification('赞赏人添加成功', 'success');
form.reset();
location.reload();
} else {
showNotification(data.error || '添加失败', 'error');
}
})
.catch(error => {
console.error('Error:', error);
showNotification('添加失败,请重试', 'error');
});
}
// 删除赞赏人
function deleteDonor(id) {
if (confirm('确定要删除这条赞赏记录吗?')) {
fetch(`{{ url_for('delete_donor', donor_id=0) }}`.replace('0', id), {
method: 'POST'
})
.then(response => response.json())
.then(data => {
if (data.success) {
showNotification('赞赏记录已删除', 'success');
location.reload();
} else {
showNotification(data.error || '删除失败', 'error');
}
})
.catch(error => {
console.error('Error:', error);
showNotification('删除失败,请重试', 'error');
});
}
}
function updateDonateEnabled(enabled) {
fetch('{{ url_for("update_donate_settings") }}', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: `donate_enabled=${enabled ? '1' : '0'}`
})
.then(response => response.json())
.then(data => {
if (data.success) {
showNotification('设置已更新', 'success');
} else {
showNotification(data.error || '更新失败', 'error');
}
})
.catch(error => {
console.error('Error:', error);
showNotification('更新失败,请重试', 'error');
});
}
// 搜索功能
document.getElementById('donor-search').addEventListener('input', function(e) {
const searchText = e.target.value.toLowerCase();
const donors = document.querySelectorAll('.donor-item');
donors.forEach(donor => {
const name = donor.querySelector('.donor-name').textContent.toLowerCase();
const message = donor.querySelector('.donor-message')?.textContent.toLowerCase() || '';
if (name.includes(searchText) || message.includes(searchText)) {
donor.style.display = '';
} else {
donor.style.display = 'none';
}
});
});
// 编辑功能
function editDonor(id) {
const modal = document.getElementById('edit-donor-modal');
const donorItem = document.querySelector(`.donor-item[data-donor-id="${id}"]`);
// 填充表单
document.getElementById('edit-donor-id').value = id;
document.getElementById('edit-donor-name').value = donorItem.querySelector('.donor-name').textContent;
document.getElementById('edit-donor-amount').value = donorItem.querySelector('.donor-amount').textContent;
document.getElementById('edit-donor-link').value = donorItem.querySelector('.donor-link a')?.href || '';
document.getElementById('edit-donor-message').value = donorItem.querySelector('.donor-message')?.textContent || '';
modal.style.display = 'block';
}
// 关闭模态框
document.querySelector('.close').addEventListener('click', function() {
document.getElementById('edit-donor-modal').style.display = 'none';
});
// 提交编辑表单
document.getElementById('edit-donor-form').addEventListener('submit', function(e) {
e.preventDefault();
const id = document.getElementById('edit-donor-id').value;
const formData = new FormData(this);
fetch(`/admin/edit_donor/${id}`, {
method: 'POST',
body: formData
})
.then(response => response.json())
.then(data => {
if (data.success) {
showNotification('赞赏信息已更新', 'success');
document.getElementById('edit-donor-modal').style.display = 'none';
location.reload();
} else {
showNotification(data.error || '修改失败', 'error');
}
})
.catch(error => {
console.error('Error:', error);
showNotification('修改失败,请重试', 'error');
});
});
</script>
<style>
.donate-layout {
display: flex;
gap: 40px; /* 增加间距 */
align-items: flex-start;
margin: 0 auto; /* 居中布局 */
max-width: 1000px; /* 限制最大宽度 */
}
.donate-form-section {
width: 50%; /* 固定宽度为50% */
padding-right: 20px; /* 添加右内边距 */
border-right: 1px solid #e0e0e0; /* 添加分隔线 */
}
.preview-section {
width: 50%; /* 固定宽度为50% */
padding-left: 20px; /* 添加左内边距 */
}
.preview-section h4 {
margin: 0 0 15px 0;
color: #333;
font-size: 14px;
text-align: center; /* 居中标题 */
}
.donate-preview {
background: white;
padding: 20px;
border-radius: 8px;
text-align: center;
box-shadow: 0 2px 8px rgba(0,0,0,0.1); /* 添加阴影 */
}
.donate-text {
margin-bottom: 15px;
color: #333;
font-size: 14px;
}
.donate-qr {
max-width: 150px; /* 调整二维码尺寸 */
border-radius: 8px;
margin: 0 auto;
display: block;
}
.admin-form input[type="text"],
.admin-form input[type="url"] {
width: 100%;
padding: 8px;
border: 1px solid #d2d2d7;
border-radius: 6px;
font-size: 14px;
}
.admin-form input:focus {
border-color: #0066cc;
outline: none;
box-shadow: 0 0 0 2px rgba(0,102,204,0.1);
}
.file-input-wrapper {
margin-bottom: 10px;
}
.file-input-label {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
background: #f5f5f7;
border-radius: 6px;
cursor: pointer;
transition: all 0.3s ease;
}
.file-input-label:hover {
background: #e5e5e7;
}
/* 响应式布局 */
@media (max-width: 768px) {
.donate-layout {
flex-direction: column;
gap: 20px;
}
.donate-form-section,
.preview-section {
width: 100%;
padding: 0;
border: none;
}
.preview-section {
margin-top: 20px;
border-top: 1px solid #e0e0e0;
padding-top: 20px;
}
}
/* 添加赞赏人列表样式 */
.donors-list {
margin-top: 20px;
}
.donor-item {
display: flex;
align-items: flex-start;
justify-content: space-between;
padding: 12px;
border: 1px solid #e0e0e0;
border-radius: 8px;
margin-bottom: 10px;
background: #fff;
width: 100%;
box-sizing: border-box;
}
.donor-info {
flex: 1;
margin-right: 15px;
min-width: 0;
overflow: hidden;
}
.donor-name {
font-size: 14px;
color: #333;
margin-bottom: 4px;
}
.donor-amount {
font-size: 14px;
color: #007AFF;
margin-bottom: 4px;
}
.donor-message {
font-size: 13px;
color: #666;
line-height: 1.4;
word-break: break-word;
white-space: pre-wrap;
overflow-wrap: break-word;
max-width: 100%;
display: -webkit-box;
-webkit-box-orient: vertical;
overflow: hidden;
}
.donor-controls {
display: flex;
gap: 8px;
flex-shrink: 0;
align-self: flex-start;
}
.btn-edit, .btn-delete {
background: none;
border: none;
cursor: pointer;
padding: 4px;
transition: all 0.3s ease;
}
.btn-edit {
color: #007AFF;
}
.btn-delete {
color: #ff3b30;
}
.btn-edit:hover {
color: #0056b3;
}
.btn-delete:hover {
color: #dc3545;
}
/* 移动端适配 */
@media (max-width: 768px) {
.donor-item {
padding: 10px;
}
.donor-info {
margin-right: 12px;
}
.donor-name, .donor-amount {
font-size: 13px;
}
.donor-message {
font-size: 12px;
line-height: 1.3;
}
.donor-controls {
gap: 6px;
}
.btn-edit, .btn-delete {
padding: 3px;
}
}
/* 开关样式 */
.switch-label {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 15px;
}
.switch {
position: relative;
display: inline-block;
width: 50px;
height: 24px;
}
.switch input {
opacity: 0;
width: 0;
height: 0;
}
.slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #ccc;
transition: .4s;
border-radius: 24px;
}
.slider:before {
position: absolute;
content: "";
height: 20px;
width: 20px;
left: 2px;
bottom: 2px;
background-color: white;
transition: .4s;
border-radius: 50%;
}
input:checked + .slider {
background-color: #2196F3;
}
input:checked + .slider:before {
transform: translateX(26px);
}
/* 当前图片预览 */
.current-image {
margin-top: 10px;
max-width: 200px;
}
.current-image img {
width: 100%;
height: auto;
border-radius: 8px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 15px;
}
.header-left {
display: flex;
align-items: center;
gap: 15px;
}
.header-left h3 {
margin: 0;
font-size: 16px;
}
.search-box {
margin: 0 0 0 20px; /* 调整左边距 */
}
.search-box input {
padding: 6px 12px;
border: 1px solid #d2d2d7;
border-radius: 6px;
font-size: 14px;
width: 200px;
transition: all 0.3s ease;
}
.search-box input:focus {
border-color: #0066cc;
outline: none;
box-shadow: 0 0 0 2px rgba(0,102,204,0.1);
}
.total-amount {
font-size: 16px;
color: #333;
font-weight: 500;
}
.total-amount span {
color: #007AFF;
margin-left: 5px;
}
textarea {
width: 100%;
padding: 8px;
border: 1px solid #d2d2d7;
border-radius: 6px;
font-size: 14px;
resize: vertical;
min-height: 80px;
}
textarea:focus {
border-color: #0066cc;
outline: none;
box-shadow: 0 0 0 2px rgba(0,102,204,0.1);
}
.donor-message {
color: #666;
font-size: 13px;
font-style: italic;
max-width: 300px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 响应式调整 */
@media (max-width: 768px) {
.donor-info {
flex-direction: column;
gap: 5px;
align-items: flex-start;
}
.donor-message {
max-width: 100%;
}
.total-amount {
font-size: 14px;
}
.header-left {
flex-wrap: wrap;
gap: 10px;
}
.search-box {
margin: 0;
width: 100%;
order: 2; /* 在移动端将搜索框放到下一行 */
}
.search-box input {
width: 100%;
}
.card-header {
flex-direction: column;
align-items: flex-start;
gap: 10px;
}
.header-right {
width: 100%;
}
}
.modal {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0,0,0,0.5);
z-index: 1000;
}
.modal-content {
position: relative;
background: white;
margin: 10% auto;
padding: 20px;
width: 90%;
max-width: 500px;
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.close {
font-size: 24px;
color: #666;
cursor: pointer;
}
.close:hover {
color: #333;
}
/* 添加提示框样式 */
.notification {
position: fixed;
bottom: 20px;
right: 20px;
padding: 12px 24px;
border-radius: 8px;
color: white;
font-size: 14px;
z-index: 1000;
opacity: 0;
transform: translateY(20px);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
display: flex;
align-items: center;
gap: 12px;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
min-width: 280px;
max-width: 400px;
pointer-events: auto;
will-change: transform, opacity;
}
.notification.success {
background: #4CAF50;
}
.notification.error {
background: #f44336;
}
.notification i {
font-size: 16px;
}
.notification .close-notification {
margin-left: auto;
cursor: pointer;
opacity: 0.8;
transition: opacity 0.2s ease;
padding: 4px;
}
.notification .close-notification:hover {
opacity: 1;
}
/* 添加悬停效果 */
.notification:hover {
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(0,0,0,0.2);
}
/* 确保动画在移动端也正常工作 */
@media (max-width: 768px) {
.notification {
width: calc(100% - 32px);
right: 16px;
min-width: 0;
}
}
</style>
{% endblock %}

292
templates/donors.html Executable file
View File

@@ -0,0 +1,292 @@
{% extends "base.html" %}
{% block content %}
<div class="donors-page">
<div class="header">
<a href="{{ url_for('index') }}" class="back-link" title="返回首页">
<i class="fas fa-arrow-left"></i>
</a>
<h1>赞赏名单</h1>
</div>
<div class="donors-grid">
{% for donor in donors %}
<div class="donor-card" {% if donor.link %}onclick="window.open('{{ donor.link }}', '_blank')"{% endif %}>
<div class="donor-info">
<div class="donor-name">{{ donor.name }}</div>
<div class="donor-amount">
{% set amount = donor.amount.replace('¥', '').strip() %}
{% set float_amount = amount|float %}
¥{{ "%.2f"|format(float_amount) }}
</div>
{% if donor.message %}
<div class="message-divider"></div>
<div class="donor-message">{{ donor.message }}</div>
{% endif %}
{% if donor.link %}
<div class="donor-link-indicator">
<i class="fas fa-link"></i>
</div>
{% endif %}
</div>
</div>
{% endfor %}
</div>
</div>
<style>
.donors-page {
max-width: 1200px;
margin: 60px auto 20px;
padding: 0 15px;
}
.header {
display: flex;
align-items: center;
gap: 15px;
margin-bottom: 30px;
background: white;
padding: 10px 15px 10px 15px;
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;
transition: all 0.3s ease;
}
.back-link:hover {
background: #e5e5e7;
color: #333;
}
.header h1 {
margin: 0;
font-size: 20px;
color: #333;
font-weight: 500;
font-family: "SimHei", "黑体", sans-serif;
}
.donors-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 15px;
}
.donor-card {
background: white;
padding: 15px;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
transition: transform 0.3s ease;
position: relative;
}
.donor-card:hover {
transform: translateY(-2px);
}
.donor-info {
display: flex;
flex-direction: column;
gap: 4px;
}
.donor-name {
font-size: 14px;
color: #333;
font-family: "SimHei", "黑体", sans-serif;
}
.donor-amount {
font-size: 14px;
color: #007AFF;
font-family: "SimHei", "黑体", sans-serif;
margin-bottom: 2px;
}
.donor-message {
font-size: 13px;
color: #666;
line-height: 1.3;
word-break: break-word;
font-family: "SimHei", "黑体", sans-serif;
padding-top: 2px;
}
@media (max-width: 768px) {
.donors-grid {
grid-template-columns: repeat(2, 1fr);
gap: 10px;
}
.donor-card {
padding: 12px 12px 10px 12px;
}
.donor-name, .donor-amount {
font-size: 13px;
}
.donor-message {
font-size: 12px;
line-height: 1.2;
}
.donor-link-indicator {
top: 8px;
right: 8px;
font-size: 11px;
}
.message-divider {
margin: 1px 0;
}
.donor-info {
gap: 3px;
}
}
.donor-link-indicator {
position: absolute;
top: 10px;
right: 10px;
color: #007AFF;
font-size: 12px;
opacity: 1;
}
.donor-card[onclick] {
cursor: pointer;
}
.donor-card[onclick]:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
}
.message-divider {
width: 100%;
height: 1px;
background: #e0e0e0;
margin: 2px 0;
}
/* 暗色模式样式 */
[data-theme="dark"] .donors-page {
background: #1a1a1a;
}
[data-theme="dark"] .header {
background: #242424;
}
[data-theme="dark"] .header h1 {
color: #fff;
}
[data-theme="dark"] .back-link {
color: #ccc;
background: #333;
}
[data-theme="dark"] .back-link:hover {
background: #444;
color: #fff;
}
[data-theme="dark"] .donor-card {
background: #242424;
}
[data-theme="dark"] .donor-name {
color: #fff;
}
[data-theme="dark"] .donor-amount {
color: #007AFF;
}
[data-theme="dark"] .donor-message {
color: #ccc;
}
[data-theme="dark"] .message-divider {
background: #333;
}
[data-theme="dark"] .donor-link-indicator {
color: #007AFF;
}
.floating-buttons {
position: fixed;
bottom: 40px;
right: 20px;
z-index: 1000;
display: flex;
flex-direction: column;
gap: 10px;
}
.theme-toggle {
width: 40px;
height: 40px;
border-radius: 50%;
border: none;
background: #fff;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s ease;
padding: 0;
}
.theme-toggle:hover {
transform: scale(1.1);
}
/* 暗色模式样式 */
[data-theme="dark"] .theme-toggle {
background: #333;
color: #fff;
}
/* 响应式调整 */
@media (max-width: 768px) {
.floating-buttons {
bottom: 30px;
right: 15px;
gap: 8px;
}
.theme-toggle {
width: 36px;
height: 36px;
}
}
</style>
<script>
// 修改返回链接的处理
document.querySelector('.back-link').addEventListener('click', function(e) {
e.preventDefault();
window.history.back();
});
</script>
{% endblock %}

165
templates/email_settings.html Executable file
View File

@@ -0,0 +1,165 @@
{% extends "base.html" %}
{% block content %}
{% include 'admin_nav.html' %}
<div class="admin-container">
<div class="admin-content">
<div id="toast" class="toast">
<span id="toastMessage"></span>
</div>
<div class="admin-card">
<div class="card-header">
<div class="header-left">
<i class="fas fa-envelope"></i>
<h3>邮件设置</h3>
</div>
</div>
<form onsubmit="saveEmailSettings(event)" class="admin-form">
<div class="form-group">
<label for="smtp-server">SMTP服务器</label>
<input type="text" id="smtp-server" name="smtp_server"
value="{{ settings.get('smtp_server', '') }}" required>
</div>
<div class="form-group">
<label for="smtp-port">SMTP端口</label>
<input type="number" id="smtp-port" name="smtp_port"
value="{{ settings.get('smtp_port', 465) }}" required>
</div>
<div class="form-group">
<label for="smtp-user">发件人邮箱</label>
<input type="email" id="smtp-user" name="smtp_user"
value="{{ settings.get('smtp_user', '') }}" required>
</div>
<div class="form-group">
<label for="smtp-password">SMTP授权码</label>
<input type="password" id="smtp-password" name="smtp_password"
value="{{ settings.get('smtp_password', '') }}" required>
</div>
<div class="form-group">
<label for="test-email">测试邮箱</label>
<input type="email" id="test-email" name="test_email"
value="{{ settings.get('test_email', '') }}"
placeholder="用于接收测试邮件的邮箱">
</div>
<div class="form-actions">
<button type="submit" class="btn-primary">
<i class="fas fa-save"></i> 保存设置
</button>
<button type="button" class="btn-secondary" onclick="testEmail()">
<i class="fas fa-paper-plane"></i> 发送测试邮件
</button>
</div>
</form>
</div>
</div>
</div>
<style>
/* Toast 提示样式 */
.toast {
position: fixed;
top: 100px;
left: 50%;
transform: translateX(-50%);
background: rgba(0, 0, 0, 0.7);
color: white;
padding: 10px 20px;
border-radius: 4px;
font-size: 14px;
z-index: 1000;
opacity: 0;
visibility: hidden;
transition: all 0.3s ease;
display: flex;
align-items: center;
justify-content: center;
width: auto;
white-space: pre-wrap;
max-width: calc(100% - 40px);
}
.toast.show {
opacity: 1;
visibility: visible;
animation: toastIn 0.3s ease forwards;
}
@keyframes toastIn {
from {
opacity: 0;
transform: translate(-50%, -20px);
}
to {
opacity: 1;
transform: translate(-50%, 0);
}
}
/* 暗色模式适配 */
[data-theme="dark"] .toast {
background: rgba(0, 0, 0, 0.8);
}
</style>
<script>
function showToast(message) {
const toast = document.getElementById('toast');
const toastMessage = document.getElementById('toastMessage');
toastMessage.textContent = message;
toast.classList.add('show');
setTimeout(() => {
toast.classList.remove('show');
}, 2000);
}
function saveEmailSettings(event) {
event.preventDefault();
const form = event.target;
const data = new FormData(form);
fetch('/admin/email/settings', {
method: 'POST',
body: data
})
.then(response => response.json())
.then(data => {
if (data.success) {
showToast('保存成功');
} else {
showToast(data.error || '保存失败');
}
})
.catch(error => {
console.error('Save failed:', error);
showToast('保存失败,请稍后重试');
});
}
function testEmail() {
fetch('/admin/email/test', {
method: 'POST'
})
.then(response => response.json())
.then(data => {
if (data.success) {
showToast('测试邮件已发送');
} else {
showToast(data.error || '发送失败');
}
})
.catch(error => {
console.error('Test failed:', error);
showToast('发送失败,请稍后重试');
});
}
</script>
{% endblock %}

122
templates/error.html Executable file
View File

@@ -0,0 +1,122 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>404 - 页面未找到</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.4/css/all.min.css">
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "WenQuanYi Micro Hei", Roboto, "Helvetica Neue", Arial, sans-serif;
line-height: 1.6;
background: #f5f5f7;
color: #1d1d1f;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.error-container {
text-align: center;
padding: 40px 20px;
max-width: 600px;
width: 100%;
}
.error-code {
font-size: 120px;
font-weight: 700;
color: #007AFF;
line-height: 1;
margin-bottom: 20px;
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.1);
}
.error-title {
font-size: 24px;
color: #1d1d1f;
margin-bottom: 15px;
}
.error-message {
font-size: 16px;
color: #666;
margin-bottom: 30px;
}
.back-button {
display: inline-flex;
align-items: center;
gap: 8px;
background: #007AFF;
color: white;
text-decoration: none;
padding: 12px 24px;
border-radius: 8px;
font-size: 16px;
transition: all 0.3s ease;
box-shadow: 0 2px 8px rgba(0, 122, 255, 0.2);
}
.back-button:hover {
background: #0056b3;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 122, 255, 0.3);
}
.error-icon {
font-size: 64px;
color: #007AFF;
margin-bottom: 20px;
animation: float 3s ease-in-out infinite;
}
@keyframes float {
0%, 100% {
transform: translateY(0);
}
50% {
transform: translateY(-10px);
}
}
@media (max-width: 768px) {
.error-code {
font-size: 80px;
}
.error-title {
font-size: 20px;
}
.error-message {
font-size: 14px;
}
.error-icon {
font-size: 48px;
}
}
</style>
</head>
<body>
<div class="error-container">
<i class="fas fa-exclamation-circle error-icon"></i>
<div class="error-code">404</div>
<h1 class="error-title">页面未找到</h1>
<p class="error-message">抱歉,您访问的页面不存在或已被移除。</p>
<a href="/" class="back-button">
<i class="fas fa-home"></i>
返回首页
</a>
</div>
</body>
</html>

2189
templates/explore.html Executable file

File diff suppressed because it is too large Load Diff

326
templates/hot_apps.html Executable file
View File

@@ -0,0 +1,326 @@
{% extends "base.html" %}
{% block content %}
<div class="hot-apps-page">
<div class="header">
<a href="{{ url_for('index') }}" class="back-link" title="返回首页">
<i class="fas fa-arrow-left"></i>
</a>
<h1>热门应用</h1>
</div>
<div class="apps-grid">
{% for app in hot_apps %}
<div class="app-tile" onclick="handleAppClick(event, '{{ app.id }}')">
<div class="app-tile-content">
<div class="app-tile-icon">
{% if 'http' in app.icon_path %}
<img src="{{ app.icon_path }}" alt="{{ app.name }}">
{% else %}
<img src="{{ url_for('static', filename='uploads/' + app.icon_path) }}" alt="{{ app.name }}">
{% endif %}
</div>
<div class="app-tile-info">
<div class="app-tile-header">
<h3>{{ app.name }}</h3>
<span class="category-tag">{{ app.category_name }}</span>
</div>
<div class="app-tile-meta">
{% if app.version and app.version.strip() %}
<span class="version-tag">{{ app.version }}</span>
{% endif %}
</div>
</div>
</div>
</div>
{% endfor %}
</div>
</div>
<script>
// 生成客户端ID
function generateClientId() {
return 'client_' + Math.random().toString(36).substr(2, 9);
}
// 获取或创建客户端ID
function getClientId() {
let clientId = localStorage.getItem('clientId');
if (!clientId) {
clientId = generateClientId();
localStorage.setItem('clientId', clientId);
}
return clientId;
}
// 简化点击处理函数
function handleAppClick(event, appId) {
window.location.href = `/app/${appId}`;
}
// 保存滚动位置和来源页面
window.addEventListener('beforeunload', () => {
sessionStorage.setItem('hotAppsScrollPosition', window.scrollY);
// 保存来源页面的路径,但只在不是从应用详情页来时保存
if (document.referrer && !document.referrer.includes('/app/')) {
sessionStorage.setItem('hotAppsReferer', document.referrer);
}
});
// 恢复滚动位置
document.addEventListener('DOMContentLoaded', () => {
// 检查是否是从应用详情页返回
if (document.referrer.includes('/app/')) {
const scrollPosition = sessionStorage.getItem('hotAppsScrollPosition');
if (scrollPosition) {
window.scrollTo(0, parseInt(scrollPosition));
// 不要在这里删除位置记录,因为可能还需要返回
}
}
});
// 修改返回链接的处理
document.querySelector('.back-link').addEventListener('click', function(e) {
e.preventDefault();
window.history.back();
});
// 设置客户端ID cookie
document.cookie = `client_id=${getClientId()}; path=/; max-age=31536000`;
</script>
<style>
.hot-apps-page {
max-width: 1200px;
margin: 0 auto;
padding: 60px 15px 20px 15px;
transform: none !important;
transition: none !important;
}
.header {
display: flex;
align-items: center;
gap: 15px;
margin-bottom: 30px;
background: white;
padding: 10px 15px;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
transform: none !important;
transition: none !important;
}
.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;
transform: none !important;
transition: none !important;
}
.back-link:hover {
background: #e5e5e7;
color: #333;
}
.header h1 {
margin: 0;
font-size: 20px;
color: #333;
font-weight: 500;
font-family: "SimHei", "黑体", sans-serif;
transform: none !important;
transition: none !important;
}
.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);
}
.app-tile:hover {
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
}
.app-tile-content {
padding: 15px;
display: flex;
gap: 12px;
position: relative;
}
.app-tile-icon {
width: 64px;
height: 64px;
flex-shrink: 0;
}
.app-tile-icon img {
width: 100%;
height: 100%;
border-radius: 16px;
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;
}
.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;
}
.version-tag {
font-size: 12px;
color: #007AFF;
background: #E3F2FD;
padding: 2px 8px;
border-radius: 4px;
font-weight: 500;
}
.update-badge {
display: none;
}
@media (max-width: 768px) {
.apps-grid {
grid-template-columns: 1fr;
}
}
/* 暗色模式样式 */
[data-theme="dark"] .hot-apps-page {
background: #1a1a1a;
}
[data-theme="dark"] .header {
background: #242424;
}
[data-theme="dark"] .header h1 {
color: #fff;
}
[data-theme="dark"] .back-link {
color: #ccc;
background: #333;
}
[data-theme="dark"] .back-link:hover {
background: #444;
color: #fff;
}
[data-theme="dark"] .app-tile {
background: #242424;
}
[data-theme="dark"] .app-tile-header h3 {
color: #fff;
}
[data-theme="dark"] .category-tag {
background: #333;
color: #ccc;
}
[data-theme="dark"] .version-tag {
background: #333;
color: #007AFF;
}
.floating-buttons {
position: fixed;
bottom: 40px;
right: 20px;
z-index: 1000;
display: flex;
flex-direction: column;
gap: 10px;
}
.theme-toggle {
width: 40px;
height: 40px;
border-radius: 50%;
border: none;
background: #fff;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s ease;
padding: 0;
}
.theme-toggle:hover {
transform: scale(1.1);
}
/* 暗色模式样式 */
[data-theme="dark"] .theme-toggle {
background: #333;
color: #fff;
}
/* 响应式调整 */
@media (max-width: 768px) {
.floating-buttons {
bottom: 30px;
right: 15px;
gap: 8px;
}
.theme-toggle {
width: 36px;
height: 36px;
}
}
</style>
{% endblock %}

4213
templates/index.html Executable file

File diff suppressed because it is too large Load Diff

1062
templates/invite_leaderboard.html Executable file

File diff suppressed because it is too large Load Diff

30
templates/login.html Executable file
View File

@@ -0,0 +1,30 @@
{% extends "base.html" %}
{% block content %}
<div class="login-container">
<div class="login-box">
<div class="login-header">
<h2>NEXT Store</h2>
<p>管理员登录</p>
</div>
<form method="POST" action="{{ url_for('login') }}" class="login-form">
<div class="form-group">
<i class="fas fa-user"></i>
<input type="text" name="username" placeholder="用户名" required>
</div>
<div class="form-group">
<i class="fas fa-lock"></i>
<input type="password" name="password" placeholder="密码" required>
</div>
<button type="submit">登录</button>
</form>
{% with messages = get_flashed_messages() %}
{% if messages %}
{% for message in messages %}
<div class="flash-message error">{{ message }}</div>
{% endfor %}
{% endif %}
{% endwith %}
</div>
</div>
{% endblock %}

1275
templates/more.html Executable file

File diff suppressed because it is too large Load Diff

572
templates/my_comments.html Executable file
View File

@@ -0,0 +1,572 @@
{% extends "base.html" %}
{% block title %}我的评论{% endblock %}
{% block content %}
<div class="comments-container">
<div class="header">
<a href="{{ url_for('more') }}" class="back-link" title="返回">
<i class="fas fa-arrow-left"></i>
</a>
<h1>我的评论</h1>
</div>
<div class="comments-filter">
<div class="filter-tabs">
<button class="filter-tab active" data-status="all">全部</button>
<button class="filter-tab" data-status="pending">待审核</button>
<button class="filter-tab" data-status="approved">已通过</button>
<button class="filter-tab" data-status="rejected">已拒绝</button>
</div>
</div>
<div class="comments-list-wrapper">
<div id="comments-list" class="comments-list">
<!-- Comments will be dynamically loaded here -->
</div>
<div id="comments-empty-state" class="empty-state" style="display: none;">
<div class="empty-state-icon">
<i class="fas fa-comment-slash"></i>
</div>
<h2>暂无评论</h2>
<p>您还没有发表任何评论。在 资讯 页面开始您的探索吧!</p>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
const commentsList = document.getElementById('comments-list');
const emptyState = document.getElementById('comments-empty-state');
const filterTabs = document.querySelectorAll('.filter-tab');
let allComments = [];
function fetchMyComments() {
commentsList.innerHTML = `
<div class="loading-spinner">
<i class="fas fa-spinner fa-spin"></i>
<p>正在加载评论...</p>
</div>
`;
fetch('/my_comments', {
method: 'GET',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
}
})
.then(response => {
// 检查响应状态
if (response.status === 401) {
// 未登录,重定向到登录页
window.location.href = '/login';
throw new Error('未登录');
}
if (!response.ok) {
throw new Error(`HTTP错误状态码${response.status}`);
}
return response.json();
})
.then(data => {
if (data.success) {
allComments = Array.isArray(data.comments) ? data.comments : [];
renderComments('all');
} else {
showErrorState(data.error || '获取评论失败');
}
})
.catch(error => {
if (error.message === '未登录') {
// 已在上面处理重定向
return;
}
console.error('获取评论时发生错误:', error);
showErrorState(error.message || '网络错误,请稍后重试');
});
}
function renderComments(status) {
// Update filter tab styles
filterTabs.forEach(tab => {
tab.classList.toggle('active', tab.dataset.status === status);
});
const filteredComments = status === 'all'
? allComments
: allComments.filter(comment => comment.status === status);
if (filteredComments.length === 0) {
commentsList.style.display = 'none';
emptyState.style.display = 'flex';
return;
}
commentsList.style.display = 'block';
emptyState.style.display = 'none';
const commentsHtml = filteredComments.map(comment => {
const statusClasses = {
'pending': {
icon: 'fa-clock',
text: '待审核',
color: 'warning'
},
'approved': {
icon: 'fa-check-circle',
text: '已通过',
color: 'success'
},
'rejected': {
icon: 'fa-times-circle',
text: '已拒绝',
color: 'danger'
}
}[comment.status] || {
icon: 'fa-question-circle',
text: '未知状态',
color: 'info'
};
const formatDate = (dateString) => {
try {
const date = new Date(dateString);
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
} catch (error) {
console.error('日期格式化错误:', error);
return dateString;
}
};
return `
<div class="comment-card" data-comment-id="${comment.id}">
<div class="comment-header">
<div class="comment-header-left">
<div class="comment-status ${statusClasses.color}">
<i class="fas ${statusClasses.icon}"></i>
${statusClasses.text}
</div>
<div class="comment-date">
${formatDate(comment.created_at)}
</div>
</div>
<div class="comment-header-right">
<button class="btn btn-view btn-small" onclick="window.location.href='/wiki/${comment.wiki_entry_id}'">
<i class="fas fa-eye"></i>
</button>
${status === 'all' || ['pending', 'draft'].includes(comment.status) ? `
<button class="btn btn-delete btn-small" data-comment-id="${comment.id}">
<i class="fas fa-trash"></i>
</button>
` : ''}
</div>
</div>
<div class="comment-body">
<p class="comment-text">${comment.content}</p>
<a href="/wiki/${comment.wiki_entry_id}" class="comment-wiki-link">
${comment.wiki_title || '未知条目'}
</a>
</div>
</div>
`;
}).join('');
commentsList.innerHTML = commentsHtml;
// Add delete event listeners
document.querySelectorAll('.btn-delete').forEach(button => {
button.addEventListener('click', function() {
const commentId = this.getAttribute('data-comment-id');
deleteComment(commentId);
});
});
}
function deleteComment(commentId) {
if (!confirm('确定要删除这条评论吗?')) return;
fetch(`/wiki/comment/${commentId}/delete`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
}
})
.then(response => {
if (!response.ok) {
throw new Error('删除失败');
}
return response.json();
})
.then(data => {
if (data.success) {
allComments = allComments.filter(comment => comment.id !== parseInt(commentId));
const activeTab = document.querySelector('.filter-tab.active');
renderComments(activeTab.dataset.status);
} else {
alert(data.error || '删除评论失败');
}
})
.catch(error => {
alert(error.message || '删除评论时发生网络错误');
});
}
function showErrorState(message) {
commentsList.innerHTML = `
<div class="error-state">
<i class="fas fa-exclamation-triangle"></i>
<h2>出错了</h2>
<p>${message}</p>
</div>
`;
emptyState.style.display = 'none';
}
// Add filter tab event listeners
filterTabs.forEach(tab => {
tab.addEventListener('click', function() {
renderComments(this.dataset.status);
});
});
// Initial load
fetchMyComments();
// Back link navigation
document.querySelector('.back-link').addEventListener('click', function(e) {
e.preventDefault();
window.history.back();
});
});
</script>
{% block styles %}
<style>
:root {
--bg-primary: #ffffff;
--bg-secondary: #f5f5f7;
--text-primary: #1a1a1a;
--text-secondary: #666666;
--text-light: #8a8a8a;
--border-color: #e0e0e4;
--accent-color: #007aff;
--success-color: #34c759;
--warning-color: #ff9500;
--danger-color: #ff3b30;
}
[data-theme="dark"] {
--bg-primary: #121212;
--bg-secondary: #1e1e1e;
--text-primary: #e0e0e0;
--text-secondary: #a0a0a0;
--text-light: #6a6a6a;
--border-color: #333;
--accent-color: #5e9eff;
--success-color: #30d158;
--warning-color: #ff9f0a;
--danger-color: #ff453a;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
transition: all 0.3s ease;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
background-color: var(--bg-secondary);
color: var(--text-primary);
line-height: 1.6;
}
.comments-container {
max-width: 1200px;
margin: 0 auto;
padding: 60px 15px 20px 15px;
}
.header {
display: flex;
align-items: center;
gap: 15px;
margin-bottom: 30px;
background: white;
padding: 10px 15px;
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;
}
.back-link:hover {
background: #e5e5e7;
color: #333;
}
.header h1 {
margin: 0;
font-size: 20px;
color: #333;
font-weight: 500;
font-family: "SimHei", "黑体", sans-serif;
}
.comments-filter {
margin-bottom: 20px;
display: flex;
justify-content: center;
}
.filter-tabs {
display: flex;
background-color: var(--bg-secondary);
border-radius: 20px;
overflow: hidden;
border: 1px solid var(--border-color);
justify-content: center;
}
.filter-tab {
padding: 8px 16px;
background-color: transparent;
border: none;
cursor: pointer;
color: var(--text-secondary);
font-weight: 500;
font-size: 0.9em;
transition: all 0.3s;
}
.filter-tab.active {
color: var(--accent-color);
background-color: rgba(0,122,255,0.1);
}
.comments-list-wrapper {
background: white;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
padding: 20px;
}
.comment-card {
background-color: var(--bg-secondary);
border-radius: 12px;
padding: 15px;
margin-bottom: 15px;
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
}
.comment-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
.comment-header-left {
display: flex;
align-items: center;
gap: 10px;
}
.comment-header-right {
display: flex;
align-items: center;
gap: 5px;
}
.btn-small {
width: 32px;
height: 32px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 50%;
}
.btn-small i {
margin: 0;
}
.comment-status {
font-size: 0.7em;
padding: 3px 8px;
border-radius: 12px;
font-weight: 600;
text-transform: uppercase;
display: inline-flex;
align-items: center;
gap: 5px;
}
.comment-status.warning {
background-color: rgba(255,149,0,0.1);
color: var(--warning-color);
}
.comment-status.success {
background-color: rgba(52,199,89,0.1);
color: var(--success-color);
}
.comment-status.danger {
background-color: rgba(255,59,48,0.1);
color: var(--danger-color);
}
.comment-date {
color: var(--text-light);
font-size: 0.8em;
}
.comment-body {
margin-bottom: 15px;
}
.comment-text {
color: var(--text-primary);
font-size: 0.95em;
margin-bottom: 10px;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
}
.comment-wiki-link {
color: var(--accent-color);
text-decoration: none;
font-size: 0.85em;
font-weight: 500;
}
.comment-actions {
display: none; /* Remove old actions container */
}
.btn {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 6px 12px;
border: none;
border-radius: 20px;
font-size: 0.8em;
cursor: pointer;
transition: all 0.3s;
}
.btn-view {
background-color: rgba(0,122,255,0.1);
color: var(--accent-color);
}
.btn-delete {
background-color: rgba(255,59,48,0.1);
color: var(--danger-color);
}
.btn:hover {
opacity: 0.8;
}
.empty-state, .error-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding: 50px 20px;
color: var(--text-secondary);
}
.empty-state-icon, .error-state i {
font-size: 3rem;
opacity: 0.3;
margin-bottom: 15px;
}
.loading-spinner {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 50px;
color: var(--text-secondary);
}
@media (max-width: 600px) {
.comments-container {
border-radius: 0;
}
.filter-tabs {
flex-direction: row;
width: 100%;
overflow-x: auto;
white-space: nowrap;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
justify-content: center;
}
.filter-tabs::-webkit-scrollbar {
display: none;
}
.filter-tab {
flex-shrink: 0;
}
}
/* Dark mode styles */
[data-theme="dark"] .header {
background: #242424;
}
[data-theme="dark"] .header h1 {
color: #fff;
}
[data-theme="dark"] .back-link {
color: #ccc;
background: #333;
}
[data-theme="dark"] .back-link:hover {
background: #444;
color: #fff;
}
[data-theme="dark"] .comments-list-wrapper {
background: #242424;
}
</style>
{% endblock %}
{% endblock %}

76
templates/nav_bar.html Executable file
View File

@@ -0,0 +1,76 @@
<nav class="bottom-nav">
<a href="{{ url_for('explore') }}" class="nav-item {% if request.endpoint == 'explore' %}active{% endif %}">
<i class=" fas fa-compass"></i>
<span>探索</span>
</a>
<a href="{{ url_for('index') }}" class="nav-item {% if request.endpoint == 'index' or request.endpoint == 'catch_all' %}active{% endif %}">
<i class="fas fa-th-large"></i>
<span>应用</span>
</a>
<a href="{{ url_for('more') }}" class="nav-item {% if request.endpoint == 'more' %}active{% endif %}">
<i class=" fas fa-solid fa-bars"></i>
<span>我的</span>
</a>
</nav>
<style>
.bottom-nav {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: rgba(255, 255, 255, 0.7);
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;
}
.nav-item {
display: flex;
flex-direction: column;
align-items: center;
text-decoration: none;
color: #666;
font-size: 12px;
gap: 4px;
transition: color 0.3s ease;
}
.nav-item i {
font-size: 20px;
}
.nav-item.active {
color: #007AFF;
}
/* 暗色模式适配 */
[data-theme="dark"] .bottom-nav {
background: rgba(26, 26, 26, 0.7);
border-top: none;
box-shadow: 0 -2px 10px rgba(0,0,0,0.2);
}
/* 确保在 Safari 上也有毛玻璃效果 */
@supports not (backdrop-filter: blur(10px)) {
.bottom-nav {
background: rgba(255, 255, 255, 0.85);
}
[data-theme="dark"] .bottom-nav {
background: rgba(26, 26, 26, 0.85);
}
}
[data-theme="dark"] .nav-item {
color: #999;
}
[data-theme="dark"] .nav-item.active {
color: #007AFF;
}
</style>

784
templates/new_apps.html Executable file
View File

@@ -0,0 +1,784 @@
{% extends "base.html" %}
{% block content %}
<!--<div id="wishlist-announcement" class="wishlist-announcement">-->
<!-- <div class="announcement-content">-->
<!-- <span class="announcement-text">🎉 心愿单功能全新上线</span>-->
<!-- <button onclick="window.location.href='{{ url_for('more') }}'" class="goto-btn">立即体验</button>-->
<!-- <button onclick="closeAnnouncement()" class="close-btn">×</button>-->
<!-- </div>-->
<!-- <div class="countdown-bar"></div>-->
<!--</div>-->
<!-- 添加水印容器 -->
<div class="watermark-container">
<div class="watermark-content"></div>
</div>
<div class="new-apps-page">
<div class="header">
<a href="{{ url_for('explore') }}" class="back-link" title="返回首页" onclick="handleBackClick(event)">
<i class="fas fa-arrow-left"></i>
</a>
<h1>{{ date_text }}</h1>
</div>
<div class="date-switcher">
<a href="{{ url_for('new_apps', date='today') }}" class="date-btn {{ 'active' if date == 'today' }}">今日</a>
<a href="{{ url_for('new_apps', date='yesterday') }}" class="date-btn {{ 'active' if date == 'yesterday' }}">昨日</a>
<a href="{{ url_for('new_apps', date='before_yesterday') }}" class="date-btn {{ 'active' if date == 'before_yesterday' }}">前日</a>
</div>
{% if today_apps %}
<div class="apps-grid">
{% for app in today_apps %}
<div class="app-tile" onclick="handleAppClick(event, '{{ app.id }}')">
<div class="app-tile-content">
<div class="app-tile-icon">
{% if 'http' in app.icon_path %}
<img src="{{ app.icon_path }}" alt="{{ app.name }}">
{% else %}
<img src="{{ url_for('static', filename='uploads/' + app.icon_path) }}" alt="{{ app.name }}">
{% endif %}
</div>
<div class="app-tile-info">
<div class="app-tile-header">
<h3>{{ app.name }}</h3>
</div>
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="empty-state">
<i class="fas fa-inbox"></i>
<p>今日无上新应用</p>
</div>
{% endif %}
</div>
<style>
.goto-btn {
background: rgba(255, 255, 255, 0.2);
color: white;
border: 1px solid rgba(255, 255, 255, 0.3);
padding: 6px 14px;
border-radius: 8px;
font-size: 14px;
cursor: pointer;
transition: all 0.3s ease;
font-weight: 500;
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
}
.goto-btn:hover {
background: rgba(255, 255, 255, 0.3);
transform: translateY(-1px);
border-color: rgba(255, 255, 255, 0.5);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.close-btn {
background: none;
border: none;
color: white;
font-size: 20px;
cursor: pointer;
padding: 0 6px;
opacity: 0.8;
transition: opacity 0.3s ease;
}
.close-btn:hover {
opacity: 1;
}
.countdown-bar {
height: 3px;
background: rgba(255, 255, 255, 0.2);
border-radius: 0;
overflow: hidden;
position: relative;
margin: 0 -12px;
width: calc(100% + 24px);
}
.countdown-bar::after {
content: '';
position: absolute;
top: 0;
left: 0;
height: 100%;
width: 100%;
background: linear-gradient(90deg, rgba(255,255,255,0.8), rgba(255,255,255,1));
animation: countdown 5s linear forwards;
}
@keyframes slideDown {
from {
transform: translate(-50%, -100%);
opacity: 0;
}
to {
transform: translate(-50%, 0);
opacity: 1;
}
}
@keyframes countdown {
from {
width: 100%;
}
to {
width: 0;
}
}
/* 暗色模式适配 */
[data-theme="dark"] .wishlist-announcement {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
}
/* 心愿单通知弹窗样式 */
.wishlist-announcement {
position: fixed;
top: 60px;
left: 50%;
transform: translateX(-50%);
width: 90%;
max-width: 400px;
background: linear-gradient(135deg, #4F46E5, #7C3AED);
border-radius: 12px;
padding: 12px 12px 0 12px;
box-shadow: 0 8px 20px rgba(124, 58, 237, 0.25);
z-index: 1000;
overflow: hidden;
animation: slideDown 0.5s ease-out;
touch-action: pan-x; /* 允许水平滑动 */
user-select: none; /* 防止文本选择 */
transition: transform 0.3s ease; /* 添加平滑过渡 */
}
/* 添加滑动时的动画效果 */
.wishlist-announcement.swiping {
transition: transform 0.1s ease;
}
/* 添加滑动完成后的动画效果 */
.wishlist-announcement.swipe-out {
transform: translate(calc(-50% - 150%), 0);
opacity: 0;
}
.announcement-content {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.announcement-text {
color: white;
font-size: 16px;
font-weight: 600;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
/* 添加水印相关样式 */
.watermark-container {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
width: 100vw;
height: 100vh;
pointer-events: none;
z-index: 1000;
overflow: hidden;
}
.watermark-content {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
width: 100vw;
height: 100vh;
background-repeat: repeat;
opacity: 0.2;
transform: rotate(-15deg);
pointer-events: none;
will-change: transform;
backface-visibility: hidden;
-webkit-font-smoothing: antialiased;
}
/* 保持原有样式不变 */
.new-apps-page {
max-width: 1200px;
margin: 60px auto 20px;
padding: 0 15px;
position: relative;
z-index: 1;
}
.header {
display: flex;
align-items: center;
gap: 15px;
margin-bottom: 15px;
background: white;
padding: 5px 20px;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
transform: none !important;
transition: none !important;
animation: none !important;
}
.header * {
transform: none !important;
transition: none !important;
animation: none !important;
}
.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;
}
.header h1 {
margin: 0;
font-size: 20px;
color: #333;
font-weight: 500;
font-family: "SimHei", "黑体", sans-serif;
text-align: left;
}
/* 添加空状态样式 */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px;
background: white;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
margin-top: 20px;
}
.empty-state i {
font-size: 48px;
color: #ccc;
margin-bottom: 16px;
}
.empty-state p {
font-size: 16px;
color: #666;
margin: 0;
font-family: "SimHei", "黑体", sans-serif;
}
/* 添加网格布局样式 */
.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);
margin: 0;
padding: 0;
}
/* 修改应用图标容器样式 */
.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-icon {
width: 60px;
height: 60px;
margin: 0 auto 6px;
}
.app-tile-icon img {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 14px;
}
/* 修改应用名称样式 */
.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;
width: 100%;
text-align: center;
}
/* 修改响应式布局 */
@media (max-width: 768px) {
.apps-grid {
grid-template-columns: repeat(auto-fill, minmax(70px, 1fr));
gap: 10px;
}
.app-tile-icon {
width: 52px;
height: 52px;
}
.app-tile-header h3 {
font-size: 11px;
}
}
@media (max-width: 480px) {
.apps-grid {
grid-template-columns: repeat(4, 1fr); /* 强制一行4个 */
gap: 8px;
}
.app-tile {
padding: 6px;
}
.app-tile-icon {
width: 48px;
height: 48px;
}
.app-tile-header h3 {
font-size: 11px;
}
}
/* 暗色模式样式 */
[data-theme="dark"] .new-apps-page {
background: #1a1a1a;
}
[data-theme="dark"] .header {
background: #242424;
}
[data-theme="dark"] .header h1 {
color: #fff;
}
[data-theme="dark"] .back-link {
color: #ccc;
background: #333;
}
[data-theme="dark"] .back-link:hover {
background: #444;
color: #fff;
}
[data-theme="dark"] .apps-grid {
background: #242424;
}
[data-theme="dark"] .app-tile {
background: #242424;
}
[data-theme="dark"] .app-tile:hover {
background: #333;
}
[data-theme="dark"] .app-tile-header h3 {
color: #fff;
}
[data-theme="dark"] .empty-state {
background: #242424;
}
[data-theme="dark"] .empty-state i {
color: #666;
}
[data-theme="dark"] .empty-state p {
color: #ccc;
}
/* 修改水印在暗色模式下的样式 */
[data-theme="dark"] .watermark-content {
opacity: 0.1;
}
/* 修改水印文字在暗色模式下的颜色 */
[data-theme="dark"] .watermark-content canvas {
filter: invert(1); /* 反转颜色 */
}
/* 日期切换器样式 */
.date-switcher {
display: flex;
gap: 8px;
margin-bottom: 8px;
background: white;
padding: 10px;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
justify-content: center;
}
.date-btn {
padding: 6px 16px;
border-radius: 6px;
background: #f0f0f0;
color: #666;
text-decoration: none;
font-size: 14px;
transition: all 0.2s;
}
.date-btn:hover {
background: #e0e0e0;
}
.date-btn.active {
background: #007aff;
color: white;
}
/* 暗色模式样式 */
[data-theme="dark"] .date-switcher {
background: #242424;
}
[data-theme="dark"] .date-btn {
background: #333;
color: #ccc;
}
[data-theme="dark"] .date-btn:hover {
background: #444;
}
[data-theme="dark"] .date-btn.active {
background: #0056b3;
color: white;
}
/* 响应式布局调整 */
@media (max-width: 480px) {
.header {
padding: 10px;
}
.date-switcher {
padding: 8px;
}
.date-btn {
padding: 4px 12px;
font-size: 12px;
}
}
/* 修复暗色模式切换按钮在 PC 端的显示问题 */
.floating-buttons {
position: fixed;
bottom: 40px;
right: 20px;
z-index: 1000;
display: flex;
flex-direction: column;
gap: 10px;
}
.theme-toggle {
width: 40px;
height: 40px;
border-radius: 50%;
border: none;
background: #fff;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s ease;
padding: 0;
}
.theme-toggle:hover {
transform: scale(1.1);
}
/* 暗色模式样式 */
[data-theme="dark"] .theme-toggle {
background: #333;
color: #fff;
}
/* 响应式调整 */
@media (max-width: 768px) {
.floating-buttons {
bottom: 30px;
right: 15px;
gap: 8px;
}
.theme-toggle {
width: 36px;
height: 36px;
}
}
/* 添加关闭动画 */
@keyframes slideUp {
from {
transform: translate(-50%, 0);
opacity: 1;
}
to {
transform: translate(-50%, -100%);
opacity: 0;
}
}
</style>
<script>
function handleAppClick(event, appId) {
window.location.href = '/app/' + appId;
}
function handleBackClick(event) {
event.preventDefault();
// 检查是否是从explore页面来的
if (sessionStorage.getItem('fromExplore')) {
window.location.href = '/explore';
} else {
window.location.href = '/';
}
}
// 心愿单通知弹窗相关函数
function closeAnnouncement() {
const announcement = document.getElementById('wishlist-announcement');
if (!announcement) return;
announcement.style.animation = 'slideUp 0.5s ease-out forwards';
setTimeout(() => {
announcement.remove();
}, 500);
}
// 添加自动关闭功能
document.addEventListener('DOMContentLoaded', () => {
setTimeout(closeAnnouncement, 5000);
});
let touchStartX = 0;
let touchEndX = 0;
let currentTranslateX = 0;
let isDragging = false;
document.addEventListener('DOMContentLoaded', () => {
const announcement = document.getElementById('wishlist-announcement');
if (!announcement) return;
// 触摸开始
announcement.addEventListener('touchstart', (e) => {
touchStartX = e.touches[0].clientX;
isDragging = true;
announcement.classList.add('swiping');
});
// 触摸移动
announcement.addEventListener('touchmove', (e) => {
if (!isDragging) return;
touchEndX = e.touches[0].clientX;
const diffX = touchEndX - touchStartX;
// 只允许左滑
if (diffX > 0) {
currentTranslateX = 0;
} else {
currentTranslateX = diffX;
}
// 应用变换
announcement.style.transform = `translate(calc(-50% + ${currentTranslateX}px), 0)`;
});
// 触摸结束
announcement.addEventListener('touchend', () => {
isDragging = false;
announcement.classList.remove('swiping');
// 如果滑动距离超过阈值,则关闭通知
if (currentTranslateX < -100) {
announcement.classList.add('swipe-out');
setTimeout(() => {
announcement.remove();
}, 300);
} else {
// 否则回弹
announcement.style.transform = 'translateX(-50%)';
}
// 重置状态
currentTranslateX = 0;
touchStartX = 0;
touchEndX = 0;
});
// 鼠标事件支持
announcement.addEventListener('mousedown', (e) => {
touchStartX = e.clientX;
isDragging = true;
announcement.classList.add('swiping');
});
announcement.addEventListener('mousemove', (e) => {
if (!isDragging) return;
touchEndX = e.clientX;
const diffX = touchEndX - touchStartX;
if (diffX > 0) {
currentTranslateX = 0;
} else {
currentTranslateX = diffX;
}
announcement.style.transform = `translate(calc(-50% + ${currentTranslateX}px), 0)`;
});
announcement.addEventListener('mouseup', () => {
if (!isDragging) return;
isDragging = false;
announcement.classList.remove('swiping');
if (currentTranslateX < -100) {
announcement.classList.add('swipe-out');
setTimeout(() => {
announcement.remove();
}, 300);
} else {
announcement.style.transform = 'translateX(-50%)';
}
currentTranslateX = 0;
touchStartX = 0;
touchEndX = 0;
});
// 处理鼠标离开窗口的情况
announcement.addEventListener('mouseleave', () => {
if (isDragging) {
isDragging = false;
announcement.classList.remove('swiping');
announcement.style.transform = 'translateX(-50%)';
currentTranslateX = 0;
touchStartX = 0;
touchEndX = 0;
}
});
});
// 监听浏览器返回按钮
window.addEventListener('popstate', function(event) {
if (sessionStorage.getItem('fromExplore')) {
window.location.href = '/explore';
}
});
// 修改水印相关脚本
document.addEventListener('DOMContentLoaded', function() {
const watermarkContent = document.querySelector('.watermark-content');
const settings = {{ settings|tojson|safe }};
function createWatermark() {
const text1 = settings.watermark_text_1 || '';
const text2 = settings.watermark_text_2 || '';
if (!text1 && !text2) return;
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
canvas.width = 200;
canvas.height = 160;
ctx.font = '16px Arial';
// 根据主题设置颜色
const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
ctx.fillStyle = isDark ? '#ffffff' : '#333333';
ctx.textAlign = 'center';
ctx.lineWidth = 0.6;
if (text1) {
ctx.fillText(text1, canvas.width/2, canvas.height/2 - 8);
ctx.strokeText(text1, canvas.width/2, canvas.height/2 - 8);
}
if (text2) {
ctx.fillText(text2, canvas.width/2, canvas.height/2 + 8);
ctx.strokeText(text2, canvas.width/2, canvas.height/2 + 8);
}
const pattern = ctx.createPattern(canvas, 'repeat');
watermarkContent.style.backgroundImage = `url(${canvas.toDataURL()})`;
}
createWatermark();
// 监听主题变化,重新创建水印
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.attributeName === 'data-theme') {
createWatermark();
}
});
});
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['data-theme']
});
});
</script>
{% endblock %}

502
templates/policy_base.html Executable file
View File

@@ -0,0 +1,502 @@
{% extends "base.html" %}
{% block content %}
<div class="policy-page">
<!-- Header 部分 -->
<div class="header">
<a href="javascript:void(0)" class="back-link" onclick="handleBackClick()" title="返回">
<i class="fas fa-arrow-left"></i>
</a>
<h1>{% block policy_header %}{% endblock %}</h1>
</div>
<!-- 内容区域 -->
<div class="policy-content">
<!-- 标题区域 -->
<div class="policy-title">
<i class="{% block policy_icon %}{% endblock %}"></i>
<h1>{% block policy_title %}{% endblock %}</h1>
</div>
<!-- 具体内容 -->
{% block policy_content %}{% endblock %}
</div>
</div>
<!-- 添加备案信息 -->
<div class="policy-footer">
{% if settings and settings.icp_number %}
<a href="https://beian.miit.gov.cn/" target="_blank" class="icp-link">
<i class="fas fa-shield-alt"></i>
{{ settings.icp_number }}
</a>
{% endif %}
</div>
<style>
.policy-update-date {
text-align: center;
margin-bottom: 30px;
color: #666;
font-size: 14px;
}
[data-theme="dark"] .policy-update-date {
color: #999;
}
/* 页面基本样式 */
.policy-page {
max-width: 1200px;
margin: 60px auto 20px;
padding: 0 15px;
}
/* Header 样式 */
.header {
display: flex;
align-items: center;
gap: 15px;
margin-bottom: 30px;
background: white;
padding: 10px 15px;
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;
transition: all 0.3s ease;
}
.back-link:hover {
background: #e5e5e7;
color: #333;
}
.header h1 {
margin: 0;
font-size: 20px;
color: #333;
font-weight: 500;
}
/* 内容区域样式 */
.policy-content {
background: white;
padding: 40px;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
/* 标题区域样式 */
.policy-title {
text-align: center;
margin-bottom:10px;
padding-bottom: 30px;
border-bottom: 1px solid #eee;
position: relative;
}
.policy-title::after {
content: '';
position: absolute;
bottom: -1px;
left: 50%;
transform: translateX(-50%);
width: 100px;
height: 3px;
background: #007AFF;
border-radius: 2px;
}
.policy-title i {
font-size: 48px;
color: #007AFF;
margin-bottom: 20px;
display: block;
transition: transform 0.3s ease;
}
.policy-title:hover i {
transform: scale(1.1);
}
.policy-title h1 {
font-size: 28px;
color: #333;
margin: 0;
font-weight: 600;
}
/* 暗色模式适配 */
[data-theme="dark"] .header {
background: #242424;
}
[data-theme="dark"] .header h1 {
color: #fff;
}
[data-theme="dark"] .back-link {
color: #ccc;
background: #333;
}
[data-theme="dark"] .back-link:hover {
background: #444;
color: #fff;
}
[data-theme="dark"] .policy-content {
background: #242424;
}
[data-theme="dark"] .policy-title {
border-bottom-color: #333;
}
[data-theme="dark"] .policy-title i {
color: #3a9fff;
}
[data-theme="dark"] .policy-title h1 {
color: #fff;
}
/* 移动端适配 */
@media (max-width: 768px) {
.policy-page {
margin: 70px auto 20px;
}
.header {
padding: 8px 12px;
}
.back-link {
width: 28px;
height: 28px;
padding: 6px;
}
.header h1 {
font-size: 18px;
}
.policy-content {
padding: 25px;
}
.policy-title {
margin-bottom: 10px;
padding-bottom: 25px;
}
.policy-title i {
font-size: 36px;
margin-bottom: 15px;
}
.policy-title h1 {
font-size: 24px;
}
.policy-title::after {
width: 80px;
}
}
/* 超小屏幕适配 */
@media (max-width: 360px) {
.policy-page {
margin: 50px auto 15px;
}
.header {
padding: 8px 10px;
}
.policy-content {
padding: 15px;
}
}
/* 修改序号样式 */
.policy-content section {
counter-increment: section;
margin-bottom: 30px;
}
.policy-content section h2 {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 20px;
font-size: 20px;
color: #333;
position: relative;
padding-left: 28px;
}
.policy-content section h2::before {
content: counter(section) ".";
position: absolute;
left: 0;
color: #007AFF;
font-weight: 600;
}
.policy-content section h3 {
position: relative;
padding-left: 16px;
margin: 20px 0 12px;
font-size: 16px;
color: #555;
}
.policy-content section h3::before {
content: "•";
position: absolute;
left: 0;
color: #007AFF;
}
/* 暗色模式适配 */
[data-theme="dark"] .policy-content section h2 {
color: #fff;
}
[data-theme="dark"] .policy-content section h2::before {
color: #3a9fff;
}
[data-theme="dark"] .policy-content section h3 {
color: #ddd;
}
[data-theme="dark"] .policy-content section h3::before {
color: #3a9fff;
}
/* 移动端适配 */
@media (max-width: 768px) {
.policy-content section h2 {
font-size: 18px;
padding-left: 24px;
}
}
/* 优化表样式 */
section ul {
margin-left: 24px;
list-style: none;
}
section ul li {
position: relative;
padding-left: 8px;
}
section ul li::before {
content: "";
position: absolute;
left: -12px;
top: 10px;
width: 4px;
height: 4px;
border-radius: 50%;
background: #666;
}
[data-theme="dark"] section ul li::before {
background: #999;
}
/* 内容区域暗色模式适配 */
[data-theme="dark"] .policy-content {
background: #242424;
}
[data-theme="dark"] .policy-content section h2 {
color: #fff;
}
[data-theme="dark"] .policy-content section h3 {
color: #ddd;
}
[data-theme="dark"] .policy-content p {
color: #bbb;
}
[data-theme="dark"] .policy-content ul li {
color: #bbb;
}
/* 标题区域暗色模式适配 */
[data-theme="dark"] .policy-title {
border-bottom-color: #333;
}
[data-theme="dark"] .policy-title::after {
background: #3a9fff;
}
[data-theme="dark"] .policy-title i {
color: #3a9fff;
}
[data-theme="dark"] .policy-title h1 {
color: #fff;
}
/* 序号和圆点暗色模式适配 */
[data-theme="dark"] .policy-content section h2::before {
color: #3a9fff;
}
[data-theme="dark"] .policy-content section h3::before {
color: #3a9fff;
}
[data-theme="dark"] section ul li::before {
background: #666;
}
/* Header 暗色模式适配 */
[data-theme="dark"] .header {
background: #242424;
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
}
[data-theme="dark"] .header h1 {
color: #fff;
}
[data-theme="dark"] .back-link {
color: #ccc;
background: #333;
}
[data-theme="dark"] .back-link:hover {
background: #444;
color: #fff;
}
/* 内容区域阴影调整 */
[data-theme="dark"] .policy-content {
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
}
/* 链接颜色适配 */
[data-theme="dark"] .policy-content a {
color: #3a9fff;
}
[data-theme="dark"] .policy-content a:hover {
color: #66b5ff;
}
/* 警告文本适配 */
[data-theme="dark"] .warning-text {
color: #ff9800;
}
/* 移动端暗色模式适配 */
@media (max-width: 768px) {
[data-theme="dark"] .policy-content {
background: #242424;
}
[data-theme="dark"] .header {
background: #242424;
}
}
/* 超小屏幕暗色模式适配 */
@media (max-width: 360px) {
[data-theme="dark"] .policy-content {
background: #242424;
}
}
/* 添加备案信息样式 */
.policy-footer {
text-align: center;
margin: 30px 0;
}
.policy-footer .icp-link {
color: #666;
text-decoration: none;
font-size: 14px;
display: inline-flex;
align-items: center;
gap: 6px;
transition: opacity 0.3s ease;
}
.policy-footer .icp-link:hover {
opacity: 0.8;
}
.policy-footer .icp-link i {
font-size: 14px;
}
/* 备案信息暗色模式适配 */
[data-theme="dark"] .policy-footer .icp-link {
color: #888;
}
/* 移动端适配 */
@media (max-width: 768px) {
.policy-footer {
margin: 20px 0;
}
.policy-footer .icp-link {
font-size: 12px;
}
.policy-footer .icp-link i {
font-size: 12px;
}
}
</style>
<script>
function handleBackClick() {
try {
// 先尝试浏览器的返回功能
if (window.history.length > 1) {
window.history.back();
// 设置一个超时检查如果3秒内没有成功返回则跳转到首页
setTimeout(function() {
if (document.location.pathname === '/terms' ||
document.location.pathname === '/cookie-policy') {
window.location.href = '/';
}
}, 300);
} else {
// 如果没有历史记录,直接跳转到首页
window.location.href = '/';
}
} catch (e) {
// 如果出现任何错误,直接跳转到首页
window.location.href = '/';
}
}
</script>
{% endblock %}

293
templates/privacy.html Executable file
View File

@@ -0,0 +1,293 @@
{% extends "policy_base.html" %}
{% block policy_header %}隐私政策{% endblock %}
{% block policy_icon %}fas fa-shield-alt{% endblock %}
{% block policy_title %}隐私政策{% endblock %}
{% block policy_content %}
<div class="policy-update-date">
<p>最近更新2025年1月15日</p>
</div>
<section>
<h2>引言</h2>
<p>欢迎使用 NEXT Store。我们非常重视您的隐私保护并致力于保护您的个人信息安全。本隐私政策旨在帮助您了解</p>
<ul>
<li>我们如何收集和使用您的个人信息</li>
<li>我们如何存储和保护这些信息</li>
<li>您享有的权利和选择</li>
</ul>
<p>请您仔细阅读并确保完全理解本隐私政策的所有内容。如果您不同意本政策的任何内容,请停止使用我们的服务。</p>
</section>
<section>
<h2>隐私保护原则</h2>
<p>我们遵循以下隐私保护原则:</p>
<ul>
<li>最小化收集:仅收集提供服务必要的信息</li>
<li>透明管理:清晰说明信息用途</li>
<li>用户控制:提供信息管理渠道</li>
<li>安全保护:采用先进加密技术</li>
<li>合规性:严格遵守国家相关法律法规</li>
</ul>
</section>
<section>
<h2>信息收集</h2>
<h3>账号授权信息</h3>
<p>当您使用华为账号登录时,我们会收集以下信息:</p>
<ul>
<li>华为账号的基本信息用户ID</li>
<li>您的昵称</li>
<li>电子邮件地址</li>
<li>头像图片</li>
</ul>
<h3>使用数据</h3>
<p>我们会收集您使用服务时产生的数据:</p>
<ul>
<li>浏览记录和搜索历史</li>
<li>设备信息(操作系统、浏览器类型)</li>
<li>访问日志IP地址、访问时间</li>
<li>心愿单数据</li>
<li>主题偏好设置</li>
</ul>
<h3>自动收集的信息</h3>
<p>当您访问和使用我们的网站时,我们会自动收集:</p>
<ul>
<li>网络连接信息如HTTP头信息、请求方法</li>
<li>设备标识符和硬件信息</li>
<li>浏览器设置和语言偏好</li>
<li>页面访问统计和交互数据</li>
</ul>
</section>
<section>
<h2>信息使用</h2>
<p>我们使用收集的信息用于:</p>
<ul>
<li>提供、维护和改进我们的服务</li>
<li>实现心愿单功能</li>
<li>发送应用更新通知</li>
<li>提供个性化的用户体验</li>
<li>分析网站使用情况</li>
<li>预防和处理安全问题</li>
</ul>
<h3>邮件通知</h3>
<p>我们可能会向您发送以下类型的邮件:</p>
<ul>
<li>心愿单应用上架通知</li>
<li>账号安全相关通知</li>
<li>服务更新和政策变更通知</li>
</ul>
</section>
<section>
<h2>信息共享</h2>
<p>我们承诺:</p>
<ul>
<li>不会出售您的个人信息</li>
<li>不会与第三方共享您的个人信息,除非:
<ul>
<li>获得您的明确同意</li>
<li>法律法规要求</li>
<li>保护我们或用户的权利和安全</li>
</ul>
</li>
</ul>
<h3>数据处理者</h3>
<p>在某些情况下,我们可能会使用第三方服务提供商来协助我们处理数据:</p>
<ul>
<li>网站托管服务提供商</li>
<li>数据分析服务提供商</li>
<li>电子邮件服务提供商</li>
</ul>
<p>这些服务提供商仅在必要范围内访问和处理数据,并受到严格的保密义务约束。</p>
</section>
<section>
<h2>特殊情况下的信息披露</h2>
<p>在以下特殊情况下,我们可能会披露您的个人信息:</p>
<ul>
<li>获得您的明确授权</li>
<li>根据法律法规的要求</li>
<li>为保护我们的合法权益</li>
<li>在紧急情况下保护他人的人身安全</li>
</ul>
</section>
<section>
<h2>信息存储与安全</h2>
<p>我们采取以下措施保护您的信息:</p>
<ul>
<li>使用安全的数据存储技术</li>
<li>实施访问控制机制</li>
<li>定期安全评估和更新</li>
<li>员工保密培训</li>
</ul>
<h3>数据保留</h3>
<p>我们会在以下期限内保留您的个人信息:</p>
<ul>
<li>账号相关信息:在您的账号存续期间</li>
<li>心愿单数据:直到您删除或账号注销</li>
<li>日志信息最长保留6个月</li>
</ul>
</section>
<section>
<h2>您的权利</h2>
<p>您对您的个人信息享有以下权利:</p>
<ul>
<li>访问您的个人信息</li>
<li>更正不准确的信息</li>
<li>删除您的账号和相关数据</li>
<li>撤回同意授权</li>
<li>导出您的数据</li>
<li>限制某些信息的处理</li>
</ul>
<h3>行使权利的方式</h3>
<ul>
<li>通过个人中心直接管理您的信息</li>
<li>通过网站提供的联系方式申请处理</li>
<li>我们将在15个工作日内响应您的请求</li>
</ul>
</section>
<section>
<h2>Cookie 使用</h2>
<p>我们使用 Cookie 和类似技术来:</p>
<ul>
<li>保持您的登录状态</li>
<li>记住您的偏好设置</li>
<li>提供个性化体验</li>
<li>改善网站性能</li>
</ul>
<p>详细信息请参见我们的 <a href="{{ url_for('cookie_policy') }}">Cookie 政策</a></p>
<p>您可以通过浏览器设置随时控制或删除 Cookie。但请注意禁用 Cookie 可能会影响网站的某些功能。</p>
</section>
<section>
<h2>未成年人保护</h2>
<p>我们的服务面向成年用户。如果发现我们无意中收集了未成年人的信息,我们将:</p>
<ul>
<li>及时删除相关信息</li>
<li>终止相关账号的服务</li>
<li>采取必要措施防止再次收集</li>
</ul>
<h3>家长/监护人须知</h3>
<p>如果您是未成年人的父母或监护人,发现您的孩子未经您的同意向我们提供了个人信息,请联系我们:</p>
<ul>
<li>我们将根据您的要求删除相关信息</li>
<li>为您提供必要的账号管理协助</li>
</ul>
</section>
<section>
<h2>隐私政策更新</h2>
<p>我们可能会更新本隐私政策:</p>
<ul>
<li>重大变更会通过网站公告通知</li>
<li>继续使用我们的服务表示您同意更新后的政策</li>
<li>建议您定期查看本政策了解最新变化</li>
</ul>
<h3>更新通知方式</h3>
<p>当本政策发生重大变更时,我们会:</p>
<ul>
<li>在网站显著位置发布更新通知</li>
<li>向您发送电子邮件通知</li>
<li>在更新生效前预留合理的时间供您选择是否继续使用我们的服务</li>
</ul>
</section>
<section>
<h2>联系我们</h2>
<p>如果您对本隐私政策有任何疑问或建议,请通过以下方式联系我们:</p>
<ul>
<li>通过网站提供的反馈功能</li>
<li>加入我们的用户群组</li>
<li>发送邮件至nvveex@petalmail.com</li>
</ul>
</section>
<style>
section h2 {
display: block;
margin-bottom: 20px;
font-size: 1.5em;
font-weight: 600;
color: #333;
}
section h3 {
display: block;
margin: 20px 0 12px;
font-size: 1.2em;
font-weight: 500;
color: #444;
}
[data-theme="dark"] section h2 {
color: #fff;
}
[data-theme="dark"] section h3 {
color: #eee;
}
section ul {
margin-left: 24px;
}
section ul li {
position: relative;
padding-left: 8px;
}
section ul li::before {
content: "";
position: absolute;
left: -12px;
top: 10px;
width: 4px;
height: 4px;
border-radius: 50%;
background: #666;
}
[data-theme="dark"] section ul li::before {
background: #999;
}
section p {
margin: 12px 0;
line-height: 1.6;
color: #666;
}
[data-theme="dark"] section p {
color: #bbb;
}
section a {
color: #007AFF;
text-decoration: none;
}
section a:hover {
text-decoration: underline;
}
[data-theme="dark"] section a {
color: #3a9fff;
}
</style>
{% endblock %}

589
templates/recommend.html Executable file
View File

@@ -0,0 +1,589 @@
{% extends "base.html" %}
{% block content %}
<!-- 推荐应用展示区域 -->
<div class="app-recommendation">
<a href="javascript:history.back()" class="close-button">&times;</a>
<div class="app-header">
<div class="header-title-row">
<div class="app-header-icon">
<img src="{{ url_for('static', filename='shuli.png') }}" alt="书立">
</div>
<div class="title-section">
<h1>书立<a href="https://appgallery.huawei.com/app/detail?id=com.codeartel.slinote" class="header-install-button">安装</a></h1>
</div>
</div>
</div>
<div class="app-description">
书立是一款本地优先的富文本笔记软件,拥有出众的性能,丝滑的动画,小巧的体积,丰富的功能。
</div>
<div class="feature-list">
<div class="feature-item">
<div class="feature-icon">🎨</div>
<div class="feature-content">
<div class="feature-title">富文本编辑器</div>
<div class="feature-description">
编辑器作为笔记软件的核心,其强大与否直接决定了软件的上限,书立的编辑器支持大量富文本结构,并且渲染速度很快,可以编辑超过千万字的文档。
</div>
</div>
</div>
<div class="showcase-wrapper">
<div class="showcase-image">
<img src="{{ url_for('static', filename='showcase1.png') }}" alt="富文本编辑器展示">
</div>
</div>
<div class="feature-item">
<div class="feature-icon">🎋</div>
<div class="feature-content">
<div class="feature-title">富目录树</div>
<div class="feature-description">
书立的目录树也是富文本,笔记的标题与大纲自动生成目录树,对标题与大纲设置的富文本会自动渲染到目录中,由此可以设计出丰富的目录结构,如:图文目录,表情目录,任务目录,彩色目录。
</div>
</div>
</div>
<div class="showcase-wrapper">
<div class="showcase-image">
<img src="{{ url_for('static', filename='showcase2.png') }}" alt="富目录树展示">
</div>
</div>
<div class="feature-item">
<div class="feature-icon">🗒</div>
<div class="feature-content">
<div class="feature-title">嵌套表格</div>
<div class="feature-description">
嵌套表格是书立的一大亮点支持表格的笔记软件有很多支持表格内富文本编辑的也有一些支持表格内嵌套表格的软件很少书立是少数支持表格嵌套还能富文本编辑的软件之一。书立的表格不仅可以规划信息结构还可以做UI布局比如使用表格嵌套+合并单元格设计一个康奈尔笔记模板,或者做四象限任务管理。
</div>
</div>
</div>
<div class="showcase-wrapper">
<div class="showcase-image">
<img src="{{ url_for('static', filename='showcase3.png') }}" alt="嵌套表格展示">
</div>
</div>
</div>
<div class="feature-item">
<div class="feature-icon">🪐️</div>
<div class="feature-content">
<div class="feature-title">体积小巧</div>
<div class="feature-description">
鸿蒙移动端安装包体积只有 827kb富目录树 + 富文本编辑器 + 富文本嵌套表格 + 全文搜索 + 导入导出 + 浮动目录
</div>
</div>
</div>
</div>
<div class="app-info">
<div class="company-info">
<div class="company-logo">
<img src="{{ url_for('static', filename='shuli.png') }}" alt="书立">
</div>
<h3 class="app-name">书立</h3>
<p class="app-category">实用工具 | 笔记</p>
<p class="company-name">北京源码觉醒科技有限公司</p>
</div>
<div class="install-section">
<a href="https://appgallery.huawei.com/app/detail?id=com.codeartel.slinote" class="download-button">安装</a>
</div>
</div>
</div>
<div class="bottom-popup">
<div class="popup-content">
<div class="popup-app-info">
<img src="{{ url_for('static', filename='shuli.png') }}" alt="书立" class="popup-app-icon">
<span class="popup-app-name">书立</span>
</div>
<a href="https://appgallery.huawei.com/app/detail?id=com.codeartel.slinote" class="popup-install-button">安装</a>
</div>
</div>
<style>
.app-recommendation {
background: white;
border-radius: 24px;
padding: 20px;
margin: 20px auto;
max-width: 800px;
position: relative;
}
.close-button {
position: fixed;
top: 20px;
right: 20px;
width: 40px;
height: 40px;
background: rgba(255, 255, 255, 0.8);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
color: #666;
text-decoration: none;
line-height: 1;
transition: all 0.2s ease;
cursor: pointer;
z-index: 1001;
border: 1px solid rgba(255, 255, 255, 0.2);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.close-button:hover {
background: rgba(255, 255, 255, 0.9);
transform: scale(1.05);
}
/* 暗色模式适配 */
[data-theme="dark"] .close-button {
background: rgba(26, 26, 26, 0.8);
border-color: rgba(255, 255, 255, 0.1);
color: #999;
}
[data-theme="dark"] .close-button:hover {
background: rgba(26, 26, 26, 0.9);
color: #fff;
}
@media (max-width: 768px) {
.close-button {
top: 16px;
right: 16px;
}
}
.header-title-row {
display: flex;
align-items: center;
gap: 15px;
margin-top: 25px;
}
.header-title-row h1 {
font-size: 28px;
font-weight: 600;
margin: 0;
color: #333;
display: flex;
align-items: center;
gap: 15px;
}
.header-install-button {
background: #0066ff;
color: white;
padding: 6px 24px;
border-radius: 100px;
font-size: 15px;
font-weight: 500;
text-decoration: none;
transition: background-color 0.2s ease;
margin-left: 15px;
display: inline-flex;
}
.header-install-button:active {
background: #0052cc;
}
.site-header {
display: none;
}
.app-description {
font-size: 16px;
line-height: 1.6;
color: #666;
margin-bottom: 20px;
}
.feature-list {
display: flex;
flex-direction: column;
gap: 25px;
}
.feature-item {
display: flex;
gap: 15px;
align-items: flex-start;
}
.feature-icon {
font-size: 24px;
color: #7a5af8;
flex-shrink: 0;
}
.feature-content {
flex: 1;
}
.feature-title {
font-size: 18px;
font-weight: 600;
color: #333;
margin: 0 0 8px 0;
display: flex;
align-items: center;
gap: 8px;
}
.feature-description {
font-size: 15px;
line-height: 1.6;
color: #666;
}
.app-info {
margin: 30px 0 0px 0;
padding: 20px 20px 30px 20px;
text-align: center;
border: 1px solid #eee;
border-radius: 12px;
background: #fff;
}
.company-info {
display: flex;
flex-direction: column;
align-items: center;
}
.company-logo {
width: 64px;
height: 64px;
margin-bottom: 12px;
border-radius: 12px;
overflow: hidden;
}
.company-logo img {
width: 100%;
height: 100%;
object-fit: cover;
}
.company-name {
font-size: 14px;
color: #999;
}
.install-section {
margin-top: 20px;
padding: 0 20px;
max-width: 500px;
margin-left: auto;
margin-right: auto;
}
.download-button {
background: #0066ff;
color: white;
padding: 6px 24px;
border-radius: 100px;
font-size: 15px;
font-weight: 500;
text-decoration: none;
transition: background-color 0.2s ease;
}
download-button:active {
background: #0052cc;
}
[data-theme="dark"] .close-button {
background: rgba(255, 255, 255, 0.1);
}
[data-theme="dark"] .close-button:hover {
background: rgba(255, 255, 255, 0.15);
}
@media (max-width: 768px) {
.app-recommendation {
margin: 10px;
padding: 15px;
}
}
.app-header {
margin-bottom: 20px;
}
.app-header-icon {
width: 64px;
height: 64px;
border-radius: 12px;
overflow: hidden;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
}
.app-header-icon img {
width: 100%;
height: 100%;
object-fit: cover;
}
.showcase-wrapper {
margin: 5px 0 25px;
padding: 0;
display: flex;
justify-content: center;
}
.showcase-image {
width: auto;
height: auto;
max-width: 100%;
border-radius: 12px;
overflow: hidden;
background: #f5f5f5;
}
.showcase-image img {
display: block;
max-width: 100%;
height: auto;
}
/* 暗色模式适配 */
[data-theme="dark"] .showcase-image {
background: #2a2a2a;
}
/* 响应式调整 */
@media (max-width: 768px) {
.showcase-image {
width: auto;
height: auto;
}
}
/* 暗色模式适配 */
[data-theme="dark"] .app-info {
border-color: #333;
background: #242424;
}
.title-section {
flex: 1;
display: flex;
align-items: center;
}
.app-name {
font-size: 80px;
font-weight: 700;
color: #333;
margin: 0 0 4px;
}
.app-category {
font-size: 13px;
color: #666;
margin: 0 0 8px;
}
/* 暗色模式适配 */
[data-theme="dark"] .app-name {
color: #fff;
}
[data-theme="dark"] .app-category {
color: #999;
}
footer,
.footer {
display: none !important;
}
/* 暗色模式适配 */
[data-theme="dark"] .download-button {
background: linear-gradient(to right, #0066ff, #0052cc);
}
.bottom-popup {
position: fixed;
bottom: 20px;
left: 50%;
transform: translateX(-50%) translateY(200px);
width: calc(100% - 40px);
max-width: 800px;
background: rgba(255, 255, 255, 0.6);
backdrop-filter: blur(25px) saturate(180%);
-webkit-backdrop-filter: blur(25px) saturate(180%);
padding: 16px 24px;
border-radius: 16px;
transition-property: transform, opacity, visibility;
transition-duration: 0.6s;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
z-index: 1000;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.15);
border: 1px solid rgba(255, 255, 255, 0.3);
opacity: 0;
visibility: hidden;
}
.bottom-popup.show {
transform: translateX(-50%) translateY(0);
opacity: 1;
visibility: visible;
transition-duration: 0.4s; /* 显示时可以快一点 */
}
.popup-content {
max-width: 800px;
margin: 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
}
.popup-app-info {
display: flex;
align-items: center;
gap: 12px;
}
.popup-app-icon {
width: 40px;
height: 40px;
border-radius: 8px;
object-fit: cover;
}
.popup-app-name {
font-size: 16px;
font-weight: 600;
color: #333;
}
.popup-install-button {
background: #0066ff;
color: white;
padding: 8px 24px;
border-radius: 100px;
font-size: 14px;
font-weight: 500;
text-decoration: none;
transition: all 0.2s ease;
}
.popup-install-button:active {
background: #0052cc;
transform: scale(0.98);
}
/* 暗色模式适配 */
[data-theme="dark"] .bottom-popup {
background: rgba(26, 26, 26, 0.6);
border-color: rgba(255, 255, 255, 0.15);
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.25);
}
[data-theme="dark"] .popup-app-name {
color: #fff;
}
@media (max-width: 768px) {
.bottom-popup {
bottom: 16px;
width: calc(100% - 32px);
padding: 12px 16px;
}
}
.app-info .app-name {
font-size: 20px;
font-weight: 800;
color: #333;
margin: 0 0 8px;
}
/* 暗色模式适配 */
[data-theme="dark"] .app-info .app-name {
color: #fff;
}
/* 调整过渡动画 */
.bottom-popup {
transition-property: transform, opacity, visibility !important;
transition-duration: 0.6s !important;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1) !important;
}
.bottom-popup.show {
transition-duration: 0.4s !important;
}
</style>
<script>
document.addEventListener('DOMContentLoaded', function() {
const popup = document.querySelector('.bottom-popup');
const appInfo = document.querySelector('.app-info');
let isTransitioning = false;
function checkScroll() {
const appInfoRect = appInfo.getBoundingClientRect();
const windowHeight = window.innerHeight;
// 当 app-info 的顶部进入视口底部 100px 范围内时就隐藏弹窗
if (appInfoRect.top <= windowHeight + 100) {
if (!isTransitioning && popup.classList.contains('show')) {
isTransitioning = true;
popup.style.transition = 'all 0.6s cubic-bezier(0.4, 0, 0.2, 1)';
popup.classList.remove('show');
setTimeout(() => {
isTransitioning = false;
}, 600);
}
} else {
if (!isTransitioning && !popup.classList.contains('show')) {
isTransitioning = true;
popup.style.transition = 'all 0.4s cubic-bezier(0.4, 0, 0.2, 1)';
popup.classList.add('show');
setTimeout(() => {
isTransitioning = false;
}, 400);
}
}
}
// 使用 throttle 优化滚动事件
let ticking = false;
window.addEventListener('scroll', () => {
if (!ticking) {
window.requestAnimationFrame(() => {
checkScroll();
ticking = false;
});
ticking = true;
}
});
// 初始检查
checkScroll();
});
</script>
{% endblock %}
{% block footer %}{% endblock %}

34
templates/register.html Executable file
View File

@@ -0,0 +1,34 @@
{% extends "base.html" %}
{% block content %}
<div class="login-container">
<div class="login-box">
<div class="login-header">
<h2>NEXT Store</h2>
<p>注册管理员</p>
</div>
<form method="POST" class="login-form">
<div class="form-group">
<i class="fas fa-key"></i>
<input type="password" name="access_code" placeholder="访问密码" required>
</div>
<div class="form-group">
<i class="fas fa-user"></i>
<input type="text" name="username" placeholder="用户名" required>
</div>
<div class="form-group">
<i class="fas fa-lock"></i>
<input type="password" name="password" placeholder="密码" required>
</div>
<div class="form-group">
<i class="fas fa-lock"></i>
<input type="password" name="confirm_password" placeholder="确认密码" required>
</div>
<button type="submit">注册</button>
</form>
<div class="login-footer">
<a href="{{ url_for('login') }}">返回登录</a>
</div>
</div>
</div>
{% endblock %}

1
templates/search_results.html Executable file
View File

@@ -0,0 +1 @@

520
templates/site_settings.html Executable file
View File

@@ -0,0 +1,520 @@
{% extends "base.html" %}
{% block content %}
{% include 'admin_nav.html' %}
<div class="admin-container">
<div class="admin-content">
<div class="settings-layout">
<!-- 左侧导航 -->
<div class="settings-nav">
<div class="nav-item active" data-target="basic">
<i class="fas fa-cog"></i>
<span>基础设置</span>
</div>
<div class="nav-item" data-target="groups">
<i class="fas fa-users"></i>
<span>群组设置</span>
</div>
<div class="nav-item" data-target="watermark">
<i class="fas fa-image"></i>
<span>水印设置</span>
</div>
</div>
<!-- 右侧内容区 -->
<div class="settings-content">
<!-- 基础设置 -->
<div class="settings-section active" id="basic-section">
<div class="section-header">
<h2>基础设置</h2>
<p class="section-desc">设置站点的基本信息和通知内容</p>
</div>
<form onsubmit="submitSettings(event, 'basic')" class="admin-form">
<div class="form-group">
<label for="site-notice">站点通知</label>
<textarea id="site-notice" name="site_notice" rows="3" required>{{ settings.site_notice }}</textarea>
</div>
<div class="form-group">
<label for="feedback-link">反馈链接</label>
<input type="url" id="feedback-link" name="feedback_link" value="{{ settings.feedback_link }}" required>
</div>
<div class="form-group">
<label for="discord-link">Discord 频道链接</label>
<input type="url" id="discord-link" name="discord_link" value="{{ settings.discord_link }}">
</div>
<div class="form-group">
<label for="icp-number">网站备案号</label>
<input type="text" id="icp-number" name="icp_number"
value="{{ settings.icp_number }}"
placeholder="例如京ICP备XXXXXXXX号">
</div>
<div class="form-group">
<label for="grayscale-enabled">网页黑白效果</label>
<div class="switch-wrapper">
<label class="switch">
<input type="checkbox" id="grayscale-enabled" name="grayscale_enabled"
{% if settings.grayscale_enabled == '1' %}checked{% endif %}>
<span class="slider round"></span>
</label>
<span class="switch-label">启用网页黑白效果(用于特殊纪念日)</span>
</div>
</div>
<button type="submit" class="btn-primary">
<i class="fas fa-save"></i> 保存设置
</button>
</form>
</div>
<!-- 群组设置 -->
<div class="settings-section" id="groups-section">
<div class="section-header">
<h2>群组设置</h2>
<p class="section-desc">管理QQ群和微信群的相关配置</p>
</div>
<form onsubmit="submitSettings(event, 'groups')" class="admin-form">
<div class="settings-grid">
<!-- QQ群设置 -->
<div class="setting-card">
<div class="card-header">
<i class="fab fa-qq"></i>
<h3>QQ群设置</h3>
</div>
<div class="card-body">
<div class="form-group">
<label>按钮文字</label>
<input type="text" name="qq_group_text" value="{{ settings.qq_group_text }}" class="form-control">
</div>
<div class="form-group">
<label>群号</label>
<input type="text" name="qq_group_number" value="{{ settings.qq_group_number }}" class="form-control">
</div>
<div class="form-group">
<label>加群链接</label>
<input type="text" name="qq_group_link" value="{{ settings.qq_group_link }}" class="form-control">
</div>
<div class="form-group">
<label>群二维码</label>
<input type="file" name="qq_group_qrcode" accept="image/*" class="form-control">
{% if settings.qq_group_qrcode %}
<div class="preview-image">
<img src="{{ settings.qq_group_qrcode }}" alt="QQ群二维码">
</div>
{% endif %}
</div>
</div>
</div>
<!-- 微信群设置 -->
<div class="setting-card">
<div class="card-header">
<i class="fab fa-weixin"></i>
<h3>微信群设置</h3>
</div>
<div class="card-body">
<div class="form-group">
<label>按钮文字</label>
<input type="text" name="wechat_group_text" value="{{ settings.wechat_group_text }}" class="form-control">
</div>
<div class="form-group">
<label>群二维码</label>
<input type="file" name="wechat_group_qrcode" accept="image/*" class="form-control">
{% if settings.wechat_group_qrcode %}
<div class="preview-image">
<img src="{{ settings.wechat_group_qrcode }}" alt="微信群二维码">
</div>
{% endif %}
</div>
</div>
</div>
</div>
<button type="submit" class="btn-primary">
<i class="fas fa-save"></i> 保存设置
</button>
</form>
</div>
<!-- 水印设置 -->
<div class="settings-section" id="watermark-section">
<div class="section-header">
<h2>水印设置</h2>
<p class="section-desc">设置应用图标的水印文本</p>
</div>
<form onsubmit="submitSettings(event, 'watermark')" class="admin-form">
<div class="form-group">
<label for="watermark-text-1">水印文本 1</label>
<input type="text" id="watermark-text-1" name="watermark_text_1"
value="{{ settings.watermark_text_1 }}"
placeholder="输入第一个水印文本">
</div>
<div class="form-group">
<label for="watermark-text-2">水印文本 2</label>
<input type="text" id="watermark-text-2" name="watermark_text_2"
value="{{ settings.watermark_text_2 }}"
placeholder="输入第二个水印文本">
</div>
<button type="submit" class="btn-primary">
<i class="fas fa-save"></i> 保存设置
</button>
</form>
</div>
</div>
</div>
</div>
</div>
<style>
/* 主布局 */
.settings-layout {
display: flex;
gap: 15px;
padding: 15px;
min-height: calc(100vh - 60px);
}
/* 左侧导航 */
.settings-nav {
width: 180px;
background: white;
border-radius: 8px;
padding: 10px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.nav-item {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
border-radius: 6px;
cursor: pointer;
transition: all 0.3s ease;
}
.nav-item i {
font-size: 16px;
width: 20px;
text-align: center;
}
.nav-item.active {
background: #007AFF;
color: white;
}
.nav-item:not(.active):hover {
background: #f5f5f7;
}
/* 右侧内容区 */
.settings-content {
flex: 1;
background: white;
border-radius: 8px;
padding: 20px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.settings-section {
display: none;
}
.settings-section.active {
display: block;
}
.section-header {
margin-bottom: 30px;
}
.section-header h2 {
margin: 0;
font-size: 24px;
color: #333;
}
.section-desc {
margin: 8px 0 0 0;
color: #666;
font-size: 14px;
}
/* 表单样式 */
.settings-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 20px;
margin-bottom: 20px;
}
.setting-card {
background: #f5f5f7;
border-radius: 12px;
padding: 20px;
}
.card-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 20px;
}
.card-header i {
font-size: 20px;
}
.card-header h3 {
margin: 0;
font-size: 18px;
}
.form-group {
margin-bottom: 20px;
}
.form-control {
width: 100%;
padding: 10px;
border: 1px solid #d2d2d7;
border-radius: 8px;
font-size: 14px;
transition: all 0.3s ease;
}
.form-control:focus {
border-color: #007AFF;
box-shadow: 0 0 0 2px rgba(0,122,255,0.1);
outline: none;
}
textarea.form-control {
min-height: 100px;
resize: vertical;
}
.preview-image {
margin-top: 10px;
}
.preview-image img {
max-width: 200px;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
/* 按钮样式 */
.btn-primary {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 20px;
background: #007AFF;
color: white;
border: none;
border-radius: 8px;
font-size: 14px;
cursor: pointer;
transition: all 0.3s ease;
}
.btn-primary:hover {
background: #0066CC;
transform: translateY(-1px);
}
/* 暗色模式 */
[data-theme="dark"] .settings-nav,
[data-theme="dark"] .settings-content {
background: #1c1c1e;
}
[data-theme="dark"] .setting-card {
background: #2c2c2e;
}
[data-theme="dark"] .section-header h2 {
color: #fff;
}
[data-theme="dark"] .section-desc {
color: #999;
}
[data-theme="dark"] .form-control {
background: #2c2c2e;
border-color: #3a3a3c;
color: #fff;
}
[data-theme="dark"] .nav-item:not(.active):hover {
background: #2c2c2e;
}
/* 响应式布局 */
@media (max-width: 768px) {
.settings-layout {
flex-direction: column;
}
.settings-nav {
width: 100%;
}
.settings-grid {
grid-template-columns: 1fr;
}
}
/* 开关样式 */
.switch-wrapper {
display: flex;
align-items: center;
gap: 10px;
}
.switch {
position: relative;
display: inline-block;
width: 50px;
height: 24px;
}
.switch input {
opacity: 0;
width: 0;
height: 0;
}
.slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #ccc;
transition: .4s;
border-radius: 24px;
}
.slider:before {
position: absolute;
content: "";
height: 18px;
width: 18px;
left: 3px;
bottom: 3px;
background-color: white;
transition: .4s;
border-radius: 50%;
}
input:checked + .slider {
background-color: #007AFF;
}
input:checked + .slider:before {
transform: translateX(26px);
}
.switch-label {
font-size: 14px;
color: #666;
}
/* 暗色模式适配 */
[data-theme="dark"] .switch-label {
color: #999;
}
</style>
<script>
// 标签页切换
document.querySelectorAll('.nav-item').forEach(item => {
item.addEventListener('click', () => {
// 更新导航项状态
document.querySelectorAll('.nav-item').forEach(nav => {
nav.classList.remove('active');
});
item.classList.add('active');
// 更新内容区域
const target = item.dataset.target;
document.querySelectorAll('.settings-section').forEach(section => {
section.classList.remove('active');
});
document.getElementById(`${target}-section`).classList.add('active');
});
});
// 表单提交
function submitSettings(event, type) {
event.preventDefault();
const form = event.target;
const formData = new FormData(form);
formData.append('type', type);
fetch('/admin/update_site_settings', {
method: 'POST',
body: formData
})
.then(response => response.json())
.then(data => {
if (data.success) {
showNotification(`${type}设置已更新`, 'success');
setTimeout(() => location.reload(), 1000);
} else {
showNotification(data.error || '更新失败', 'error');
}
})
.catch(error => {
console.error('Error:', error);
showNotification('更新失败,请重试', 'error');
});
}
// 通知提示
function showNotification(message, type = 'success') {
const notification = document.createElement('div');
notification.className = `notification ${type}`;
notification.textContent = message;
Object.assign(notification.style, {
position: 'fixed',
bottom: '20px',
right: '20px',
padding: '10px 20px',
borderRadius: '8px',
backgroundColor: type === 'success' ? '#4CAF50' : '#f44336',
color: 'white',
zIndex: '1000',
opacity: '0',
transform: 'translateY(20px)',
transition: 'all 0.3s ease'
});
document.body.appendChild(notification);
notification.offsetHeight;
notification.style.opacity = '1';
notification.style.transform = 'translateY(0)';
setTimeout(() => {
notification.style.opacity = '0';
notification.style.transform = 'translateY(20px)';
setTimeout(() => notification.remove(), 300);
}, 3000);
}
</script>
{% endblock %}

299
templates/tablet_filter.html Executable file
View File

@@ -0,0 +1,299 @@
{% extends "base.html" %}
{% block content %}
{% include 'admin_nav.html' %}
<div class="admin-container">
<div class="admin-content">
<div class="admin-card">
<div class="card-header">
<div class="header-left">
<i class="fas fa-tablet-alt"></i>
<h3>平板应用筛选</h3>
</div>
</div>
<div class="filter-form">
<textarea id="app-names" placeholder="请输入应用名称,批量输入用英文逗号分隔"></textarea>
<button onclick="filterTabletApps()" class="btn-primary">
<i class="fas fa-filter"></i> 开始筛选
</button>
</div>
<div class="filter-result-content" style="display: none;">
<div class="result-section">
<div class="success-section">
<div class="section-header">
<i class="fas fa-check-circle"></i>
<span class="success-count">成功: 0</span>
</div>
</div>
<div class="failed-section">
<div class="section-header">
<i class="fas fa-times-circle"></i>
<span class="failed-count">失败: 0</span>
</div>
<div class="failed-category">
<div class="category-header">
<i class="fas fa-tablet-alt"></i>
<span>已在平板区</span>
</div>
<div class="failed-list exists-list"></div>
</div>
<div class="failed-category">
<div class="category-header">
<i class="fas fa-search"></i>
<span>未找到应用</span>
</div>
<div class="failed-list not-found-list"></div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<style>
.filter-form {
margin-bottom: 20px;
}
#app-names {
width: 100%;
height: 200px;
padding: 10px;
border: 1px solid #d2d2d7;
border-radius: 8px;
margin-bottom: 15px;
font-size: 14px;
resize: vertical;
}
.filter-result-content {
margin-top: 20px;
padding: 15px;
border-radius: 8px;
background: #f5f5f7;
}
.result-section {
display: flex;
flex-direction: column;
gap: 15px;
}
.success-section,
.failed-section {
padding: 15px;
border-radius: 8px;
background: white;
}
.section-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 10px;
font-size: 16px;
font-weight: 500;
}
.success-section .section-header {
color: #34C759;
}
.failed-section .section-header {
color: #FF3B30;
}
.section-header i {
font-size: 18px;
}
.success-count,
.failed-count {
font-size: 14px;
}
/* 添加失败列表样式 */
.failed-list {
margin-top: 10px;
display: flex;
flex-direction: column;
gap: 8px;
}
.failed-item {
padding: 12px 15px;
background: white;
border-radius: 6px;
font-size: 14px;
margin-bottom: 8px;
line-height: 1.5;
}
.failed-item .app-names {
font-weight: 500;
word-break: break-all;
}
.failed-category {
margin-top: 15px;
background: #f8f9fa;
border-radius: 6px;
overflow: hidden;
}
.category-header {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
background: #f1f1f1;
font-size: 14px;
color: #666;
}
.category-header i {
font-size: 14px;
}
.failed-list {
padding: 8px;
}
.failed-list:empty {
display: none;
}
.exists-list .failed-item {
border-left: 3px solid #007AFF;
}
.not-found-list .failed-item {
border-left: 3px solid #FF3B30;
}
</style>
<script>
function filterTabletApps() {
const appNames = document.getElementById('app-names').value
.split(',') // 改为用逗号分隔
.map(name => name.trim())
.filter(name => name);
if (!appNames.length) {
showNotification('请输入应用名称', 'error');
return;
}
fetch('{{ url_for("filter_tablet_apps") }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ app_names: appNames })
})
.then(response => response.json())
.then(data => {
if (data.success) {
showResults(data.results);
showNotification(data.message, 'success');
} else {
showNotification(data.error || '筛选失败', 'error');
}
})
.catch(error => {
console.error('Error:', error);
showNotification('筛选失败,请重试', 'error');
});
}
function showResults(results) {
const resultContent = document.querySelector('.filter-result-content');
const successCount = document.querySelector('.success-count');
const failedCount = document.querySelector('.failed-count');
const existsList = document.querySelector('.exists-list');
const notFoundList = document.querySelector('.not-found-list');
// 更新计数
successCount.textContent = `成功: ${results.success.length}`;
// 清空列表
existsList.innerHTML = '';
notFoundList.innerHTML = '';
// 收集分类的应用名称
let existsApps = [];
let notFoundApps = [];
// 分类收集应用名称
Object.entries(results.failed).forEach(([appName, reason]) => {
if (reason.includes('已在平板区')) {
existsApps.push(appName);
} else if (reason.includes('未找到')) {
notFoundApps.push(appName);
}
});
// 创建已存在应用的显示项
if (existsApps.length > 0) {
const existsItem = document.createElement('div');
existsItem.className = 'failed-item';
existsItem.innerHTML = `<span class="app-names">${existsApps.join(', ')}</span>`;
existsList.appendChild(existsItem);
}
// 创建未找到应用的显示项
if (notFoundApps.length > 0) {
const notFoundItem = document.createElement('div');
notFoundItem.className = 'failed-item';
notFoundItem.innerHTML = `<span class="app-names">${notFoundApps.join(', ')}</span>`;
notFoundList.appendChild(notFoundItem);
}
// 更新总失败数
failedCount.textContent = `失败: ${existsApps.length + notFoundApps.length}`;
// 显示结果区域
resultContent.style.display = 'block';
}
function showNotification(message, type = 'success') {
const notification = document.createElement('div');
notification.className = `notification ${type}`;
notification.textContent = message;
Object.assign(notification.style, {
position: 'fixed',
bottom: '20px',
right: '20px',
padding: '10px 20px',
borderRadius: '4px',
backgroundColor: type === 'success' ? '#4CAF50' : '#f44336',
color: 'white',
zIndex: '1000',
opacity: '0',
transform: 'translateY(20px)',
transition: 'all 0.3s ease'
});
document.body.appendChild(notification);
notification.offsetHeight;
notification.style.opacity = '1';
notification.style.transform = 'translateY(0)';
setTimeout(() => {
notification.style.opacity = '0';
notification.style.transform = 'translateY(20px)';
setTimeout(() => notification.remove(), 300);
}, 3000);
}
</script>
{% endblock %}

298
templates/terms.html Executable file
View File

@@ -0,0 +1,298 @@
{% extends "policy_base.html" %}
{% block policy_header %}使用条款{% endblock %}
{% block policy_icon %}fas fa-file-alt{% endblock %}
{% block policy_title %}使用条款{% endblock %}
{% block policy_content %}
<div class="policy-update-date">
<p>最近更新2025年1月15日</p>
</div>
<section>
<h2>接受条款</h2>
<p>欢迎使用 NEXT Store访问和使用我们的服务表示您接受这些使用条款。如果您不同意这些条款的任何部分请停止使用我们的服务。我们保留随时修改这些条款的权利修改后的条款将立即生效。</p>
</section>
<section>
<h2>服务说明</h2>
<p>NEXT Store 是一个应用展示平台,我们提供以下服务:</p>
<ul>
<li>应用信息的浏览和搜索</li>
<li>应用分类导航和推荐</li>
<li>用户交互和反馈功能</li>
<li>应用更新通知服务</li>
<li>主题切换等个性化功能</li>
<li>社区交流和分享功能</li>
</ul>
</section>
<section>
<h2>服务范围与限制</h2>
<h3>服务适用范围</h3>
<p>本条款适用于 NEXT Store 提供的所有服务,包括但不限于:</p>
<ul>
<li>网页端服务</li>
<li>移动应用程序</li>
<li>API 接口</li>
<li>社区交流平台</li>
</ul>
<h3>服务使用限制</h3>
<p>我们保留以下服务使用限制权利:</p>
<ul>
<li>对用户的使用行为设置合理限制</li>
<li>要求用户提供真实、有效的注册信息</li>
<li>拒绝为不符合条件的用户提供服务</li>
<li>对违规用户采取警告、暂停或永久封禁等措施</li>
</ul>
</section>
<section>
<h2>服务变更</h2>
<p>我们保留随时修改或终止服务的权利,包括但不限于:</p>
<ul>
<li>调整网站功能和界面</li>
<li>更新服务内容和范围</li>
<li>变更使用条件和限制</li>
<li>调整技术要求和标准</li>
</ul>
<p>对于服务的任何变更,我们将通过适当方式通知用户。继续使用本服务即表示您接受这些变更。</p>
</section>
<section>
<h2>用户内容</h2>
<p>关于用户在网站上发布、上传或分享的内容:</p>
<ul>
<li>您保留您原创内容的所有权利</li>
<li>您授予我们使用、存储和展示该内容的许可</li>
<li>您确保您有权分享该内容</li>
<li>您同意对您发布的内容负责</li>
</ul>
<h3>内容审核</h3>
<p>我们保留但不承担义务来审核、筛选或删除任何用户内容。我们可能:</p>
<ul>
<li>删除违反本协议的内容</li>
<li>处理侵权投诉</li>
<li>配合执法部门的合法要求</li>
</ul>
</section>
<section>
<h2>用户责任</h2>
<p>使用我们的服务时,您同意:</p>
<ul>
<li>提供真实、准确、完整的信息</li>
<li>遵守所有适用的法律和法规</li>
<li>不从事任何可能损害网站运行的行为</li>
<li>不进行任何未经授权的访问或使用</li>
<li>不发布任何违法、侵权或不当内容</li>
<li>不使用自动化工具干扰网站正常运行</li>
<li>不传播恶意软件或有害信息</li>
<li>保护您的账户安全</li>
</ul>
</section>
<section>
<h2>禁止行为</h2>
<p>您承诺并同意以合法道德的方式依照本协议访问并使用本服务。您不得利用本服务从事以下行为:</p>
<h3>违法违规内容</h3>
<p>上传、下载、存储、发布、传输或以其他方式提供如下法律、法规和政策禁止的内容:</p>
<ul>
<li>反对宪法所确定的基本原则的内容</li>
<li>危害国家安全,泄露国家秘密,颠覆国家政权,破坏国家统一的内容</li>
<li>损害国家荣誉和利益的内容</li>
<li>煽动民族仇恨、民族歧视,破坏民族团结的内容</li>
<li>破坏国家宗教政策,宣扬邪教和封建迷信的内容</li>
<li>散布谣言,扰乱社会秩序,破坏社会稳定的内容</li>
<li>散布淫秽、色情、赌博、暴力、凶杀、恐怖或者教唆犯罪的内容</li>
<li>侮辱或者诽谤他人,侵害他人合法权益的内容</li>
<li>危害社会公德或者民族优秀文化传统的内容</li>
</ul>
<h3>侵权行为</h3>
<ul>
<li>侵害他人隐私权、商业秘密、商标权、著作权、专利权等合法权益</li>
<li>未经授权访问或尝试访问本服务的系统或网络</li>
<li>复制、修改或分发本服务的内容</li>
<li>冒充他人或虚假声明与他人的关系</li>
<li>收集用户信息或使用自动化手段访问本服务</li>
</ul>
<h3>技术滥用</h3>
<ul>
<li>分解、解码或逆向开发本服务</li>
<li>植入病毒或其他恶意代码</li>
<li>使用爬虫、脚本等自动化工具</li>
<li>干扰或损害服务器正常运行</li>
<li>尝试破解或入侵系统</li>
</ul>
<h3>商业滥用</h3>
<ul>
<li>未经许可将本服务用于商业用途</li>
<li>从事任何非法交易活动</li>
<li>发布虚假或误导性商业信息</li>
<li>参与洗钱、套现或传销活动</li>
</ul>
</section>
<section>
<h2>处罚措施</h2>
<p>如果发现或收到举报您违反上述规定,我们有权:</p>
<ul>
<li>独立判断并删除、屏蔽相关内容</li>
<li>暂停或终止您的使用权限</li>
<li>保存相关证据并向有关部门报告</li>
<li>要求您承担相应的法律责任</li>
<li>要求您赔偿可能造成的损失</li>
</ul>
<p>您理解并同意,对于违反协议的行为,您应独自承担由此产生的一切法律责任,包括但不限于对第三方的赔偿责任。</p>
</section>
<section>
<h2>知识产权</h2>
<p>网站上的现有内容,包括但不限于:</p>
<ul>
<li>文本、文章和描述</li>
<li>图像、图标和标志</li>
<li>界面设计和布局</li>
<li>程序代码和功能</li>
<li>数据库内容</li>
</ul>
<p>均受适用的知识产权法律保护。未经明确许可,不得复制、修改、传播或用于商业目的。</p>
</section>
<section>
<h2>免责声明</h2>
<p>我们的服务按"现状"提供,不提供任何明示或暗示的保证。具体而言:</p>
<ul>
<li>我们不保证服务永久可用或不会中断</li>
<li>我们不对服务的及时性、安全性或错误做出保证</li>
<li>我们不对用户产生的任何直接或间接损失负责</li>
<li>我们不对第三方链接的内容负责</li>
<li>我们保留随时修改或终止服务的权利</li>
</ul>
</section>
<section>
<h2>隐私保护</h2>
<p>我们重视您的隐私保护,具体请参见我们的<a href="{{ url_for('privacy') }}">隐私政策</a>。使用我们的服务即表示您同意:</p>
<ul>
<li>我们按照隐私政策收集和使用您的信息</li>
<li>我们可能使用 Cookie 和类似技术</li>
<li>我们可能向您发送服务相关通知</li>
</ul>
</section>
<section>
<h2>条款修改</h2>
<p>我们保留随时修改这些条款的权利,修改后:</p>
<ul>
<li>我们会在网站上发布更新的条款</li>
<li>重大更改将通过网站通知</li>
<li>继续使用我们的服务即表示您接受修改后的条款</li>
<li>如果您不同意修改后的条款,应停止使用我们的服务</li>
</ul>
</section>
<section>
<h2>联系我们</h2>
<p>如果您对这些条款有任何疑问或建议,请通过网站提供的联系方式与我们联系。我们会认真对待每一个反馈,并在合理的时间内回复您的询问。</p>
</section>
<section>
<h2>争议解决与法律适用</h2>
<p>对于因使用我们的服务而产生的任何争议:</p>
<ul>
<li>双方应首先通过友好协商解决</li>
<li>协商无法解决的,提交至网站运营地有管辖权的人民法院</li>
<li>适用中华人民共和国法律</li>
</ul>
</section>
<section>
<h2>其他规定</h2>
<ul>
<li>本协议的任何条款被认定无效的,不影响其他条款的效力</li>
<li>我们未行使或执行本协议中的任何权利或规定,不构成对该权利或规定的放弃</li>
<li>本协议中的标题仅为方便阅读,不影响协议的解释</li>
</ul>
</section>
<style>
section h2 {
display: block;
margin-bottom: 20px;
font-size: 1.5em;
font-weight: 600;
color: #333;
}
section h3 {
display: block;
margin: 20px 0 12px;
font-size: 1.2em;
font-weight: 500;
color: #444;
}
[data-theme="dark"] section h2 {
color: #fff;
}
[data-theme="dark"] section h3 {
color: #eee;
}
section p {
margin: 12px 0;
line-height: 1.6;
color: #666;
}
[data-theme="dark"] section p {
color: #bbb;
}
section a {
color: #007AFF;
text-decoration: none;
}
section a:hover {
text-decoration: underline;
}
[data-theme="dark"] section a {
color: #3a9fff;
}
/* 优化列表样式 */
section ul {
margin-left: 24px;
}
section ul li {
position: relative;
padding-left: 8px;
}
section ul li::before {
content: "";
position: absolute;
left: -12px;
top: 10px;
width: 4px;
height: 4px;
border-radius: 50%;
background: #666;
}
[data-theme="dark"] section ul li::before {
background: #999;
}
</style>
{% endblock %}

363
templates/user_management.html Executable file
View File

@@ -0,0 +1,363 @@
{% extends "base.html" %}
{% block content %}
{% include 'admin_nav.html' %}
<div class="admin-container">
<div class="admin-content">
<div class="admin-card">
<div class="card-header">
<div class="header-left">
<i class="fas fa-user-plus"></i>
<h3>添加管理员</h3>
</div>
</div>
<form onsubmit="submitAddAdminForm(event, this)" class="admin-form">
<div class="form-group">
<label for="username">用户名</label>
<input type="text" id="username" name="username" placeholder="请输入用户名" required>
</div>
<div class="form-group">
<label for="password">密码</label>
<div class="password-input-wrapper">
<input type="password" id="password" name="password" placeholder="请输入密码" required>
<button type="button" class="toggle-password" onclick="togglePassword('password')">
<i class="fas fa-eye"></i>
</button>
</div>
</div>
<div class="form-group">
<label for="confirm_password">确认密码</label>
<div class="password-input-wrapper">
<input type="password" id="confirm_password" name="confirm_password" placeholder="请再次输入密码" required>
<button type="button" class="toggle-password" onclick="togglePassword('confirm_password')">
<i class="fas fa-eye"></i>
</button>
</div>
</div>
<button type="submit" class="btn-primary">
<i class="fas fa-plus"></i> 添加管理员
</button>
</form>
</div>
<div class="admin-card full-width">
<div class="card-header">
<div class="header-left">
<i class="fas fa-users"></i>
<h3>管理员列表</h3>
</div>
<form class="search-form header-search" method="GET">
<div class="search-wrapper">
<i class="fas fa-search"></i>
<input type="text" name="search" placeholder="搜索管理员..." value="{{ request.args.get('search', '') }}">
</div>
</form>
</div>
<div class="table-responsive">
<table class="admin-table">
<thead>
<tr>
<th>用户名</th>
<th>角色</th>
<th>创建时间</th>
<th>最后登录</th>
<th>登录次数</th>
<th>最后操作</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{% for admin in admins %}
<tr>
<td>{{ admin.username }}</td>
<td>{{ '超级管理员' if admin.is_superadmin else '管理员' }}</td>
<td>{{ admin.created_at }}</td>
<td>{{ (admin.last_login|default('从未登录', true))|datetime_format }}</td>
<td>{{ admin.login_count }}</td>
<td>{{ admin.last_action or '无' }}</td>
<td>
<div class="action-buttons">
{% if not admin.is_superadmin %}
<a href="#" class="btn-view" onclick="showLogs('{{ admin.id }}'); return false;">
<i class="fas fa-history"></i>
</a>
<a href="{{ url_for('reset_admin_password', admin_id=admin.id) }}"
class="btn-edit"
onclick="return confirm('确定要重置该管理员的密码吗?')">
<i class="fas fa-key"></i>
</a>
<a href="#" class="btn-delete" onclick="deleteAdmin('{{ admin.id }}'); return false;">
<i class="fas fa-trash"></i>
</a>
{% endif %}
</div>
</td>
</tr>
<!-- 管理员日志详情 -->
<tr id="logs-{{ admin.id }}" style="display: none;">
<td colspan="8">
<div class="admin-logs">
<h4>最近操作记录</h4>
<table class="logs-table">
<thead>
<tr>
<th>时间</th>
<th>操作</th>
<th>IP地址</th>
<th>浏览器信息</th>
</tr>
</thead>
<tbody>
{% for log in admin_logs[admin.id] %}
<tr>
<td>{{ log.created_at|datetime_format }}</td>
<td>{{ log.action }}</td>
<td>{{ log.ip_address }}</td>
<td>
{% if 'Mobile' in log.user_agent %}
{% if 'Android' in log.user_agent %}
{% if 'MI' in log.user_agent or 'XiaoMi' in log.user_agent or 'Redmi' in log.user_agent %}
小米{{ log.user_agent.split('MI ')[1].split(' ')[0] if 'MI ' in log.user_agent else log.user_agent.split('Redmi ')[1].split(' ')[0] if 'Redmi ' in log.user_agent else '' }}
{% elif 'HUAWEI' in log.user_agent %}
华为{{ log.user_agent.split('HUAWEI ')[1].split(' ')[0] if 'HUAWEI ' in log.user_agent else '' }}
{% elif 'HONOR' in log.user_agent %}
荣耀{{ log.user_agent.split('HONOR ')[1].split(' ')[0] if 'HONOR ' in log.user_agent else '' }}
{% elif 'OPPO' in log.user_agent %}
OPPO{{ log.user_agent.split('OPPO ')[1].split(' ')[0] if 'OPPO ' in log.user_agent else '' }}
{% elif 'vivo' in log.user_agent %}
vivo{{ log.user_agent.split('vivo ')[1].split(' ')[0] if 'vivo ' in log.user_agent else '' }}
{% elif 'SAMSUNG' in log.user_agent %}
三星{{ log.user_agent.split('SAMSUNG ')[1].split(' ')[0] if 'SAMSUNG ' in log.user_agent else '' }}
{% elif 'Build/' in log.user_agent %}
{% set model = log.user_agent.split('Build/')[0].split(';')[-1].strip() %}
{{ model if model else 'HarmonyOS NEXT' }}
{% else %}
HarmonyOS NEXT
{% endif %}
{% elif 'iPhone' in log.user_agent %}
iPhone{{ log.user_agent.split('iPhone ')[1].split(' ')[0] if 'iPhone ' in log.user_agent else '' }}
{% elif 'iPad' in log.user_agent %}
iPad{{ log.user_agent.split('iPad ')[1].split(' ')[0] if 'iPad ' in log.user_agent else '' }}
{% else %}
HarmonyOS NEXT
{% endif %}
{% else %}
{% if 'Windows' in log.user_agent %}
Windows{% if 'Win64' in log.user_agent %} 64位{% endif %}
{% elif 'Macintosh' in log.user_agent %}
macOS
{% elif 'Linux' in log.user_agent %}
Linux{% if 'Ubuntu' in log.user_agent %} (Ubuntu){% elif 'Fedora' in log.user_agent %} (Fedora){% elif 'Debian' in log.user_agent %} (Debian){% endif %}
{% else %}
HarmonyOS NEXT
{% endif %}
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
<style>
.password-input-wrapper {
position: relative;
display: flex;
align-items: center;
width: 100%;
}
.password-input-wrapper input {
width: 100%;
padding: 8px 40px 8px 12px;
border: 1px solid #d2d2d7;
border-radius: 6px;
font-size: 14px;
transition: all 0.3s ease;
}
.toggle-password {
position: absolute;
right: 12px;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
color: #666;
cursor: pointer;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
z-index: 2;
}
.toggle-password:hover {
color: #333;
}
.toggle-password:focus {
outline: none;
}
.password-input-wrapper input:hover {
border-color: #b2b2b2;
}
.password-input-wrapper input:focus {
border-color: #0066cc;
outline: none;
}
</style>
<script>
// 添加通知显示函数
function showNotification(message, type = 'success') {
const notification = document.createElement('div');
notification.className = `notification ${type}`;
notification.textContent = message;
notification.style.position = 'fixed';
notification.style.bottom = '20px';
notification.style.right = '20px';
notification.style.padding = '10px 20px';
notification.style.borderRadius = '4px';
notification.style.backgroundColor = type === 'success' ? '#4CAF50' : '#f44336';
notification.style.color = 'white';
notification.style.zIndex = '1000';
notification.style.animation = 'fadeInOut 3s forwards';
document.body.appendChild(notification);
setTimeout(() => {
notification.remove();
}, 3000);
}
// 修改添加管理员函数
function submitAddAdminForm(event, form) {
event.preventDefault();
const formData = new FormData(form);
fetch('{{ url_for("add_admin") }}', {
method: 'POST',
body: formData
})
.then(response => response.json())
.then(data => {
if (data.success) {
showNotification(data.message, 'success');
form.reset();
// 刷新页面以显示新管理员
location.reload();
} else {
showNotification(data.error, 'error');
}
})
.catch(error => {
showNotification('操作失败,请重试', 'error');
console.error('Error:', error);
});
}
// 修改删除管理员函数
function deleteAdmin(adminId) {
if (!confirm('确定要删除这个管理员吗?')) return;
fetch(`/delete_admin/${adminId}`, {
method: 'POST'
})
.then(response => response.json())
.then(data => {
if (data.success) {
showNotification(data.message, 'success');
// 使用更可靠的选择器
const adminRows = document.querySelectorAll('tr');
adminRows.forEach(row => {
// 检查行中是否包含带有特定 onclick 属性的删除按钮
const deleteButton = row.querySelector(`a[onclick*="deleteAdmin(${adminId})"]`);
if (deleteButton) {
row.remove();
// 同时删除对应的日志行
const logsRow = document.getElementById(`logs-${adminId}`);
if (logsRow) {
logsRow.remove();
}
}
});
} else {
showNotification(data.error, 'error');
}
})
.catch(error => {
showNotification('删除失败,请重试', 'error');
console.error('Error:', error);
});
}
// 修改日志查看函数
function showLogs(adminId) {
const logsRow = document.getElementById(`logs-${adminId}`);
if (logsRow.style.display === 'none') {
logsRow.style.display = 'table-row';
// 平滑滚动到日志行
const rect = logsRow.getBoundingClientRect();
const absoluteTop = window.pageYOffset + rect.top - 100; // 减去100px的偏移量让视图更好
window.scrollTo({
top: absoluteTop,
behavior: 'smooth'
});
} else {
logsRow.style.display = 'none';
}
}
// 添加密码显示/隐藏功能
function togglePassword(inputId) {
const input = document.getElementById(inputId);
const button = input.nextElementSibling;
const icon = button.querySelector('i');
if (input.type === 'password') {
input.type = 'text';
icon.className = 'fas fa-eye-slash';
} else {
input.type = 'password';
icon.className = 'fas fa-eye';
}
}
// 添加日期格式化过滤器
function formatDateTime(dateStr) {
if (!dateStr || dateStr === '从未登录') return dateStr;
const date = new Date(dateStr);
return date.toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' });
}
// 添加动画样式
const style = document.createElement('style');
style.textContent = `
@keyframes fadeInOut {
0% { opacity: 0; transform: translateY(20px); }
10% { opacity: 1; transform: translateY(0); }
90% { opacity: 1; transform: translateY(0); }
100% { opacity: 0; transform: translateY(-20px); }
}
`;
document.head.appendChild(style);
</script>
{% endblock %}

556
templates/user_profile.html Executable file
View File

@@ -0,0 +1,556 @@
{% extends "base.html" %}
{% block content %}
<div class="coming-apps-page">
<div class="header">
<a href="{{ url_for('more') }}" class="back-link" title="返回">
<i class="fas fa-arrow-left"></i>
</a>
<h1>个人信息</h1>
</div>
<div id="toast" class="developing-tip">
<span id="toastMessage"></span>
</div>
<div class="profile-container">
<div class="profile-section">
<div class="profile-header">
<div class="profile-avatar-wrapper">
<div class="profile-avatar">
{% if session.huawei_user.avatar %}
<img src="{{ session.huawei_user.avatar }}" alt="{{ session.huawei_user.name }}" class="avatar-img">
{% else %}
<i class="fas fa-user"></i>
{% endif %}
</div>
</div>
<div class="profile-name">{{ session.huawei_user.name }}</div>
<div class="profile-subtitle">个人资料</div>
</div>
<div class="profile-info">
<div class="info-item">
<div class="info-label">
<i class="fas fa-user-tag"></i>
<span>昵称</span>
</div>
<div class="info-value">
<input type="text"
class="info-input"
value="{{ session.huawei_user.name }}"
data-field="name"
onchange="updateField(this)">
</div>
</div>
<div class="info-item">
<div class="info-label">
<i class="fas fa-envelope"></i>
<span>邮箱</span>
</div>
<div class="info-value">
<input type="email"
class="info-input"
value="{{ session.huawei_user.email or '' }}"
placeholder="未设置"
data-field="email"
onchange="updateField(this)">
</div>
</div>
</div>
<div class="profile-actions">
<button class="save-btn ripple" onclick="saveAllChanges()">
<i class="fas fa-save"></i> 保存更改
</button>
<button class="action-button ripple" onclick="logout()">
<i class="fas fa-sign-out-alt"></i> 退出登录
</button>
</div>
</div>
</div>
</div>
<style>
.coming-apps-page {
max-width: 1200px;
margin: 0 auto;
padding: 20px 15px;
min-height: 100vh;
background: #f5f7fa;
}
[data-theme="dark"] .coming-apps-page {
background: #1a1a1a;
}
.header {
display: flex;
align-items: center;
gap: 15px;
margin-bottom: 30px;
background: white;
padding: 10px 15px;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 100;
}
.back-link {
color: #666;
text-decoration: none;
padding: 10px;
border-radius: 50%;
background: #f5f5f7;
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.back-link:hover {
background: #e5e5e7;
color: #333;
transform: translateX(-2px);
}
.header h1 {
margin: 0;
font-size: 20px;
color: #333;
font-weight: 600;
flex: 1;
}
.profile-container {
margin-top: 90px;
padding: 0 15px;
}
.profile-section {
max-width: 800px;
margin: 0 auto;
background: white;
border-radius: 24px;
overflow: hidden;
box-shadow: 0 4px 20px rgba(0,0,0,0.08);
transition: transform 0.3s ease;
}
.profile-section:hover {
transform: translateY(-2px);
}
.profile-header {
padding: 40px 30px;
text-align: center;
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
color: white;
position: relative;
overflow: hidden;
}
.profile-header::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: url("data:image/svg+xml,%3Csvg width='100' height='100' viewBox='0 0 100 100' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M11 18c3.866 0 7-3.134 7-7s-3.134-7-7-7-7 3.134-7 7 3.134 7 7 7zm48 25c3.866 0 7-3.134 7-7s-3.134-7-7-7-7 3.134-7 7 3.134 7 7 7zm-43-7c1.657 0 3-1.343 3-3s-1.343-3-3-3-3 1.343-3 3 1.343 3 3 3zm63 31c1.657 0 3-1.343 3-3s-1.343-3-3-3-3 1.343-3 3 1.343 3 3 3zM34 90c1.657 0 3-1.343 3-3s-1.343-3-3-3-3 1.343-3 3 1.343 3 3 3zm56-76c1.657 0 3-1.343 3-3s-1.343-3-3-3-3 1.343-3 3 1.343 3 3 3zM12 86c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm28-65c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm23-11c2.76 0 5-2.24 5-5s-2.24-5-5-5-5 2.24-5 5 2.24 5 5 5zm-6 60c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm29 22c2.76 0 5-2.24 5-5s-2.24-5-5-5-5 2.24-5 5 2.24 5 5 5zM32 63c2.76 0 5-2.24 5-5s-2.24-5-5-5-5 2.24-5 5 2.24 5 5 5zm57-13c2.76 0 5-2.24 5-5s-2.24-5-5-5-5 2.24-5 5 2.24 5 5 5zm-9-21c1.105 0 2-.895 2-2s-.895-2-2-2-2 .895-2 2 .895 2 2 2zM60 91c1.105 0 2-.895 2-2s-.895-2-2-2-2 .895-2 2 .895 2 2 2zM35 41c1.105 0 2-.895 2-2s-.895-2-2-2-2 .895-2 2 .895 2 2 2zM12 60c1.105 0 2-.895 2-2s-.895-2-2-2-2 .895-2 2 .895 2 2 2z' fill='rgba(255,255,255,0.1)' fill-rule='evenodd'/%3E%3C/svg%3E");
opacity: 0.5;
}
.profile-avatar-wrapper {
position: relative;
width: 100px;
height: 100px;
margin: 0 auto 20px;
}
.profile-avatar {
width: 100%;
height: 100%;
border-radius: 50%;
background: rgba(255,255,255,0.2);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
border: 3px solid rgba(255,255,255,0.3);
}
.avatar-img {
width: 100%;
height: 100%;
object-fit: cover;
}
.profile-avatar i {
font-size: 40px;
color: rgba(255,255,255,0.8);
}
.profile-name {
font-size: 24px;
font-weight: 600;
margin-bottom: 5px;
}
.profile-subtitle {
font-size: 14px;
opacity: 0.8;
}
.profile-info {
padding: 20px;
max-width: 800px;
margin: 0 auto;
}
.info-item {
padding: 12px;
border-bottom: 1px solid rgba(0,0,0,0.08);
display: flex;
align-items: center;
transition: all 0.3s ease;
}
.info-item:last-child {
border-bottom: none;
}
.info-item:hover {
background-color: rgba(0,0,0,0.02);
}
.info-label {
width: 70px;
padding-right: 12px;
color: #666;
font-size: 14px;
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
.info-label i {
width: 14px;
color: #6366f1;
}
.info-value {
flex: 1;
display: flex;
align-items: center;
padding-right: 12px;
}
.info-input {
width: 100%;
padding: 8px 12px;
border: 1px solid transparent;
border-radius: 8px;
font-size: 14px;
background: transparent;
color: inherit;
transition: all 0.3s ease;
}
.info-input:hover {
border-color: #e5e7eb;
background: rgba(0,0,0,0.02);
}
.profile-actions {
padding: 20px 30px;
display: flex;
flex-direction: column;
gap: 15px;
}
.save-btn, .action-button {
padding: 12px 30px;
border: none;
border-radius: 12px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
position: relative;
overflow: hidden;
}
.save-btn {
background: #6366f1;
color: white;
}
.save-btn:hover {
background: #4f46e5;
transform: translateY(-1px);
}
.action-button {
background: #ef4444;
color: white;
}
.action-button:hover {
background: #dc2626;
transform: translateY(-1px);
}
.ripple {
position: relative;
overflow: hidden;
}
.ripple::after {
content: '';
position: absolute;
width: 100%;
height: 100%;
top: 0;
left: 0;
pointer-events: none;
background-image: radial-gradient(circle, rgba(255,255,255,0.3) 10%, transparent 10.01%);
background-repeat: no-repeat;
background-position: 50%;
transform: scale(10, 10);
opacity: 0;
transition: transform 0.5s, opacity 1s;
}
.ripple:active::after {
transform: scale(0, 0);
opacity: 0.3;
transition: 0s;
}
.developing-tip {
position: fixed;
bottom: 100px;
left: 50%;
transform: translateX(-50%);
background: rgba(0, 0, 0, 0.8);
color: white;
padding: 12px 24px;
border-radius: 12px;
font-size: 14px;
z-index: 1000;
opacity: 0;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
pointer-events: none;
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
}
.developing-tip.show {
opacity: 1;
transform: translate(-50%, -10px);
}
/* Dark mode styles */
[data-theme="dark"] .header {
background: rgba(30,30,30,0.8);
}
[data-theme="dark"] .header h1 {
color: #fff;
}
[data-theme="dark"] .back-link {
color: #ccc;
background: rgba(255,255,255,0.1);
}
[data-theme="dark"] .back-link:hover {
background: rgba(255,255,255,0.15);
color: #fff;
}
[data-theme="dark"] .profile-section {
background: #242424;
}
[data-theme="dark"] .info-item {
border-color: rgba(255,255,255,0.1);
}
[data-theme="dark"] .info-item:hover {
background-color: rgba(255,255,255,0.05);
}
[data-theme="dark"] .info-input {
color: #fff;
}
[data-theme="dark"] .info-input:hover {
border-color: rgba(255,255,255,0.2);
background: rgba(255,255,255,0.05);
}
[data-theme="dark"] .info-input:focus {
border-color: #6366f1;
background: rgba(99,102,241,0.1);
}
[data-theme="dark"] .developing-tip {
background: rgba(255,255,255,0.9);
color: black;
}
</style>
<script>
let changedFields = new Set();
function updateField(element) {
const field = element.dataset.field;
const value = element.value;
if (!value.trim()) {
showToast('输入不能为空');
element.value = element.defaultValue;
return;
}
if (field === 'email' && value.trim() !== '') {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(value)) {
showToast('请输入有效的邮箱地址');
element.value = element.defaultValue;
return;
}
}
changedFields.add(field);
element.dataset.changed = 'true';
element.classList.add('modified');
}
function saveAllChanges() {
const changedInputs = document.querySelectorAll('[data-changed="true"]');
if (!changedInputs.length) {
showToast('没有需要保存的更改');
return;
}
const saveBtn = document.querySelector('.save-btn');
saveBtn.disabled = true;
saveBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> 保存中...';
let promises = [];
changedInputs.forEach(input => {
const field = input.dataset.field;
const value = input.value.trim();
promises.push(
fetch('/user/update_profile', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
field: field,
value: value
})
}).then(response => response.json())
);
});
Promise.all(promises)
.then(results => {
const allSuccess = results.every(result => result.success);
if (allSuccess) {
showToast('保存成功');
changedInputs.forEach(input => {
input.removeAttribute('data-changed');
input.classList.remove('modified');
if (input.dataset.field === 'name') {
document.querySelector('.profile-name').textContent = input.value;
}
});
changedFields.clear();
} else {
showToast('部分更改保存失败');
}
})
.catch(error => {
console.error('Save failed:', error);
showToast('保存失败,请稍后重试');
})
.finally(() => {
saveBtn.disabled = false;
saveBtn.innerHTML = '<i class="fas fa-save"></i> 保存更改';
});
}
function showToast(message) {
const toast = document.getElementById('toast');
const toastMessage = document.getElementById('toastMessage');
toastMessage.textContent = message;
toast.classList.add('show');
setTimeout(() => {
toast.classList.remove('show');
}, 2000);
}
function logout() {
if (confirm('确定要退出登录吗?')) {
const logoutBtn = document.querySelector('.action-button');
logoutBtn.disabled = true;
logoutBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> 退出中...';
showToast('正在退出...');
fetch('/user/logout', {
method: 'POST',
credentials: 'same-origin'
}).then(() => {
showToast('退出成功');
setTimeout(() => {
window.location.href = '{{ url_for("more") }}';
}, 500);
}).catch(error => {
console.error('Logout failed:', error);
showToast('退出失败,请重试');
logoutBtn.disabled = false;
logoutBtn.innerHTML = '<i class="fas fa-sign-out-alt"></i> 退出登录';
});
}
}
document.querySelector('.back-link').addEventListener('click', function(e) {
if (changedFields.size > 0) {
if (!confirm('有未保存的更改,确定要离开吗?')) {
e.preventDefault();
return;
}
}
});
// 添加输入动画效果
document.querySelectorAll('.info-input').forEach(input => {
input.addEventListener('focus', () => {
input.parentElement.parentElement.classList.add('focused');
});
input.addEventListener('blur', () => {
input.parentElement.parentElement.classList.remove('focused');
});
});
</script>
{% endblock %}

1708
templates/wiki.html Executable file

File diff suppressed because it is too large Load Diff

838
templates/wiki_detail.html Executable file
View File

@@ -0,0 +1,838 @@
{% extends "base.html" %}
{% block title %}{{ entry.title }} - Wiki{% endblock %}
{% block head %}
{{ super() }}
<script>
window.entryContent = {{ entry.content|tojson|safe }};
window.entryId = {{ entry.id }};
window.isLoggedIn = {{ 'true' if session.get('huawei_user') else 'false' }};
</script>
<script src="{{ url_for('static', filename='js/wiki_detail.js') }}"></script>
<script src="{{ url_for('static', filename='js/wiki_comments.js') }}"></script>
<link href="{{ url_for('static', filename='libs/quill/quill.snow.css') }}" rel="stylesheet">
<script src="{{ url_for('static', filename='libs/highlight/highlight.min.js') }}"></script>
<script src="{{ url_for('static', filename='libs/quill/quill.min.js') }}"></script>
<link href="{{ url_for('static', filename='libs/lightbox2/css/lightbox.min.css') }}" rel="stylesheet">
<!--<script src="{{ url_for('static', filename='libs/lightbox2/js/lightbox-plus-jquery.min.js') }}"></script>-->
<!--<script src="{{ url_for('static', filename='libs/moment/moment.min.js') }}"></script>-->
<!--<script src="{{ url_for('static', filename='libs/moment/moment-timezone.min.js') }}"></script>-->
<style>
:root {
/* 浅色模式配色 */
--primary-color: #3b82f6;
--secondary-color: #60a5fa;
--text-primary: #0f172a;
--text-secondary: #64748b;
--background-primary: #ffffff;
--background-secondary: #f8fafc;
--border-color: #e2e8f0;
}
[data-theme="dark"] {
/* 深色模式配色 */
--primary-color: #4b5563;
--secondary-color: #374151;
--text-primary: #f9fafb;
--text-secondary: #9ca3af;
--background-primary: #1a1a1a;
--background-secondary: #1f2937;
--border-color: #374151;
}
.wiki-container {
max-width: 1200px;
margin: 0 auto;
}
.wiki-main {
background-color: var(--background-primary);
padding: 2.5rem;
border-radius: 12px;
box-shadow: 0 4px 15px rgba(0,0,0,0.05);
position: relative;
}
.back-link {
position: absolute;
top: 20px;
left: 20px;
display: flex;
align-items: center;
gap: 0.75rem;
color: #3b82f6;
text-decoration: none;
font-weight: 600;
transition: all 0.2s ease;
font-size: 0.875rem;
z-index: 10;
}
.back-link:hover {
color: #2563eb;
transform: translateX(-4px);
}
.wiki-article {
position: relative;
max-width: 800px;
margin: 0 auto;
}
.article-header {
margin-bottom: 0.5rem;
padding-bottom: 0.2rem;
border-bottom: 1px solid var(--border-color);
}
.article-meta {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 1rem;
color: var(--text-secondary);
}
.meta-tag {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.25rem 0.75rem;
border-radius: 9999px;
font-size: 0.75rem;
background-color: rgba(59, 130, 246, 0.1);
color: var(--primary-color);
}
.article-title {
font-size: 2.5rem;
font-weight: 700;
line-height: 1.2;
color: var(--text-primary);
}
.article-content {
font-size: 1rem;
line-height: 1.8;
color: var(--text-primary);
}
.content-viewer {
margin-bottom: 1.5rem;
}
.content-images {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
gap: 0.5rem;
margin-top: 1rem;
width: 100%;
}
.content-images img {
width: 100%;
max-width: 100%;
max-height: 300px;
object-fit: contain;
display: block;
margin: 0 auto;
transition: transform 0.3s ease;
border-radius: 8px;
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
cursor: pointer;
}
.content-images img:hover {
transform: scale(1.02);
}
/* 确保图片不超过 ql-editor 容器 */
.ql-editor img {
max-width: 100% !important;
height: auto !important;
object-fit: contain !important;
}
.version-list-header {
display: flex;
justify-content: space-between;
align-items: center;
cursor: pointer;
padding: 0.5rem 0;
border-bottom: 1px solid var(--border-color);
}
.version-list {
max-height: 300px;
overflow-y: auto;
transition: all 0.3s ease;
}
.version-list.collapsed {
max-height: 0;
overflow: hidden;
opacity: 0;
}
.version-item {
display: block;
padding: 0.5rem 0;
text-decoration: none;
color: var(--text-secondary);
transition: all 0.3s ease;
}
.version-item:hover {
color: var(--primary-color);
}
.version-item.active {
color: var(--primary-color);
font-weight: bold;
}
/* 暗色模式适配 */
[data-theme="dark"] .wiki-main {
background-color: var(--background-primary);
box-shadow: 0 4px 15px rgba(255,255,255,0.05);
}
[data-theme="dark"] .article-header {
border-bottom-color: var(--border-color);
}
[data-theme="dark"] .article-title {
color: var(--text-primary);
}
[data-theme="dark"] .article-content {
color: var(--text-primary);
}
[data-theme="dark"] .version-list-header {
border-bottom-color: var(--border-color);
}
[data-theme="dark"] .version-item {
color: var(--text-secondary);
}
[data-theme="dark"] .version-item:hover {
color: var(--secondary-color);
}
[data-theme="dark"] .version-item.active {
color: var(--secondary-color);
}
/* 响应式设计 */
@media (max-width: 768px) {
.wiki-main {
padding: 1.5rem;
}
.article-title {
font-size: 2rem;
}
.back-link {
position: static;
margin-bottom: 1rem;
}
.wiki-article {
max-width: 100%;
}
}
/* 评论区样式优化 */
.wiki-comments-section {
background-color: var(--background-primary);
border-radius: 16px;
padding: 0 0 1.5rem 0;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05);
transition: all 0.3s ease;
}
.comments-tabs {
display: flex;
gap: 1rem;
margin-bottom: 1.5rem;
border-bottom: 1px solid var(--border-color);
padding-bottom: 0.5rem;
}
.comments-tabs .tab {
background: none;
border: none;
font-weight: 500;
color: var(--text-secondary);
padding: 0.5rem 1rem;
position: relative;
cursor: pointer;
transition: all 0.3s ease;
}
.comments-tabs .tab.active {
color: var(--primary-color);
font-weight: 600;
}
.comments-tabs .tab::before {
content: '';
position: absolute;
bottom: -2px;
left: 0;
width: 0;
height: 2px;
background-color: var(--primary-color);
transition: width 0.3s ease;
}
.comments-tabs .tab.active::before {
width: 100%;
}
.comment-composer {
margin-bottom: 1.5rem;
}
.comment-input-container {
display: flex;
align-items: flex-start;
gap: 1rem;
background-color: var(--background-secondary);
border-radius: 12px;
padding: 1rem;
transition: all 0.3s ease;
}
.comment-input-container:focus-within {
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2);
}
.comment-avatar {
width: 48px;
height: 48px;
border-radius: 50%;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
background-color: var(--border-color);
}
.comment-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.comment-avatar i {
color: var(--text-secondary);
font-size: 1.5rem;
}
.comment-input-wrapper {
flex-grow: 1;
display: flex;
flex-direction: column;
}
.comment-input-wrapper textarea {
width: 100%;
min-height: 60px;
max-height: 200px;
resize: none;
border: none;
background: transparent;
color: var(--text-primary);
font-size: 0.95rem;
line-height: 1.6;
overflow: hidden;
transition: all 0.3s ease;
}
.comment-input-wrapper textarea:focus {
outline: none;
}
.comment-input-wrapper #submit-comment {
align-self: flex-end;
margin-top: 0.5rem;
background-color: var(--primary-color);
color: white;
border: none;
border-radius: 8px;
padding: 0.5rem 1rem;
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
transition: all 0.3s ease;
}
.comment-input-wrapper #submit-comment:hover {
background-color: var(--secondary-color);
}
.comment-input-wrapper #submit-comment:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.comments-list {
display: none;
}
.comments-list.active {
display: block;
}
.empty-comments {
text-align: center;
color: var(--text-secondary);
padding: 2rem 0;
background-color: var(--background-primary);
border-radius: 8px;
font-size: 0.875rem;
}
/* 暗色模式适配 */
[data-theme="dark"] .wiki-comments-section {
background-color: var(--background-secondary);
box-shadow: 0 4px 10px rgba(255,255,255,0.05);
}
[data-theme="dark"] .comments-tabs .tab {
color: var(--text-secondary);
}
[data-theme="dark"] .comments-tabs .tab.active {
color: var(--secondary-color);
}
[data-theme="dark"] .comments-tabs .tab.active::after {
background-color: var(--secondary-color);
}
[data-theme="dark"] .comment-input-container {
background-color: var(--background-primary);
}
[data-theme="dark"] .comment-avatar {
background-color: rgba(55, 65, 81, 0.2);
border-color: var(--secondary-color);
}
[data-theme="dark"] .comment-input-wrapper textarea {
background-color: var(--background-primary);
color: var(--text-primary);
border-color: var(--border-color);
}
[data-theme="dark"] .comment-input-wrapper textarea:focus {
border-color: var(--secondary-color);
}
[data-theme="dark"] .comment-input-wrapper #submit-comment {
background: var(--secondary-color);
}
[data-theme="dark"] .comment-input-wrapper #submit-comment:hover {
background: var(--primary-color);
}
[data-theme="dark"] .empty-comments {
background-color: var(--background-primary);
}
/* 响应式设计 */
@media (max-width: 768px) {
.comment-input-container {
flex-direction: column;
align-items: stretch;
}
.comment-avatar {
align-self: center;
margin-bottom: 1rem;
}
.comment-input-wrapper textarea {
min-height: 80px;
}
}
/* 开发提示弹窗样式 */
.developing-tip {
position: fixed;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
padding: 10px 20px;
border-radius: 6px;
color: white;
font-size: 0.875rem;
z-index: 1000;
opacity: 0;
transition: opacity 0.3s ease, transform 0.3s ease;
max-width: 90%;
text-align: center;
}
.developing-tip.show {
opacity: 1;
transform: translate(-50%, -10px);
}
.developing-tip.toast-info {
background-color: rgba(59, 130, 246, 0.9);
}
.developing-tip.toast-success {
background-color: rgba(34, 197, 94, 0.9);
}
.developing-tip.toast-error {
background-color: rgba(244, 63, 94, 0.9);
}
.developing-tip.toast-warning {
background-color: rgba(245, 158, 11, 0.9);
}
/* Lightbox 导航按钮垂直居中 */
.lb-container .lb-nav {
top: 50% !important;
transform: translateY(-50%) !important;
width: 10% !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
}
.lb-container .lb-prev {
left: 0 !important;
margin-left: 20px !important;
}
.lb-container .lb-next {
right: 0 !important;
margin-right: 20px !important;
}
.lb-container .lb-nav a {
opacity: 0.6 !important;
transition: opacity 0.3s ease !important;
background: rgba(0,0,0,0.3) !important;
border-radius: 50% !important;
width: 50px !important;
height: 50px !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
}
.lb-container .lb-nav a:hover {
opacity: 1 !important;
background: rgba(0,0,0,0.5) !important;
}
.lb-container .lb-nav a i {
font-size: 24px !important;
color: white !important;
}
</style>
{% endblock %}
{% block content %}
<div class="wiki-container">
<main class="wiki-main">
<a href="javascript:history.back()" class="back-link">
<i class="fas fa-arrow-left"></i>返回
</a>
<article class="wiki-article">
<!-- 文章头部 -->
<header class="article-header">
<div class="article-meta">
<span class="meta-tag">
<i class="fas fa-code-branch"></i>
{{ entry.version }}
</span>
<span class="meta-tag">
<i class="far fa-calendar-alt"></i>
{{ entry.created_at }}
</span>
</div>
<h1 class="article-title">{{ entry.title }}</h1>
</header>
<!-- 文章内容 -->
<div class="article-content">
<div id="content-viewer" class="content-viewer"></div>
{% if entry.images and entry.images|length > 0 %}
<div class="content-images" id="gallery">
{% for image in entry.images %}
<a href="{{ url_for('static', filename=image) }}"
data-lightbox="wiki-gallery"
data-title="{{ entry.title }}">
<img src="{{ url_for('static', filename=image) }}"
alt="{{ entry.title }}">
</a>
{% endfor %}
</div>
{% endif %}
<!-- 版本列表 -->
<!-- <div class="version-list-section">
<div class="version-list-header">
<h3>版本列表</h3>
<i class="fas fa-chevron-down"></i>
</div>
<div class="version-list">
{% for ver in all_entries %}
<a href="{{ url_for('wiki.wiki_detail', entry_id=ver.id) }}"
class="version-item {% if ver.id == entry.id %}active{% endif %}">
{{ ver.version }}
</a>
{% endfor %}
</div>
</div> -->
<!-- 评论区域 -->
<section class="wiki-comments-section">
<div class="comments-tabs">
<button class="tab active" data-tab="feature-requests" data-type="feature_request">功能建议</button>
<button class="tab" data-tab="bug-reports" data-type="bug_report">已知问题</button>
<button class="tab" data-tab="experience-share" data-type="experience_share">新增功能</button>
</div>
<div class="comment-composer">
<div class="comment-input-container">
<div class="comment-avatar">
{% if session.get('huawei_user') and session.huawei_user.avatar %}
<img src="{{ session.huawei_user.avatar }}" alt="{{ session.huawei_user.name }}">
{% else %}
<i class="fas fa-user"></i>
{% endif %}
</div>
<div class="comment-input-wrapper">
<textarea
id="comment-content"
placeholder="{% if session.get('huawei_user') %}{{ session.huawei_user.name }}{% endif %}分享你的想法..."
rows="1"
></textarea>
<button type="submit" id="submit-comment">
<i class="fas fa-paper-plane"></i>
</button>
</div>
</div>
</div>
<div class="comments-content">
<div id="feature-requests" class="comments-list active">
<!-- 功能建议列表 -->
<div class="empty-comments">
<p>暂无功能建议,快来提出你的创意吧!</p>
</div>
</div>
<div id="bug-reports" class="comments-list">
<!-- 已知问题列表 -->
<div class="empty-comments">
<p>暂无已知问题,系统运行良好!</p>
</div>
</div>
<div id="experience-share" class="comments-list">
<!-- 新增功能列表 -->
<div class="empty-comments">
<p>还没有人分享新增功能,快来抢沙发!</p>
</div>
</div>
</div>
</section>
</div>
</article>
</main>
</div>
<!-- Developing Tip for Notifications -->
<div id="developingTip" class="developing-tip"></div>
{% endblock %}
{% block footer %}
<script>
function showDevelopingTip(message, type = 'info') {
const tip = document.getElementById('developingTip');
tip.textContent = message;
tip.className = `developing-tip toast-${type} show`;
setTimeout(() => {
tip.classList.remove('show');
}, 3000);
}
document.addEventListener('DOMContentLoaded', function() {
window.showDevelopingTip = showDevelopingTip;
// Lightbox2 配置
lightbox.option({
'resizeDuration': 200,
'wrapAround': true,
'disableScrolling': true,
'fadeDuration': 300,
'maxWidth': window.innerWidth * 0.9,
'maxHeight': window.innerHeight * 0.9
});
// 为 ql-editor 和 content-images 中的图片添加 Lightbox 功能
const imageSections = [
document.querySelector('.ql-editor'),
document.querySelector('.content-images')
];
imageSections.forEach(section => {
if (section) {
section.addEventListener('click', function(event) {
const img = event.target.closest('img');
if (img) {
// 查找最近的 a 标签
const link = img.closest('a') || document.createElement('a');
// 如果没有 a 标签,创建一个
if (!img.closest('a')) {
link.href = img.src;
link.setAttribute('data-lightbox', 'content-gallery');
link.style.display = 'none';
document.body.appendChild(link);
}
// 使用原生方法触发点击
const clickEvent = new MouseEvent('click', {
view: window,
bubbles: true,
cancelable: true
});
link.dispatchEvent(clickEvent);
// 如果是动态创建的 a 标签,用完即删
if (!img.closest('a')) {
document.body.removeChild(link);
}
}
});
}
});
// 移除自定义的 lightbox.start 方法
delete lightbox.start;
});
document.addEventListener('DOMContentLoaded', function() {
// 创建 Lightbox 控制按钮
function createLightboxControls() {
// 创建按钮容器
const controlsDiv = document.createElement('div');
controlsDiv.className = 'lightbox-custom-controls';
controlsDiv.innerHTML = `
<button class="lightbox-prev" title="上一张">
<i class="fas fa-chevron-left"></i>
</button>
<button class="lightbox-next" title="下一张">
<i class="fas fa-chevron-right"></i>
</button>
<button class="lightbox-close" title="关闭">
<i class="fas fa-times"></i>
</button>
`;
// 添加到文档
document.body.appendChild(controlsDiv);
// 上一张按钮事件
controlsDiv.querySelector('.lightbox-prev').addEventListener('click', function() {
const prevBtn = document.querySelector('.lb-container .lb-prev');
if (prevBtn) prevBtn.click();
});
// 下一张按钮事件
controlsDiv.querySelector('.lightbox-next').addEventListener('click', function() {
const nextBtn = document.querySelector('.lb-container .lb-next');
if (nextBtn) nextBtn.click();
});
// 关闭按钮事件
controlsDiv.querySelector('.lightbox-close').addEventListener('click', function() {
lightbox.end();
});
}
// 监听 Lightbox 打开事件
$(document).on('shown.lightbox', function() {
// 移除已存在的控制按钮
const existingControls = document.querySelector('.lightbox-custom-controls');
if (existingControls) existingControls.remove();
// 创建新的控制按钮
createLightboxControls();
});
// 监听 Lightbox 关闭事件
$(document).on('closed.lightbox', function() {
const controls = document.querySelector('.lightbox-custom-controls');
if (controls) controls.remove();
});
});
</script>
<style>
.lightbox-custom-controls {
position: fixed;
bottom: 50px;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 20px;
z-index: 10001;
}
.lightbox-custom-controls button {
background-color: rgba(0,0,0,0.6);
color: white;
border: none;
border-radius: 50%;
width: 50px;
height: 50px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.3s ease;
}
.lightbox-custom-controls button:hover {
background-color: rgba(0,0,0,0.8);
transform: scale(1.1);
}
.lightbox-custom-controls button i {
font-size: 24px;
}
.lightbox-custom-controls .lightbox-close {
background-color: rgba(255,0,0,0.6);
}
.lightbox-custom-controls .lightbox-close:hover {
background-color: rgba(255,0,0,0.8);
}
</style>
{% endblock %}

1069
templates/wishlist.html Executable file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff