Compare commits
6 Commits
7fa24a893b
...
detached
| Author | SHA1 | Date | |
|---|---|---|---|
| c5a090abf3 | |||
| 09d78f57a5 | |||
| 8f0d019627 | |||
| 023380d14b | |||
| d10c823e23 | |||
| c1d76de658 |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -9,4 +9,8 @@
|
|||||||
/frontend/.vscode/
|
/frontend/.vscode/
|
||||||
/frontend/.idea/
|
/frontend/.idea/
|
||||||
/开发路径.md
|
/开发路径.md
|
||||||
|
**/__pycache__/
|
||||||
|
**/*.pyc
|
||||||
|
**/logs/
|
||||||
|
**/logs/*.log
|
||||||
|
|
||||||
|
|||||||
7
backend/.dockerignore
Normal file
7
backend/.dockerignore
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
__pycache__
|
||||||
|
*.pyc
|
||||||
|
.env
|
||||||
|
.git
|
||||||
|
*.md
|
||||||
|
venv
|
||||||
|
.venv
|
||||||
28
backend/.env.production
Normal file
28
backend/.env.production
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
# ACG Blog 生产环境配置
|
||||||
|
# 复制此文件为 .env 并修改对应值
|
||||||
|
|
||||||
|
# 应用配置
|
||||||
|
APP_NAME=ACG Blog
|
||||||
|
APP_VERSION=1.0.0
|
||||||
|
DEBUG=false
|
||||||
|
|
||||||
|
# 数据库配置
|
||||||
|
DB_HOST=postgres
|
||||||
|
DB_PORT=5432
|
||||||
|
DB_USER=postgres
|
||||||
|
DB_PASSWORD=postgres
|
||||||
|
DB_NAME=acg_blog
|
||||||
|
|
||||||
|
# Redis 配置
|
||||||
|
REDIS_HOST=redis
|
||||||
|
REDIS_PORT=6379
|
||||||
|
REDIS_DB=0
|
||||||
|
|
||||||
|
# JWT 配置 (请修改为随机字符串)
|
||||||
|
SECRET_KEY=your-super-secret-key-change-this-in-production
|
||||||
|
ALGORITHM=HS256
|
||||||
|
ACCESS_TOKEN_EXPIRE_MINUTES=30
|
||||||
|
REFRESH_TOKEN_EXPIRE_DAYS=7
|
||||||
|
|
||||||
|
# CORS 配置 (前端域名)
|
||||||
|
BACKEND_CORS_ORIGINS=http://localhost,https://your-domain.com
|
||||||
25
backend/Dockerfile
Normal file
25
backend/Dockerfile
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# FastAPI Backend
|
||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 安装系统依赖
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
gcc \
|
||||||
|
libpq-dev \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# 复制依赖文件
|
||||||
|
COPY requirements.txt .
|
||||||
|
|
||||||
|
# 安装 Python 依赖
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# 复制应用代码
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# 暴露端口
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
# 启动命令
|
||||||
|
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
136
backend/schema.sql
Normal file
136
backend/schema.sql
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
-- ACG Blog 数据库建表脚本
|
||||||
|
-- PostgreSQL
|
||||||
|
-- 运行前请先创建数据库: CREATE DATABASE acg_blog;
|
||||||
|
|
||||||
|
-- 启用 UUID 扩展
|
||||||
|
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||||
|
|
||||||
|
-- ==================== 用户表 ====================
|
||||||
|
CREATE TABLE IF NOT EXISTS "users" (
|
||||||
|
"id" UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
"username" VARCHAR(50) UNIQUE NOT NULL,
|
||||||
|
"email" VARCHAR(255) UNIQUE NOT NULL,
|
||||||
|
"password_hash" VARCHAR(255) NOT NULL,
|
||||||
|
"avatar" VARCHAR(500),
|
||||||
|
"bio" TEXT,
|
||||||
|
"is_active" BOOLEAN DEFAULT TRUE,
|
||||||
|
"is_superuser" BOOLEAN DEFAULT FALSE,
|
||||||
|
"created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ==================== 分类表 ====================
|
||||||
|
CREATE TABLE IF NOT EXISTS "categories" (
|
||||||
|
"id" UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
"name" VARCHAR(50) UNIQUE NOT NULL,
|
||||||
|
"slug" VARCHAR(50) UNIQUE NOT NULL,
|
||||||
|
"description" TEXT,
|
||||||
|
"created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ==================== 标签表 ====================
|
||||||
|
CREATE TABLE IF NOT EXISTS "tags" (
|
||||||
|
"id" UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
"name" VARCHAR(50) UNIQUE NOT NULL,
|
||||||
|
"slug" VARCHAR(50) UNIQUE NOT NULL,
|
||||||
|
"created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ==================== 文章表 ====================
|
||||||
|
CREATE TABLE IF NOT EXISTS "posts" (
|
||||||
|
"id" UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
"title" VARCHAR(200) NOT NULL,
|
||||||
|
"slug" VARCHAR(200) UNIQUE NOT NULL,
|
||||||
|
"content" TEXT NOT NULL,
|
||||||
|
"summary" TEXT,
|
||||||
|
"cover_image" VARCHAR(500),
|
||||||
|
|
||||||
|
-- 外键关联
|
||||||
|
"author_id" UUID NOT NULL REFERENCES "users"("id") ON DELETE CASCADE,
|
||||||
|
"category_id" UUID REFERENCES "categories"("id") ON DELETE SET NULL,
|
||||||
|
|
||||||
|
-- 统计数据
|
||||||
|
"view_count" INTEGER DEFAULT 0,
|
||||||
|
"like_count" INTEGER DEFAULT 0,
|
||||||
|
"comment_count" INTEGER DEFAULT 0,
|
||||||
|
|
||||||
|
-- 状态: draft/published/archived
|
||||||
|
"status" VARCHAR(20) DEFAULT 'draft',
|
||||||
|
|
||||||
|
-- SEO
|
||||||
|
"meta_title" VARCHAR(200),
|
||||||
|
"meta_description" TEXT,
|
||||||
|
|
||||||
|
-- 时间戳
|
||||||
|
"published_at" TIMESTAMP,
|
||||||
|
"created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 索引
|
||||||
|
CREATE INDEX IF NOT EXISTS "idx_posts_status_published_at" ON "posts"("status", "published_at");
|
||||||
|
CREATE INDEX IF NOT EXISTS "idx_posts_author_status" ON "posts"("author_id", "status");
|
||||||
|
|
||||||
|
-- ==================== 文章标签关联表 ====================
|
||||||
|
CREATE TABLE IF NOT EXISTS "post_tags" (
|
||||||
|
"id" UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
"post_id" UUID NOT NULL REFERENCES "posts"("id") ON DELETE CASCADE,
|
||||||
|
"tag_id" UUID NOT NULL REFERENCES "tags"("id") ON DELETE CASCADE,
|
||||||
|
UNIQUE("post_id", "tag_id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ==================== 评论表 ====================
|
||||||
|
CREATE TABLE IF NOT EXISTS "comments" (
|
||||||
|
"id" UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
"content" TEXT NOT NULL,
|
||||||
|
"is_approved" BOOLEAN DEFAULT TRUE,
|
||||||
|
|
||||||
|
-- 外键关联
|
||||||
|
"author_id" UUID NOT NULL REFERENCES "users"("id") ON DELETE CASCADE,
|
||||||
|
"post_id" UUID NOT NULL REFERENCES "posts"("id") ON DELETE CASCADE,
|
||||||
|
"parent_id" UUID REFERENCES "comments"("id") ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- 时间戳
|
||||||
|
"created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 索引
|
||||||
|
CREATE INDEX IF NOT EXISTS "idx_comments_post_id" ON "comments"("post_id");
|
||||||
|
CREATE INDEX IF NOT EXISTS "idx_comments_author_id" ON "comments"("author_id");
|
||||||
|
|
||||||
|
-- ==================== 初始化数据 ====================
|
||||||
|
|
||||||
|
-- 插入默认分类
|
||||||
|
INSERT INTO "categories" ("name", "slug", "description") VALUES
|
||||||
|
('动漫资讯', 'anime', '最新动漫新闻、番剧更新、业界动态'),
|
||||||
|
('游戏攻略', 'game', '游戏通关指南、角色培养、剧情解析'),
|
||||||
|
('二次元美图', 'pictures', '精选壁纸、Cosplay、插画作品'),
|
||||||
|
('同人创作', 'fanwork', '同人小说、同人绘画、手办模型')
|
||||||
|
ON CONFLICT ("slug") DO NOTHING;
|
||||||
|
|
||||||
|
-- 插入默认标签
|
||||||
|
INSERT INTO "tags" ("name", "slug") VALUES
|
||||||
|
('原神', 'genshin'),
|
||||||
|
('崩坏星穹铁道', 'honkai-star-rail'),
|
||||||
|
('我的世界', 'minecraft'),
|
||||||
|
('EVA', 'evangelion'),
|
||||||
|
('约定的梦幻岛', 'neverland'),
|
||||||
|
('咒术回战', 'jujutsu-kaisen'),
|
||||||
|
('Cosplay', 'cosplay'),
|
||||||
|
('手办', 'figure')
|
||||||
|
ON CONFLICT ("slug") DO NOTHING;
|
||||||
|
|
||||||
|
-- 插入管理员用户 (密码: admin123)
|
||||||
|
-- 密码哈希基于 bcrypt,使用前请替换为实际哈希值
|
||||||
|
INSERT INTO "users" ("username", "email", "password_hash", "is_superuser") VALUES
|
||||||
|
('admin', 'admin@acgblog.com', '$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/X4.TZND60LMTUBu.K', TRUE)
|
||||||
|
ON CONFLICT ("username") DO NOTHING;
|
||||||
|
|
||||||
|
-- ==================== 注释 ====================
|
||||||
|
COMMENT ON TABLE "users" IS '用户表';
|
||||||
|
COMMENT ON TABLE "categories" IS '文章分类表';
|
||||||
|
COMMENT ON TABLE "tags" IS '文章标签表';
|
||||||
|
COMMENT ON TABLE "posts" IS '文章表';
|
||||||
|
COMMENT ON TABLE "post_tags" IS '文章标签关联表';
|
||||||
|
COMMENT ON TABLE "comments" IS '评论表';
|
||||||
78
docker-compose.yml
Normal file
78
docker-compose.yml
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
# PostgreSQL 数据库
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: acg_blog_db
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: postgres
|
||||||
|
POSTGRES_PASSWORD: postgres
|
||||||
|
POSTGRES_DB: acg_blog
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
- ./backend/schema.sql:/docker-entrypoint-initdb.d/schema.sql:ro
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
networks:
|
||||||
|
- acg_blog_network
|
||||||
|
|
||||||
|
# Redis 缓存
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
container_name: acg_blog_redis
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "6379:6379"
|
||||||
|
volumes:
|
||||||
|
- redis_data:/data
|
||||||
|
networks:
|
||||||
|
- acg_blog_network
|
||||||
|
|
||||||
|
# 后端 API
|
||||||
|
backend:
|
||||||
|
build:
|
||||||
|
context: ./backend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: acg_blog_backend
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
- DB_HOST=postgres
|
||||||
|
- DB_PORT=5432
|
||||||
|
- DB_USER=postgres
|
||||||
|
- DB_PASSWORD=postgres
|
||||||
|
- DB_NAME=acg_blog
|
||||||
|
- REDIS_HOST=redis
|
||||||
|
- REDIS_PORT=6379
|
||||||
|
- SECRET_KEY=your-secret-key-change-in-production
|
||||||
|
- DEBUG=false
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
depends_on:
|
||||||
|
- postgres
|
||||||
|
- redis
|
||||||
|
networks:
|
||||||
|
- acg_blog_network
|
||||||
|
|
||||||
|
# 前端 Nginx
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: ./frontend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: acg_blog_frontend
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
networks:
|
||||||
|
- acg_blog_network
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
|
redis_data:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
acg_blog_network:
|
||||||
|
driver: bridge
|
||||||
69
docker-deploy.md
Normal file
69
docker-deploy.md
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
# Docker 部署指南
|
||||||
|
|
||||||
|
## 快速启动
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 启动所有服务
|
||||||
|
docker-compose up -d
|
||||||
|
|
||||||
|
# 查看服务状态
|
||||||
|
docker-compose ps
|
||||||
|
|
||||||
|
# 查看日志
|
||||||
|
docker-compose logs -f
|
||||||
|
```
|
||||||
|
|
||||||
|
## 服务地址
|
||||||
|
|
||||||
|
| 服务 | 地址 |
|
||||||
|
|------|------|
|
||||||
|
| 前端 | http://localhost |
|
||||||
|
| 后端 API | http://localhost:8000 |
|
||||||
|
| API 文档 | http://localhost:8000/docs |
|
||||||
|
| PostgreSQL | localhost:5432 |
|
||||||
|
| Redis | localhost:6379 |
|
||||||
|
|
||||||
|
## 初始化数据库
|
||||||
|
|
||||||
|
首次启动时,数据库会自动创建表结构和初始数据。
|
||||||
|
|
||||||
|
管理员账户:
|
||||||
|
- 用户名: `admin`
|
||||||
|
- 邮箱: `admin@acgblog.com`
|
||||||
|
- 密码: `admin123`
|
||||||
|
|
||||||
|
**重要**: 请在部署后修改管理员密码!
|
||||||
|
|
||||||
|
## 常用命令
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 重新构建镜像
|
||||||
|
docker-compose build --no-cache
|
||||||
|
|
||||||
|
# 停止所有服务
|
||||||
|
docker-compose down
|
||||||
|
|
||||||
|
# 停止并删除数据卷
|
||||||
|
docker-compose down -v
|
||||||
|
|
||||||
|
# 进入后端容器
|
||||||
|
docker exec -it acg_blog_backend sh
|
||||||
|
|
||||||
|
# 进入数据库
|
||||||
|
docker exec -it acg_blog_db psql -U postgres -d acg_blog
|
||||||
|
```
|
||||||
|
|
||||||
|
## 生产环境部署
|
||||||
|
|
||||||
|
1. 修改 `backend/.env.production` 中的配置:
|
||||||
|
- `SECRET_KEY` - 使用随机字符串
|
||||||
|
- `BACKEND_CORS_ORIGINS` - 改为你的域名
|
||||||
|
|
||||||
|
2. 修改 `docker-compose.yml` 中的端口映射(移除端口暴露,仅通过 nginx 反向代理)
|
||||||
|
|
||||||
|
3. 使用 Nginx 或 Traefik 等反向代理配置 HTTPS
|
||||||
|
|
||||||
|
4. 定期备份数据库:
|
||||||
|
```bash
|
||||||
|
docker exec acg_blog_db pg_dump -U postgres acg_blog > backup.sql
|
||||||
|
```
|
||||||
6
frontend/.dockerignore
Normal file
6
frontend/.dockerignore
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.git
|
||||||
|
.env
|
||||||
|
*.md
|
||||||
|
.vscode
|
||||||
29
frontend/Dockerfile
Normal file
29
frontend/Dockerfile
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
# Vue 3 Frontend
|
||||||
|
FROM node:20-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 复制依赖文件
|
||||||
|
COPY package*.json ./
|
||||||
|
|
||||||
|
# 安装依赖
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
# 复制源代码
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# 构建生产版本
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# ==================== Nginx 运行阶段 ====================
|
||||||
|
FROM nginx:alpine
|
||||||
|
|
||||||
|
# 复制构建产物
|
||||||
|
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||||
|
|
||||||
|
# 复制 Nginx 配置
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
|
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
32
frontend/nginx.conf
Normal file
32
frontend/nginx.conf
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name localhost;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# Gzip 压缩
|
||||||
|
gzip on;
|
||||||
|
gzip_types text/plain text/css application/json application/javascript text/xml application/xml;
|
||||||
|
gzip_min_length 1000;
|
||||||
|
|
||||||
|
# 前端路由(SPA)
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# API 代理
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://backend:8000/api/;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
# 静态资源缓存
|
||||||
|
location /assets/ {
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,8 +9,10 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@kangc/v-md-editor": "^2.3.18",
|
||||||
"@vueuse/core": "^14.2.1",
|
"@vueuse/core": "^14.2.1",
|
||||||
"axios": "^1.13.6",
|
"axios": "^1.13.6",
|
||||||
|
"highlight.js": "^11.10.0",
|
||||||
"naive-ui": "^2.44.1",
|
"naive-ui": "^2.44.1",
|
||||||
"pinia": "^3.0.4",
|
"pinia": "^3.0.4",
|
||||||
"vue": "^3.5.30",
|
"vue": "^3.5.30",
|
||||||
|
|||||||
14
frontend/src/api/category.ts
Normal file
14
frontend/src/api/category.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { http } from './index'
|
||||||
|
import type { Category } from '@/types'
|
||||||
|
|
||||||
|
export const categoryApi = {
|
||||||
|
// 获取所有分类
|
||||||
|
getAll() {
|
||||||
|
return http.get<Category[]>('/categories')
|
||||||
|
},
|
||||||
|
|
||||||
|
// 获取单个分类
|
||||||
|
getDetail(id: string) {
|
||||||
|
return http.get<Category>(`/categories/${id}`)
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import axios, { type AxiosInstance, type AxiosRequestConfig, type AxiosResponse } from 'axios'
|
import axios, { type AxiosInstance, type AxiosRequestConfig } from 'axios'
|
||||||
import type { ApiResponse } from '@/types'
|
|
||||||
|
|
||||||
// 创建 axios 实例
|
// 创建 axios 实例
|
||||||
const request: AxiosInstance = axios.create({
|
const request: AxiosInstance = axios.create({
|
||||||
@@ -27,20 +26,22 @@ request.interceptors.request.use(
|
|||||||
|
|
||||||
// 响应拦截器
|
// 响应拦截器
|
||||||
request.interceptors.response.use(
|
request.interceptors.response.use(
|
||||||
(response: AxiosResponse<ApiResponse>) => {
|
(response) => {
|
||||||
return response
|
// 成功时返回 response.data(即 ApiResponse)
|
||||||
|
return response.data
|
||||||
},
|
},
|
||||||
(error: any) => {
|
(error: any) => {
|
||||||
if (error.response) {
|
if (error.response) {
|
||||||
const { status } = error.response
|
const { status, data } = error.response
|
||||||
|
|
||||||
// 401 未授权,跳转登录页
|
// 401 未授权,清除 token
|
||||||
if (status === 401) {
|
if (status === 401) {
|
||||||
localStorage.removeItem('access_token')
|
localStorage.removeItem('access_token')
|
||||||
window.location.href = '/login'
|
window.location.href = '/login'
|
||||||
}
|
}
|
||||||
|
|
||||||
return Promise.reject(error.response.data)
|
// 返回错误信息
|
||||||
|
return Promise.reject(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
return Promise.reject(error)
|
return Promise.reject(error)
|
||||||
@@ -48,21 +49,22 @@ request.interceptors.response.use(
|
|||||||
)
|
)
|
||||||
|
|
||||||
// 封装请求方法
|
// 封装请求方法
|
||||||
|
// 返回 Promise<ApiResponse<T>>,调用者通过 .data 访问实际数据
|
||||||
export const http = {
|
export const http = {
|
||||||
get<T = any>(url: string, config?: AxiosRequestConfig): Promise<ApiResponse<T>> {
|
get<T = any>(url: string, config?: AxiosRequestConfig): Promise<{ code: number; message: string; data: T }> {
|
||||||
return request.get(url, config)
|
return request.get(url, config).then(res => res.data)
|
||||||
},
|
},
|
||||||
|
|
||||||
post<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<ApiResponse<T>> {
|
post<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<{ code: number; message: string; data: T }> {
|
||||||
return request.post(url, data, config)
|
return request.post(url, data, config).then(res => res.data)
|
||||||
},
|
},
|
||||||
|
|
||||||
put<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<ApiResponse<T>> {
|
put<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<{ code: number; message: string; data: T }> {
|
||||||
return request.put(url, data, config)
|
return request.put(url, data, config).then(res => res.data)
|
||||||
},
|
},
|
||||||
|
|
||||||
delete<T = any>(url: string, config?: AxiosRequestConfig): Promise<ApiResponse<T>> {
|
delete<T = any>(url: string, config?: AxiosRequestConfig): Promise<{ code: number; message: string; data: T }> {
|
||||||
return request.delete(url, config)
|
return request.delete(url, config).then(res => res.data)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
59
frontend/src/components/MarkdownEditor.vue
Normal file
59
frontend/src/components/MarkdownEditor.vue
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch } from 'vue'
|
||||||
|
import VMdEditor from '@kangc/v-md-editor'
|
||||||
|
import '@kangc/v-md-editor/lib/style/style.css'
|
||||||
|
import githubTheme from '@kangc/v-md-editor/lib/theme/github.js'
|
||||||
|
import '@kangc/v-md-editor/lib/theme/style/github.css'
|
||||||
|
import hljs from 'highlight.js'
|
||||||
|
|
||||||
|
// 配置主题
|
||||||
|
VMdEditor.use(githubTheme, {
|
||||||
|
hljs,
|
||||||
|
})
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: string
|
||||||
|
placeholder?: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: string]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const content = ref(props.modelValue)
|
||||||
|
|
||||||
|
watch(() => props.modelValue, (newVal) => {
|
||||||
|
content.value = newVal
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(content, (newVal) => {
|
||||||
|
emit('update:modelValue', newVal)
|
||||||
|
})
|
||||||
|
|
||||||
|
function handleInput(value: string) {
|
||||||
|
content.value = value
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="markdown-editor">
|
||||||
|
<VMdEditor
|
||||||
|
v-model="content"
|
||||||
|
:placeholder="placeholder || '开始撰写...'"
|
||||||
|
height="400px"
|
||||||
|
mode="edit"
|
||||||
|
@change="handleInput"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.markdown-editor :deep(.v-md-editor) {
|
||||||
|
border: 1px solid var(--gray-300, #d1d5db);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-editor :deep(.v-md-editor--fullscreen) {
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,14 +1,18 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useThemeStore } from '@/store/theme'
|
import { useThemeStore } from '@/store/theme'
|
||||||
import { useUserStore } from '@/store/user'
|
import { useUserStore } from '@/store/user'
|
||||||
import { RouterLink } from 'vue-router'
|
import { RouterLink, useRoute } from 'vue-router'
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
const themeStore = useThemeStore()
|
const themeStore = useThemeStore()
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
|
const isAdmin = computed(() => route.path.startsWith('/admin'))
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<nav class="navbar">
|
<nav v-if="!isAdmin" class="navbar">
|
||||||
<div class="nav-container">
|
<div class="nav-container">
|
||||||
<!-- Logo -->
|
<!-- Logo -->
|
||||||
<RouterLink to="/" class="logo">
|
<RouterLink to="/" class="logo">
|
||||||
@@ -18,24 +22,23 @@ const userStore = useUserStore()
|
|||||||
|
|
||||||
<!-- 导航链接 -->
|
<!-- 导航链接 -->
|
||||||
<div class="nav-links">
|
<div class="nav-links">
|
||||||
<RouterLink to="/" class="nav-link">首页</RouterLink>
|
<RouterLink to="/" class="nav-link" exact-active-class="active">首页</RouterLink>
|
||||||
<RouterLink to="/category" class="nav-link">分类</RouterLink>
|
<RouterLink to="/category" class="nav-link" active-class="active">分类</RouterLink>
|
||||||
<RouterLink to="/archive" class="nav-link">归档</RouterLink>
|
<RouterLink to="/about" class="nav-link" active-class="active">关于</RouterLink>
|
||||||
<RouterLink to="/about" class="nav-link">关于</RouterLink>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 右侧操作区 -->
|
<!-- 右侧操作区 -->
|
||||||
<div class="nav-actions">
|
<div class="nav-actions">
|
||||||
<!-- 主题切换 -->
|
<!-- 主题切换 -->
|
||||||
<button @click="themeStore.toggleTheme()" class="action-btn">
|
<button @click="themeStore.toggleTheme()" class="action-btn" :title="themeStore.theme === 'light' ? '切换深色模式' : '切换浅色模式'">
|
||||||
{{ themeStore.theme === 'light' ? '☀️' : themeStore.theme === 'dark' ? '🌙' : '🌗' }}
|
<span class="text-lg">{{ themeStore.theme === 'light' ? '🌙' : '☀️' }}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- 登录按钮 -->
|
<!-- 登录按钮 -->
|
||||||
<RouterLink v-if="!userStore.isLoggedIn" to="/login" class="login-btn">
|
<RouterLink v-if="!userStore.isLoggedIn" to="/login" class="login-btn">
|
||||||
登录
|
登录
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
<RouterLink v-else to="/profile" class="user-avatar">
|
<RouterLink v-else to="/profile" class="user-avatar" :title="userStore.user?.username">
|
||||||
{{ userStore.user?.username?.[0]?.toUpperCase() || 'U' }}
|
{{ userStore.user?.username?.[0]?.toUpperCase() || 'U' }}
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
@@ -50,19 +53,19 @@ const userStore = useUserStore()
|
|||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
z-index: 50;
|
z-index: 50;
|
||||||
background: rgba(255, 255, 255, 0.85);
|
background: rgba(255, 255, 255, 0.8);
|
||||||
backdrop-filter: blur(12px);
|
backdrop-filter: blur(20px);
|
||||||
border-bottom: 1px solid rgba(229, 231, 235, 0.5);
|
border-bottom: 1px solid rgba(229, 231, 235, 0.3);
|
||||||
}
|
}
|
||||||
:global(.dark) .navbar {
|
:global(.dark) .navbar {
|
||||||
background: rgba(17, 24, 39, 0.85);
|
background: rgba(17, 24, 39, 0.8);
|
||||||
border-bottom-color: rgba(75, 85, 99, 0.5);
|
border-bottom-color: rgba(75, 85, 99, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-container {
|
.nav-container {
|
||||||
max-width: 1280px;
|
max-width: 1280px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 0 1rem;
|
padding: 0 1.5rem;
|
||||||
height: 4rem;
|
height: 4rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -77,9 +80,9 @@ const userStore = useUserStore()
|
|||||||
}
|
}
|
||||||
|
|
||||||
.logo-icon {
|
.logo-icon {
|
||||||
width: 2rem;
|
width: 2.25rem;
|
||||||
height: 2rem;
|
height: 2.25rem;
|
||||||
border-radius: 0.5rem;
|
border-radius: 0.625rem;
|
||||||
background: linear-gradient(135deg, #FFB7C5, #D4B5E6);
|
background: linear-gradient(135deg, #FFB7C5, #D4B5E6);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -87,6 +90,7 @@ const userStore = useUserStore()
|
|||||||
color: white;
|
color: white;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
font-size: 1.125rem;
|
font-size: 1.125rem;
|
||||||
|
box-shadow: 0 2px 10px rgba(255, 183, 197, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.logo-text {
|
.logo-text {
|
||||||
@@ -100,7 +104,7 @@ const userStore = useUserStore()
|
|||||||
|
|
||||||
.nav-links {
|
.nav-links {
|
||||||
display: none;
|
display: none;
|
||||||
gap: 1.5rem;
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
@media (min-width: 768px) {
|
@media (min-width: 768px) {
|
||||||
.nav-links {
|
.nav-links {
|
||||||
@@ -110,18 +114,24 @@ const userStore = useUserStore()
|
|||||||
|
|
||||||
.nav-link {
|
.nav-link {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
color: #6B7280;
|
color: #6B7280;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
font-size: 0.875rem;
|
font-size: 0.9375rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
transition: color 0.2s;
|
transition: all 0.2s;
|
||||||
}
|
}
|
||||||
:global(.dark) .nav-link {
|
:global(.dark) .nav-link {
|
||||||
color: #9CA3AF;
|
color: #9CA3AF;
|
||||||
}
|
}
|
||||||
.nav-link:hover,
|
.nav-link:hover {
|
||||||
.nav-link.router-link-active {
|
|
||||||
color: #FFB7C5;
|
color: #FFB7C5;
|
||||||
|
background: rgba(255, 183, 197, 0.1);
|
||||||
|
}
|
||||||
|
.nav-link.active {
|
||||||
|
color: #FFB7C5;
|
||||||
|
background: rgba(255, 183, 197, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-actions {
|
.nav-actions {
|
||||||
@@ -131,9 +141,9 @@ const userStore = useUserStore()
|
|||||||
}
|
}
|
||||||
|
|
||||||
.action-btn {
|
.action-btn {
|
||||||
width: 2.25rem;
|
width: 2.5rem;
|
||||||
height: 2.25rem;
|
height: 2.5rem;
|
||||||
border-radius: 9999px;
|
border-radius: 0.75rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -146,27 +156,29 @@ const userStore = useUserStore()
|
|||||||
background: #374151;
|
background: #374151;
|
||||||
}
|
}
|
||||||
.action-btn:hover {
|
.action-btn:hover {
|
||||||
background: #FFB7C5;
|
background: rgba(255, 183, 197, 0.3);
|
||||||
|
transform: scale(1.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-btn {
|
.login-btn {
|
||||||
padding: 0.5rem 1rem;
|
padding: 0.5rem 1.25rem;
|
||||||
border-radius: 9999px;
|
border-radius: 9999px;
|
||||||
border: 2px solid #FFB7C5;
|
background: linear-gradient(135deg, #FFB7C5, #D4B5E6);
|
||||||
color: #FFB7C5;
|
color: white;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
font-weight: 500;
|
font-weight: 600;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
|
box-shadow: 0 2px 10px rgba(255, 183, 197, 0.3);
|
||||||
}
|
}
|
||||||
.login-btn:hover {
|
.login-btn:hover {
|
||||||
background: #FFB7C5;
|
transform: translateY(-1px);
|
||||||
color: white;
|
box-shadow: 0 4px 15px rgba(255, 183, 197, 0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-avatar {
|
.user-avatar {
|
||||||
width: 2.25rem;
|
width: 2.5rem;
|
||||||
height: 2.25rem;
|
height: 2.5rem;
|
||||||
border-radius: 9999px;
|
border-radius: 9999px;
|
||||||
background: linear-gradient(135deg, #FFB7C5, #D4B5E6);
|
background: linear-gradient(135deg, #FFB7C5, #D4B5E6);
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -175,5 +187,11 @@ const userStore = useUserStore()
|
|||||||
color: white;
|
color: white;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
box-shadow: 0 2px 10px rgba(255, 183, 197, 0.3);
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
.user-avatar:hover {
|
||||||
|
transform: scale(1.05);
|
||||||
|
box-shadow: 0 4px 15px rgba(255, 183, 197, 0.5);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,26 +1,34 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { RouterLink } from 'vue-router'
|
import { RouterLink } from 'vue-router'
|
||||||
|
import { categoryApi } from '@/api/category'
|
||||||
|
import { postApi } from '@/api/post'
|
||||||
|
import type { Category, Post } from '@/types'
|
||||||
|
|
||||||
const categories = ref([
|
const categories = ref<Category[]>([])
|
||||||
{ id: '1', name: '动漫资讯', slug: 'anime', post_count: 12 },
|
const tags = ref<string[]>(['原神', '崩坏星穹铁道', '我的世界', 'EVA', '约定的梦幻岛', '咒术回战', 'Cosplay', '手办'])
|
||||||
{ id: '2', name: '游戏攻略', slug: 'game', post_count: 8 },
|
const hotPosts = ref<Post[]>([])
|
||||||
{ id: '3', name: '二次元美图', slug: 'pictures', post_count: 25 },
|
|
||||||
{ id: '4', name: '同人创作', slug: 'fanwork', post_count: 15 },
|
|
||||||
])
|
|
||||||
|
|
||||||
const tags = ref([
|
async function fetchSidebarData() {
|
||||||
'原神', '崩坏星穹铁道', '我的世界', 'EVA',
|
try {
|
||||||
'约定的梦幻岛', '咒术回战', 'Cosplay', '手办'
|
// 获取分类
|
||||||
])
|
const catResponse = await categoryApi.getAll()
|
||||||
|
categories.value = catResponse.data
|
||||||
|
|
||||||
const hotPosts = ref([
|
// 获取热门文章
|
||||||
{ id: '1', title: '《原神》4.2版本前瞻:芙宁娜技能演示', view_count: 5200 },
|
const postsResponse = await postApi.getList({ page: 1, page_size: 10 })
|
||||||
{ id: '2', title: '2024年必追的10部春季新番', view_count: 3800 },
|
hotPosts.value = postsResponse.data.items
|
||||||
{ id: '3', title: '《崩坏星穹铁道》角色强度榜更新', view_count: 2900 },
|
.filter((p: Post) => p.status === 'published')
|
||||||
{ id: '4', title: '二次元手游开服大横评', view_count: 2100 },
|
.sort((a: Post, b: Post) => b.view_count - a.view_count)
|
||||||
{ id: '5', title: '手办入坑指南:从萌新到进阶', view_count: 1800 },
|
.slice(0, 5)
|
||||||
])
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch sidebar data:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchSidebarData()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -53,9 +61,8 @@ const hotPosts = ref([
|
|||||||
<h3 class="sidebar-title">分类</h3>
|
<h3 class="sidebar-title">分类</h3>
|
||||||
<ul class="category-list">
|
<ul class="category-list">
|
||||||
<li v-for="cat in categories" :key="cat.id">
|
<li v-for="cat in categories" :key="cat.id">
|
||||||
<RouterLink :to="`/category/${cat.slug}`" class="category-item">
|
<RouterLink :to="`/category/${cat.id}`" class="category-item">
|
||||||
<span>{{ cat.name }}</span>
|
<span>{{ cat.name }}</span>
|
||||||
<span class="category-count">{{ cat.post_count }}</span>
|
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -65,9 +72,9 @@ const hotPosts = ref([
|
|||||||
<div class="sidebar-card">
|
<div class="sidebar-card">
|
||||||
<h3 class="sidebar-title">标签</h3>
|
<h3 class="sidebar-title">标签</h3>
|
||||||
<div class="tag-cloud">
|
<div class="tag-cloud">
|
||||||
<RouterLink v-for="tag in tags" :key="tag" :to="`/tag/${tag}`" class="tag">
|
<span v-for="tag in tags" :key="tag" class="tag">
|
||||||
{{ tag }}
|
{{ tag }}
|
||||||
</RouterLink>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import { createApp } from 'vue'
|
import { createApp } from 'vue'
|
||||||
import { createPinia } from 'pinia'
|
import { createPinia } from 'pinia'
|
||||||
|
import naive from 'naive-ui'
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
import router from './router'
|
import router from './router'
|
||||||
// import naive-ui (按需引入,后续按需配置)
|
import './style.css'
|
||||||
|
|
||||||
const app = createApp(App)
|
const app = createApp(App)
|
||||||
|
|
||||||
app.use(createPinia())
|
app.use(createPinia())
|
||||||
app.use(router)
|
app.use(router)
|
||||||
|
app.use(naive)
|
||||||
|
|
||||||
app.mount('#app')
|
app.mount('#app')
|
||||||
|
|||||||
@@ -83,17 +83,37 @@ const router = createRouter({
|
|||||||
})
|
})
|
||||||
|
|
||||||
// 路由守卫
|
// 路由守卫
|
||||||
router.beforeEach((to, _from, next) => {
|
router.beforeEach(async (to, _from, next) => {
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
const token = localStorage.getItem('access_token')
|
const token = localStorage.getItem('access_token')
|
||||||
|
|
||||||
|
// 需要登录但未登录
|
||||||
if (to.meta.requiresAuth && !token) {
|
if (to.meta.requiresAuth && !token) {
|
||||||
next({ name: 'Login' })
|
next({ name: 'Login' })
|
||||||
} else if (to.meta.requiresAdmin && userStore.user?.is_active !== true) {
|
return
|
||||||
next({ name: 'Home' })
|
|
||||||
} else {
|
|
||||||
next()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 需要管理员权限但不是管理员
|
||||||
|
if (to.meta.requiresAdmin) {
|
||||||
|
// 如果还没有用户信息,先获取
|
||||||
|
if (!userStore.user && token) {
|
||||||
|
try {
|
||||||
|
await userStore.fetchUser()
|
||||||
|
} catch {
|
||||||
|
// 获取失败,清除 token
|
||||||
|
userStore.logout()
|
||||||
|
next({ name: 'Login' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!userStore.user?.is_superuser) {
|
||||||
|
next({ name: 'Home' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
next()
|
||||||
})
|
})
|
||||||
|
|
||||||
export default router
|
export default router
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export const useUserStore = defineStore('user', () => {
|
|||||||
|
|
||||||
// 计算属性
|
// 计算属性
|
||||||
const isLoggedIn = computed(() => !!token.value)
|
const isLoggedIn = computed(() => !!token.value)
|
||||||
const isAdmin = computed(() => user.value?.is_active === true)
|
const isAdmin = computed(() => user.value?.is_superuser === true)
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
function setToken(newToken: string | null) {
|
function setToken(newToken: string | null) {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export interface User {
|
|||||||
email: string
|
email: string
|
||||||
avatar?: string
|
avatar?: string
|
||||||
is_active: boolean
|
is_active: boolean
|
||||||
|
is_superuser: boolean
|
||||||
created_at: string
|
created_at: string
|
||||||
updated_at: string
|
updated_at: string
|
||||||
}
|
}
|
||||||
@@ -82,6 +83,7 @@ export interface Category {
|
|||||||
export interface Tag {
|
export interface Tag {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
|
slug?: string
|
||||||
created_at: string
|
created_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
10
frontend/src/types/v-md-editor.d.ts
vendored
Normal file
10
frontend/src/types/v-md-editor.d.ts
vendored
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
declare module '@kangc/v-md-editor' {
|
||||||
|
import { DefineComponent } from 'vue'
|
||||||
|
const VMdEditor: DefineComponent<any, any, any>
|
||||||
|
export default VMdEditor
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '@kangc/v-md-editor/lib/theme/github.js' {
|
||||||
|
const theme: any
|
||||||
|
export default theme
|
||||||
|
}
|
||||||
@@ -1,19 +1,43 @@
|
|||||||
<template>
|
<script setup lang="ts">
|
||||||
<div class="about">
|
import Navbar from '@/components/Navbar.vue'
|
||||||
<nav class="glass fixed top-0 left-0 right-0 z-50">
|
import Footer from '@/components/Footer.vue'
|
||||||
<div class="max-w-4xl mx-auto px-4 py-4">
|
</script>
|
||||||
<RouterLink to="/" class="text-acg-pink hover:underline">← 返回首页</RouterLink>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<main class="pt-20 px-4 max-w-4xl mx-auto">
|
<template>
|
||||||
|
<div class="about min-h-screen">
|
||||||
|
<Navbar />
|
||||||
|
|
||||||
|
<main class="pt-24 px-4 pb-12 max-w-4xl mx-auto">
|
||||||
<div class="glass rounded-xl p-8">
|
<div class="glass rounded-xl p-8">
|
||||||
<h1 class="text-3xl font-bold mb-4">关于 ACG Blog</h1>
|
<h1 class="text-3xl font-bold mb-6">关于 ACG Blog</h1>
|
||||||
<p class="text-gray-600 dark:text-gray-400 leading-relaxed">
|
<div class="space-y-4 text-gray-600 dark:text-gray-400 leading-relaxed">
|
||||||
|
<p>
|
||||||
ACG Blog 是一个专注于二次元文化的博客平台,使用 Vue 3 + TypeScript + Naive UI 构建。
|
ACG Blog 是一个专注于二次元文化的博客平台,使用 Vue 3 + TypeScript + Naive UI 构建。
|
||||||
在这里,你可以分享你的二次元生活、动漫评论、游戏攻略等内容。
|
在这里,你可以分享你的二次元生活、动漫评论、游戏攻略等内容。
|
||||||
</p>
|
</p>
|
||||||
|
<p>
|
||||||
|
本博客系统采用前后端分离架构,前端使用 Vue 3 + Vite,后端使用 FastAPI,
|
||||||
|
数据存储使用 PostgreSQL,缓存使用 Redis,为你提供流畅的使用体验。
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
如果你有任何问题或建议,欢迎联系我们!
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-8 pt-8 border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<h2 class="text-xl font-bold mb-4">技术栈</h2>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<span class="px-3 py-1 rounded-full bg-acg-pink/20 text-acg-pink text-sm">Vue 3</span>
|
||||||
|
<span class="px-3 py-1 rounded-full bg-acg-purple/20 text-acg-purple text-sm">FastAPI</span>
|
||||||
|
<span class="px-3 py-1 rounded-full bg-acg-pink/20 text-acg-pink text-sm">TypeScript</span>
|
||||||
|
<span class="px-3 py-1 rounded-full bg-acg-purple/20 text-acg-purple text-sm">Naive UI</span>
|
||||||
|
<span class="px-3 py-1 rounded-full bg-acg-pink/20 text-acg-pink text-sm">Tailwind CSS</span>
|
||||||
|
<span class="px-3 py-1 rounded-full bg-acg-purple/20 text-acg-purple text-sm">PostgreSQL</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<Footer />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,25 +1,105 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
|
import Navbar from '@/components/Navbar.vue'
|
||||||
|
import Footer from '@/components/Footer.vue'
|
||||||
|
import { postApi } from '@/api/post'
|
||||||
|
import { categoryApi } from '@/api/category'
|
||||||
|
import type { Post, Category } from '@/types'
|
||||||
|
import { ref, onMounted, computed } from 'vue'
|
||||||
|
import PostCard from '@/components/PostCard.vue'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const categoryId = route.params.id as string
|
const categoryId = route.params.id as string
|
||||||
|
const category = ref<Category | null>(null)
|
||||||
|
const posts = ref<Post[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
const routePath = computed(() => route.path)
|
||||||
|
|
||||||
|
async function fetchCategoryAndPosts() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
if (routePath.value === '/category') {
|
||||||
|
// 全部分类页
|
||||||
|
category.value = null
|
||||||
|
const response = await postApi.getList({ page: 1, page_size: 20 })
|
||||||
|
posts.value = response.data.items.filter((p: Post) => p.status === 'published')
|
||||||
|
} else {
|
||||||
|
// 特定分类
|
||||||
|
const [catResponse, postsResponse] = await Promise.all([
|
||||||
|
categoryApi.getDetail(categoryId),
|
||||||
|
postApi.getList({ category_id: categoryId, page: 1, page_size: 20 }),
|
||||||
|
])
|
||||||
|
category.value = catResponse.data
|
||||||
|
posts.value = postsResponse.data.items.filter((p: Post) => p.status === 'published')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch category:', error)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchCategoryAndPosts()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="category">
|
<div class="category min-h-screen">
|
||||||
<nav class="glass fixed top-0 left-0 right-0 z-50">
|
<Navbar />
|
||||||
<div class="max-w-6xl mx-auto px-4 py-4">
|
|
||||||
<RouterLink to="/" class="text-acg-pink hover:underline">← 返回首页</RouterLink>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<main class="pt-20 px-4 max-w-6xl mx-auto">
|
<!-- Hero Section -->
|
||||||
<div class="glass rounded-xl p-8">
|
<section class="relative py-16 px-4 overflow-hidden">
|
||||||
<h1 class="text-2xl font-bold mb-4">分类 - {{ categoryId }}</h1>
|
<div class="absolute inset-0 bg-gradient-to-br from-pink-500/10 via-purple-500/10 to-blue-500/10"></div>
|
||||||
<div class="text-gray-600 dark:text-gray-400">
|
<div class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-96 h-96 bg-acg-pink/20 rounded-full blur-3xl"></div>
|
||||||
分类内容加载中...
|
<div class="relative max-w-6xl mx-auto text-center">
|
||||||
|
<div class="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-white/50 dark:bg-gray-800/50 backdrop-blur-sm mb-6">
|
||||||
|
<span class="text-lg">📁</span>
|
||||||
|
<span class="text-sm font-medium">分类</span>
|
||||||
|
</div>
|
||||||
|
<h1 class="text-4xl md:text-5xl font-bold mb-4">
|
||||||
|
{{ category?.name || '全部分类' }}
|
||||||
|
</h1>
|
||||||
|
<p v-if="category?.description" class="text-gray-600 dark:text-gray-400 max-w-2xl mx-auto">
|
||||||
|
{{ category.description }}
|
||||||
|
</p>
|
||||||
|
<p v-else class="text-gray-500">
|
||||||
|
浏览所有文章
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<main class="px-4 pb-12 max-w-6xl mx-auto">
|
||||||
|
<div v-if="loading" class="glass rounded-xl p-12 text-center">
|
||||||
|
<div class="animate-pulse space-y-4">
|
||||||
|
<div class="h-6 bg-gray-200 dark:bg-gray-700 rounded w-1/4 mx-auto"></div>
|
||||||
|
<div class="h-4 bg-gray-200 dark:bg-gray-700 rounded w-1/2 mx-auto"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="posts.length === 0" class="glass rounded-xl p-12 text-center">
|
||||||
|
<div class="text-6xl mb-4">📭</div>
|
||||||
|
<h3 class="text-xl font-bold mb-2">暂无文章</h3>
|
||||||
|
<p class="text-gray-500">该分类下还没有发布的文章</p>
|
||||||
|
<RouterLink to="/" class="inline-block mt-4 text-acg-pink hover:underline">
|
||||||
|
返回首页
|
||||||
|
</RouterLink>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else>
|
||||||
|
<div class="flex items-center justify-between mb-6">
|
||||||
|
<h2 class="text-lg font-medium">
|
||||||
|
共 {{ posts.length }} 篇文章
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<PostCard v-for="post in posts" :key="post.id" :post="post" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<Footer />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,115 +1,54 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import Navbar from '@/components/Navbar.vue'
|
import Navbar from '@/components/Navbar.vue'
|
||||||
import Hero from '@/components/Hero.vue'
|
import Hero from '@/components/Hero.vue'
|
||||||
import PostCard from '@/components/PostCard.vue'
|
import PostCard from '@/components/PostCard.vue'
|
||||||
import Sidebar from '@/components/Sidebar.vue'
|
import Sidebar from '@/components/Sidebar.vue'
|
||||||
import Footer from '@/components/Footer.vue'
|
import Footer from '@/components/Footer.vue'
|
||||||
import type { Post } from '@/types'
|
import { postApi } from '@/api/post'
|
||||||
|
import { categoryApi } from '@/api/category'
|
||||||
// 模拟文章数据 - 后端完成后替换为真实API调用
|
import type { Post, Category } from '@/types'
|
||||||
const posts = ref<Post[]>([
|
|
||||||
{
|
|
||||||
id: '1',
|
|
||||||
title: '《原神》4.2版本前瞻:芙宁娜技能演示与剧情预测',
|
|
||||||
slug: 'genshin-4-2-preview',
|
|
||||||
content: '',
|
|
||||||
summary: '4.2版本即将到来,让我们提前了解水神芙宁娜的技能机制以及可能的剧情发展...',
|
|
||||||
cover_image: 'https://images.unsplash.com/photo-1542751371-adc38448a05e?w=800',
|
|
||||||
author: { id: '1', username: 'Miyako', email: '', is_active: true, created_at: '', updated_at: '' },
|
|
||||||
category: { id: '1', name: '游戏攻略', slug: 'game', created_at: '' },
|
|
||||||
tags: [{ id: '1', name: '原神', created_at: '' }, { id: '2', name: '攻略', created_at: '' }],
|
|
||||||
view_count: 5200,
|
|
||||||
status: 'published',
|
|
||||||
created_at: '2024-03-15T10:00:00Z',
|
|
||||||
updated_at: '2024-03-15T10:00:00Z'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '2',
|
|
||||||
title: '2024年必追的10部春季新番推荐',
|
|
||||||
slug: 'spring-2024-anime',
|
|
||||||
content: '',
|
|
||||||
summary: '春天到了,又到了补番的季节!本期为大家带来2024年春季最值得期待的新番动画...',
|
|
||||||
cover_image: 'https://images.unsplash.com/photo-1535016120720-40c6874c3b1c?w=800',
|
|
||||||
author: { id: '2', username: 'Akari', email: '', is_active: true, created_at: '', updated_at: '' },
|
|
||||||
category: { id: '2', name: '动漫资讯', slug: 'anime', created_at: '' },
|
|
||||||
tags: [{ id: '3', name: '新番', created_at: '' }, { id: '4', name: '推荐', created_at: '' }],
|
|
||||||
view_count: 3800,
|
|
||||||
status: 'published',
|
|
||||||
created_at: '2024-03-14T08:00:00Z',
|
|
||||||
updated_at: '2024-03-14T08:00:00Z'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '3',
|
|
||||||
title: '《崩坏星穹铁道》角色强度榜 - 3月版',
|
|
||||||
slug: 'honkai-star-rail-tier-list',
|
|
||||||
content: '',
|
|
||||||
summary: '版本强势角色有哪些?本期强度榜为你详细分析当前版本的T0、T1角色...',
|
|
||||||
cover_image: 'https://images.unsplash.com/photo-1550745165-9bc0b252726f?w=800',
|
|
||||||
author: { id: '1', username: 'Miyako', email: '', is_active: true, created_at: '', updated_at: '' },
|
|
||||||
category: { id: '1', name: '游戏攻略', slug: 'game', created_at: '' },
|
|
||||||
tags: [{ id: '2', name: '崩坏星穹铁道', created_at: '' }, { id: '5', name: '强度榜', created_at: '' }],
|
|
||||||
view_count: 2900,
|
|
||||||
status: 'published',
|
|
||||||
created_at: '2024-03-13T15:30:00Z',
|
|
||||||
updated_at: '2024-03-13T15:30:00Z'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '4',
|
|
||||||
title: '手办入坑指南:从萌新到进阶的全方位攻略',
|
|
||||||
slug: 'figure-beginner-guide',
|
|
||||||
content: '',
|
|
||||||
summary: '想入手第一只手办但不知道如何选择?这篇指南将带你了解手办的各种分类、选购渠道...',
|
|
||||||
cover_image: 'https://images.unsplash.com/photo-1608889175123-8ee362201f81?w=800',
|
|
||||||
author: { id: '3', username: 'Sakura', email: '', is_active: true, created_at: '', updated_at: '' },
|
|
||||||
category: { id: '3', name: '二次元美图', slug: 'pictures', created_at: '' },
|
|
||||||
tags: [{ id: '6', name: '手办', created_at: '' }, { id: '7', name: '教程', created_at: '' }],
|
|
||||||
view_count: 2100,
|
|
||||||
status: 'published',
|
|
||||||
created_at: '2024-03-12T12:00:00Z',
|
|
||||||
updated_at: '2024-03-12T12:00:00Z'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '5',
|
|
||||||
title: '同人创作分享:用画笔描绘心中的二次元世界',
|
|
||||||
slug: 'fanart-sharing',
|
|
||||||
content: '',
|
|
||||||
summary: '本期收录了多位优秀画师的作品,让我们一起欣赏这些充满想象力的同人创作...',
|
|
||||||
cover_image: 'https://images.unsplash.com/photo-1618005182384-a83a8bd57fbe?w=800',
|
|
||||||
author: { id: '2', username: 'Akari', email: '', is_active: true, created_at: '', updated_at: '' },
|
|
||||||
category: { id: '4', name: '同人创作', slug: 'fanwork', created_at: '' },
|
|
||||||
tags: [{ id: '8', name: '同人', created_at: '' }, { id: '9', name: '绘画', created_at: '' }],
|
|
||||||
view_count: 1800,
|
|
||||||
status: 'published',
|
|
||||||
created_at: '2024-03-11T09:00:00Z',
|
|
||||||
updated_at: '2024-03-11T09:00:00Z'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '6',
|
|
||||||
title: '《约定的梦幻岛》最新话解析:剧情走向深度分析',
|
|
||||||
slug: 'neverland-analysis',
|
|
||||||
content: '',
|
|
||||||
summary: '最新一话的剧情信息量巨大,让我们来深度分析一下剧情走向和角色心理...',
|
|
||||||
cover_image: 'https://images.unsplash.com/photo-1518709268805-4e9042af9f23?w=800',
|
|
||||||
author: { id: '4', username: 'Ken', email: '', is_active: true, created_at: '', updated_at: '' },
|
|
||||||
category: { id: '2', name: '动漫资讯', slug: 'anime', created_at: '' },
|
|
||||||
tags: [{ id: '10', name: '约定的梦幻岛', created_at: '' }, { id: '11', name: '分析', created_at: '' }],
|
|
||||||
view_count: 1500,
|
|
||||||
status: 'published',
|
|
||||||
created_at: '2024-03-10T20:00:00Z',
|
|
||||||
updated_at: '2024-03-10T20:00:00Z'
|
|
||||||
}
|
|
||||||
])
|
|
||||||
|
|
||||||
const categories = [
|
|
||||||
{ name: '全部', slug: '' },
|
|
||||||
{ name: '动漫资讯', slug: 'anime' },
|
|
||||||
{ name: '游戏攻略', slug: 'game' },
|
|
||||||
{ name: '二次元美图', slug: 'pictures' },
|
|
||||||
{ name: '同人创作', slug: 'fanwork' },
|
|
||||||
]
|
|
||||||
|
|
||||||
|
const posts = ref<Post[]>([])
|
||||||
|
const categories = ref<Category[]>([{ id: '', name: '全部', slug: '', created_at: '' }])
|
||||||
const selectedCategory = ref('')
|
const selectedCategory = ref('')
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
async function fetchPosts(categoryId?: string) {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const params: any = { page: 1, page_size: 20 }
|
||||||
|
if (categoryId) {
|
||||||
|
params.category_id = categoryId
|
||||||
|
}
|
||||||
|
const response = await postApi.getList(params)
|
||||||
|
posts.value = response.data.items.filter((p: any) => p.status === 'published')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch posts:', error)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchCategories() {
|
||||||
|
try {
|
||||||
|
const response = await categoryApi.getAll()
|
||||||
|
categories.value = [{ id: '', name: '全部', slug: '', created_at: '' }, ...response.data]
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch categories:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCategoryChange(slug: string) {
|
||||||
|
selectedCategory.value = slug
|
||||||
|
const catId = slug ? categories.value.find(c => c.slug === slug)?.id : ''
|
||||||
|
fetchPosts(catId)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchCategories()
|
||||||
|
fetchPosts()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -123,8 +62,8 @@ const selectedCategory = ref('')
|
|||||||
<div class="category-buttons">
|
<div class="category-buttons">
|
||||||
<button
|
<button
|
||||||
v-for="cat in categories"
|
v-for="cat in categories"
|
||||||
:key="cat.slug"
|
:key="cat.id"
|
||||||
@click="selectedCategory = cat.slug"
|
@click="handleCategoryChange(cat.slug)"
|
||||||
class="category-btn"
|
class="category-btn"
|
||||||
:class="{ active: selectedCategory === cat.slug }"
|
:class="{ active: selectedCategory === cat.slug }"
|
||||||
>
|
>
|
||||||
@@ -138,7 +77,9 @@ const selectedCategory = ref('')
|
|||||||
<!-- 文章列表 -->
|
<!-- 文章列表 -->
|
||||||
<section class="posts-section">
|
<section class="posts-section">
|
||||||
<h2 class="section-title">最新文章</h2>
|
<h2 class="section-title">最新文章</h2>
|
||||||
<div class="posts-grid">
|
<div v-if="loading" class="text-center py-8 text-gray-500">加载中...</div>
|
||||||
|
<div v-else-if="posts.length === 0" class="text-center py-8 text-gray-500">暂无文章</div>
|
||||||
|
<div v-else class="posts-grid">
|
||||||
<PostCard v-for="post in posts" :key="post.id" :post="post" />
|
<PostCard v-for="post in posts" :key="post.id" :post="post" />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { RouterLink } from 'vue-router'
|
import { RouterLink } from 'vue-router'
|
||||||
|
import Navbar from '@/components/Navbar.vue'
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="not-found min-h-screen flex items-center justify-center px-4">
|
<div class="not-found min-h-screen">
|
||||||
|
<Navbar />
|
||||||
|
<main class="pt-24 px-4 pb-12 flex items-center justify-center">
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<h1 class="text-6xl font-bold text-acg-pink mb-4">404</h1>
|
<h1 class="text-6xl font-bold text-acg-pink mb-4">404</h1>
|
||||||
<p class="text-xl mb-6">页面不存在</p>
|
<p class="text-xl mb-6 text-gray-600 dark:text-gray-400">页面不存在</p>
|
||||||
<RouterLink to="/" class="btn-acg">返回首页</RouterLink>
|
<RouterLink to="/" class="btn-acg">返回首页</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,25 +1,100 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useRoute } from 'vue-router'
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { postApi } from '@/api/post'
|
||||||
|
import type { Post } from '@/types'
|
||||||
|
import { useMessage } from 'naive-ui'
|
||||||
|
import Navbar from '@/components/Navbar.vue'
|
||||||
|
import Footer from '@/components/Footer.vue'
|
||||||
|
|
||||||
|
const message = useMessage()
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
const postId = route.params.id as string
|
const postId = route.params.id as string
|
||||||
|
const post = ref<Post | null>(null)
|
||||||
|
const loading = ref(true)
|
||||||
|
|
||||||
|
async function fetchPost() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const response = await postApi.getDetail(postId)
|
||||||
|
post.value = response.data
|
||||||
|
// 增加浏览量
|
||||||
|
postApi.incrementView(postId).catch(() => {})
|
||||||
|
} catch (error) {
|
||||||
|
message.error('文章不存在或已被删除')
|
||||||
|
router.push('/')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateStr: string) {
|
||||||
|
return new Date(dateStr).toLocaleDateString('zh-CN', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchPost()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="post-detail">
|
<div class="post-detail min-h-screen">
|
||||||
<nav class="glass fixed top-0 left-0 right-0 z-50">
|
<Navbar />
|
||||||
<div class="max-w-4xl mx-auto px-4 py-4">
|
|
||||||
<RouterLink to="/" class="text-acg-pink hover:underline">← 返回首页</RouterLink>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<main class="pt-20 px-4 max-w-4xl mx-auto">
|
<main class="pt-24 px-4 pb-12 max-w-4xl mx-auto">
|
||||||
<div class="glass rounded-xl p-8">
|
<div v-if="loading" class="glass rounded-xl p-8 text-center">
|
||||||
<h1 class="text-3xl font-bold mb-4">文章详情 - {{ postId }}</h1>
|
<div class="text-gray-500">加载中...</div>
|
||||||
<div class="text-gray-600 dark:text-gray-400">
|
|
||||||
文章内容加载中...
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<article v-else-if="post" class="glass rounded-xl overflow-hidden">
|
||||||
|
<!-- 封面图 -->
|
||||||
|
<div v-if="post.cover_image" class="w-full h-64 md:h-80 overflow-hidden">
|
||||||
|
<img :src="post.cover_image" :alt="post.title" class="w-full h-full object-cover" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="p-6 md:p-8">
|
||||||
|
<!-- 标题 -->
|
||||||
|
<h1 class="text-3xl md:text-4xl font-bold mb-4">{{ post.title }}</h1>
|
||||||
|
|
||||||
|
<!-- 元信息 -->
|
||||||
|
<div class="flex flex-wrap items-center gap-4 text-sm text-gray-500 mb-6 pb-6 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<img
|
||||||
|
v-if="post.author.avatar"
|
||||||
|
:src="post.author.avatar"
|
||||||
|
:alt="post.author.username"
|
||||||
|
class="w-8 h-8 rounded-full"
|
||||||
|
/>
|
||||||
|
<span class="text-acg-pink">{{ post.author.username }}</span>
|
||||||
|
</div>
|
||||||
|
<span>发布于 {{ formatDate(post.created_at) }}</span>
|
||||||
|
<span v-if="post.category">{{ post.category.name }}</span>
|
||||||
|
<span>阅读 {{ post.view_count }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 标签 -->
|
||||||
|
<div v-if="post.tags?.length" class="flex flex-wrap gap-2 mb-6">
|
||||||
|
<span
|
||||||
|
v-for="tag in post.tags"
|
||||||
|
:key="tag.id"
|
||||||
|
class="px-3 py-1 text-sm rounded-full bg-acg-pink/20 text-acg-pink"
|
||||||
|
>
|
||||||
|
{{ tag.name }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 文章内容 -->
|
||||||
|
<div class="prose prose-lg max-w-none dark:prose-invert" v-html="post.content"></div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<Footer />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,8 +1,197 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { useMessage, useDialog } from 'naive-ui'
|
||||||
|
import type { Category } from '@/types'
|
||||||
|
|
||||||
|
const message = useMessage()
|
||||||
|
const dialog = useDialog()
|
||||||
|
|
||||||
|
const categories = ref<Category[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const showModal = ref(false)
|
||||||
|
const isEditing = ref(false)
|
||||||
|
const currentId = ref<string | null>(null)
|
||||||
|
|
||||||
|
const formData = ref({
|
||||||
|
name: '',
|
||||||
|
slug: '',
|
||||||
|
description: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
async function fetchCategories() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
// 模拟数据 - 实际应该调用API
|
||||||
|
categories.value = []
|
||||||
|
} catch (error) {
|
||||||
|
message.error('获取分类失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openModal(category?: Category) {
|
||||||
|
if (category) {
|
||||||
|
isEditing.value = true
|
||||||
|
currentId.value = category.id
|
||||||
|
formData.value = {
|
||||||
|
name: category.name,
|
||||||
|
slug: category.slug,
|
||||||
|
description: category.description || '',
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
isEditing.value = false
|
||||||
|
currentId.value = null
|
||||||
|
formData.value = { name: '', slug: '', description: '' }
|
||||||
|
}
|
||||||
|
showModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal() {
|
||||||
|
showModal.value = false
|
||||||
|
isEditing.value = false
|
||||||
|
currentId.value = null
|
||||||
|
formData.value = { name: '', slug: '', description: '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveCategory() {
|
||||||
|
if (!formData.value.name || !formData.value.slug) {
|
||||||
|
message.warning('请填写名称和别名')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (isEditing.value && currentId.value) {
|
||||||
|
message.success('分类更新成功')
|
||||||
|
} else {
|
||||||
|
message.success('分类创建成功')
|
||||||
|
}
|
||||||
|
closeModal()
|
||||||
|
fetchCategories()
|
||||||
|
} catch (error) {
|
||||||
|
message.error('保存失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteCategory(_id: string) {
|
||||||
|
try {
|
||||||
|
await dialog.warning({
|
||||||
|
title: '确认删除',
|
||||||
|
content: '确定要删除这个分类吗?',
|
||||||
|
positiveText: '删除',
|
||||||
|
negativeText: '取消',
|
||||||
|
})
|
||||||
|
message.success('删除成功')
|
||||||
|
fetchCategories()
|
||||||
|
} catch {
|
||||||
|
// 取消
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateSlug() {
|
||||||
|
// 简单的 slug 生成
|
||||||
|
formData.value.slug = formData.value.name
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9\u4e00-\u9fa5]/g, '-')
|
||||||
|
.replace(/-+/g, '-')
|
||||||
|
.replace(/^-|-$/g, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchCategories()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="category-manage">
|
<div class="category-manage">
|
||||||
<h1 class="text-2xl font-bold mb-6">分类管理</h1>
|
<div class="flex justify-between items-center mb-6">
|
||||||
<div class="glass rounded-xl p-6">
|
<h1 class="text-2xl font-bold">分类管理</h1>
|
||||||
<p class="text-gray-600 dark:text-gray-400">分类管理功能开发中...</p>
|
<button @click="openModal()" class="btn-acg">
|
||||||
|
新建分类
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="glass rounded-xl overflow-hidden">
|
||||||
|
<table class="w-full">
|
||||||
|
<thead class="bg-gray-100 dark:bg-gray-800">
|
||||||
|
<tr>
|
||||||
|
<th class="px-4 py-3 text-left text-sm font-medium">名称</th>
|
||||||
|
<th class="px-4 py-3 text-left text-sm font-medium">别名</th>
|
||||||
|
<th class="px-4 py-3 text-left text-sm font-medium">描述</th>
|
||||||
|
<th class="px-4 py-3 text-left text-sm font-medium">创建时间</th>
|
||||||
|
<th class="px-4 py-3 text-right text-sm font-medium">操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
<tr v-for="cat in categories" :key="cat.id" class="hover:bg-gray-50 dark:hover:bg-gray-800">
|
||||||
|
<td class="px-4 py-3 font-medium">{{ cat.name }}</td>
|
||||||
|
<td class="px-4 py-3 text-gray-500 text-sm">{{ cat.slug }}</td>
|
||||||
|
<td class="px-4 py-3 text-gray-500 text-sm">{{ cat.description || '-' }}</td>
|
||||||
|
<td class="px-4 py-3 text-gray-500 text-sm">{{ new Date(cat.created_at).toLocaleDateString() }}</td>
|
||||||
|
<td class="px-4 py-3 text-right">
|
||||||
|
<button @click="openModal(cat)" class="text-acg-pink hover:underline mr-4">编辑</button>
|
||||||
|
<button @click="deleteCategory(cat.id)" class="text-red-500 hover:underline">删除</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="loading">
|
||||||
|
<td colspan="5" class="px-4 py-8 text-center text-gray-500">加载中...</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="!loading && categories.length === 0">
|
||||||
|
<td colspan="5" class="px-4 py-8 text-center text-gray-500">暂无分类,点击上方按钮创建</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 弹窗 -->
|
||||||
|
<div v-if="showModal" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50">
|
||||||
|
<div class="glass rounded-xl w-full max-w-md">
|
||||||
|
<div class="p-6">
|
||||||
|
<h2 class="text-xl font-bold mb-6">{{ isEditing ? '编辑分类' : '新建分类' }}</h2>
|
||||||
|
|
||||||
|
<form @submit.prevent="saveCategory" class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium mb-2">名称</label>
|
||||||
|
<input
|
||||||
|
v-model="formData.name"
|
||||||
|
type="text"
|
||||||
|
placeholder="分类名称"
|
||||||
|
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800"
|
||||||
|
@blur="generateSlug"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium mb-2">别名</label>
|
||||||
|
<input
|
||||||
|
v-model="formData.slug"
|
||||||
|
type="text"
|
||||||
|
placeholder="url-slug"
|
||||||
|
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium mb-2">描述</label>
|
||||||
|
<textarea
|
||||||
|
v-model="formData.description"
|
||||||
|
placeholder="分类描述(可选)"
|
||||||
|
rows="3"
|
||||||
|
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800"
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end gap-4 pt-4">
|
||||||
|
<button type="button" @click="closeModal" class="px-6 py-2 rounded-lg border border-gray-300 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-800">
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button type="submit" class="btn-acg">
|
||||||
|
保存
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,18 +1,120 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { postApi } from '@/api/post'
|
||||||
|
import { useUserStore } from '@/store/user'
|
||||||
|
|
||||||
|
const userStore = useUserStore()
|
||||||
|
const stats = ref({
|
||||||
|
postCount: 0,
|
||||||
|
userCount: 0,
|
||||||
|
viewCount: 0,
|
||||||
|
})
|
||||||
|
const recentPosts = ref<any[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
async function fetchDashboardData() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const response = await postApi.getList({ page: 1, page_size: 5 })
|
||||||
|
recentPosts.value = response.data.items
|
||||||
|
stats.value.postCount = response.data.total
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch dashboard data:', error)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateStr: string) {
|
||||||
|
return new Date(dateStr).toLocaleDateString('zh-CN')
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchDashboardData()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="dashboard">
|
<div class="dashboard">
|
||||||
<h1 class="text-2xl font-bold mb-6">仪表盘</h1>
|
<div class="mb-6">
|
||||||
<div class="grid gap-4 md:grid-cols-3">
|
<h1 class="text-2xl font-bold">仪表盘</h1>
|
||||||
<div class="glass rounded-xl p-6">
|
<p class="text-gray-500 text-sm mt-1">欢迎回来,{{ userStore.user?.username || '管理员' }}</p>
|
||||||
<h3 class="text-gray-600 dark:text-gray-400">文章总数</h3>
|
|
||||||
<p class="text-3xl font-bold text-acg-pink">0</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="glass rounded-xl p-6">
|
|
||||||
<h3 class="text-gray-600 dark:text-gray-400">用户总数</h3>
|
<!-- 统计卡片 -->
|
||||||
<p class="text-3xl font-bold text-acg-blue">0</p>
|
<div class="grid gap-4 md:grid-cols-3 mb-8">
|
||||||
|
<div class="glass rounded-xl p-6 relative overflow-hidden">
|
||||||
|
<div class="absolute top-0 right-0 w-20 h-20 bg-gradient-to-br from-pink-500/20 to-transparent rounded-bl-full"></div>
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<div class="w-12 h-12 rounded-xl bg-pink-500/20 flex items-center justify-center">
|
||||||
|
<span class="text-2xl">📝</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-gray-500 text-sm">文章总数</p>
|
||||||
|
<p class="text-3xl font-bold text-pink-500">{{ stats.postCount }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="glass rounded-xl p-6 relative overflow-hidden">
|
||||||
|
<div class="absolute top-0 right-0 w-20 h-20 bg-gradient-to-br from-blue-500/20 to-transparent rounded-bl-full"></div>
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<div class="w-12 h-12 rounded-xl bg-blue-500/20 flex items-center justify-center">
|
||||||
|
<span class="text-2xl">👤</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-gray-500 text-sm">用户总数</p>
|
||||||
|
<p class="text-3xl font-bold text-blue-500">{{ stats.userCount }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="glass rounded-xl p-6 relative overflow-hidden">
|
||||||
|
<div class="absolute top-0 right-0 w-20 h-20 bg-gradient-to-br from-purple-500/20 to-transparent rounded-bl-full"></div>
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<div class="w-12 h-12 rounded-xl bg-purple-500/20 flex items-center justify-center">
|
||||||
|
<span class="text-2xl">👁️</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-gray-500 text-sm">总访问量</p>
|
||||||
|
<p class="text-3xl font-bold text-purple-500">{{ stats.viewCount.toLocaleString() }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 最近文章 -->
|
||||||
<div class="glass rounded-xl p-6">
|
<div class="glass rounded-xl p-6">
|
||||||
<h3 class="text-gray-600 dark:text-gray-400">总访问量</h3>
|
<h2 class="text-lg font-bold mb-4">最近文章</h2>
|
||||||
<p class="text-3xl font-bold text-acg-purple">0</p>
|
<div v-if="loading" class="text-center text-gray-500 py-4">加载中...</div>
|
||||||
|
<div v-else-if="recentPosts.length === 0" class="text-center text-gray-500 py-4">暂无文章</div>
|
||||||
|
<div v-else class="space-y-3">
|
||||||
|
<div
|
||||||
|
v-for="post in recentPosts"
|
||||||
|
:key="post.id"
|
||||||
|
class="flex items-center justify-between p-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="w-10 h-10 rounded-lg bg-acg-pink/10 flex items-center justify-center text-acg-pink">
|
||||||
|
📄
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<RouterLink :to="`/post/${post.id}`" class="font-medium hover:text-acg-pink">
|
||||||
|
{{ post.title }}
|
||||||
|
</RouterLink>
|
||||||
|
<p class="text-sm text-gray-500">{{ formatDate(post.created_at) }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
:class="{
|
||||||
|
'bg-green-500/20 text-green-500': post.status === 'published',
|
||||||
|
'bg-yellow-500/20 text-yellow-500': post.status === 'draft',
|
||||||
|
}"
|
||||||
|
class="px-2 py-1 text-xs rounded-full"
|
||||||
|
>
|
||||||
|
{{ post.status === 'published' ? '已发布' : '草稿' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,8 +1,250 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { useMessage } from 'naive-ui'
|
||||||
|
import { postApi } from '@/api/post'
|
||||||
|
import type { Post, PostCreateRequest } from '@/types'
|
||||||
|
import Navbar from '@/components/Navbar.vue'
|
||||||
|
import Footer from '@/components/Footer.vue'
|
||||||
|
|
||||||
|
const message = useMessage()
|
||||||
|
const posts = ref<Post[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const showEditor = ref(false)
|
||||||
|
const isEditing = ref(false)
|
||||||
|
const currentPostId = ref<string | null>(null)
|
||||||
|
|
||||||
|
// 表单数据
|
||||||
|
const formData = ref<PostCreateRequest>({
|
||||||
|
title: '',
|
||||||
|
content: '',
|
||||||
|
summary: '',
|
||||||
|
cover_image: '',
|
||||||
|
category_id: '',
|
||||||
|
tags: [],
|
||||||
|
status: 'draft',
|
||||||
|
})
|
||||||
|
|
||||||
|
const searchParams = ref({
|
||||||
|
page: 1,
|
||||||
|
page_size: 20,
|
||||||
|
})
|
||||||
|
|
||||||
|
async function fetchPosts() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const response = await postApi.getList(searchParams.value)
|
||||||
|
posts.value = response.data.items
|
||||||
|
} catch (error) {
|
||||||
|
message.error('获取文章列表失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditor(post?: Post) {
|
||||||
|
if (post) {
|
||||||
|
isEditing.value = true
|
||||||
|
currentPostId.value = post.id
|
||||||
|
formData.value = {
|
||||||
|
title: post.title,
|
||||||
|
content: post.content,
|
||||||
|
summary: post.summary || '',
|
||||||
|
cover_image: post.cover_image || '',
|
||||||
|
category_id: post.category?.id || '',
|
||||||
|
tags: [],
|
||||||
|
status: post.status === 'archived' ? 'draft' : post.status,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
isEditing.value = false
|
||||||
|
currentPostId.value = null
|
||||||
|
formData.value = {
|
||||||
|
title: '',
|
||||||
|
content: '',
|
||||||
|
summary: '',
|
||||||
|
cover_image: '',
|
||||||
|
category_id: '',
|
||||||
|
tags: [],
|
||||||
|
status: 'draft',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
showEditor.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeEditor() {
|
||||||
|
showEditor.value = false
|
||||||
|
isEditing.value = false
|
||||||
|
currentPostId.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function savePost() {
|
||||||
|
if (!formData.value.title || !formData.value.content) {
|
||||||
|
message.warning('请填写标题和内容')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (isEditing.value && currentPostId.value) {
|
||||||
|
await postApi.update(currentPostId.value, formData.value)
|
||||||
|
message.success('文章更新成功')
|
||||||
|
} else {
|
||||||
|
await postApi.create(formData.value)
|
||||||
|
message.success('文章创建成功')
|
||||||
|
}
|
||||||
|
closeEditor()
|
||||||
|
fetchPosts()
|
||||||
|
} catch (error: any) {
|
||||||
|
message.error(error?.message || '保存失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deletePost(id: string) {
|
||||||
|
try {
|
||||||
|
await postApi.delete(id)
|
||||||
|
message.success('删除成功')
|
||||||
|
fetchPosts()
|
||||||
|
} catch (error) {
|
||||||
|
message.error('删除失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateStr: string) {
|
||||||
|
return new Date(dateStr).toLocaleDateString('zh-CN')
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchPosts()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="post-manage">
|
<div class="post-manage min-h-screen">
|
||||||
<h1 class="text-2xl font-bold mb-6">文章管理</h1>
|
<Navbar />
|
||||||
<div class="glass rounded-xl p-6">
|
|
||||||
<p class="text-gray-600 dark:text-gray-400">文章管理功能开发中...</p>
|
<main class="pt-24 px-4 pb-12 max-w-6xl mx-auto">
|
||||||
|
<div class="flex justify-between items-center mb-6">
|
||||||
|
<h1 class="text-2xl font-bold">文章管理</h1>
|
||||||
|
<button @click="openEditor()" class="btn-acg">
|
||||||
|
新建文章
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="glass rounded-xl overflow-hidden">
|
||||||
|
<table class="w-full">
|
||||||
|
<thead class="bg-gray-100 dark:bg-gray-800">
|
||||||
|
<tr>
|
||||||
|
<th class="px-4 py-3 text-left text-sm font-medium">标题</th>
|
||||||
|
<th class="px-4 py-3 text-left text-sm font-medium">分类</th>
|
||||||
|
<th class="px-4 py-3 text-left text-sm font-medium">状态</th>
|
||||||
|
<th class="px-4 py-3 text-left text-sm font-medium">发布时间</th>
|
||||||
|
<th class="px-4 py-3 text-right text-sm font-medium">操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
<tr v-for="post in posts" :key="post.id" class="hover:bg-gray-50 dark:hover:bg-gray-800">
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<RouterLink :to="`/post/${post.id}`" class="text-acg-pink hover:underline">
|
||||||
|
{{ post.title }}
|
||||||
|
</RouterLink>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-sm text-gray-500">{{ post.category?.name || '-' }}</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<span
|
||||||
|
:class="{
|
||||||
|
'bg-green-500/20 text-green-500': post.status === 'published',
|
||||||
|
'bg-yellow-500/20 text-yellow-500': post.status === 'draft',
|
||||||
|
'bg-gray-500/20 text-gray-500': post.status === 'archived',
|
||||||
|
}"
|
||||||
|
class="px-2 py-1 text-xs rounded-full"
|
||||||
|
>
|
||||||
|
{{ post.status === 'published' ? '已发布' : post.status === 'draft' ? '草稿' : '归档' }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-sm text-gray-500">{{ formatDate(post.created_at) }}</td>
|
||||||
|
<td class="px-4 py-3 text-right">
|
||||||
|
<button @click="openEditor(post)" class="text-acg-pink hover:underline mr-4">编辑</button>
|
||||||
|
<button @click="deletePost(post.id)" class="text-red-500 hover:underline">删除</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div v-if="loading" class="p-8 text-center text-gray-500">加载中...</div>
|
||||||
|
<div v-if="!loading && posts.length === 0" class="p-8 text-center text-gray-500">暂无文章</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- 编辑器弹窗 -->
|
||||||
|
<div v-if="showEditor" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50">
|
||||||
|
<div class="glass rounded-xl w-full max-w-4xl max-h-[90vh] overflow-y-auto">
|
||||||
|
<div class="p-6">
|
||||||
|
<h2 class="text-xl font-bold mb-6">{{ isEditing ? '编辑文章' : '新建文章' }}</h2>
|
||||||
|
|
||||||
|
<form @submit.prevent="savePost" class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium mb-2">标题</label>
|
||||||
|
<input
|
||||||
|
v-model="formData.title"
|
||||||
|
type="text"
|
||||||
|
placeholder="文章标题"
|
||||||
|
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium mb-2">摘要</label>
|
||||||
|
<textarea
|
||||||
|
v-model="formData.summary"
|
||||||
|
placeholder="文章摘要(可选)"
|
||||||
|
rows="2"
|
||||||
|
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800"
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium mb-2">封面图 URL</label>
|
||||||
|
<input
|
||||||
|
v-model="formData.cover_image"
|
||||||
|
type="url"
|
||||||
|
placeholder="https://..."
|
||||||
|
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium mb-2">内容 (Markdown)</label>
|
||||||
|
<textarea
|
||||||
|
v-model="formData.content"
|
||||||
|
placeholder="使用 Markdown 编写文章内容..."
|
||||||
|
rows="12"
|
||||||
|
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 font-mono text-sm"
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium mb-2">状态</label>
|
||||||
|
<select
|
||||||
|
v-model="formData.status"
|
||||||
|
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800"
|
||||||
|
>
|
||||||
|
<option value="draft">草稿</option>
|
||||||
|
<option value="published">发布</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end gap-4 pt-4">
|
||||||
|
<button type="button" @click="closeEditor" class="px-6 py-2 rounded-lg border border-gray-300 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-800">
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button type="submit" class="btn-acg">
|
||||||
|
保存
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Footer />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,8 +1,171 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { useMessage, useDialog } from 'naive-ui'
|
||||||
|
import type { Tag } from '@/types'
|
||||||
|
|
||||||
|
const message = useMessage()
|
||||||
|
const dialog = useDialog()
|
||||||
|
|
||||||
|
const tags = ref<Tag[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const showModal = ref(false)
|
||||||
|
const isEditing = ref(false)
|
||||||
|
const currentId = ref<string | null>(null)
|
||||||
|
|
||||||
|
const formData = ref({
|
||||||
|
name: '',
|
||||||
|
slug: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
async function fetchTags() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
// 模拟数据 - 实际应该调用API
|
||||||
|
tags.value = []
|
||||||
|
} catch (error) {
|
||||||
|
message.error('获取标签失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openModal(tag?: Tag) {
|
||||||
|
if (tag) {
|
||||||
|
isEditing.value = true
|
||||||
|
currentId.value = tag.id
|
||||||
|
formData.value = {
|
||||||
|
name: tag.name,
|
||||||
|
slug: tag.slug || '',
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
isEditing.value = false
|
||||||
|
currentId.value = null
|
||||||
|
formData.value = { name: '', slug: '' }
|
||||||
|
}
|
||||||
|
showModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal() {
|
||||||
|
showModal.value = false
|
||||||
|
isEditing.value = false
|
||||||
|
currentId.value = null
|
||||||
|
formData.value = { name: '', slug: '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveTag() {
|
||||||
|
if (!formData.value.name || !formData.value.slug) {
|
||||||
|
message.warning('请填写名称和别名')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (isEditing.value && currentId.value) {
|
||||||
|
message.success('标签更新成功')
|
||||||
|
} else {
|
||||||
|
message.success('标签创建成功')
|
||||||
|
}
|
||||||
|
closeModal()
|
||||||
|
fetchTags()
|
||||||
|
} catch (error) {
|
||||||
|
message.error('保存失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteTag(_id: string) {
|
||||||
|
try {
|
||||||
|
await dialog.warning({
|
||||||
|
title: '确认删除',
|
||||||
|
content: '确定要删除这个标签吗?',
|
||||||
|
positiveText: '删除',
|
||||||
|
negativeText: '取消',
|
||||||
|
})
|
||||||
|
message.success('删除成功')
|
||||||
|
fetchTags()
|
||||||
|
} catch {
|
||||||
|
// 取消
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateSlug() {
|
||||||
|
formData.value.slug = formData.value.name
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9\u4e00-\u9fa5]/g, '-')
|
||||||
|
.replace(/-+/g, '-')
|
||||||
|
.replace(/^-|-$/g, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchTags()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="tag-manage">
|
<div class="tag-manage">
|
||||||
<h1 class="text-2xl font-bold mb-6">标签管理</h1>
|
<div class="flex justify-between items-center mb-6">
|
||||||
|
<h1 class="text-2xl font-bold">标签管理</h1>
|
||||||
|
<button @click="openModal()" class="btn-acg">
|
||||||
|
新建标签
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="glass rounded-xl p-6">
|
<div class="glass rounded-xl p-6">
|
||||||
<p class="text-gray-600 dark:text-gray-400">标签管理功能开发中...</p>
|
<div v-if="loading" class="text-center text-gray-500 py-4">加载中...</div>
|
||||||
|
<div v-else-if="tags.length === 0" class="text-center text-gray-500 py-8">
|
||||||
|
<p class="mb-2">暂无标签</p>
|
||||||
|
<button @click="openModal()" class="text-acg-pink hover:underline">点击创建第一个标签</button>
|
||||||
|
</div>
|
||||||
|
<div v-else class="flex flex-wrap gap-3">
|
||||||
|
<div
|
||||||
|
v-for="tag in tags"
|
||||||
|
:key="tag.id"
|
||||||
|
class="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-acg-pink/10 text-acg-pink"
|
||||||
|
>
|
||||||
|
<span>{{ tag.name }}</span>
|
||||||
|
<button @click="openModal(tag)" class="hover:text-white">✏️</button>
|
||||||
|
<button @click="deleteTag(tag.id)" class="hover:text-white">✕</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 弹窗 -->
|
||||||
|
<div v-if="showModal" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50">
|
||||||
|
<div class="glass rounded-xl w-full max-w-md">
|
||||||
|
<div class="p-6">
|
||||||
|
<h2 class="text-xl font-bold mb-6">{{ isEditing ? '编辑标签' : '新建标签' }}</h2>
|
||||||
|
|
||||||
|
<form @submit.prevent="saveTag" class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium mb-2">名称</label>
|
||||||
|
<input
|
||||||
|
v-model="formData.name"
|
||||||
|
type="text"
|
||||||
|
placeholder="标签名称"
|
||||||
|
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800"
|
||||||
|
@blur="generateSlug"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium mb-2">别名</label>
|
||||||
|
<input
|
||||||
|
v-model="formData.slug"
|
||||||
|
type="text"
|
||||||
|
placeholder="url-slug"
|
||||||
|
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end gap-4 pt-4">
|
||||||
|
<button type="button" @click="closeModal" class="px-6 py-2 rounded-lg border border-gray-300 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-800">
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button type="submit" class="btn-acg">
|
||||||
|
保存
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,15 +1,32 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
|
import { useUserStore } from '@/store/user'
|
||||||
|
import { useMessage } from 'naive-ui'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const userStore = useUserStore()
|
||||||
|
const message = useMessage()
|
||||||
const email = ref('')
|
const email = ref('')
|
||||||
const password = ref('')
|
const password = ref('')
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
async function handleLogin() {
|
async function handleLogin() {
|
||||||
// TODO: 实现登录逻辑
|
if (!email.value || !password.value) {
|
||||||
console.log('Login:', email.value, password.value)
|
message.warning('请填写邮箱和密码')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
await userStore.login(email.value, password.value)
|
||||||
|
message.success('登录成功')
|
||||||
router.push('/')
|
router.push('/')
|
||||||
|
} catch (error: any) {
|
||||||
|
message.error(error?.message || '登录失败,请检查邮箱和密码')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -41,8 +58,8 @@ async function handleLogin() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="submit" class="w-full btn-acg">
|
<button type="submit" class="w-full btn-acg" :disabled="loading">
|
||||||
登录
|
{{ loading ? '登录中...' : '登录' }}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,41 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
|
import { authApi } from '@/api/auth'
|
||||||
|
import { useMessage } from 'naive-ui'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const message = useMessage()
|
||||||
const username = ref('')
|
const username = ref('')
|
||||||
const email = ref('')
|
const email = ref('')
|
||||||
const password = ref('')
|
const password = ref('')
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
async function handleRegister() {
|
async function handleRegister() {
|
||||||
// TODO: 实现注册逻辑
|
if (!username.value || !email.value || !password.value) {
|
||||||
console.log('Register:', username.value, email.value, password.value)
|
message.warning('请填写所有字段')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password.value.length < 6) {
|
||||||
|
message.warning('密码长度至少为6位')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
await authApi.register({
|
||||||
|
username: username.value,
|
||||||
|
email: email.value,
|
||||||
|
password: password.value,
|
||||||
|
})
|
||||||
|
message.success('注册成功,请登录')
|
||||||
router.push('/login')
|
router.push('/login')
|
||||||
|
} catch (error: any) {
|
||||||
|
message.error(error?.message || '注册失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -53,8 +78,8 @@ async function handleRegister() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="submit" class="w-full btn-acg">
|
<button type="submit" class="w-full btn-acg" :disabled="loading">
|
||||||
注册
|
{{ loading ? '注册中...' : '注册' }}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
|||||||
@@ -1,28 +1,108 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { onMounted } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
import { useUserStore } from '@/store/user'
|
import { useUserStore } from '@/store/user'
|
||||||
|
import { authApi } from '@/api/auth'
|
||||||
|
import { useMessage, useDialog } from 'naive-ui'
|
||||||
|
import Navbar from '@/components/Navbar.vue'
|
||||||
|
import Footer from '@/components/Footer.vue'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
|
const message = useMessage()
|
||||||
|
const dialog = useDialog()
|
||||||
|
|
||||||
|
function formatDate(dateStr: string) {
|
||||||
|
return new Date(dateStr).toLocaleDateString('zh-CN', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleLogout() {
|
||||||
|
try {
|
||||||
|
await dialog.warning({
|
||||||
|
title: '提示',
|
||||||
|
content: '确定要退出登录吗?',
|
||||||
|
positiveText: '确定',
|
||||||
|
negativeText: '取消',
|
||||||
|
})
|
||||||
|
await authApi.logout()
|
||||||
|
} catch {
|
||||||
|
// 用户取消
|
||||||
|
return
|
||||||
|
}
|
||||||
|
userStore.logout()
|
||||||
|
message.success('已退出登录')
|
||||||
|
router.push('/')
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (!userStore.isLoggedIn) {
|
||||||
|
message.warning('请先登录')
|
||||||
|
router.push('/login')
|
||||||
|
}
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="profile">
|
<div class="profile min-h-screen">
|
||||||
<nav class="glass fixed top-0 left-0 right-0 z-50">
|
<Navbar />
|
||||||
<div class="max-w-4xl mx-auto px-4 py-4">
|
|
||||||
<RouterLink to="/" class="text-acg-pink hover:underline">← 返回首页</RouterLink>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<main class="pt-20 px-4 max-w-4xl mx-auto">
|
<main class="pt-24 px-4 pb-12 max-w-2xl mx-auto">
|
||||||
<div class="glass rounded-xl p-8">
|
<div class="glass rounded-xl p-6 md:p-8">
|
||||||
<h1 class="text-2xl font-bold mb-4">个人中心</h1>
|
<h1 class="text-2xl font-bold mb-6">个人中心</h1>
|
||||||
<div v-if="userStore.user">
|
|
||||||
<p>用户名: {{ userStore.user.username }}</p>
|
<div v-if="userStore.user" class="space-y-6">
|
||||||
<p>邮箱: {{ userStore.user.email }}</p>
|
<!-- 头像和基本信息 -->
|
||||||
|
<div class="flex items-center gap-6">
|
||||||
|
<div class="w-20 h-20 rounded-full overflow-hidden bg-acg-pink/20 flex items-center justify-center">
|
||||||
|
<img
|
||||||
|
v-if="userStore.user.avatar"
|
||||||
|
:src="userStore.user.avatar"
|
||||||
|
:alt="userStore.user.username"
|
||||||
|
class="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
<span v-else class="text-3xl text-acg-pink">{{ userStore.user.username[0].toUpperCase() }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-else>
|
<div>
|
||||||
|
<h2 class="text-xl font-bold">{{ userStore.user.username }}</h2>
|
||||||
|
<p class="text-gray-500 text-sm">加入于 {{ formatDate(userStore.user.created_at) }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 详细信息 -->
|
||||||
|
<div class="space-y-4 py-4 border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-gray-500">邮箱</span>
|
||||||
|
<span>{{ userStore.user.email }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-gray-500">状态</span>
|
||||||
|
<span :class="userStore.user.is_active ? 'text-green-500' : 'text-gray-400'">
|
||||||
|
{{ userStore.user.is_active ? '已激活' : '未激活' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 操作按钮 -->
|
||||||
|
<div class="flex gap-4 pt-4">
|
||||||
|
<button
|
||||||
|
@click="handleLogout"
|
||||||
|
class="px-6 py-2 rounded-lg bg-red-500/20 text-red-500 hover:bg-red-500/30 transition-colors"
|
||||||
|
>
|
||||||
|
退出登录
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="text-center text-gray-500 py-8">
|
||||||
加载中...
|
加载中...
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<Footer />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
275
readme.md
275
readme.md
@@ -1,124 +1,189 @@
|
|||||||
# 二次元风格博客技术文档
|
# ACG Blog - 二次元风格博客系统
|
||||||
打算自己写个博客博客项目练练手,并把原来博客的东西都搬过去,先把仓库和前端写了
|
|
||||||
|
|
||||||
### 项目基本信息
|
<p align="center">
|
||||||
|
<img src="https://img.shields.io/badge/Python-3.11+-blue.svg" alt="Python">
|
||||||
|
<img src="https://img.shields.io/badge/FastAPI-0.110-green.svg" alt="FastAPI">
|
||||||
|
<img src="https://img.shields.io/badge/Vue-3.5-brightgreen.svg" alt="Vue">
|
||||||
|
<img src="https://img.shields.io/badge/TypeScript-5.9-blue.svg" alt="TypeScript">
|
||||||
|
<img src="https://img.shields.io/badge/Docker-Ready-blue.svg" alt="Docker">
|
||||||
|
</p>
|
||||||
|
|
||||||
- **项目名称**:ACG Blog(二次元风格博客)
|
基于 **FastAPI** 与 **Vue 3** 的前后端分离博客系统,主打二次元视觉体验与高性能响应。支持 Markdown 文章发布、访问量统计、分类标签管理、深色模式切换等功能。
|
||||||
- **项目简介**:基于 **FastAPI** 与 **Vue 3** 的前后端分离博客系统,主打二次元视觉体验与高性能响应。支持 Markdown 文章发布、动态看板娘、访问量统计、热搜排行、深色模式切换等特色功能。
|
|
||||||
- **技术栈概览**:
|
|
||||||
- 后端:Python + FastAPI + SupaBase + Tortoise‑ORM + Redis + JWT
|
|
||||||
- 前端:Vue 3 (Vite) + Pinia + Naive UI + Tailwind CSS + GSAP
|
|
||||||
- **系统架构**:**B/S 架构**(Browser/Server,浏览器/服务器架构)
|
|
||||||
- 前端:单页应用(SPA)运行于浏览器
|
|
||||||
- 后端:RESTful API 服务器
|
|
||||||
- 通信协议:HTTP/HTTPS + JSON
|
|
||||||
- **后端架构模式**:**类 MVC 模式**
|
|
||||||
- **Model(模型层)**:`models/` 目录,Tortoise-ORM 数据模型定义
|
|
||||||
- **View(视图层)**:`api/endpoints/` 目录,FastAPI 路由返回 JSON 响应
|
|
||||||
- **Controller(控制器层)**:`api/endpoints/` 中的路由处理函数,协调业务逻辑
|
|
||||||
- **Service(服务层)**:`services/` 目录,封装核心业务(如看板娘互动、统计逻辑)
|
|
||||||
- **Schema(数据传输层)**:`schemas/` 目录,Pydantic 模型校验请求与响应
|
|
||||||
- **CRUD(数据操作层)**:`crud/` 目录,封装数据库增删改查逻辑,辅助 Model 层
|
|
||||||
- **Core(核心配置)**:`core/` 目录,管理环境变量、日志、JWT 安全等基础设施
|
|
||||||
- **开发状态**:规划中
|
|
||||||
|
|
||||||
> 详细技术选型及项目结构请参见下文。
|
## 技术栈
|
||||||
|
|
||||||
### 后端技术栈
|
### 后端
|
||||||
|
| 技术 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| FastAPI | 高性能异步 Web 框架 |
|
||||||
|
| Tortoise-ORM | 异步 ORM,支持 PostgreSQL |
|
||||||
|
| PostgreSQL | 关系型数据库 |
|
||||||
|
| Redis | 缓存与会话存储 |
|
||||||
|
| JWT | 无状态身份认证 |
|
||||||
|
| Pydantic | 数据验证 |
|
||||||
|
| Loguru | 日志处理 |
|
||||||
|
|
||||||
| **模块** | **技术选型** | **说明** |
|
### 前端
|
||||||
| -------------- | --------------------- | ------------------------------------------------------------ |
|
| 技术 | 说明 |
|
||||||
| **核心框架** | **FastAPI** | 高性能、原生异步(Async),满足二次元素材(图片/视频)的高并发加载需求。 |
|
|------|------|
|
||||||
| **数据库** | **SupaBace** | **内置 Auth**,支持第三方登录 (Github/Google) |
|
| Vue 3 | 组合式 API (Script Setup) |
|
||||||
| **ORM (异步)** | **Tortoise-ORM** | 语法类似 Django,且原生支持异步操作。 |
|
| Vite | 快速构建工具 |
|
||||||
| **缓存/任务** | **Redis** | 用于文章点击量统计、热搜排行以及 Session 存储。 |
|
| TypeScript | 类型安全 |
|
||||||
| **认证** | **JWT (python-jose)** | 无状态认证,方便前后端分离部署。 |
|
| Pinia | 状态管理 |
|
||||||
| **数据校验** | **Pydantic v2** | 确保前端传来的数据不会让后端“炸掉”。 |
|
| Naive UI | UI 组件库 |
|
||||||
| **日志** | **Loguru** | 极简且强大的异步日志处理,便于排错 |
|
| Tailwind CSS | 样式引擎 |
|
||||||
|
| Axios | HTTP 客户端 |
|
||||||
|
|
||||||
### 前端技术栈 (The Visuals)
|
## 项目结构
|
||||||
|
|
||||||
| **模块** | **技术选型** | **说明** |
|
```
|
||||||
| ------------- | ---------------- | ------------------------------------------------------------ |
|
├── backend/ # FastAPI 后端
|
||||||
| **框架** | **Vue 3 (Vite)** | 组合式 API (Script Setup) 开发体验极佳,构建速度快。 |
|
|
||||||
| **状态管理** | **Pinia** | 轻量、简洁,存储用户偏好(如:深色/浅色模式、看板娘状态)。 |
|
|
||||||
| **UI 组件库** | **Naive UI** | 设计感极强,配置主题非常自由,适合定制二次元配色。 |
|
|
||||||
| **样式引擎** | **Tailwind CSS** | 极其方便编写自定义 UI。你可以轻松实现“毛玻璃”、“卡片悬浮”等特效。 |
|
|
||||||
| **编辑器** | **V-MD-Editor** | 基于 Vue 3 的 Markdown 编辑器,支持预览、代码高亮和 LaTeX 公式。 |
|
|
||||||
| **动画库** | **GSAP** | 强大的动效库。二次元风格需要细腻的入场和交互动画。 |
|
|
||||||
|
|
||||||
### 预计项目结构
|
|
||||||
|
|
||||||
```bash
|
|
||||||
acg-blog/
|
|
||||||
├── backend/ # FastAPI 后端项目
|
|
||||||
│ ├── app/
|
│ ├── app/
|
||||||
│ │ ├── api/ # 接口层 (v1, v2...)
|
│ │ ├── api/endpoints/ # API 端点 (auth, posts, comments, users)
|
||||||
│ │ │ ├── endpoints/ # 具体业务接口 (posts.py, users.py, etc.)
|
│ │ ├── core/ # 核心配置 (config, security, database, logger)
|
||||||
│ │ │ └── api.py # 路由汇总
|
│ │ ├── crud/ # 数据库增删改查
|
||||||
│ │ ├── core/ # 核心配置
|
│ │ ├── models/ # Tortoise-ORM 数据模型
|
||||||
│ │ │ ├── config.py # 环境变量与全局配置
|
|
||||||
│ │ │ ├── logger.py # 日志拦截与配置
|
|
||||||
│ │ │ └── security.py # JWT 与权限相关
|
|
||||||
│ │ ├── crud/ # 数据库增删改查逻辑
|
|
||||||
│ │ ├── db/ # 数据库连接与初始化
|
|
||||||
│ │ ├── models/ # Tortoise-ORM 数据库模型
|
|
||||||
│ │ ├── schemas/ # Pydantic 数据验证模型
|
│ │ ├── schemas/ # Pydantic 数据验证模型
|
||||||
│ │ ├── services/ # 业务服务逻辑 (如:看板娘互动 API)
|
│ │ └── main.py # 应用入口
|
||||||
│ │ └── main.py # 后端入口
|
│ ├── Dockerfile # 后端容器配置
|
||||||
│ ├── logs/ # 日志存储目录 (自动生成 .log 文件)
|
│ ├── schema.sql # 数据库建表脚本
|
||||||
│ ├── static/ # 静态资源 (用户上传的插图、头像)
|
│ └── requirements.txt # Python 依赖
|
||||||
│ ├── tests/ # 测试用例
|
│
|
||||||
│ ├── .env # 敏感配置文件
|
├── frontend/ # Vue 3 前端
|
||||||
│ ├── pyproject.toml # Poetry 配置或使用 requirements
|
|
||||||
│ └── requirements.txt # 依赖清单
|
|
||||||
├── frontend/ # Vue 3 前端项目 (Vite)
|
|
||||||
│ ├── public/ # 公共静态资源 (Live2D 模型、Favicon)
|
|
||||||
│ ├── src/
|
│ ├── src/
|
||||||
│ │ ├── api/ # Axios 接口封装
|
│ │ ├── api/ # Axios 接口封装
|
||||||
│ │ ├── assets/ # 样式、二次元字体、插画
|
│ │ ├── components/ # 公共组件
|
||||||
│ │ ├── components/ # 组件 (看板娘、播放器、卡片)
|
│ │ ├── views/ # 页面视图
|
||||||
│ │ ├── router/ # 路由配置
|
|
||||||
│ │ ├── store/ # Pinia 状态管理
|
│ │ ├── store/ # Pinia 状态管理
|
||||||
│ │ ├── views/ # 页面布局 (首页、文章页、归档)
|
│ │ ├── router/ # 路由配置
|
||||||
│ │ ├── App.vue # 根组件
|
│ │ └── types/ # TypeScript 类型定义
|
||||||
│ │ └── main.ts # 前端入口
|
│ ├── Dockerfile # 前端容器配置
|
||||||
│ ├── tailwind.config.js # Tailwind CSS 配置
|
│ └── nginx.conf # Nginx 配置
|
||||||
│ └── package.json # 前端依赖
|
│
|
||||||
├── docker-compose.yml # 全栈 Docker 编排
|
├── docker-compose.yml # Docker 编排
|
||||||
└── README.md
|
├── schema.sql # 数据库建表脚本
|
||||||
|
└── docker-deploy.md # 部署文档
|
||||||
```
|
```
|
||||||
|
|
||||||
### 后端requirements
|
## 快速开始
|
||||||
|
|
||||||
|
### 环境要求
|
||||||
|
- Python 3.11+
|
||||||
|
- Node.js 18+
|
||||||
|
- PostgreSQL 14+
|
||||||
|
- Redis 7+ (可选)
|
||||||
|
|
||||||
|
### 本地开发
|
||||||
|
|
||||||
|
**1. 克隆项目**
|
||||||
|
```bash
|
||||||
|
git clone <repository-url>
|
||||||
|
cd acg-blog
|
||||||
|
```
|
||||||
|
|
||||||
|
**2. 启动后端**
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
|
||||||
|
# 创建虚拟环境
|
||||||
|
python -m venv venv
|
||||||
|
source venv/bin/activate # Linux/Mac
|
||||||
|
# 或 venv\Scripts\activate # Windows
|
||||||
|
|
||||||
|
# 安装依赖
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
# 配置环境变量
|
||||||
|
cp .env.example .env # 修改数据库连接信息
|
||||||
|
|
||||||
|
# 初始化数据库 (确保 PostgreSQL 运行中)
|
||||||
|
psql -U postgres -c "CREATE DATABASE acg_blog;"
|
||||||
|
psql -U postgres -d acg_blog -f schema.sql
|
||||||
|
|
||||||
|
# 启动服务
|
||||||
|
uvicorn main:app --reload --port 8000
|
||||||
|
```
|
||||||
|
|
||||||
|
**3. 启动前端**
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
|
||||||
|
# 安装依赖
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# 启动开发服务器
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
**4. 访问应用**
|
||||||
|
- 前端: http://localhost:5173
|
||||||
|
- 后端 API: http://localhost:8000
|
||||||
|
- API 文档: http://localhost:8000/docs
|
||||||
|
|
||||||
|
### Docker 部署
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Web 框架
|
# 启动所有服务
|
||||||
fastapi==0.110.0
|
docker-compose up -d
|
||||||
uvicorn[standard]==0.27.1
|
|
||||||
|
|
||||||
# 数据库与异步 ORM
|
# 查看服务状态
|
||||||
tortoise-orm==0.20.0
|
docker-compose ps
|
||||||
aerich==0.7.2 # 数据库迁移工具
|
|
||||||
asyncpg==0.29.0 # PostgreSQL 驱动
|
|
||||||
aioredis==2.0.1 # Redis 驱动
|
|
||||||
|
|
||||||
# 日志系统
|
|
||||||
loguru==0.7.2 # 极简且强大的异步日志处理
|
|
||||||
|
|
||||||
# 配置与安全
|
|
||||||
pydantic[email]==2.6.3 # 包含 Email 校验
|
|
||||||
pydantic-settings==2.2.1 # 处理 .env 环境变量
|
|
||||||
python-jose[cryptography]==3.3.0 # JWT
|
|
||||||
passlib[bcrypt]==1.7.4 # 密码哈希
|
|
||||||
|
|
||||||
# 业务
|
|
||||||
python-multipart==0.0.9 # 处理表单与文件上传
|
|
||||||
mistune==3.0.2 # 快速 Markdown 解析
|
|
||||||
pillow==10.2.0 # 处理图片
|
|
||||||
httpx==0.27.0 # 异步 HTTP 请求
|
|
||||||
|
|
||||||
# 开发与部署
|
|
||||||
python-dotenv==1.0.1
|
|
||||||
gunicorn==21.2.0 # 生产环境容器部署使用
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
访问 http://localhost 即可使用。
|
||||||
|
|
||||||
|
## API 接口
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| POST | /api/v1/auth/register | 用户注册 |
|
||||||
|
| POST | /api/v1/auth/login | 用户登录 |
|
||||||
|
| POST | /api/v1/auth/refresh | 刷新 Token |
|
||||||
|
| GET | /api/v1/auth/me | 获取当前用户 |
|
||||||
|
| GET | /api/v1/posts | 获取文章列表 |
|
||||||
|
| GET | /api/v1/posts/{id} | 获取文章详情 |
|
||||||
|
| POST | /api/v1/posts | 创建文章 |
|
||||||
|
| PUT | /api/v1/posts/{id} | 更新文章 |
|
||||||
|
| DELETE | /api/v1/posts/{id} | 删除文章 |
|
||||||
|
| GET | /api/v1/categories | 获取分类列表 |
|
||||||
|
| GET | /api/v1/tags | 获取标签列表 |
|
||||||
|
| GET | /api/v1/comments/post/{id} | 获取文章评论 |
|
||||||
|
|
||||||
|
## 默认账户
|
||||||
|
|
||||||
|
- 用户名: `admin`
|
||||||
|
- 邮箱: `admin@acgblog.com`
|
||||||
|
- 密码: `admin123`
|
||||||
|
|
||||||
|
**请在部署后立即修改管理员密码!**
|
||||||
|
|
||||||
|
## 功能特性
|
||||||
|
|
||||||
|
- [x] 用户认证 (JWT)
|
||||||
|
- [x] 文章管理 (CRUD)
|
||||||
|
- [x] 分类与标签
|
||||||
|
- [x] Markdown 编辑器
|
||||||
|
- [x] 评论系统
|
||||||
|
- [x] 浏览量统计
|
||||||
|
- [x] 深色/浅色模式
|
||||||
|
- [x] 响应式设计
|
||||||
|
- [x] Docker 部署
|
||||||
|
|
||||||
|
## 开发说明
|
||||||
|
|
||||||
|
### 前端组件
|
||||||
|
- `Navbar.vue` - 顶部导航栏
|
||||||
|
- `Hero.vue` - 首页横幅
|
||||||
|
- `PostCard.vue` - 文章卡片
|
||||||
|
- `Sidebar.vue` - 侧边栏
|
||||||
|
- `Footer.vue` - 页脚
|
||||||
|
|
||||||
|
### 管理后台
|
||||||
|
- `/admin/dashboard` - 仪表盘
|
||||||
|
- `/admin/posts` - 文章管理
|
||||||
|
- `/admin/categories` - 分类管理
|
||||||
|
- `/admin/tags` - 标签管理
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT License
|
||||||
|
|||||||
Reference in New Issue
Block a user