feat: 实现 Nuxt4 + Nuxt UI 博客前端完整功能
核心功能: - 项目初始化 (Nuxt 4 + Nuxt UI + Pinia + ofetch) - TypeScript 类型定义 (User, Article, Comment, API 响应) - 认证系统 (登录/登出、Cookie 支持、权限中间件) - 文章列表页 (筛选、分页、响应式布局) - 文章详情页 (Markdown 渲染、评论系统) - 文章编辑器 (左右分栏、实时预览、Markdown 工具栏) 管理后台: - 侧边栏布局、权限检查 - 数据分析 (数据统计卡片、热门文章、评论审核统计) - 文章管理 (表格、筛选、删除) - 评论管理 (审核通过/拒绝、删除) - 用户管理 (角色管理、删除) 全局组件: - 导航栏 (暗色模式切换、移动端菜单) - 页脚 - 403/404 错误页 配置文件: - .env.example 环境变量模板 - nuxt.config.ts 完整配置 - 自定义 CSS 样式
This commit is contained in:
parent
b1864f9967
commit
ce208df092
6
.env.example
Normal file
6
.env.example
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
# API 配置
|
||||||
|
NUXT_PUBLIC_API_BASE=http://localhost:3001
|
||||||
|
|
||||||
|
# 站点配置
|
||||||
|
NUXT_PUBLIC_SITE_URL=http://localhost:3000
|
||||||
|
NUXT_PUBLIC_SITE_NAME=LinkShare Blog
|
||||||
144
.gitignore
vendored
144
.gitignore
vendored
@ -1,132 +1,24 @@
|
|||||||
# ---> Node
|
# Nuxt dev/build outputs
|
||||||
|
.output
|
||||||
|
.data
|
||||||
|
.nuxt
|
||||||
|
.nitro
|
||||||
|
.cache
|
||||||
|
dist
|
||||||
|
|
||||||
|
# Node dependencies
|
||||||
|
node_modules
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
logs
|
logs
|
||||||
*.log
|
*.log
|
||||||
npm-debug.log*
|
|
||||||
yarn-debug.log*
|
|
||||||
yarn-error.log*
|
|
||||||
lerna-debug.log*
|
|
||||||
.pnpm-debug.log*
|
|
||||||
|
|
||||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
# Misc
|
||||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
.DS_Store
|
||||||
|
.fleet
|
||||||
|
.idea
|
||||||
|
|
||||||
# Runtime data
|
# Local env files
|
||||||
pids
|
|
||||||
*.pid
|
|
||||||
*.seed
|
|
||||||
*.pid.lock
|
|
||||||
|
|
||||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
|
||||||
lib-cov
|
|
||||||
|
|
||||||
# Coverage directory used by tools like istanbul
|
|
||||||
coverage
|
|
||||||
*.lcov
|
|
||||||
|
|
||||||
# nyc test coverage
|
|
||||||
.nyc_output
|
|
||||||
|
|
||||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
|
||||||
.grunt
|
|
||||||
|
|
||||||
# Bower dependency directory (https://bower.io/)
|
|
||||||
bower_components
|
|
||||||
|
|
||||||
# node-waf configuration
|
|
||||||
.lock-wscript
|
|
||||||
|
|
||||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
|
||||||
build/Release
|
|
||||||
|
|
||||||
# Dependency directories
|
|
||||||
node_modules/
|
|
||||||
jspm_packages/
|
|
||||||
|
|
||||||
# Snowpack dependency directory (https://snowpack.dev/)
|
|
||||||
web_modules/
|
|
||||||
|
|
||||||
# TypeScript cache
|
|
||||||
*.tsbuildinfo
|
|
||||||
|
|
||||||
# Optional npm cache directory
|
|
||||||
.npm
|
|
||||||
|
|
||||||
# Optional eslint cache
|
|
||||||
.eslintcache
|
|
||||||
|
|
||||||
# Optional stylelint cache
|
|
||||||
.stylelintcache
|
|
||||||
|
|
||||||
# Microbundle cache
|
|
||||||
.rpt2_cache/
|
|
||||||
.rts2_cache_cjs/
|
|
||||||
.rts2_cache_es/
|
|
||||||
.rts2_cache_umd/
|
|
||||||
|
|
||||||
# Optional REPL history
|
|
||||||
.node_repl_history
|
|
||||||
|
|
||||||
# Output of 'npm pack'
|
|
||||||
*.tgz
|
|
||||||
|
|
||||||
# Yarn Integrity file
|
|
||||||
.yarn-integrity
|
|
||||||
|
|
||||||
# dotenv environment variable files
|
|
||||||
.env
|
.env
|
||||||
.env.development.local
|
.env.*
|
||||||
.env.test.local
|
!.env.example
|
||||||
.env.production.local
|
|
||||||
.env.local
|
|
||||||
|
|
||||||
# parcel-bundler cache (https://parceljs.org/)
|
|
||||||
.cache
|
|
||||||
.parcel-cache
|
|
||||||
|
|
||||||
# Next.js build output
|
|
||||||
.next
|
|
||||||
out
|
|
||||||
|
|
||||||
# Nuxt.js build / generate output
|
|
||||||
.nuxt
|
|
||||||
dist
|
|
||||||
|
|
||||||
# Gatsby files
|
|
||||||
.cache/
|
|
||||||
# Comment in the public line in if your project uses Gatsby and not Next.js
|
|
||||||
# https://nextjs.org/blog/next-9-1#public-directory-support
|
|
||||||
# public
|
|
||||||
|
|
||||||
# vuepress build output
|
|
||||||
.vuepress/dist
|
|
||||||
|
|
||||||
# vuepress v2.x temp and cache directory
|
|
||||||
.temp
|
|
||||||
.cache
|
|
||||||
|
|
||||||
# Docusaurus cache and generated files
|
|
||||||
.docusaurus
|
|
||||||
|
|
||||||
# Serverless directories
|
|
||||||
.serverless/
|
|
||||||
|
|
||||||
# FuseBox cache
|
|
||||||
.fusebox/
|
|
||||||
|
|
||||||
# DynamoDB Local files
|
|
||||||
.dynamodb/
|
|
||||||
|
|
||||||
# TernJS port file
|
|
||||||
.tern-port
|
|
||||||
|
|
||||||
# Stores VSCode versions used for testing VSCode extensions
|
|
||||||
.vscode-test
|
|
||||||
|
|
||||||
# yarn v2
|
|
||||||
.yarn/cache
|
|
||||||
.yarn/unplugged
|
|
||||||
.yarn/build-state.yml
|
|
||||||
.yarn/install-state.gz
|
|
||||||
.pnp.*
|
|
||||||
|
|
||||||
|
|||||||
837
.opencode/plans/PLAN.md
Normal file
837
.opencode/plans/PLAN.md
Normal file
@ -0,0 +1,837 @@
|
|||||||
|
# Nuxt4 + Nuxt-UI 博客前端开发提示词
|
||||||
|
|
||||||
|
## 项目概述
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# 任务:构建 LinkShare Blog 前端应用
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
- **框架**: Nuxt 4 (Vue 3 + Vite)
|
||||||
|
- **UI 库**: Nuxt UI (基于 Tailwind CSS + Headless UI)
|
||||||
|
- **状态管理**: Pinia
|
||||||
|
- **HTTP 客户端**: fetch / ofetch
|
||||||
|
- **Markdown 渲染**: @nuxt/content 或 markdown-it
|
||||||
|
- **代码规范**: ESLint + Prettier
|
||||||
|
|
||||||
|
## 后端 API 信息
|
||||||
|
|
||||||
|
- **地址**: http://localhost:3001 (开发) / https://api.example.com (生产)
|
||||||
|
- **认证**: Session Cookie (HttpOnly)
|
||||||
|
- **文档**: http://localhost:3001/api-docs
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 项目初始化
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
请创建一个 Nuxt 4 项目,要求:
|
||||||
|
|
||||||
|
1. 安装 Nuxt UI: `npx nuxi@latest module add ui`
|
||||||
|
2. 配置 Tailwind CSS
|
||||||
|
3. 配置 Pinia 状态管理
|
||||||
|
4. 设置 API 请求拦截器(携带 Cookie)
|
||||||
|
5. 配置路由中间件(认证守卫)
|
||||||
|
6. 创建布局文件:default.vue、admin.vue
|
||||||
|
|
||||||
|
目录结构:
|
||||||
|
```
|
||||||
|
|
||||||
|
├── components/
|
||||||
|
│ ├── ui/ # Nuxt UI 组件封装
|
||||||
|
│ ├── layout/ # 布局组件
|
||||||
|
│ ├── article/ # 文章相关组件
|
||||||
|
│ ├── comment/ # 评论相关组件
|
||||||
|
│ └── admin/ # 管理后台组件
|
||||||
|
├── composables/
|
||||||
|
│ ├── useAuth.ts # 认证逻辑
|
||||||
|
│ ├── useApi.ts # API 请求封装
|
||||||
|
│ └── useArticles.ts # 文章数据获取
|
||||||
|
├── layouts/
|
||||||
|
│ ├── default.vue # 默认布局
|
||||||
|
│ └── admin.vue # 管理后台布局
|
||||||
|
├── middleware/
|
||||||
|
│ ├── auth.global.ts # 全局认证检查
|
||||||
|
│ └── admin.ts # 管理员权限检查
|
||||||
|
├── pages/
|
||||||
|
│ ├── index.vue # 首页(文章列表)
|
||||||
|
│ ├── article/
|
||||||
|
│ │ └── [slug].vue # 文章详情
|
||||||
|
│ ├── login.vue # 登录页
|
||||||
|
│ ├── create.vue # 创建文章
|
||||||
|
│ └── admin/
|
||||||
|
│ ├── dashboard.vue # 管理后台首页
|
||||||
|
│ ├── articles.vue # 文章管理
|
||||||
|
│ ├── comments.vue # 评论管理
|
||||||
|
│ └── users.vue # 用户管理
|
||||||
|
├── stores/
|
||||||
|
│ ├── auth.ts # 认证状态
|
||||||
|
│ └── article.ts # 文章状态
|
||||||
|
├── types/
|
||||||
|
│ ├── api.ts # API 响应类型
|
||||||
|
│ └── models.ts # 数据模型
|
||||||
|
└── utils/
|
||||||
|
├── constants.ts # 常量配置
|
||||||
|
└── validators.ts # 表单验证
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. API 类型定义
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
请根据后端 API 创建 TypeScript 类型定义:
|
||||||
|
|
||||||
|
### 用户模型 (User)
|
||||||
|
|
||||||
|
- id: number
|
||||||
|
- email: string
|
||||||
|
- username: string
|
||||||
|
- displayName: string | null
|
||||||
|
- avatarUrl: string | null
|
||||||
|
- role: 'admin' | 'moderator' | 'user'
|
||||||
|
- isActive: boolean
|
||||||
|
- createdAt: DateTime
|
||||||
|
- updatedAt: DateTime
|
||||||
|
|
||||||
|
### 文章模型 (Article)
|
||||||
|
|
||||||
|
- id: number
|
||||||
|
- slug: string
|
||||||
|
- title: string
|
||||||
|
- content: string | null (Markdown)
|
||||||
|
- excerpt: string | null
|
||||||
|
- coverImageUrl: string | null
|
||||||
|
- status: 'draft' | 'published' | 'archived'
|
||||||
|
- visibility: 'public' | 'unlisted' | 'private'
|
||||||
|
- viewCount: number
|
||||||
|
- publishedAt: DateTime | null
|
||||||
|
- createdAt: DateTime
|
||||||
|
- updatedAt: DateTime
|
||||||
|
- author: User (仅 id, username, displayName, avatarUrl)
|
||||||
|
- comments: Comment[] (已审核)
|
||||||
|
- reactions: Reaction[]
|
||||||
|
|
||||||
|
### 评论模型 (Comment)
|
||||||
|
|
||||||
|
- id: number
|
||||||
|
- articleId: number
|
||||||
|
- parentId: number | null
|
||||||
|
- content: string
|
||||||
|
- status: 'pending' | 'approved' | 'rejected' | 'suspicious'
|
||||||
|
- authorName: string | null
|
||||||
|
- authorEmail: string | null
|
||||||
|
- authorId: number | null
|
||||||
|
- createdAt: DateTime
|
||||||
|
- updatedAt: DateTime
|
||||||
|
- author: User | null
|
||||||
|
- replies: Comment[]
|
||||||
|
|
||||||
|
### 分页响应
|
||||||
|
|
||||||
|
{
|
||||||
|
items: T[]
|
||||||
|
total: number
|
||||||
|
page: number
|
||||||
|
limit: number
|
||||||
|
totalPages: number
|
||||||
|
}
|
||||||
|
|
||||||
|
### API 端点
|
||||||
|
|
||||||
|
#### 认证
|
||||||
|
|
||||||
|
- POST /login - { emailOrUsername, password } → { user }
|
||||||
|
- POST /logout - → { ok: true }
|
||||||
|
- GET /me - → { user }
|
||||||
|
|
||||||
|
#### 文章
|
||||||
|
|
||||||
|
- GET /articles?page=1&limit=20&status=&visibility=&authorId=
|
||||||
|
- GET /articles/:id
|
||||||
|
- GET /articles/slug/:slug
|
||||||
|
- POST /articles (需登录) - { title, content, excerpt?, coverImageUrl?, status, visibility }
|
||||||
|
- PUT /articles/:id (作者/管理员)
|
||||||
|
- DELETE /articles/:id (作者/管理员)
|
||||||
|
|
||||||
|
#### 评论
|
||||||
|
|
||||||
|
- GET /articles/:id/comments?status=approved
|
||||||
|
- POST /articles/:id/comments (需登录) - { content }
|
||||||
|
|
||||||
|
#### 管理后台 (需 admin 角色)
|
||||||
|
|
||||||
|
- GET /admin/comments?page=&limit=&articleId=&status=&authorId=
|
||||||
|
- PUT /admin/comments/:id/status?status=approved|rejected
|
||||||
|
- DELETE /admin/comments/:id
|
||||||
|
- GET /admin/comments/:id/audits
|
||||||
|
- GET /admin/articles?page=&limit=&status=
|
||||||
|
- GET /analytics-summary?days=30
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 认证 Composable
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
创建 useAuth composable,实现:
|
||||||
|
|
||||||
|
### 状态管理
|
||||||
|
|
||||||
|
- user: Ref<User | null>
|
||||||
|
- isAuthenticated: ComputedRef<boolean>
|
||||||
|
- isAdmin: ComputedRef<boolean>
|
||||||
|
- isLoading: Ref<boolean>
|
||||||
|
|
||||||
|
### 方法
|
||||||
|
|
||||||
|
- login(credentials): Promise<User>
|
||||||
|
- logout(): Promise<void>
|
||||||
|
- fetchUser(): Promise<User | null>
|
||||||
|
- checkAuth(): Promise<boolean>
|
||||||
|
|
||||||
|
### 要求
|
||||||
|
|
||||||
|
- 自动携带 Cookie (credentials: 'include')
|
||||||
|
- 错误处理(401 清除用户状态)
|
||||||
|
- SSR 兼容(useFetch + useNuxtApp)
|
||||||
|
- 登录成功后自动跳转回上一页
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 文章列表页
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
创建首页文章列表,要求:
|
||||||
|
|
||||||
|
### 布局
|
||||||
|
|
||||||
|
- 顶部导航栏(Logo、菜单、登录/用户头像)
|
||||||
|
- 响应式网格布局(移动端 1 列,桌面 3 列)
|
||||||
|
- 分页组件(使用 Nuxt UI 的 UPagination)
|
||||||
|
|
||||||
|
### 文章卡片 (UCard 组件)
|
||||||
|
|
||||||
|
- 封面图(可选,使用 UImage)
|
||||||
|
- 标题(UButton 样式,链接到详情页)
|
||||||
|
- 摘要(最多 150 字)
|
||||||
|
- 作者信息(头像 + 名称)
|
||||||
|
- 发布时间(相对时间,如"3 天前")
|
||||||
|
- 查看数(UIcon + 数字)
|
||||||
|
- 状态标签(UBadge:已发布/草稿/归档)
|
||||||
|
|
||||||
|
### 筛选功能
|
||||||
|
|
||||||
|
- 搜索框(按标题)
|
||||||
|
- 状态下拉框(全部/已发布/草稿/归档)
|
||||||
|
- 作者筛选(可选)
|
||||||
|
- URL 同步筛选参数(使用 useRouteQuery)
|
||||||
|
|
||||||
|
### 加载状态
|
||||||
|
|
||||||
|
- 骨架屏(USkeleton)
|
||||||
|
- 空状态(UEmpty:无文章时显示)
|
||||||
|
- 错误状态(UAlert:加载失败时)
|
||||||
|
|
||||||
|
### SEO
|
||||||
|
|
||||||
|
- useSeoMeta 设置 title、description
|
||||||
|
- Open Graph 标签
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 文章详情页
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
创建文章详情页(/article/[slug]),要求:
|
||||||
|
|
||||||
|
### 页面结构
|
||||||
|
|
||||||
|
- 文章头部:标题、作者信息、发布时间、查看数
|
||||||
|
- 封面图(如果有)
|
||||||
|
- Markdown 内容渲染区
|
||||||
|
- 文章底部:标签、分享按钮
|
||||||
|
|
||||||
|
### Markdown 渲染
|
||||||
|
|
||||||
|
- 使用 @nuxt/content 或 markdown-it
|
||||||
|
- 支持代码高亮(Prism.js / Shiki)
|
||||||
|
- 支持表格、引用、列表
|
||||||
|
- 响应式图片(懒加载)
|
||||||
|
|
||||||
|
### 评论系统
|
||||||
|
|
||||||
|
- 评论列表(嵌套回复,最多 2 层)
|
||||||
|
- 评论表单(登录后可评论)
|
||||||
|
- 评论状态提示(待审核/已通过/已拒绝)
|
||||||
|
- 实时刷新(提交后刷新评论列表)
|
||||||
|
|
||||||
|
### 侧边栏(桌面端)
|
||||||
|
|
||||||
|
- 目录(TOC,基于文章标题自动生成)
|
||||||
|
- 相关文章推荐
|
||||||
|
- 作者信息卡片
|
||||||
|
|
||||||
|
### 功能
|
||||||
|
|
||||||
|
- 文章版本历史(如果有多版本)
|
||||||
|
- 密码保护文章(输入密码后查看)
|
||||||
|
- 私密文章验证(通过 token 访问)
|
||||||
|
- 阅读进度条(顶部固定)
|
||||||
|
|
||||||
|
### 交互
|
||||||
|
|
||||||
|
- 点赞/表情反应(ULikeButton 风格)
|
||||||
|
- 分享功能(复制链接、Twitter、微信)
|
||||||
|
- 返回目录(滚动监听)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 创建/编辑文章页
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
创建文章编辑器(/create 和 /article/[id]/edit),要求:
|
||||||
|
|
||||||
|
### 编辑器布局
|
||||||
|
|
||||||
|
- 左右分栏(左侧编辑,右侧预览)
|
||||||
|
- 可切换全屏编辑模式
|
||||||
|
- 实时预览(防抖,500ms)
|
||||||
|
|
||||||
|
### 表单字段 (使用 UForm + UInput)
|
||||||
|
|
||||||
|
- 标题(UInput,最大 200 字,必填)
|
||||||
|
- Slug(自动生成,可手动修改)
|
||||||
|
- 内容(UTextarea,支持 Markdown 语法提示)
|
||||||
|
- 摘要(UTextarea,最大 500 字,可选)
|
||||||
|
- 封面图 URL(UInput,可选)
|
||||||
|
- 状态(URadio:草稿/已发布/归档)
|
||||||
|
- 可见性(URadio:公开/未列出/私密)
|
||||||
|
- 访问令牌(私密文章时显示)
|
||||||
|
|
||||||
|
### 验证规则
|
||||||
|
|
||||||
|
- 标题:必填,1-200 字
|
||||||
|
- 内容:必填
|
||||||
|
- 摘要:可选,0-500 字
|
||||||
|
- 自动保存草稿(每 60 秒)
|
||||||
|
|
||||||
|
### 功能
|
||||||
|
|
||||||
|
- Markdown 工具栏(粗体、斜体、链接、图片、代码块)
|
||||||
|
- 图片上传(拖拽上传,返回 URL)
|
||||||
|
- 发布确认对话框
|
||||||
|
- 版本历史(显示编辑记录)
|
||||||
|
|
||||||
|
### 权限
|
||||||
|
|
||||||
|
- 仅作者和管理员可编辑
|
||||||
|
- 未授权时显示 403 页面
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 登录页
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
创建登录页(/login),要求:
|
||||||
|
|
||||||
|
### 表单设计 (UCard 居中布局)
|
||||||
|
|
||||||
|
- 邮箱/用户名输入框(UInput)
|
||||||
|
- 密码输入框(UInput type="password")
|
||||||
|
- 记住我复选框(UCheckbox)
|
||||||
|
- 登录按钮(UButton,带加载状态)
|
||||||
|
- 忘记密码链接(可选)
|
||||||
|
|
||||||
|
### 验证
|
||||||
|
|
||||||
|
- 邮箱/用户名:必填
|
||||||
|
- 密码:必填,最少 6 位
|
||||||
|
- 错误提示(UAlert 显示在表单顶部)
|
||||||
|
|
||||||
|
### 功能
|
||||||
|
|
||||||
|
- 限流处理(5 次/分钟,显示重试时间)
|
||||||
|
- 登录成功后跳转到来源页面
|
||||||
|
- 已登录用户自动跳转到首页
|
||||||
|
- Social 登录按钮(预留位置)
|
||||||
|
|
||||||
|
### 安全
|
||||||
|
|
||||||
|
- CSRF Token(如后端需要)
|
||||||
|
- HTTPS 强制(生产环境)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 管理后台
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
创建管理后台(/admin/\*),要求:
|
||||||
|
|
||||||
|
### 布局 (admin.vue)
|
||||||
|
|
||||||
|
- 侧边栏导航(文章管理、评论管理、用户管理、数据分析)
|
||||||
|
- 顶部栏(返回首页、用户信息、退出登录)
|
||||||
|
- 权限检查(仅 admin 角色可访问)
|
||||||
|
|
||||||
|
### 评论管理页 (/admin/comments)
|
||||||
|
|
||||||
|
- 表格展示(UTable)
|
||||||
|
- 评论内容(截断)
|
||||||
|
- 文章标题(链接)
|
||||||
|
- 作者(名称/邮箱)
|
||||||
|
- 状态(UBadge:待审核/通过/拒绝/可疑)
|
||||||
|
- 创建时间
|
||||||
|
- 操作(审核通过/拒绝/删除/查看日志)
|
||||||
|
- 筛选:文章 ID、状态、作者、分页
|
||||||
|
- 批量操作(批量通过/拒绝/删除)
|
||||||
|
- 审核日志弹窗(显示 AI 审核记录)
|
||||||
|
|
||||||
|
### 文章管理页 (/admin/articles)
|
||||||
|
|
||||||
|
- 表格展示
|
||||||
|
- 标题 + 封面图缩略图
|
||||||
|
- 作者
|
||||||
|
- 状态(UBadge)
|
||||||
|
- 可见性(UIcon)
|
||||||
|
- 查看数
|
||||||
|
- 发布时间
|
||||||
|
- 操作(编辑/删除/强制发布/归档)
|
||||||
|
- 筛选:状态、可见性、作者、分页
|
||||||
|
- 强制操作(管理员可编辑/删除任何文章)
|
||||||
|
|
||||||
|
### 用户管理页 (/admin/users)
|
||||||
|
|
||||||
|
- 表格展示
|
||||||
|
- 用户信息(头像 + 名称 + 邮箱)
|
||||||
|
- 角色(UBadge:admin/moderator/user)
|
||||||
|
- 状态(激活/禁用)
|
||||||
|
- 创建时间
|
||||||
|
- 操作(编辑角色/禁用/删除)
|
||||||
|
- 创建用户按钮(弹窗表单)
|
||||||
|
|
||||||
|
### 数据分析页 (/admin/analytics)
|
||||||
|
|
||||||
|
- 数据卡片(UStat)
|
||||||
|
- 总文章数
|
||||||
|
- 总评论数
|
||||||
|
- 总用户数
|
||||||
|
- 近 30 天查看数
|
||||||
|
- 图表(使用 Chart.js 或 ECharts)
|
||||||
|
- 每日查看趋势
|
||||||
|
- 热门文章 Top 10
|
||||||
|
- 评论审核统计
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 评论组件
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
创建评论系统组件,要求:
|
||||||
|
|
||||||
|
### 评论列表 (CommentList.vue)
|
||||||
|
|
||||||
|
- 嵌套结构(父评论 + 回复列表)
|
||||||
|
- 头像 + 用户名 + 时间
|
||||||
|
- 内容(支持简单 Markdown)
|
||||||
|
- 状态标签(待审核/已审核)
|
||||||
|
- 回复按钮(登录后可用)
|
||||||
|
- 点赞数(可选)
|
||||||
|
|
||||||
|
### 评论表单 (CommentForm.vue)
|
||||||
|
|
||||||
|
- 文本域(最小 3 行,支持 Markdown)
|
||||||
|
- 字符计数器(最大 2000 字)
|
||||||
|
- 提交按钮(带加载状态)
|
||||||
|
- 登录提示(未登录时显示)
|
||||||
|
- 预览功能(可选)
|
||||||
|
|
||||||
|
### 回复表单 (ReplyForm.vue)
|
||||||
|
|
||||||
|
- 简化版评论表单
|
||||||
|
- 内联显示(点击回复后展开)
|
||||||
|
- 取消按钮
|
||||||
|
|
||||||
|
### 审核提示
|
||||||
|
|
||||||
|
- 提交成功:显示"评论待审核"
|
||||||
|
- 审核通过:自动刷新显示评论
|
||||||
|
- 审核拒绝:显示拒绝原因(如果公开)
|
||||||
|
|
||||||
|
### 权限
|
||||||
|
|
||||||
|
- 仅登录用户可评论
|
||||||
|
- 作者/管理员可删除评论
|
||||||
|
- 管理员可在后台审核
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 全局组件
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
创建以下全局通用组件:
|
||||||
|
|
||||||
|
### 导航栏 (AppHeader.vue)
|
||||||
|
|
||||||
|
- Logo(左侧)
|
||||||
|
- 菜单(首页、关于、管理后台)
|
||||||
|
- 搜索框(可选)
|
||||||
|
- 用户菜单(头像下拉:个人中心、退出登录)
|
||||||
|
- 登录/注册按钮(未登录时)
|
||||||
|
- 移动端汉堡菜单
|
||||||
|
|
||||||
|
### 页脚 (AppFooter.vue)
|
||||||
|
|
||||||
|
- 版权信息
|
||||||
|
- 友情链接
|
||||||
|
- Social 图标
|
||||||
|
- ICP 备案(如果需要)
|
||||||
|
|
||||||
|
### 加载器 (AppLoading.vue)
|
||||||
|
|
||||||
|
- 全局加载中(USpinner)
|
||||||
|
- 页面切换进度条(NuxtPageTransition)
|
||||||
|
|
||||||
|
### 错误页 (AppError.vue)
|
||||||
|
|
||||||
|
- 404 页面(UEmpty + 返回首页按钮)
|
||||||
|
- 403 页面(无权限提示)
|
||||||
|
- 500 页面(服务器错误)
|
||||||
|
|
||||||
|
### SEO 组件 (SeoMeta.vue)
|
||||||
|
|
||||||
|
- 动态设置 title、description
|
||||||
|
- Open Graph 标签
|
||||||
|
- Twitter Card 标签
|
||||||
|
- JSON-LD 结构化数据
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Pinia Store
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
创建以下 Pinia stores:
|
||||||
|
|
||||||
|
### auth store (stores/auth.ts)
|
||||||
|
|
||||||
|
- State: user, isLoading, error
|
||||||
|
- Getters: isAuthenticated, isAdmin, userRole
|
||||||
|
- Actions: login, logout, fetchUser, checkAuth, refreshUser
|
||||||
|
|
||||||
|
### article store (stores/article.ts)
|
||||||
|
|
||||||
|
- State: articles, currentArticle, loading, pagination
|
||||||
|
- Getters: publishedArticles, draftArticles, totalArticles
|
||||||
|
- Actions: fetchArticles, fetchArticle, createArticle, updateArticle, deleteArticle
|
||||||
|
|
||||||
|
### comment store (stores/comment.ts)
|
||||||
|
|
||||||
|
- State: comments, loading
|
||||||
|
- Getters: approvedComments, pendingComments
|
||||||
|
- Actions: fetchComments, createComment, approveComment, rejectComment, deleteComment
|
||||||
|
|
||||||
|
### admin store (stores/admin.ts)
|
||||||
|
|
||||||
|
- State: stats, loading
|
||||||
|
- Getters: commentStats, articleStats, userStats
|
||||||
|
- Actions: fetchStats, updateCommentStatus, deleteUser
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. 样式与主题
|
||||||
|
|
||||||
|
````markdown
|
||||||
|
配置 Nuxt UI 主题:
|
||||||
|
|
||||||
|
### 颜色方案 (nuxt.config.ts)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export default defineNuxtConfig({
|
||||||
|
ui: {
|
||||||
|
primary: 'indigo',
|
||||||
|
gray: 'slate',
|
||||||
|
theme: {
|
||||||
|
dark: true, // 支持暗色模式
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
````
|
||||||
|
|
||||||
|
### 自定义样式 (assets/css/main.css)
|
||||||
|
|
||||||
|
- 全局字体(Inter / Noto Sans SC)
|
||||||
|
- 文章排版样式(prose 类)
|
||||||
|
- 代码块主题(One Dark / GitHub)
|
||||||
|
- 响应式断点调整
|
||||||
|
|
||||||
|
### 暗色模式切换
|
||||||
|
|
||||||
|
- 使用 useDark() 和 useToggle()
|
||||||
|
- 切换按钮(UIcon:sun/moon)
|
||||||
|
- 持久化到 localStorage
|
||||||
|
- 系统偏好自动检测
|
||||||
|
|
||||||
|
````
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. SEO 优化
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
实现 SEO 优化:
|
||||||
|
|
||||||
|
### 动态 Meta 标签 (useSeoMeta)
|
||||||
|
- title / titleTemplate
|
||||||
|
- description
|
||||||
|
- canonical URL
|
||||||
|
- robots
|
||||||
|
|
||||||
|
### Open Graph
|
||||||
|
- og:title
|
||||||
|
- og:description
|
||||||
|
- og:image(文章封面)
|
||||||
|
- og:url
|
||||||
|
- og:type
|
||||||
|
|
||||||
|
### Twitter Card
|
||||||
|
- twitter:card
|
||||||
|
- twitter:title
|
||||||
|
- twitter:description
|
||||||
|
- twitter:image
|
||||||
|
|
||||||
|
### 结构化数据 (JSON-LD)
|
||||||
|
- Article Schema(文章页)
|
||||||
|
- BreadcrumbList(面包屑)
|
||||||
|
- Organization(网站信息)
|
||||||
|
|
||||||
|
### Sitemap
|
||||||
|
- @nuxtjs/sitemap 模块
|
||||||
|
- 自动生成文章路由
|
||||||
|
- 静态路由配置
|
||||||
|
````
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. 性能优化
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
实现性能优化:
|
||||||
|
|
||||||
|
### 图片优化
|
||||||
|
|
||||||
|
- Nuxt Image 模块(自动 WebP)
|
||||||
|
- 懒加载(loading="lazy")
|
||||||
|
- 响应式图片(srcset)
|
||||||
|
- LQIP(低质量占位图)
|
||||||
|
|
||||||
|
### 代码分割
|
||||||
|
|
||||||
|
- 路由懒加载(默认启用)
|
||||||
|
- 组件异步加载(defineAsyncComponent)
|
||||||
|
- 第三方库按需引入
|
||||||
|
|
||||||
|
### 缓存策略
|
||||||
|
|
||||||
|
- API 响应缓存(useFetch + cache)
|
||||||
|
- 静态资源 CDN
|
||||||
|
- Service Worker(可选)
|
||||||
|
|
||||||
|
### 渲染优化
|
||||||
|
|
||||||
|
- 骨架屏(Skeleton)
|
||||||
|
- 虚拟滚动(长列表)
|
||||||
|
- 防抖/节流(搜索、滚动)
|
||||||
|
- 预加载(hover 时预加载文章)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15. 测试
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
编写测试:
|
||||||
|
|
||||||
|
### 单元测试 (Vitest)
|
||||||
|
|
||||||
|
- Composables 测试
|
||||||
|
- Utils 函数测试
|
||||||
|
- Stores 测试
|
||||||
|
|
||||||
|
### 组件测试 (Vue Test Utils)
|
||||||
|
|
||||||
|
- 按钮点击
|
||||||
|
- 表单验证
|
||||||
|
- 条件渲染
|
||||||
|
|
||||||
|
### E2E 测试 (Playwright)
|
||||||
|
|
||||||
|
- 登录流程
|
||||||
|
- 文章创建/编辑
|
||||||
|
- 评论提交
|
||||||
|
- 管理后台操作
|
||||||
|
|
||||||
|
### 测试覆盖率
|
||||||
|
|
||||||
|
- 目标:80%+
|
||||||
|
- 关键路径:认证、文章 CRUD、评论审核
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 16. 环境变量配置
|
||||||
|
|
||||||
|
````markdown
|
||||||
|
配置环境变量:
|
||||||
|
|
||||||
|
### .env 文件
|
||||||
|
|
||||||
|
```env
|
||||||
|
NUXT_PUBLIC_API_BASE=http://localhost:3001
|
||||||
|
NUXT_PUBLIC_SITE_URL=http://localhost:3000
|
||||||
|
NUXT_PUBLIC_SITE_NAME=LinkShare Blog
|
||||||
|
```
|
||||||
|
````
|
||||||
|
|
||||||
|
### nuxt.config.ts
|
||||||
|
|
||||||
|
```ts
|
||||||
|
runtimeConfig: {
|
||||||
|
public: {
|
||||||
|
apiBase: process.env.NUXT_PUBLIC_API_BASE,
|
||||||
|
siteUrl: process.env.NUXT_PUBLIC_SITE_URL,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 类型安全
|
||||||
|
|
||||||
|
```ts
|
||||||
|
declare module '@nuxt/schema' {
|
||||||
|
interface RuntimeConfig {
|
||||||
|
public: {
|
||||||
|
apiBase: string;
|
||||||
|
siteUrl: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
````
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 17. 部署配置
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
部署配置:
|
||||||
|
|
||||||
|
### Dockerfile
|
||||||
|
```dockerfile
|
||||||
|
FROM node:20-alpine
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm install
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
EXPOSE 3000
|
||||||
|
CMD ["node", ".output/server/index.mjs"]
|
||||||
|
````
|
||||||
|
|
||||||
|
### Docker Compose
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
frontend:
|
||||||
|
build: .
|
||||||
|
ports:
|
||||||
|
- '3000:3000'
|
||||||
|
environment:
|
||||||
|
- NUXT_PUBLIC_API_BASE=http://api:3001
|
||||||
|
```
|
||||||
|
|
||||||
|
### Nginx 配置
|
||||||
|
|
||||||
|
- 反向代理到 Nuxt
|
||||||
|
- 静态资源缓存
|
||||||
|
- Gzip 压缩
|
||||||
|
- HTTPS 配置
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 开发优先级
|
||||||
|
|
||||||
|
1. **第一阶段 - 核心功能**
|
||||||
|
- 项目初始化
|
||||||
|
- 认证系统(登录/登出)
|
||||||
|
- 文章列表页
|
||||||
|
- 文章详情页
|
||||||
|
- 评论系统
|
||||||
|
|
||||||
|
2. **第二阶段 - 内容创作**
|
||||||
|
- 文章创建/编辑页
|
||||||
|
- Markdown 编辑器
|
||||||
|
- 图片上传
|
||||||
|
|
||||||
|
3. **第三阶段 - 管理后台**
|
||||||
|
- 管理后台布局
|
||||||
|
- 评论管理
|
||||||
|
- 文章管理
|
||||||
|
- 用户管理
|
||||||
|
|
||||||
|
4. **第四阶段 - 优化与完善**
|
||||||
|
- SEO 优化
|
||||||
|
- 性能优化
|
||||||
|
- 测试覆盖
|
||||||
|
- 部署配置
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 后端 API 参考
|
||||||
|
|
||||||
|
### 数据库模型 (Prisma Schema)
|
||||||
|
|
||||||
|
详见 `prisma/schema.prisma`,主要模型:
|
||||||
|
- User(用户)
|
||||||
|
- Article(文章)
|
||||||
|
- Comment(评论)
|
||||||
|
- ArticleVersion(文章版本)
|
||||||
|
- CommentAudit(评论审核日志)
|
||||||
|
- OAuth2Token(OAuth2 令牌)
|
||||||
|
- AnalyticsEvent(分析事件)
|
||||||
|
|
||||||
|
### 认证机制
|
||||||
|
|
||||||
|
- **Web 端**: Session Cookie(HttpOnly,Secure)
|
||||||
|
- **API 端**: OAuth2 Bearer Token
|
||||||
|
- **角色**: admin、moderator、user
|
||||||
|
- **限流**: /login 和 /oauth2/token 接口 5 次/分钟
|
||||||
|
|
||||||
|
### 内容审核
|
||||||
|
|
||||||
|
- **规则审核**: 敏感词、长度限制
|
||||||
|
- **AI 审核**: OpenRouter API(异步)
|
||||||
|
- **状态**: pending → approved/rejected/suspicious
|
||||||
|
- **审计**: 所有审核操作记录到 CommentAudit
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**创建日期**: 2026-03-28
|
||||||
|
**后端版本**: 1.0.0
|
||||||
|
**适用框架**: Nuxt 4 + Nuxt UI
|
||||||
|
```
|
||||||
75
README.md
75
README.md
@ -1,2 +1,75 @@
|
|||||||
# BingLogyBlog-Frontend
|
# Nuxt Minimal Starter
|
||||||
|
|
||||||
|
Look at the [Nuxt documentation](https://nuxt.com/docs/getting-started/introduction) to learn more.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
Make sure to install dependencies:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# npm
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# pnpm
|
||||||
|
pnpm install
|
||||||
|
|
||||||
|
# yarn
|
||||||
|
yarn install
|
||||||
|
|
||||||
|
# bun
|
||||||
|
bun install
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development Server
|
||||||
|
|
||||||
|
Start the development server on `http://localhost:3000`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# npm
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# pnpm
|
||||||
|
pnpm dev
|
||||||
|
|
||||||
|
# yarn
|
||||||
|
yarn dev
|
||||||
|
|
||||||
|
# bun
|
||||||
|
bun run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## Production
|
||||||
|
|
||||||
|
Build the application for production:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# npm
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
# pnpm
|
||||||
|
pnpm build
|
||||||
|
|
||||||
|
# yarn
|
||||||
|
yarn build
|
||||||
|
|
||||||
|
# bun
|
||||||
|
bun run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Locally preview production build:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# npm
|
||||||
|
npm run preview
|
||||||
|
|
||||||
|
# pnpm
|
||||||
|
pnpm preview
|
||||||
|
|
||||||
|
# yarn
|
||||||
|
yarn preview
|
||||||
|
|
||||||
|
# bun
|
||||||
|
bun run preview
|
||||||
|
```
|
||||||
|
|
||||||
|
Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information.
|
||||||
|
|||||||
16
app/app.vue
Normal file
16
app/app.vue
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<NuxtLayout>
|
||||||
|
<NuxtPage />
|
||||||
|
</NuxtLayout>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
useHead({
|
||||||
|
meta: [
|
||||||
|
{ charset: 'utf-8' },
|
||||||
|
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
</script>
|
||||||
166
app/assets/css/main.css
Normal file
166
app/assets/css/main.css
Normal file
@ -0,0 +1,166 @@
|
|||||||
|
@import 'tailwindcss';
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
--font-sans: 'Inter', 'Noto Sans SC', ui-sans-serif, system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Prose styles for article content */
|
||||||
|
.prose h1 {
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose h2 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-top: 2rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose h3 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose p {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
line-height: 1.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose ul, .prose ol {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
padding-left: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose li {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose blockquote {
|
||||||
|
border-left: 4px solid var(--color-indigo-500);
|
||||||
|
padding-left: 1rem;
|
||||||
|
margin: 1rem 0;
|
||||||
|
color: var(--color-gray-600);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .prose blockquote {
|
||||||
|
color: var(--color-gray-300);
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose pre {
|
||||||
|
background: var(--color-gray-900);
|
||||||
|
color: var(--color-gray-100);
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
margin: 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose code {
|
||||||
|
background: var(--color-gray-100);
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
font-size: 0.875em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .prose code {
|
||||||
|
background: var(--color-gray-800);
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose pre code {
|
||||||
|
background: transparent;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose a {
|
||||||
|
color: var(--color-indigo-600);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .prose a {
|
||||||
|
color: var(--color-indigo-400);
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose img {
|
||||||
|
max-width: 100%;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
margin: 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin: 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose th, .prose td {
|
||||||
|
border: 1px solid var(--color-gray-300);
|
||||||
|
padding: 0.75rem;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .prose th, .dark .prose td {
|
||||||
|
border-color: var(--color-gray-700);
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose th {
|
||||||
|
background: var(--color-gray-100);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .prose th {
|
||||||
|
background: var(--color-gray-800);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Line clamp utilities */
|
||||||
|
.line-clamp-2 {
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-clamp-3 {
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 3;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom scrollbar */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--color-gray-400);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--color-gray-500);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark ::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--color-gray-600);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark ::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--color-gray-500);
|
||||||
|
}
|
||||||
118
app/components/article/ArticleCard.vue
Normal file
118
app/components/article/ArticleCard.vue
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
<template>
|
||||||
|
<UCard class="h-full flex flex-col">
|
||||||
|
<NuxtLink
|
||||||
|
v-if="article.coverImageUrl"
|
||||||
|
:to="`/article/${article.slug}`"
|
||||||
|
class="block -mx-4 -mt-4 mb-4"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
:src="article.coverImageUrl"
|
||||||
|
:alt="article.title"
|
||||||
|
class="w-full h-48 object-cover rounded-t-lg"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
</NuxtLink>
|
||||||
|
|
||||||
|
<div class="flex-1 flex flex-col">
|
||||||
|
<div class="flex items-center justify-between mb-2">
|
||||||
|
<UBadge
|
||||||
|
:label="statusLabels[article.status]"
|
||||||
|
:color="statusColors[article.status]"
|
||||||
|
size="xs"
|
||||||
|
variant="subtle"
|
||||||
|
/>
|
||||||
|
<div class="flex items-center text-gray-500 dark:text-gray-400 text-xs">
|
||||||
|
<UIcon name="i-heroicons-eye" class="w-4 h-4 mr-1" />
|
||||||
|
{{ formatNumber(article.viewCount) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<NuxtLink :to="`/article/${article.slug}`" class="group">
|
||||||
|
<h3
|
||||||
|
class="text-lg font-semibold text-gray-900 dark:text-white mb-2 group-hover:text-indigo-600 dark:group-hover:text-indigo-400 transition-colors line-clamp-2"
|
||||||
|
>
|
||||||
|
{{ article.title }}
|
||||||
|
</h3>
|
||||||
|
</NuxtLink>
|
||||||
|
|
||||||
|
<p
|
||||||
|
v-if="article.excerpt"
|
||||||
|
class="text-gray-600 dark:text-gray-400 text-sm mb-4 line-clamp-3 flex-1"
|
||||||
|
>
|
||||||
|
{{ article.excerpt }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between pt-4 border-t border-gray-100 dark:border-gray-700">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<UAvatar
|
||||||
|
:src="article.author.avatarUrl"
|
||||||
|
:alt="article.author.displayName || article.author.username"
|
||||||
|
size="2xs"
|
||||||
|
class="mr-2"
|
||||||
|
/>
|
||||||
|
<span class="text-sm text-gray-700 dark:text-gray-300">
|
||||||
|
{{ article.author.displayName || article.author.username }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<time
|
||||||
|
:datetime="article.createdAt"
|
||||||
|
class="text-xs text-gray-500 dark:text-gray-400"
|
||||||
|
>
|
||||||
|
{{ timeAgo(article.createdAt) }}
|
||||||
|
</time>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</UCard>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { Article } from '~/types/models'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
article: Article
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const statusLabels: Record<string, string> = {
|
||||||
|
published: '已发布',
|
||||||
|
draft: '草稿',
|
||||||
|
archived: '归档',
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusColors: Record<string, string> = {
|
||||||
|
published: 'green',
|
||||||
|
draft: 'yellow',
|
||||||
|
archived: 'gray',
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatNumber = (num: number) => {
|
||||||
|
if (num >= 10000) {
|
||||||
|
return `${(num / 10000).toFixed(1)}w`
|
||||||
|
}
|
||||||
|
if (num >= 1000) {
|
||||||
|
return `${(num / 1000).toFixed(1)}k`
|
||||||
|
}
|
||||||
|
return num.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeAgo = (date: string) => {
|
||||||
|
const now = new Date()
|
||||||
|
const past = new Date(date)
|
||||||
|
const diff = now.getTime() - past.getTime()
|
||||||
|
const days = Math.floor(diff / (1000 * 60 * 60 * 24))
|
||||||
|
|
||||||
|
if (days === 0) {
|
||||||
|
return '今天'
|
||||||
|
} else if (days === 1) {
|
||||||
|
return '昨天'
|
||||||
|
} else if (days < 7) {
|
||||||
|
return `${days}天前`
|
||||||
|
} else if (days < 30) {
|
||||||
|
return `${Math.floor(days / 7)}周前`
|
||||||
|
} else if (days < 365) {
|
||||||
|
return `${Math.floor(days / 30)}个月前`
|
||||||
|
} else {
|
||||||
|
return `${Math.floor(days / 365)}年前`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
77
app/components/article/MarkdownRenderer.vue
Normal file
77
app/components/article/MarkdownRenderer.vue
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
<template>
|
||||||
|
<div class="prose prose-indigo dark:prose-invert max-w-none">
|
||||||
|
<slot name="content" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { marked } from 'marked'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
content: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const renderedContent = computed(() => {
|
||||||
|
return marked.parse(props.content || '', {
|
||||||
|
breaks: true,
|
||||||
|
gfm: true,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.prose {
|
||||||
|
max-width: 65ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose pre {
|
||||||
|
background-color: #1e1e1e;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose code {
|
||||||
|
background-color: #f3f4f6;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
font-size: 0.875em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .prose code {
|
||||||
|
background-color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose pre code {
|
||||||
|
background-color: transparent;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose img {
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose th,
|
||||||
|
.prose td {
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .prose th,
|
||||||
|
.dark .prose td {
|
||||||
|
border-color: #4b5563;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose th {
|
||||||
|
background-color: #f9fafb;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .prose th {
|
||||||
|
background-color: #374151;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
31
app/components/article/MarkdownToolbar.vue
Normal file
31
app/components/article/MarkdownToolbar.vue
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
<template>
|
||||||
|
<UCard class="mb-4">
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<UButton variant="ghost" size="sm" icon="i-heroicons-bars-3" @click="insertSyntax('# ', '')" title="标题 H1" />
|
||||||
|
<UButton variant="ghost" size="sm" icon="i-heroicons-bars-3" class="font-bold" @click="insertSyntax('## ', '')" title="标题 H2" />
|
||||||
|
<UButton variant="ghost" size="sm" icon="i-heroicons-bold" @click="insertSyntax('**', '**')" title="粗体" />
|
||||||
|
<UButton variant="ghost" size="sm" icon="i-heroicons-italic" @click="insertSyntax('*', '*')" title="斜体" />
|
||||||
|
<UDivider orientation="vertical" class="h-8" />
|
||||||
|
<UButton variant="ghost" size="sm" icon="i-heroicons-link" @click="insertSyntax('[', '](url)')" title="链接" />
|
||||||
|
<UButton variant="ghost" size="sm" icon="i-heroicons-photo" @click="insertSyntax('')" title="图片" />
|
||||||
|
<UDivider orientation="vertical" class="h-8" />
|
||||||
|
<UButton variant="ghost" size="sm" icon="i-heroicons-list-bullet" @click="insertSyntax('- ', '')" title="无序列表" />
|
||||||
|
<UButton variant="ghost" size="sm" icon="i-heroicons-numbered-list" @click="insertSyntax('1. ', '')" title="有序列表" />
|
||||||
|
<UButton variant="ghost" size="sm" icon="i-heroicons-code-bracket" @click="insertSyntax('```\n', '\n```')" title="代码块" />
|
||||||
|
<UButton variant="ghost" size="sm" icon="i-heroicons-command-line" @click="insertSyntax('`', '`')" title="行内代码" />
|
||||||
|
<UDivider orientation="vertical" class="h-8" />
|
||||||
|
<UButton variant="ghost" size="sm" icon="i-heroicons-minus" @click="insertSyntax('\n---\n', '')" title="分割线" />
|
||||||
|
<UButton variant="ghost" size="sm" icon="i-heroicons-table-cells" @click="insertSyntax('\n| 列1 | 列2 |\n|-----|-----|\n| 值1 | 值2 |\n', '')" title="表格" />
|
||||||
|
</div>
|
||||||
|
</UCard>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const emit = defineEmits<{
|
||||||
|
insert: [before: string, after: string]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const insertSyntax = (before: string, after: string) => {
|
||||||
|
emit('insert', before, after)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
91
app/components/comment/CommentForm.vue
Normal file
91
app/components/comment/CommentForm.vue
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
<template>
|
||||||
|
<div class="mt-4">
|
||||||
|
<div v-if="isAuthenticated">
|
||||||
|
<UForm :state="form" @submit="onSubmit">
|
||||||
|
<UFormField class="w-full">
|
||||||
|
<UTextarea
|
||||||
|
v-model="form.content"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
:minrows="3"
|
||||||
|
:maxrows="8"
|
||||||
|
class="w-full"
|
||||||
|
autoresize
|
||||||
|
/>
|
||||||
|
</UFormField>
|
||||||
|
|
||||||
|
<div class="mt-2 flex justify-between items-center">
|
||||||
|
<span class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{{ form.content.length }}/2000
|
||||||
|
</span>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<UButton
|
||||||
|
v-if="parentId"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
@click="$emit('cancel')"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</UButton>
|
||||||
|
<UButton
|
||||||
|
type="submit"
|
||||||
|
color="indigo"
|
||||||
|
size="sm"
|
||||||
|
:loading="submitting"
|
||||||
|
:disabled="!form.content.trim()"
|
||||||
|
>
|
||||||
|
{{ parentId ? '回复' : '发表评论' }}
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</UForm>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 text-center">
|
||||||
|
<p class="text-gray-600 dark:text-gray-400 mb-2">登录后发表评论</p>
|
||||||
|
<UButton to="/login" color="indigo" size="sm">立即登录</UButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const props = defineProps<{
|
||||||
|
articleId: number
|
||||||
|
parentId?: number
|
||||||
|
placeholder?: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
submitted: []
|
||||||
|
cancel: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { createComment } = useComments()
|
||||||
|
const { isAuthenticated } = useAuth()
|
||||||
|
|
||||||
|
const form = ref({
|
||||||
|
content: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const submitting = ref(false)
|
||||||
|
|
||||||
|
const onSubmit = async () => {
|
||||||
|
if (!form.value.content.trim()) return
|
||||||
|
|
||||||
|
submitting.value = true
|
||||||
|
try {
|
||||||
|
await createComment(
|
||||||
|
props.articleId,
|
||||||
|
form.value.content,
|
||||||
|
props.parentId,
|
||||||
|
)
|
||||||
|
form.value.content = ''
|
||||||
|
emit('submitted')
|
||||||
|
// TODO: 显示成功提示
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to submit comment:', error)
|
||||||
|
// TODO: 显示错误提示
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
110
app/components/comment/CommentItem.vue
Normal file
110
app/components/comment/CommentItem.vue
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex gap-4" :class="{ 'ml-12': depth > 0 }">
|
||||||
|
<UAvatar
|
||||||
|
:src="comment.author?.avatarUrl"
|
||||||
|
:alt="comment.author?.displayName || comment.author?.username"
|
||||||
|
size="md"
|
||||||
|
class="flex-shrink-0"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="flex-1">
|
||||||
|
<UCard class="bg-gray-50 dark:bg-gray-800">
|
||||||
|
<div class="flex items-center justify-between mb-2">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="font-medium text-gray-900 dark:text-white">
|
||||||
|
{{ comment.author?.displayName || comment.author?.username || comment.authorName || '匿名用户' }}
|
||||||
|
</span>
|
||||||
|
<time class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{{ formatTime(comment.createdAt) }}
|
||||||
|
</time>
|
||||||
|
<UBadge
|
||||||
|
v-if="comment.status !== 'approved'"
|
||||||
|
:label="statusLabels[comment.status]"
|
||||||
|
:color="statusColors[comment.status]"
|
||||||
|
size="xs"
|
||||||
|
variant="subtle"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="prose prose-sm dark:prose-invert max-w-none text-gray-700 dark:text-gray-300">
|
||||||
|
{{ comment.content }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-3 flex items-center gap-4">
|
||||||
|
<UButton
|
||||||
|
variant="ghost"
|
||||||
|
size="xs"
|
||||||
|
icon="i-heroicons-arrow-turn-left-up"
|
||||||
|
@click="showReply = !showReply"
|
||||||
|
>
|
||||||
|
回复
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CommentForm
|
||||||
|
v-if="showReply"
|
||||||
|
:article-id="comment.articleId"
|
||||||
|
:parent-id="comment.id"
|
||||||
|
:placeholder="`回复 ${comment.author?.displayName || comment.author?.username || '匿名用户'}`"
|
||||||
|
@submitted="onReplySubmitted"
|
||||||
|
@cancel="showReply = false"
|
||||||
|
/>
|
||||||
|
</UCard>
|
||||||
|
|
||||||
|
<div v-if="comment.replies?.length && depth < 2" class="mt-4 space-y-4">
|
||||||
|
<CommentItem
|
||||||
|
v-for="reply in comment.replies"
|
||||||
|
:key="reply.id"
|
||||||
|
:comment="reply"
|
||||||
|
:depth="depth + 1"
|
||||||
|
@reply="$emit('reply', $event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { Comment } from '~/types/models'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
comment: Comment
|
||||||
|
depth: number
|
||||||
|
}>()
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
reply: [comment: Comment]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const showReply = ref(false)
|
||||||
|
|
||||||
|
const statusLabels: Record<string, string> = {
|
||||||
|
pending: '待审核',
|
||||||
|
approved: '已通过',
|
||||||
|
rejected: '已拒绝',
|
||||||
|
suspicious: '可疑',
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusColors: Record<string, string> = {
|
||||||
|
pending: 'yellow',
|
||||||
|
approved: 'green',
|
||||||
|
rejected: 'red',
|
||||||
|
suspicious: 'orange',
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatTime = (date: string) => {
|
||||||
|
return new Date(date).toLocaleString('zh-CN', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const onReplySubmitted = () => {
|
||||||
|
showReply.value = false
|
||||||
|
// TODO: 刷新评论列表
|
||||||
|
}
|
||||||
|
</script>
|
||||||
71
app/components/comment/CommentList.vue
Normal file
71
app/components/comment/CommentList.vue
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div v-if="loading" class="space-y-4">
|
||||||
|
<USkeleton v-for="i in 3" :key="i" class="h-24" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="comments.length === 0" class="text-center py-8">
|
||||||
|
<p class="text-gray-600 dark:text-gray-400">暂无评论</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="space-y-6">
|
||||||
|
<CommentItem
|
||||||
|
v-for="comment in comments"
|
||||||
|
:key="comment.id"
|
||||||
|
:comment="comment"
|
||||||
|
:depth="0"
|
||||||
|
@reply="handleReply"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-8">
|
||||||
|
<CommentForm
|
||||||
|
:article-id="articleId"
|
||||||
|
@submitted="onCommentSubmitted"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { Comment } from '~/types/models'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
articleId: number
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { fetchComments, createComment } = useComments()
|
||||||
|
const { isAuthenticated } = useAuth()
|
||||||
|
|
||||||
|
const comments = ref<Comment[]>([])
|
||||||
|
const loading = ref(true)
|
||||||
|
const replyTo = ref<Comment | null>(null)
|
||||||
|
|
||||||
|
const loadComments = async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
comments.value = await fetchComments(props.articleId)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load comments:', error)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleReply = (comment: Comment) => {
|
||||||
|
if (!isAuthenticated.value) {
|
||||||
|
// TODO: 跳转到登录页
|
||||||
|
return
|
||||||
|
}
|
||||||
|
replyTo.value = comment
|
||||||
|
}
|
||||||
|
|
||||||
|
const onCommentSubmitted = async () => {
|
||||||
|
replyTo.value = null
|
||||||
|
await loadComments()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadComments()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
52
app/components/layout/AppFooter.vue
Normal file
52
app/components/layout/AppFooter.vue
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
<template>
|
||||||
|
<footer class="bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 mt-auto">
|
||||||
|
<div class="container mx-auto px-4 py-8">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||||
|
LinkShare Blog
|
||||||
|
</h3>
|
||||||
|
<p class="text-gray-600 dark:text-gray-400 text-sm">
|
||||||
|
分享技术与生活
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h4 class="text-sm font-semibold text-gray-900 dark:text-white mb-4">
|
||||||
|
链接
|
||||||
|
</h4>
|
||||||
|
<ul class="space-y-2">
|
||||||
|
<li>
|
||||||
|
<UButton to="/" variant="link" class="p-0 h-auto text-gray-600 dark:text-gray-400">
|
||||||
|
首页
|
||||||
|
</UButton>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<UButton
|
||||||
|
v-if="isAuthenticated"
|
||||||
|
to="/create"
|
||||||
|
variant="link"
|
||||||
|
class="p-0 h-auto text-gray-600 dark:text-gray-400"
|
||||||
|
>
|
||||||
|
写文章
|
||||||
|
</UButton>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h4 class="text-sm font-semibold text-gray-900 dark:text-white mb-4">
|
||||||
|
关于
|
||||||
|
</h4>
|
||||||
|
<p class="text-gray-600 dark:text-gray-400 text-sm">
|
||||||
|
© {{ new Date().getFullYear() }} LinkShare Blog. All rights reserved.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const { isAuthenticated } = useAuth()
|
||||||
|
</script>
|
||||||
122
app/components/layout/AppHeader.vue
Normal file
122
app/components/layout/AppHeader.vue
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
<template>
|
||||||
|
<header
|
||||||
|
class="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 sticky top-0 z-50"
|
||||||
|
>
|
||||||
|
<div class="container mx-auto px-4">
|
||||||
|
<div class="flex items-center justify-between h-16">
|
||||||
|
<div class="flex items-center space-x-8">
|
||||||
|
<UButton to="/" variant="ghost" class="text-xl font-bold">
|
||||||
|
<UIcon name="i-heroicons-newspaper" class="w-6 h-6 mr-2" />
|
||||||
|
LinkShare Blog
|
||||||
|
</UButton>
|
||||||
|
|
||||||
|
<nav class="hidden md:flex items-center space-x-4">
|
||||||
|
<UButton to="/" variant="ghost" :active="$route.path === '/'">
|
||||||
|
首页
|
||||||
|
</UButton>
|
||||||
|
<UButton
|
||||||
|
v-if="isAuthenticated"
|
||||||
|
to="/create"
|
||||||
|
variant="ghost"
|
||||||
|
:active="$route.path === '/create'"
|
||||||
|
>
|
||||||
|
写文章
|
||||||
|
</UButton>
|
||||||
|
<UButton
|
||||||
|
v-if="isAdmin"
|
||||||
|
to="/admin/dashboard"
|
||||||
|
variant="ghost"
|
||||||
|
:active="$route.path.startsWith('/admin')"
|
||||||
|
>
|
||||||
|
管理后台
|
||||||
|
</UButton>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center space-x-4">
|
||||||
|
<UButton
|
||||||
|
variant="ghost"
|
||||||
|
icon="i-heroicons-magnifying-glass"
|
||||||
|
size="sm"
|
||||||
|
class="hidden sm:flex"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<UButton
|
||||||
|
v-if="isDark"
|
||||||
|
variant="ghost"
|
||||||
|
icon="i-heroicons-sun"
|
||||||
|
size="sm"
|
||||||
|
@click="toggleDark()"
|
||||||
|
/>
|
||||||
|
<UButton
|
||||||
|
v-else
|
||||||
|
variant="ghost"
|
||||||
|
icon="i-heroicons-moon"
|
||||||
|
size="sm"
|
||||||
|
@click="toggleDark()"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<template v-if="isAuthenticated">
|
||||||
|
<UserMenu />
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<UButton to="/login" color="indigo" size="sm">
|
||||||
|
登录
|
||||||
|
</UButton>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<UButton
|
||||||
|
variant="ghost"
|
||||||
|
icon="i-heroicons-bars-3"
|
||||||
|
size="sm"
|
||||||
|
class="md:hidden"
|
||||||
|
@click="mobileMenuOpen = !mobileMenuOpen"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="mobileMenuOpen" class="md:hidden border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<nav class="px-4 py-4 space-y-2">
|
||||||
|
<UButton to="/" variant="ghost" class="w-full justify-start" @click="mobileMenuOpen = false">
|
||||||
|
首页
|
||||||
|
</UButton>
|
||||||
|
<UButton
|
||||||
|
v-if="isAuthenticated"
|
||||||
|
to="/create"
|
||||||
|
variant="ghost"
|
||||||
|
class="w-full justify-start"
|
||||||
|
@click="mobileMenuOpen = false"
|
||||||
|
>
|
||||||
|
写文章
|
||||||
|
</UButton>
|
||||||
|
<UButton
|
||||||
|
v-if="isAdmin"
|
||||||
|
to="/admin/dashboard"
|
||||||
|
variant="ghost"
|
||||||
|
class="w-full justify-start"
|
||||||
|
@click="mobileMenuOpen = false"
|
||||||
|
>
|
||||||
|
管理后台
|
||||||
|
</UButton>
|
||||||
|
<UButton
|
||||||
|
v-if="!isAuthenticated"
|
||||||
|
to="/login"
|
||||||
|
color="indigo"
|
||||||
|
class="w-full"
|
||||||
|
@click="mobileMenuOpen = false"
|
||||||
|
>
|
||||||
|
登录
|
||||||
|
</UButton>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const { isAuthenticated, isAdmin } = useAuth()
|
||||||
|
const mobileMenuOpen = ref(false)
|
||||||
|
|
||||||
|
const isDark = useDark()
|
||||||
|
const toggleDark = useToggle(isDark)
|
||||||
|
</script>
|
||||||
38
app/components/layout/UserMenu.vue
Normal file
38
app/components/layout/UserMenu.vue
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
<template>
|
||||||
|
<UDropdown :items="items" :popper="{ placement: 'bottom-end' }">
|
||||||
|
<UButton
|
||||||
|
:label="user?.displayName || user?.username"
|
||||||
|
variant="ghost"
|
||||||
|
class="p-2"
|
||||||
|
>
|
||||||
|
<template v-if="user?.avatarUrl" #leading>
|
||||||
|
<UAvatar :src="user.avatarUrl" size="2xs" />
|
||||||
|
</template>
|
||||||
|
<template v-else #leading>
|
||||||
|
<div
|
||||||
|
class="w-6 h-6 rounded-full bg-indigo-100 dark:bg-indigo-900 flex items-center justify-center text-xs font-medium text-indigo-700 dark:text-indigo-300"
|
||||||
|
>
|
||||||
|
{{ (user?.displayName || user?.username || 'U').charAt(0).toUpperCase() }}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</UButton>
|
||||||
|
|
||||||
|
<template #item="{ item }">
|
||||||
|
<span>{{ item.label }}</span>
|
||||||
|
</template>
|
||||||
|
</UDropdown>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const { user, logout } = useAuth()
|
||||||
|
|
||||||
|
const items = computed(() => [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
label: '退出登录',
|
||||||
|
icon: 'i-heroicons-arrow-right-start-on-rectangle',
|
||||||
|
click: () => logout(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
])
|
||||||
|
</script>
|
||||||
39
app/composables/useApi.ts
Normal file
39
app/composables/useApi.ts
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import type { FetchOptions } from 'ofetch'
|
||||||
|
|
||||||
|
export function useApi() {
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
const apiBase = config.public.apiBase as string
|
||||||
|
|
||||||
|
const fetch = ofetch.create({
|
||||||
|
baseURL: apiBase,
|
||||||
|
credentials: 'include',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const request = async <T>(url: string, options?: FetchOptions): Promise<T> => {
|
||||||
|
try {
|
||||||
|
return await fetch<T>(url, {
|
||||||
|
...options,
|
||||||
|
credentials: 'include',
|
||||||
|
})
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.status === 401) {
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
authStore.clearUser()
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
get: <T>(url: string, options?: FetchOptions) => request<T>(url, { ...options, method: 'GET' }),
|
||||||
|
post: <T>(url: string, body?: any, options?: FetchOptions) =>
|
||||||
|
request<T>(url, { ...options, method: 'POST', body }),
|
||||||
|
put: <T>(url: string, body?: any, options?: FetchOptions) =>
|
||||||
|
request<T>(url, { ...options, method: 'PUT', body }),
|
||||||
|
delete: <T>(url: string, options?: FetchOptions) =>
|
||||||
|
request<T>(url, { ...options, method: 'DELETE' }),
|
||||||
|
}
|
||||||
|
}
|
||||||
42
app/composables/useArticles.ts
Normal file
42
app/composables/useArticles.ts
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import type { Article, PaginationResponse } from '~/types/models'
|
||||||
|
|
||||||
|
export function useArticles() {
|
||||||
|
const { get } = useApi()
|
||||||
|
|
||||||
|
const fetchArticles = async (params?: {
|
||||||
|
page?: number
|
||||||
|
limit?: number
|
||||||
|
status?: string
|
||||||
|
visibility?: string
|
||||||
|
authorId?: number
|
||||||
|
search?: string
|
||||||
|
}): Promise<PaginationResponse<Article>> => {
|
||||||
|
const queryParams = new URLSearchParams()
|
||||||
|
|
||||||
|
if (params?.page) queryParams.set('page', params.page.toString())
|
||||||
|
if (params?.limit) queryParams.set('limit', params.limit.toString())
|
||||||
|
if (params?.status) queryParams.set('status', params.status)
|
||||||
|
if (params?.visibility) queryParams.set('visibility', params.visibility)
|
||||||
|
if (params?.authorId) queryParams.set('authorId', params.authorId.toString())
|
||||||
|
if (params?.search) queryParams.set('search', params.search)
|
||||||
|
|
||||||
|
const query = queryParams.toString()
|
||||||
|
const url = `/articles${query ? `?${query}` : ''}`
|
||||||
|
|
||||||
|
return await get<PaginationResponse<Article>>(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchArticleBySlug = async (slug: string): Promise<Article> => {
|
||||||
|
return await get<Article>(`/articles/slug/${slug}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchArticleById = async (id: number): Promise<Article> => {
|
||||||
|
return await get<Article>(`/articles/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
fetchArticles,
|
||||||
|
fetchArticleBySlug,
|
||||||
|
fetchArticleById,
|
||||||
|
}
|
||||||
|
}
|
||||||
105
app/composables/useAuth.ts
Normal file
105
app/composables/useAuth.ts
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
import type { User, LoginCredentials } from '~/types/models'
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const { get, post, delete: deleteRequest } = useApi()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const fetchUser = async (): Promise<User | null> => {
|
||||||
|
try {
|
||||||
|
const user = await get<User>('/me')
|
||||||
|
authStore.setUser(user)
|
||||||
|
return user
|
||||||
|
} catch {
|
||||||
|
authStore.clearUser()
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const checkAuth = async (): Promise<boolean> => {
|
||||||
|
authStore.setLoading(true)
|
||||||
|
try {
|
||||||
|
const user = await fetchUser()
|
||||||
|
return !!user
|
||||||
|
} finally {
|
||||||
|
authStore.setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const login = async (credentials: LoginCredentials): Promise<User> => {
|
||||||
|
authStore.setLoading(true)
|
||||||
|
authStore.setError(null)
|
||||||
|
try {
|
||||||
|
const response = await post<{ user: User }>('/login', credentials)
|
||||||
|
authStore.setUser(response.user)
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const from = route.query.from as string
|
||||||
|
if (from && !from.includes('/admin')) {
|
||||||
|
await router.push(from)
|
||||||
|
} else {
|
||||||
|
await router.push('/')
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.user
|
||||||
|
} catch (err: any) {
|
||||||
|
authStore.setError(err.data?.message || '登录失败')
|
||||||
|
throw err
|
||||||
|
} finally {
|
||||||
|
authStore.setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const logout = async (): Promise<void> => {
|
||||||
|
try {
|
||||||
|
await deleteRequest('/logout')
|
||||||
|
} finally {
|
||||||
|
authStore.clearUser()
|
||||||
|
await router.push('/login')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const requireAuth = async (redirect = true): Promise<boolean> => {
|
||||||
|
if (authStore.isAuthenticated) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (authStore.isLoading) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100))
|
||||||
|
return requireAuth(redirect)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (redirect) {
|
||||||
|
const route = useRoute()
|
||||||
|
await router.push(`/login?from=${route.fullPath}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const requireAdmin = async (): Promise<boolean> => {
|
||||||
|
const authenticated = await requireAuth()
|
||||||
|
if (!authenticated) return false
|
||||||
|
|
||||||
|
if (!authStore.isAdmin) {
|
||||||
|
await router.push('/403')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
user: computed(() => authStore.user),
|
||||||
|
isAuthenticated: computed(() => authStore.isAuthenticated),
|
||||||
|
isAdmin: computed(() => authStore.isAdmin),
|
||||||
|
isLoading: computed(() => authStore.isLoading),
|
||||||
|
error: computed(() => authStore.error),
|
||||||
|
login,
|
||||||
|
logout,
|
||||||
|
fetchUser,
|
||||||
|
checkAuth,
|
||||||
|
requireAuth,
|
||||||
|
requireAdmin,
|
||||||
|
}
|
||||||
|
}
|
||||||
41
app/composables/useComments.ts
Normal file
41
app/composables/useComments.ts
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import type { Comment, PaginationResponse } from '~/types/models'
|
||||||
|
|
||||||
|
export function useComments() {
|
||||||
|
const { get, post, put, delete: deleteRequest } = useApi()
|
||||||
|
|
||||||
|
const fetchComments = async (articleId: number, status = 'approved'): Promise<Comment[]> => {
|
||||||
|
const response = await get<PaginationResponse<Comment>>(
|
||||||
|
`/articles/${articleId}/comments?status=${status}`,
|
||||||
|
)
|
||||||
|
return response.items
|
||||||
|
}
|
||||||
|
|
||||||
|
const createComment = async (
|
||||||
|
articleId: number,
|
||||||
|
content: string,
|
||||||
|
parentId?: number,
|
||||||
|
): Promise<Comment> => {
|
||||||
|
return await post<Comment>(`/articles/${articleId}/comments`, {
|
||||||
|
content,
|
||||||
|
parentId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateCommentStatus = async (
|
||||||
|
commentId: number,
|
||||||
|
status: 'approved' | 'rejected',
|
||||||
|
): Promise<Comment> => {
|
||||||
|
return await put<Comment>(`/admin/comments/${commentId}/status`, { status })
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteComment = async (commentId: number): Promise<void> => {
|
||||||
|
await deleteRequest(`/admin/comments/${commentId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
fetchComments,
|
||||||
|
createComment,
|
||||||
|
updateCommentStatus,
|
||||||
|
deleteComment,
|
||||||
|
}
|
||||||
|
}
|
||||||
71
app/layouts/admin.vue
Normal file
71
app/layouts/admin.vue
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
<template>
|
||||||
|
<div class="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||||
|
<div class="flex">
|
||||||
|
<aside
|
||||||
|
class="fixed left-0 top-0 h-full w-64 bg-white dark:bg-gray-800 border-r border-gray-200 dark:border-gray-700"
|
||||||
|
>
|
||||||
|
<nav class="p-4 space-y-2">
|
||||||
|
<UButton
|
||||||
|
to="/admin/dashboard"
|
||||||
|
variant="ghost"
|
||||||
|
class="w-full justify-start"
|
||||||
|
:active="$route.path.startsWith('/admin/dashboard')"
|
||||||
|
>
|
||||||
|
<UIcon name="i-heroicons-chart-bar" class="w-5 h-5 mr-3" />
|
||||||
|
数据分析
|
||||||
|
</UButton>
|
||||||
|
<UButton
|
||||||
|
to="/admin/articles"
|
||||||
|
variant="ghost"
|
||||||
|
class="w-full justify-start"
|
||||||
|
:active="$route.path.startsWith('/admin/articles')"
|
||||||
|
>
|
||||||
|
<UIcon name="i-heroicons-document-text" class="w-5 h-5 mr-3" />
|
||||||
|
文章管理
|
||||||
|
</UButton>
|
||||||
|
<UButton
|
||||||
|
to="/admin/comments"
|
||||||
|
variant="ghost"
|
||||||
|
class="w-full justify-start"
|
||||||
|
:active="$route.path.startsWith('/admin/comments')"
|
||||||
|
>
|
||||||
|
<UIcon name="i-heroicons-chat-bubble-left-right" class="w-5 h-5 mr-3" />
|
||||||
|
评论管理
|
||||||
|
</UButton>
|
||||||
|
<UButton
|
||||||
|
to="/admin/users"
|
||||||
|
variant="ghost"
|
||||||
|
class="w-full justify-start"
|
||||||
|
:active="$route.path.startsWith('/admin/users')"
|
||||||
|
>
|
||||||
|
<UIcon name="i-heroicons-users" class="w-5 h-5 mr-3" />
|
||||||
|
用户管理
|
||||||
|
</UButton>
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div class="flex-1 ml-64">
|
||||||
|
<header
|
||||||
|
class="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 px-6 py-4"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h1 class="text-xl font-semibold text-gray-900 dark:text-white">
|
||||||
|
管理后台
|
||||||
|
</h1>
|
||||||
|
<div class="flex items-center space-x-4">
|
||||||
|
<UButton to="/" variant="ghost" size="sm">
|
||||||
|
<UIcon name="i-heroicons-arrow-left" class="w-4 h-4 mr-2" />
|
||||||
|
返回首页
|
||||||
|
</UButton>
|
||||||
|
<UserMenu />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="p-6">
|
||||||
|
<slot />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
9
app/layouts/default.vue
Normal file
9
app/layouts/default.vue
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
<template>
|
||||||
|
<div class="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||||
|
<AppHeader />
|
||||||
|
<main class="container mx-auto px-4 py-8">
|
||||||
|
<slot />
|
||||||
|
</main>
|
||||||
|
<AppFooter />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
12
app/middleware/admin.ts
Normal file
12
app/middleware/admin.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
export default defineNuxtRouteMiddleware(async (to) => {
|
||||||
|
const { requireAdmin } = useAuth()
|
||||||
|
|
||||||
|
if (import.meta.server) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAdmin = await requireAdmin()
|
||||||
|
if (!isAdmin) {
|
||||||
|
return navigateTo('/403')
|
||||||
|
}
|
||||||
|
})
|
||||||
12
app/middleware/auth.global.ts
Normal file
12
app/middleware/auth.global.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
export default defineNuxtRouteMiddleware(async () => {
|
||||||
|
const { isAuthenticated, checkAuth } = useAuth()
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
|
if (route.path.startsWith('/admin')) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAuthenticated.value) {
|
||||||
|
await checkAuth()
|
||||||
|
}
|
||||||
|
})
|
||||||
36
app/pages/403.vue
Normal file
36
app/pages/403.vue
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
<template>
|
||||||
|
<div class="min-h-[60vh] flex items-center justify-center">
|
||||||
|
<UCard class="max-w-md w-full text-center">
|
||||||
|
<template #header>
|
||||||
|
<UIcon
|
||||||
|
name="i-heroicons-no-entry"
|
||||||
|
class="w-16 h-16 mx-auto text-red-500 mb-4"
|
||||||
|
/>
|
||||||
|
<h1 class="text-2xl font-bold text-gray-900 dark:text-white mb-2">
|
||||||
|
403 - 禁止访问
|
||||||
|
</h1>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<p class="text-gray-600 dark:text-gray-400 mb-6">
|
||||||
|
抱歉,您没有权限访问此页面。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="flex justify-center space-x-4">
|
||||||
|
<UButton to="/" color="indigo">
|
||||||
|
返回首页
|
||||||
|
</UButton>
|
||||||
|
<UButton v-if="!isAuthenticated" to="/login" variant="outline">
|
||||||
|
登录
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
</UCard>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const { isAuthenticated } = useAuth()
|
||||||
|
|
||||||
|
useSeoMeta({
|
||||||
|
title: '403 - 禁止访问',
|
||||||
|
})
|
||||||
|
</script>
|
||||||
29
app/pages/404.vue
Normal file
29
app/pages/404.vue
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
<template>
|
||||||
|
<div class="min-h-[60vh] flex items-center justify-center">
|
||||||
|
<UCard class="max-w-md w-full text-center">
|
||||||
|
<template #header>
|
||||||
|
<UIcon
|
||||||
|
name="i-heroicons-exclamation-circle"
|
||||||
|
class="w-16 h-16 mx-auto text-orange-500 mb-4"
|
||||||
|
/>
|
||||||
|
<h1 class="text-2xl font-bold text-gray-900 dark:text-white mb-2">
|
||||||
|
404 - 页面未找到
|
||||||
|
</h1>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<p class="text-gray-600 dark:text-gray-400 mb-6">
|
||||||
|
抱歉,您访问的页面不存在。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<UButton to="/" color="indigo">
|
||||||
|
返回首页
|
||||||
|
</UButton>
|
||||||
|
</UCard>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
useSeoMeta({
|
||||||
|
title: '404 - 页面未找到',
|
||||||
|
})
|
||||||
|
</script>
|
||||||
228
app/pages/admin/articles.vue
Normal file
228
app/pages/admin/articles.vue
Normal file
@ -0,0 +1,228 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center justify-between mb-6">
|
||||||
|
<h1 class="text-2xl font-bold">文章管理</h1>
|
||||||
|
<UButton to="/create" color="indigo" icon="i-heroicons-plus">
|
||||||
|
新建文章
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<UCard>
|
||||||
|
<div class="flex gap-4 mb-4">
|
||||||
|
<USelect
|
||||||
|
v-model="statusFilter"
|
||||||
|
:options="statusOptions"
|
||||||
|
class="w-40"
|
||||||
|
/>
|
||||||
|
<USelect
|
||||||
|
v-model="visibilityFilter"
|
||||||
|
:options="visibilityOptions"
|
||||||
|
class="w-40"
|
||||||
|
/>
|
||||||
|
<UButton
|
||||||
|
variant="ghost"
|
||||||
|
icon="i-heroicons-arrow-path"
|
||||||
|
@click="refresh"
|
||||||
|
:loading="loading"
|
||||||
|
>
|
||||||
|
刷新
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<UTable
|
||||||
|
:columns="columns"
|
||||||
|
:rows="articles"
|
||||||
|
:loading="loading"
|
||||||
|
hover
|
||||||
|
>
|
||||||
|
<template #title-data="{ row }">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<img
|
||||||
|
v-if="row.coverImageUrl"
|
||||||
|
:src="row.coverImageUrl"
|
||||||
|
class="w-10 h-10 object-cover rounded"
|
||||||
|
/>
|
||||||
|
<div class="w-10 h-10 bg-gray-200 rounded flex items-center justify-center" v-else>
|
||||||
|
<UIcon name="i-heroicons-image" class="w-5 h-5 text-gray-400" />
|
||||||
|
</div>
|
||||||
|
<NuxtLink :to="`/article/${row.slug}`" class="text-indigo-600 dark:text-indigo-400 hover:underline font-medium">
|
||||||
|
{{ row.title }}
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #author-data="{ row }">
|
||||||
|
{{ row.author.displayName || row.author.username }}
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #status-data="{ row }">
|
||||||
|
<UBadge
|
||||||
|
:label="statusLabels[row.status]"
|
||||||
|
:color="statusColors[row.status]"
|
||||||
|
size="xs"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #visibility-data="{ row }">
|
||||||
|
<UIcon :name="visibilityIcons[row.visibility]" class="w-5 h-5" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #actions-data="{ row }">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<UButton
|
||||||
|
variant="ghost"
|
||||||
|
size="xs"
|
||||||
|
icon="i-heroicons-pencil"
|
||||||
|
:to="`/article/${row.id}/edit`"
|
||||||
|
/>
|
||||||
|
<UButton
|
||||||
|
variant="ghost"
|
||||||
|
size="xs"
|
||||||
|
icon="i-heroicons-trash"
|
||||||
|
color="red"
|
||||||
|
@click="confirmDelete(row)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</UTable>
|
||||||
|
|
||||||
|
<div v-if="totalPages > 1" class="mt-4 flex justify-center">
|
||||||
|
<UPagination
|
||||||
|
v-model="page"
|
||||||
|
:total="total"
|
||||||
|
:page-size="limit"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</UCard>
|
||||||
|
|
||||||
|
<UModal v-model="deleteModalOpen">
|
||||||
|
<UCard>
|
||||||
|
<template #header>
|
||||||
|
<h3 class="text-lg font-semibold">确认删除</h3>
|
||||||
|
</template>
|
||||||
|
<p>确定要删除文章 "{{ selectedArticle?.title }}" 吗?此操作不可撤销。</p>
|
||||||
|
<template #footer>
|
||||||
|
<div class="flex gap-4">
|
||||||
|
<UButton variant="ghost" @click="deleteModalOpen = false">取消</UButton>
|
||||||
|
<UButton color="red" @click="deleteArticle" :loading="deleting">删除</UButton>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</UCard>
|
||||||
|
</UModal>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
definePageMeta({
|
||||||
|
layout: 'admin',
|
||||||
|
middleware: ['admin'],
|
||||||
|
})
|
||||||
|
|
||||||
|
const { fetchArticles } = useArticles()
|
||||||
|
const { delete: deleteRequest } = useApi()
|
||||||
|
|
||||||
|
const articles = ref<any[]>([])
|
||||||
|
const loading = ref(true)
|
||||||
|
const page = ref(1)
|
||||||
|
const limit = 20
|
||||||
|
const total = ref(0)
|
||||||
|
const totalPages = ref(0)
|
||||||
|
|
||||||
|
const statusFilter = ref('')
|
||||||
|
const visibilityFilter = ref('')
|
||||||
|
|
||||||
|
const deleteModalOpen = ref(false)
|
||||||
|
const selectedArticle = ref<any>(null)
|
||||||
|
const deleting = ref(false)
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ key: 'title', label: '标题' },
|
||||||
|
{ key: 'author', label: '作者' },
|
||||||
|
{ key: 'status', label: '状态' },
|
||||||
|
{ key: 'visibility', label: '可见性' },
|
||||||
|
{ key: 'viewCount', label: '查看数' },
|
||||||
|
{ key: 'publishedAt', label: '发布时间' },
|
||||||
|
{ key: 'actions', label: '操作' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const statusOptions = [
|
||||||
|
{ label: '全部状态', value: '' },
|
||||||
|
{ label: '草稿', value: 'draft' },
|
||||||
|
{ label: '已发布', value: 'published' },
|
||||||
|
{ label: '归档', value: 'archived' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const visibilityOptions = [
|
||||||
|
{ label: '全部可见性', value: '' },
|
||||||
|
{ label: '公开', value: 'public' },
|
||||||
|
{ label: '未列出', value: 'unlisted' },
|
||||||
|
{ label: '私密', value: 'private' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const statusLabels: Record<string, string> = {
|
||||||
|
draft: '草稿',
|
||||||
|
published: '已发布',
|
||||||
|
archived: '归档',
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusColors: Record<string, string> = {
|
||||||
|
draft: 'yellow',
|
||||||
|
published: 'green',
|
||||||
|
archived: 'gray',
|
||||||
|
}
|
||||||
|
|
||||||
|
const visibilityIcons: Record<string, string> = {
|
||||||
|
public: 'i-heroicons-globe',
|
||||||
|
unlisted: 'i-heroicons-link',
|
||||||
|
private: 'i-heroicons-lock-closed',
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadArticles = async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const response = await fetchArticles({
|
||||||
|
page: page.value,
|
||||||
|
limit,
|
||||||
|
status: statusFilter.value,
|
||||||
|
visibility: visibilityFilter.value,
|
||||||
|
})
|
||||||
|
articles.value = response.items
|
||||||
|
total.value = response.total
|
||||||
|
totalPages.value = response.totalPages
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load articles:', error)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const refresh = () => {
|
||||||
|
loadArticles()
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmDelete = (article: any) => {
|
||||||
|
selectedArticle.value = article
|
||||||
|
deleteModalOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteArticle = async () => {
|
||||||
|
if (!selectedArticle.value) return
|
||||||
|
deleting.value = true
|
||||||
|
try {
|
||||||
|
await deleteRequest(`/articles/${selectedArticle.value.id}`)
|
||||||
|
deleteModalOpen.value = false
|
||||||
|
selectedArticle.value = null
|
||||||
|
await loadArticles()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to delete article:', error)
|
||||||
|
} finally {
|
||||||
|
deleting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch([page, statusFilter, visibilityFilter], loadArticles)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadArticles()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
230
app/pages/admin/comments.vue
Normal file
230
app/pages/admin/comments.vue
Normal file
@ -0,0 +1,230 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold mb-6">评论管理</h1>
|
||||||
|
|
||||||
|
<UCard>
|
||||||
|
<div class="flex gap-4 mb-4">
|
||||||
|
<USelect
|
||||||
|
v-model="statusFilter"
|
||||||
|
:options="statusOptions"
|
||||||
|
class="w-40"
|
||||||
|
/>
|
||||||
|
<UInput
|
||||||
|
v-model="articleIdFilter"
|
||||||
|
placeholder="文章 ID"
|
||||||
|
class="w-32"
|
||||||
|
/>
|
||||||
|
<UButton
|
||||||
|
variant="ghost"
|
||||||
|
icon="i-heroicons-arrow-path"
|
||||||
|
@click="refresh"
|
||||||
|
:loading="loading"
|
||||||
|
>
|
||||||
|
刷新
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<UTable
|
||||||
|
:columns="columns"
|
||||||
|
:rows="comments"
|
||||||
|
:loading="loading"
|
||||||
|
hover
|
||||||
|
>
|
||||||
|
<template #content-data="{ row }">
|
||||||
|
<p class="max-w-md truncate">{{ row.content }}</p>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #article-data="{ row }">
|
||||||
|
<NuxtLink :to="`/article/${row.articleId}`" class="text-indigo-600 dark:text-indigo-400 hover:underline">
|
||||||
|
查看文章
|
||||||
|
</NuxtLink>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #author-data="{ row }">
|
||||||
|
{{ row.author?.displayName || row.author?.username || row.authorName || '匿名用户' }}
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #status-data="{ row }">
|
||||||
|
<UBadge
|
||||||
|
:label="statusLabels[row.status]"
|
||||||
|
:color="statusColors[row.status]"
|
||||||
|
size="xs"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #actions-data="{ row }">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<UButton
|
||||||
|
v-if="row.status === 'pending'"
|
||||||
|
variant="ghost"
|
||||||
|
size="xs"
|
||||||
|
icon="i-heroicons-check"
|
||||||
|
color="green"
|
||||||
|
@click="approveComment(row)"
|
||||||
|
/>
|
||||||
|
<UButton
|
||||||
|
v-if="row.status === 'pending'"
|
||||||
|
variant="ghost"
|
||||||
|
size="xs"
|
||||||
|
icon="i-heroicons-x-mark"
|
||||||
|
color="red"
|
||||||
|
@click="rejectComment(row)"
|
||||||
|
/>
|
||||||
|
<UButton
|
||||||
|
variant="ghost"
|
||||||
|
size="xs"
|
||||||
|
icon="i-heroicons-trash"
|
||||||
|
color="red"
|
||||||
|
@click="confirmDelete(row)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</UTable>
|
||||||
|
|
||||||
|
<div v-if="totalPages > 1" class="mt-4 flex justify-center">
|
||||||
|
<UPagination
|
||||||
|
v-model="page"
|
||||||
|
:total="total"
|
||||||
|
:page-size="limit"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</UCard>
|
||||||
|
|
||||||
|
<UModal v-model="deleteModalOpen">
|
||||||
|
<UCard>
|
||||||
|
<template #header>
|
||||||
|
<h3 class="text-lg font-semibold">确认删除</h3>
|
||||||
|
</template>
|
||||||
|
<p>确定要删除这条评论吗?此操作不可撤销。</p>
|
||||||
|
<template #footer>
|
||||||
|
<div class="flex gap-4">
|
||||||
|
<UButton variant="ghost" @click="deleteModalOpen = false">取消</UButton>
|
||||||
|
<UButton color="red" @click="deleteComment" :loading="deleting">删除</UButton>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</UCard>
|
||||||
|
</UModal>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
definePageMeta({
|
||||||
|
layout: 'admin',
|
||||||
|
middleware: ['admin'],
|
||||||
|
})
|
||||||
|
|
||||||
|
const { get, delete: deleteRequest } = useApi()
|
||||||
|
|
||||||
|
const comments = ref<any[]>([])
|
||||||
|
const loading = ref(true)
|
||||||
|
const page = ref(1)
|
||||||
|
const limit = 20
|
||||||
|
const total = ref(0)
|
||||||
|
const totalPages = ref(0)
|
||||||
|
|
||||||
|
const statusFilter = ref('')
|
||||||
|
const articleIdFilter = ref('')
|
||||||
|
|
||||||
|
const deleteModalOpen = ref(false)
|
||||||
|
const selectedComment = ref<any>(null)
|
||||||
|
const deleting = ref(false)
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ key: 'content', label: '评论内容' },
|
||||||
|
{ key: 'article', label: '文章' },
|
||||||
|
{ key: 'author', label: '作者' },
|
||||||
|
{ key: 'status', label: '状态' },
|
||||||
|
{ key: 'createdAt', label: '时间' },
|
||||||
|
{ key: 'actions', label: '操作' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const statusOptions = [
|
||||||
|
{ label: '全部状态', value: '' },
|
||||||
|
{ label: '待审核', value: 'pending' },
|
||||||
|
{ label: '已通过', value: 'approved' },
|
||||||
|
{ label: '已拒绝', value: 'rejected' },
|
||||||
|
{ label: '可疑', value: 'suspicious' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const statusLabels: Record<string, string> = {
|
||||||
|
pending: '待审核',
|
||||||
|
approved: '已通过',
|
||||||
|
rejected: '已拒绝',
|
||||||
|
suspicious: '可疑',
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusColors: Record<string, string> = {
|
||||||
|
pending: 'yellow',
|
||||||
|
approved: 'green',
|
||||||
|
rejected: 'red',
|
||||||
|
suspicious: 'orange',
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadComments = async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const query = new URLSearchParams()
|
||||||
|
query.set('page', page.value.toString())
|
||||||
|
query.set('limit', limit.toString())
|
||||||
|
if (statusFilter.value) query.set('status', statusFilter.value)
|
||||||
|
if (articleIdFilter.value) query.set('articleId', articleIdFilter.value)
|
||||||
|
|
||||||
|
const response = await get(`/admin/comments?${query}`)
|
||||||
|
comments.value = response.items
|
||||||
|
total.value = response.total
|
||||||
|
totalPages.value = response.totalPages
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load comments:', error)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const refresh = () => {
|
||||||
|
loadComments()
|
||||||
|
}
|
||||||
|
|
||||||
|
const approveComment = async (comment: any) => {
|
||||||
|
try {
|
||||||
|
await useApi().put(`/admin/comments/${comment.id}/status`, { status: 'approved' })
|
||||||
|
await loadComments()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to approve comment:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rejectComment = async (comment: any) => {
|
||||||
|
try {
|
||||||
|
await useApi().put(`/admin/comments/${comment.id}/status`, { status: 'rejected' })
|
||||||
|
await loadComments()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to reject comment:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmDelete = (comment: any) => {
|
||||||
|
selectedComment.value = comment
|
||||||
|
deleteModalOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteComment = async () => {
|
||||||
|
if (!selectedComment.value) return
|
||||||
|
deleting.value = true
|
||||||
|
try {
|
||||||
|
await deleteRequest(`/admin/comments/${selectedComment.value.id}`)
|
||||||
|
deleteModalOpen.value = false
|
||||||
|
selectedComment.value = null
|
||||||
|
await loadComments()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to delete comment:', error)
|
||||||
|
} finally {
|
||||||
|
deleting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch([page, statusFilter, articleIdFilter], loadComments)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadComments()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
127
app/pages/admin/dashboard.vue
Normal file
127
app/pages/admin/dashboard.vue
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold mb-6">数据分析</h1>
|
||||||
|
|
||||||
|
<div v-if="loading" class="space-y-6">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
<USkeleton v-for="i in 4" :key="i" class="h-24" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="space-y-6">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
<UCard>
|
||||||
|
<template #header>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-sm text-gray-500 dark:text-gray-400">总文章数</span>
|
||||||
|
<UIcon name="i-heroicons-document-text" class="w-5 h-5 text-indigo-500" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<p class="text-3xl font-bold">{{ stats?.totalArticles || 0 }}</p>
|
||||||
|
</UCard>
|
||||||
|
|
||||||
|
<UCard>
|
||||||
|
<template #header>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-sm text-gray-500 dark:text-gray-400">总评论数</span>
|
||||||
|
<UIcon name="i-heroicons-chat-bubble-left-right" class="w-5 h-5 text-green-500" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<p class="text-3xl font-bold">{{ stats?.totalComments || 0 }}</p>
|
||||||
|
</UCard>
|
||||||
|
|
||||||
|
<UCard>
|
||||||
|
<template #header>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-sm text-gray-500 dark:text-gray-400">总用户数</span>
|
||||||
|
<UIcon name="i-heroicons-users" class="w-5 h-5 text-blue-500" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<p class="text-3xl font-bold">{{ stats?.totalUsers || 0 }}</p>
|
||||||
|
</UCard>
|
||||||
|
|
||||||
|
<UCard>
|
||||||
|
<template #header>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-sm text-gray-500 dark:text-gray-400">近30天查看</span>
|
||||||
|
<UIcon name="i-heroicons-eye" class="w-5 h-5 text-orange-500" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<p class="text-3xl font-bold">{{ stats?.totalViews || 0 }}</p>
|
||||||
|
</UCard>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<UCard v-if="stats?.popularArticles?.length">
|
||||||
|
<template #header>
|
||||||
|
<h3 class="text-lg font-semibold">热门文章 TOP 10</h3>
|
||||||
|
</template>
|
||||||
|
<UTable :columns="columns" :rows="stats.popularArticles">
|
||||||
|
<template #title-data="{ row }">
|
||||||
|
<NuxtLink :to="`/article/${row.slug}`" class="text-indigo-600 dark:text-indigo-400 hover:underline">
|
||||||
|
{{ row.title }}
|
||||||
|
</NuxtLink>
|
||||||
|
</template>
|
||||||
|
<template #viewCount-data="{ row }">
|
||||||
|
{{ row.viewCount }}
|
||||||
|
</template>
|
||||||
|
</UTable>
|
||||||
|
</UCard>
|
||||||
|
|
||||||
|
<UCard v-if="stats?.commentStats">
|
||||||
|
<template #header>
|
||||||
|
<h3 class="text-lg font-semibold">评论审核统计</h3>
|
||||||
|
</template>
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
<div class="text-center">
|
||||||
|
<p class="text-2xl font-bold text-yellow-600">{{ stats.commentStats.pending }}</p>
|
||||||
|
<p class="text-sm text-gray-500">待审核</p>
|
||||||
|
</div>
|
||||||
|
<div class="text-center">
|
||||||
|
<p class="text-2xl font-bold text-green-600">{{ stats.commentStats.approved }}</p>
|
||||||
|
<p class="text-sm text-gray-500">已通过</p>
|
||||||
|
</div>
|
||||||
|
<div class="text-center">
|
||||||
|
<p class="text-2xl font-bold text-red-600">{{ stats.commentStats.rejected }}</p>
|
||||||
|
<p class="text-sm text-gray-500">已拒绝</p>
|
||||||
|
</div>
|
||||||
|
<div class="text-center">
|
||||||
|
<p class="text-2xl font-bold text-orange-600">{{ stats.commentStats.suspicious }}</p>
|
||||||
|
<p class="text-sm text-gray-500">可疑</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</UCard>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
definePageMeta({
|
||||||
|
layout: 'admin',
|
||||||
|
middleware: ['admin'],
|
||||||
|
})
|
||||||
|
|
||||||
|
const { get } = useApi()
|
||||||
|
|
||||||
|
const stats = ref<any>(null)
|
||||||
|
const loading = ref(true)
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ key: 'title', label: '标题' },
|
||||||
|
{ key: 'viewCount', label: '查看数' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const loadStats = async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
stats.value = await get('/analytics-summary?days=30')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load stats:', error)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadStats()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
203
app/pages/admin/users.vue
Normal file
203
app/pages/admin/users.vue
Normal file
@ -0,0 +1,203 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold mb-6">用户管理</h1>
|
||||||
|
|
||||||
|
<UCard>
|
||||||
|
<div class="flex gap-4 mb-4">
|
||||||
|
<USelect
|
||||||
|
v-model="roleFilter"
|
||||||
|
:options="roleOptions"
|
||||||
|
class="w-40"
|
||||||
|
/>
|
||||||
|
<UButton
|
||||||
|
variant="ghost"
|
||||||
|
icon="i-heroicons-arrow-path"
|
||||||
|
@click="refresh"
|
||||||
|
:loading="loading"
|
||||||
|
>
|
||||||
|
刷新
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<UTable
|
||||||
|
:columns="columns"
|
||||||
|
:rows="users"
|
||||||
|
:loading="loading"
|
||||||
|
hover
|
||||||
|
>
|
||||||
|
<template #user-data="{ row }">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<UAvatar
|
||||||
|
:src="row.avatarUrl"
|
||||||
|
:alt="row.displayName || row.username"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<p class="font-medium">{{ row.displayName || row.username }}</p>
|
||||||
|
<p class="text-sm text-gray-500">{{ row.email }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #role-data="{ row }">
|
||||||
|
<UBadge
|
||||||
|
:label="roleLabels[row.role]"
|
||||||
|
:color="roleColors[row.role]"
|
||||||
|
size="xs"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #isActive-data="{ row }">
|
||||||
|
<UBadge
|
||||||
|
:label="row.isActive ? '激活' : '禁用'"
|
||||||
|
:color="row.isActive ? 'green' : 'red'"
|
||||||
|
size="xs"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #actions-data="{ row }">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<UButton
|
||||||
|
variant="ghost"
|
||||||
|
size="xs"
|
||||||
|
icon="i-heroicons-pencil"
|
||||||
|
@click="editUser(row)"
|
||||||
|
/>
|
||||||
|
<UButton
|
||||||
|
variant="ghost"
|
||||||
|
size="xs"
|
||||||
|
icon="i-heroicons-trash"
|
||||||
|
color="red"
|
||||||
|
@click="confirmDelete(row)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</UTable>
|
||||||
|
|
||||||
|
<div v-if="totalPages > 1" class="mt-4 flex justify-center">
|
||||||
|
<UPagination
|
||||||
|
v-model="page"
|
||||||
|
:total="total"
|
||||||
|
:page-size="limit"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</UCard>
|
||||||
|
|
||||||
|
<UModal v-model="deleteModalOpen">
|
||||||
|
<UCard>
|
||||||
|
<template #header>
|
||||||
|
<h3 class="text-lg font-semibold">确认删除</h3>
|
||||||
|
</template>
|
||||||
|
<p>确定要删除用户 "{{ selectedUser?.displayName || selectedUser?.username }}" 吗?此操作不可撤销。</p>
|
||||||
|
<template #footer>
|
||||||
|
<div class="flex gap-4">
|
||||||
|
<UButton variant="ghost" @click="deleteModalOpen = false">取消</UButton>
|
||||||
|
<UButton color="red" @click="deleteUser" :loading="deleting">删除</UButton>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</UCard>
|
||||||
|
</UModal>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
definePageMeta({
|
||||||
|
layout: 'admin',
|
||||||
|
middleware: ['admin'],
|
||||||
|
})
|
||||||
|
|
||||||
|
const { get, delete: deleteRequest } = useApi()
|
||||||
|
|
||||||
|
const users = ref<any[]>([])
|
||||||
|
const loading = ref(true)
|
||||||
|
const page = ref(1)
|
||||||
|
const limit = 20
|
||||||
|
const total = ref(0)
|
||||||
|
const totalPages = ref(0)
|
||||||
|
|
||||||
|
const roleFilter = ref('')
|
||||||
|
|
||||||
|
const deleteModalOpen = ref(false)
|
||||||
|
const selectedUser = ref<any>(null)
|
||||||
|
const deleting = ref(false)
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ key: 'user', label: '用户' },
|
||||||
|
{ key: 'role', label: '角色' },
|
||||||
|
{ key: 'isActive', label: '状态' },
|
||||||
|
{ key: 'createdAt', label: '注册时间' },
|
||||||
|
{ key: 'actions', label: '操作' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const roleOptions = [
|
||||||
|
{ label: '全部角色', value: '' },
|
||||||
|
{ label: '管理员', value: 'admin' },
|
||||||
|
{ label: '版主', value: 'moderator' },
|
||||||
|
{ label: '用户', value: 'user' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const roleLabels: Record<string, string> = {
|
||||||
|
admin: '管理员',
|
||||||
|
moderator: '版主',
|
||||||
|
user: '用户',
|
||||||
|
}
|
||||||
|
|
||||||
|
const roleColors: Record<string, string> = {
|
||||||
|
admin: 'red',
|
||||||
|
moderator: 'yellow',
|
||||||
|
user: 'blue',
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadUsers = async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const query = new URLSearchParams()
|
||||||
|
query.set('page', page.value.toString())
|
||||||
|
query.set('limit', limit.toString())
|
||||||
|
if (roleFilter.value) query.set('role', roleFilter.value)
|
||||||
|
|
||||||
|
const response = await get(`/admin/users?${query}`)
|
||||||
|
users.value = response.items
|
||||||
|
total.value = response.total
|
||||||
|
totalPages.value = response.totalPages
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load users:', error)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const refresh = () => {
|
||||||
|
loadUsers()
|
||||||
|
}
|
||||||
|
|
||||||
|
const editUser = (user: any) => {
|
||||||
|
// TODO: 打开编辑弹窗
|
||||||
|
console.log('Edit user:', user)
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmDelete = (user: any) => {
|
||||||
|
selectedUser.value = user
|
||||||
|
deleteModalOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteUser = async () => {
|
||||||
|
if (!selectedUser.value) return
|
||||||
|
deleting.value = true
|
||||||
|
try {
|
||||||
|
await deleteRequest(`/admin/users/${selectedUser.value.id}`)
|
||||||
|
deleteModalOpen.value = false
|
||||||
|
selectedUser.value = null
|
||||||
|
await loadUsers()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to delete user:', error)
|
||||||
|
} finally {
|
||||||
|
deleting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch([page, roleFilter], loadUsers)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadUsers()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
200
app/pages/article/[id]/edit.vue
Normal file
200
app/pages/article/[id]/edit.vue
Normal file
@ -0,0 +1,200 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold mb-6">编辑文章</h1>
|
||||||
|
|
||||||
|
<div v-if="loading" class="space-y-4">
|
||||||
|
<USkeleton class="h-10" />
|
||||||
|
<USkeleton class="h-10" />
|
||||||
|
<USkeleton class="h-96" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="!article" class="text-center py-16">
|
||||||
|
<p class="text-gray-600">文章不存在</p>
|
||||||
|
<UButton to="/" color="indigo" class="mt-4">返回首页</UButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<UForm :state="form" @submit="onSubmit" class="space-y-6">
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
<!-- 编辑区 -->
|
||||||
|
<div class="space-y-4">
|
||||||
|
<UFormField label="标题" name="title" required>
|
||||||
|
<UInput v-model="form.title" placeholder="请输入文章标题" class="w-full" />
|
||||||
|
</UFormField>
|
||||||
|
|
||||||
|
<UFormField label="Slug" name="slug">
|
||||||
|
<UInput v-model="form.slug" placeholder="自动生成" class="w-full" />
|
||||||
|
</UFormField>
|
||||||
|
|
||||||
|
<UFormField label="摘要" name="excerpt">
|
||||||
|
<UTextarea v-model="form.excerpt" placeholder="请输入文章摘要(可选)" class="w-full" />
|
||||||
|
</UFormField>
|
||||||
|
|
||||||
|
<UFormField label="封面图 URL" name="coverImageUrl">
|
||||||
|
<UInput v-model="form.coverImageUrl" placeholder="输入封面图 URL(可选)" class="w-full" />
|
||||||
|
</UFormField>
|
||||||
|
|
||||||
|
<MarkdownToolbar @insert="insertMarkdown" />
|
||||||
|
|
||||||
|
<UFormField label="内容" name="content" required>
|
||||||
|
<UTextarea
|
||||||
|
v-model="form.content"
|
||||||
|
:minrows="15"
|
||||||
|
:maxrows="30"
|
||||||
|
placeholder="在此输入 Markdown 内容..."
|
||||||
|
class="w-full font-mono"
|
||||||
|
ref="contentRef"
|
||||||
|
/>
|
||||||
|
</UFormField>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<UFormField label="状态" name="status">
|
||||||
|
<USelect v-model="form.status" :options="statusOptions" class="w-full" />
|
||||||
|
</UFormField>
|
||||||
|
|
||||||
|
<UFormField label="可见性" name="visibility">
|
||||||
|
<USelect v-model="form.visibility" :options="visibilityOptions" class="w-full" />
|
||||||
|
</UFormField>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-4">
|
||||||
|
<UButton type="submit" color="indigo" :loading="saving">
|
||||||
|
更新
|
||||||
|
</UButton>
|
||||||
|
<UButton variant="outline" @click="$router.push(`/article/${article.slug}`)">
|
||||||
|
预览
|
||||||
|
</UButton>
|
||||||
|
<UButton variant="ghost" @click="$router.back()">
|
||||||
|
取消
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 预览区 -->
|
||||||
|
<div class="space-y-4">
|
||||||
|
<h3 class="font-medium text-gray-700 dark:text-gray-300">实时预览</h3>
|
||||||
|
<div class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-4 min-h-[600px] max-h-[800px] overflow-auto">
|
||||||
|
<div v-if="form.content" class="prose prose-indigo dark:prose-invert max-w-none" v-html="renderedContent" />
|
||||||
|
<div v-else class="text-gray-400 text-center py-12">
|
||||||
|
<UIcon name="i-heroicons-eye" class="w-12 h-12 mx-auto mb-4" />
|
||||||
|
<p>实时预览内容</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</UForm>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { marked } from 'marked'
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
layout: 'default',
|
||||||
|
middleware: ['auth'],
|
||||||
|
})
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const { put } = useApi()
|
||||||
|
const { fetchArticleById } = useArticles()
|
||||||
|
|
||||||
|
const articleId = parseInt(route.params.id as string)
|
||||||
|
const contentRef = ref<HTMLTextAreaElement | null>(null)
|
||||||
|
|
||||||
|
const article = ref<any>(null)
|
||||||
|
const loading = ref(true)
|
||||||
|
const saving = ref(false)
|
||||||
|
|
||||||
|
const form = ref({
|
||||||
|
title: '',
|
||||||
|
slug: '',
|
||||||
|
content: '',
|
||||||
|
excerpt: '',
|
||||||
|
coverImageUrl: '',
|
||||||
|
status: 'draft' as const,
|
||||||
|
visibility: 'public' as const,
|
||||||
|
})
|
||||||
|
|
||||||
|
const statusOptions = [
|
||||||
|
{ label: '草稿', value: 'draft' },
|
||||||
|
{ label: '已发布', value: 'published' },
|
||||||
|
{ label: '归档', value: 'archived' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const visibilityOptions = [
|
||||||
|
{ label: '公开', value: 'public' },
|
||||||
|
{ label: '未列出', value: 'unlisted' },
|
||||||
|
{ label: '私密', value: 'private' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const renderedContent = computed(() => {
|
||||||
|
return marked.parse(form.value.content || '', { breaks: true, gfm: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
const insertMarkdown = (before: string, after: string) => {
|
||||||
|
const textarea = contentRef.value?.querySelector('textarea')
|
||||||
|
if (!textarea) return
|
||||||
|
|
||||||
|
const start = textarea.selectionStart
|
||||||
|
const end = textarea.selectionEnd
|
||||||
|
const text = form.value.content
|
||||||
|
const selected = text.substring(start, end)
|
||||||
|
|
||||||
|
form.value.content = text.substring(0, start) + before + selected + after + text.substring(end)
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
textarea.focus()
|
||||||
|
const newCursor = start + before.length + selected.length
|
||||||
|
textarea.setSelectionRange(newCursor, newCursor)
|
||||||
|
}, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadArticle = async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
article.value = await fetchArticleById(articleId)
|
||||||
|
form.value = {
|
||||||
|
title: article.value.title,
|
||||||
|
slug: article.value.slug,
|
||||||
|
content: article.value.content || '',
|
||||||
|
excerpt: article.value.excerpt || '',
|
||||||
|
coverImageUrl: article.value.coverImageUrl || '',
|
||||||
|
status: article.value.status,
|
||||||
|
visibility: article.value.visibility,
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load article:', error)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onSubmit = async () => {
|
||||||
|
if (!form.value.title.trim() || !form.value.content.trim()) return
|
||||||
|
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
const data = { ...form.value }
|
||||||
|
if (!data.slug) {
|
||||||
|
data.slug = data.title.toLowerCase().replace(/[^\w\s-]/g, '').replace(/\s+/g, '-')
|
||||||
|
}
|
||||||
|
|
||||||
|
await put(`/articles/${articleId}`, data)
|
||||||
|
router.push(`/article/${data.slug}`)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to update article:', error)
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadArticle()
|
||||||
|
})
|
||||||
|
|
||||||
|
useSeoMeta({
|
||||||
|
title: () => article.value ? `编辑:${article.value.title} - LinkShare Blog` : '编辑文章 - LinkShare Blog',
|
||||||
|
})
|
||||||
|
</script>
|
||||||
154
app/pages/article/[slug].vue
Normal file
154
app/pages/article/[slug].vue
Normal file
@ -0,0 +1,154 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="loading" class="min-h-screen">
|
||||||
|
<USkeleton class="h-10 w-3/4 mb-4" />
|
||||||
|
<USkeleton class="h-6 w-1/2 mb-8" />
|
||||||
|
<USkeleton class="h-64 w-full mb-8" />
|
||||||
|
<USkeleton class="h-4 w-full mb-2" />
|
||||||
|
<USkeleton class="h-4 w-full mb-2" />
|
||||||
|
<USkeleton class="h-4 w-2/3" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="!article" class="text-center py-16">
|
||||||
|
<p class="text-gray-600 dark:text-gray-400">文章不存在</p>
|
||||||
|
<UButton to="/" color="indigo" class="mt-4">返回首页</UButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="max-w-4xl mx-auto">
|
||||||
|
<article>
|
||||||
|
<header class="mb-8">
|
||||||
|
<h1 class="text-4xl font-bold text-gray-900 dark:text-white mb-4">
|
||||||
|
{{ article.title }}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between flex-wrap gap-4">
|
||||||
|
<div class="flex items-center space-x-4">
|
||||||
|
<UAvatar
|
||||||
|
:src="article.author.avatarUrl"
|
||||||
|
:alt="article.author.displayName || article.author.username"
|
||||||
|
size="md"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<p class="font-medium text-gray-900 dark:text-white">
|
||||||
|
{{ article.author.displayName || article.author.username }}
|
||||||
|
</p>
|
||||||
|
<time
|
||||||
|
:datetime="article.createdAt"
|
||||||
|
class="text-sm text-gray-500 dark:text-gray-400"
|
||||||
|
>
|
||||||
|
{{ formatDate(article.createdAt) }}
|
||||||
|
</time>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center space-x-4 text-gray-500 dark:text-gray-400">
|
||||||
|
<span class="flex items-center">
|
||||||
|
<UIcon name="i-heroicons-eye" class="w-5 h-5 mr-1" />
|
||||||
|
{{ article.viewCount }}
|
||||||
|
</span>
|
||||||
|
<UBadge
|
||||||
|
:label="article.status"
|
||||||
|
:color="article.status === 'published' ? 'green' : 'gray'"
|
||||||
|
variant="subtle"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<img
|
||||||
|
v-if="article.coverImageUrl"
|
||||||
|
:src="article.coverImageUrl"
|
||||||
|
:alt="article.title"
|
||||||
|
class="w-full h-96 object-cover rounded-lg mb-8"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="prose prose-indigo dark:prose-invert max-w-none mb-8">
|
||||||
|
<div v-html="renderedContent" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="border-t border-gray-200 dark:border-gray-700 pt-8">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<span class="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
最后更新:{{ formatDate(article.updatedAt) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<UButton
|
||||||
|
variant="ghost"
|
||||||
|
icon="i-heroicons-share"
|
||||||
|
size="sm"
|
||||||
|
@click="copyLink"
|
||||||
|
>
|
||||||
|
分享
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<section class="mt-12">
|
||||||
|
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-6">
|
||||||
|
评论
|
||||||
|
</h2>
|
||||||
|
<CommentList :article-id="article.id" />
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { marked } from 'marked'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const { fetchArticleBySlug } = useArticles()
|
||||||
|
|
||||||
|
const article = ref<any>(null)
|
||||||
|
const loading = ref(true)
|
||||||
|
|
||||||
|
const slug = route.params.slug as string
|
||||||
|
|
||||||
|
const renderedContent = computed(() => {
|
||||||
|
if (!article.value?.content) return ''
|
||||||
|
return marked.parse(article.value.content, {
|
||||||
|
breaks: true,
|
||||||
|
gfm: true,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const formatDate = (date: string) => {
|
||||||
|
return new Date(date).toLocaleDateString('zh-CN', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const copyLink = () => {
|
||||||
|
const url = window.location.href
|
||||||
|
navigator.clipboard.writeText(url)
|
||||||
|
// TODO: 显示提示
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadArticle = async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
article.value = await fetchArticleBySlug(slug)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load article:', error)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadArticle()
|
||||||
|
})
|
||||||
|
|
||||||
|
useSeoMeta({
|
||||||
|
title: () => article.value ? `${article.value.title} - LinkShare Blog` : '文章详情',
|
||||||
|
description: () => article.value?.excerpt || '',
|
||||||
|
ogTitle: () => article.value?.title || '',
|
||||||
|
ogDescription: () => article.value?.excerpt || '',
|
||||||
|
ogType: 'article',
|
||||||
|
})
|
||||||
|
</script>
|
||||||
163
app/pages/create.vue
Normal file
163
app/pages/create.vue
Normal file
@ -0,0 +1,163 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold mb-6">{{ isEdit ? '编辑文章' : '写文章' }}</h1>
|
||||||
|
|
||||||
|
<UForm :state="form" @submit="onSubmit" class="space-y-6">
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
<!-- 编辑区 -->
|
||||||
|
<div class="space-y-4">
|
||||||
|
<UFormField label="标题" name="title" required>
|
||||||
|
<UInput v-model="form.title" placeholder="请输入文章标题" class="w-full" />
|
||||||
|
</UFormField>
|
||||||
|
|
||||||
|
<UFormField label="Slug" name="slug">
|
||||||
|
<UInput v-model="form.slug" placeholder="自动生成" class="w-full" />
|
||||||
|
</UFormField>
|
||||||
|
|
||||||
|
<UFormField label="摘要" name="excerpt">
|
||||||
|
<UTextarea v-model="form.excerpt" placeholder="请输入文章摘要(可选)" class="w-full" />
|
||||||
|
</UFormField>
|
||||||
|
|
||||||
|
<UFormField label="封面图 URL" name="coverImageUrl">
|
||||||
|
<UInput v-model="form.coverImageUrl" placeholder="输入封面图 URL(可选)" class="w-full" />
|
||||||
|
</UFormField>
|
||||||
|
|
||||||
|
<MarkdownToolbar @insert="insertMarkdown" />
|
||||||
|
|
||||||
|
<UFormField label="内容" name="content" required>
|
||||||
|
<UTextarea
|
||||||
|
v-model="form.content"
|
||||||
|
:minrows="15"
|
||||||
|
:maxrows="30"
|
||||||
|
placeholder="在此输入 Markdown 内容..."
|
||||||
|
class="w-full font-mono"
|
||||||
|
ref="contentRef"
|
||||||
|
/>
|
||||||
|
</UFormField>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<UFormField label="状态" name="status">
|
||||||
|
<USelect v-model="form.status" :options="statusOptions" class="w-full" />
|
||||||
|
</UFormField>
|
||||||
|
|
||||||
|
<UFormField label="可见性" name="visibility">
|
||||||
|
<USelect v-model="form.visibility" :options="visibilityOptions" class="w-full" />
|
||||||
|
</UFormField>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-4">
|
||||||
|
<UButton type="submit" color="indigo" :loading="saving">
|
||||||
|
{{ isEdit ? '更新' : '发布' }}
|
||||||
|
</UButton>
|
||||||
|
<UButton variant="outline" @click="$router.back()">
|
||||||
|
取消
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 预览区 -->
|
||||||
|
<div class="space-y-4">
|
||||||
|
<h3 class="font-medium text-gray-700 dark:text-gray-300">实时预览</h3>
|
||||||
|
<div class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-4 min-h-[600px] max-h-[800px] overflow-auto">
|
||||||
|
<div v-if="form.content" class="prose prose-indigo dark:prose-invert max-w-none" v-html="renderedContent" />
|
||||||
|
<div v-else class="text-gray-400 text-center py-12">
|
||||||
|
<UIcon name="i-heroicons-eye" class="w-12 h-12 mx-auto mb-4" />
|
||||||
|
<p>实时预览内容</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</UForm>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { marked } from 'marked'
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
middleware: ['auth'],
|
||||||
|
})
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const { post, put } = useApi()
|
||||||
|
|
||||||
|
const isEdit = computed(() => !!route.params.id)
|
||||||
|
const contentRef = ref<HTMLTextAreaElement | null>(null)
|
||||||
|
|
||||||
|
const form = ref({
|
||||||
|
title: '',
|
||||||
|
slug: '',
|
||||||
|
content: '',
|
||||||
|
excerpt: '',
|
||||||
|
coverImageUrl: '',
|
||||||
|
status: 'draft' as const,
|
||||||
|
visibility: 'public' as const,
|
||||||
|
})
|
||||||
|
|
||||||
|
const saving = ref(false)
|
||||||
|
|
||||||
|
const statusOptions = [
|
||||||
|
{ label: '草稿', value: 'draft' },
|
||||||
|
{ label: '已发布', value: 'published' },
|
||||||
|
{ label: '归档', value: 'archived' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const visibilityOptions = [
|
||||||
|
{ label: '公开', value: 'public' },
|
||||||
|
{ label: '未列出', value: 'unlisted' },
|
||||||
|
{ label: '私密', value: 'private' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const renderedContent = computed(() => {
|
||||||
|
return marked.parse(form.value.content || '', { breaks: true, gfm: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
const insertMarkdown = (before: string, after: string) => {
|
||||||
|
const textarea = contentRef.value?.querySelector('textarea')
|
||||||
|
if (!textarea) return
|
||||||
|
|
||||||
|
const start = textarea.selectionStart
|
||||||
|
const end = textarea.selectionEnd
|
||||||
|
const text = form.value.content
|
||||||
|
const selected = text.substring(start, end)
|
||||||
|
|
||||||
|
form.value.content = text.substring(0, start) + before + selected + after + text.substring(end)
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
textarea.focus()
|
||||||
|
const newCursor = start + before.length + selected.length
|
||||||
|
textarea.setSelectionRange(newCursor, newCursor)
|
||||||
|
}, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onSubmit = async () => {
|
||||||
|
if (!form.value.title.trim() || !form.value.content.trim()) return
|
||||||
|
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
const data = { ...form.value }
|
||||||
|
if (!data.slug) {
|
||||||
|
data.slug = data.title.toLowerCase().replace(/[^\w\s-]/g, '').replace(/\s+/g, '-')
|
||||||
|
}
|
||||||
|
|
||||||
|
let article
|
||||||
|
if (isEdit.value) {
|
||||||
|
article = await put(`/articles/${route.params.id}`, data)
|
||||||
|
} else {
|
||||||
|
article = await post('/articles', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
router.push(`/article/${article.slug}`)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save article:', error)
|
||||||
|
// TODO: 显示错误提示
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useSeoMeta({
|
||||||
|
title: isEdit.value ? '编辑文章 - LinkShare Blog' : '写文章 - LinkShare Blog',
|
||||||
|
})
|
||||||
|
</script>
|
||||||
123
app/pages/index.vue
Normal file
123
app/pages/index.vue
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="mb-8">
|
||||||
|
<h1 class="text-3xl font-bold text-gray-900 dark:text-white mb-4">
|
||||||
|
最新文章
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div class="flex flex-col sm:flex-row gap-4">
|
||||||
|
<UInput
|
||||||
|
v-model="search"
|
||||||
|
placeholder="搜索文章..."
|
||||||
|
icon="i-heroicons-magnifying-glass"
|
||||||
|
class="flex-1"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<USelect v-model="statusFilter" :options="statusOptions" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loading" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
<USkeleton v-for="i in 6" :key="i" class="h-64" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-else-if="articles.length === 0"
|
||||||
|
class="text-center py-16"
|
||||||
|
>
|
||||||
|
<UIcon
|
||||||
|
name="i-heroicons-document-text"
|
||||||
|
class="w-16 h-16 mx-auto text-gray-300 dark:text-gray-600 mb-4"
|
||||||
|
/>
|
||||||
|
<p class="text-gray-600 dark:text-gray-400">
|
||||||
|
{{ search || statusFilter ? '没有找到相关文章' : '暂无文章' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"
|
||||||
|
>
|
||||||
|
<ArticleCard
|
||||||
|
v-for="article in articles"
|
||||||
|
:key="article.id"
|
||||||
|
:article="article"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="totalPages > 1" class="mt-8 flex justify-center">
|
||||||
|
<UPagination
|
||||||
|
v-model="page"
|
||||||
|
:total="total"
|
||||||
|
:page-size="limit"
|
||||||
|
:max="7"
|
||||||
|
@update:model-value="onPageChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
definePageMeta({
|
||||||
|
layout: 'default',
|
||||||
|
})
|
||||||
|
|
||||||
|
useSeoMeta({
|
||||||
|
title: '首页 - LinkShare Blog',
|
||||||
|
description: 'LinkShare Blog - 分享技术与生活',
|
||||||
|
ogTitle: 'LinkShare Blog',
|
||||||
|
ogDescription: '分享技术与生活',
|
||||||
|
ogType: 'website',
|
||||||
|
})
|
||||||
|
|
||||||
|
const { fetchArticles } = useArticles()
|
||||||
|
const route = useRouter()
|
||||||
|
|
||||||
|
const search = useRouteQuery('search', '')
|
||||||
|
const statusFilter = useRouteQuery<string>('status', '')
|
||||||
|
const page = useRouteQuery<number>('page', 1)
|
||||||
|
const limit = 20
|
||||||
|
|
||||||
|
const articles = ref<any[]>([])
|
||||||
|
const total = ref(0)
|
||||||
|
const totalPages = ref(0)
|
||||||
|
const loading = ref(true)
|
||||||
|
|
||||||
|
const statusOptions = [
|
||||||
|
{ label: '全部状态', value: '' },
|
||||||
|
{ label: '已发布', value: 'published' },
|
||||||
|
{ label: '草稿', value: 'draft' },
|
||||||
|
{ label: '归档', value: 'archived' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const loadArticles = async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const response = await fetchArticles({
|
||||||
|
page: page.value,
|
||||||
|
limit,
|
||||||
|
status: statusFilter.value,
|
||||||
|
search: search.value,
|
||||||
|
})
|
||||||
|
articles.value = response.items
|
||||||
|
total.value = response.total
|
||||||
|
totalPages.value = response.totalPages
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load articles:', error)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onPageChange = (newPage: number) => {
|
||||||
|
page.value = newPage
|
||||||
|
}
|
||||||
|
|
||||||
|
watch([search, statusFilter, page], () => {
|
||||||
|
loadArticles()
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadArticles()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
107
app/pages/login.vue
Normal file
107
app/pages/login.vue
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
<template>
|
||||||
|
<UCard class="max-w-md mx-auto">
|
||||||
|
<template #header>
|
||||||
|
<h2 class="text-2xl font-bold text-center text-gray-900 dark:text-white">
|
||||||
|
登录
|
||||||
|
</h2>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<UAlert
|
||||||
|
v-if="error"
|
||||||
|
:title="error"
|
||||||
|
color="red"
|
||||||
|
variant="subtle"
|
||||||
|
class="mb-4"
|
||||||
|
@close="error = null"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<UForm :state="form" :validate="validate" @submit="onSubmit" class="space-y-4">
|
||||||
|
<UFormField label="邮箱/用户名" name="emailOrUsername" required>
|
||||||
|
<UInput
|
||||||
|
v-model="form.emailOrUsername"
|
||||||
|
placeholder="请输入邮箱或用户名"
|
||||||
|
autocomplete="username"
|
||||||
|
/>
|
||||||
|
</UFormField>
|
||||||
|
|
||||||
|
<UFormField label="密码" name="password" required>
|
||||||
|
<UInput
|
||||||
|
v-model="form.password"
|
||||||
|
type="password"
|
||||||
|
placeholder="请输入密码"
|
||||||
|
autocomplete="current-password"
|
||||||
|
/>
|
||||||
|
</UFormField>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<UCheckbox v-model="form.remember" label="记住我" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<UButton
|
||||||
|
type="submit"
|
||||||
|
color="indigo"
|
||||||
|
class="w-full"
|
||||||
|
:loading="isLoading"
|
||||||
|
:disabled="isLoading"
|
||||||
|
>
|
||||||
|
登录
|
||||||
|
</UButton>
|
||||||
|
</UForm>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<p class="text-center text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
还没有账号?请联系管理员
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
</UCard>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { FormSubmitEvent } from '#ui/types'
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
layout: 'default',
|
||||||
|
middleware: (to) => {
|
||||||
|
const { isAuthenticated } = useAuth()
|
||||||
|
if (isAuthenticated.value) {
|
||||||
|
return navigateTo('/')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
useSeoMeta({
|
||||||
|
title: '登录 - LinkShare Blog',
|
||||||
|
description: '登录到您的 LinkShare Blog 账号',
|
||||||
|
})
|
||||||
|
|
||||||
|
const { login, isLoading } = useAuth()
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
|
||||||
|
const form = ref({
|
||||||
|
emailOrUsername: '',
|
||||||
|
password: '',
|
||||||
|
remember: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const validate = (state: any) => {
|
||||||
|
const errors = []
|
||||||
|
if (!state.emailOrUsername) {
|
||||||
|
errors.push({ path: 'emailOrUsername', message: '请输入邮箱或用户名' })
|
||||||
|
}
|
||||||
|
if (!state.password) {
|
||||||
|
errors.push({ path: 'password', message: '请输入密码' })
|
||||||
|
} else if (state.password.length < 6) {
|
||||||
|
errors.push({ path: 'password', message: '密码至少需要 6 位' })
|
||||||
|
}
|
||||||
|
return errors
|
||||||
|
}
|
||||||
|
|
||||||
|
const onSubmit = async (event: FormSubmitEvent<any>) => {
|
||||||
|
try {
|
||||||
|
error.value = null
|
||||||
|
await login(form.value)
|
||||||
|
} catch (err: any) {
|
||||||
|
error.value = err.data?.message || '登录失败,请检查账号密码'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
44
app/stores/auth.ts
Normal file
44
app/stores/auth.ts
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import type { User } from '~/types/models'
|
||||||
|
|
||||||
|
export const useAuthStore = defineStore('auth', () => {
|
||||||
|
const user = ref<User | null>(null)
|
||||||
|
const isLoading = ref(false)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
|
||||||
|
const isAuthenticated = computed(() => !!user.value)
|
||||||
|
const isAdmin = computed(() => user.value?.role === 'admin')
|
||||||
|
const isModerator = computed(() =>
|
||||||
|
user.value?.role === 'admin' || user.value?.role === 'moderator',
|
||||||
|
)
|
||||||
|
|
||||||
|
const setUser = (userData: User | null) => {
|
||||||
|
user.value = userData
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearUser = () => {
|
||||||
|
user.value = null
|
||||||
|
error.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const setLoading = (loading: boolean) => {
|
||||||
|
isLoading.value = loading
|
||||||
|
}
|
||||||
|
|
||||||
|
const setError = (err: string | null) => {
|
||||||
|
error.value = err
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
user,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
isAuthenticated,
|
||||||
|
isAdmin,
|
||||||
|
isModerator,
|
||||||
|
setUser,
|
||||||
|
clearUser,
|
||||||
|
setLoading,
|
||||||
|
setError,
|
||||||
|
}
|
||||||
|
})
|
||||||
123
app/types/models.ts
Normal file
123
app/types/models.ts
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
export interface User {
|
||||||
|
id: number
|
||||||
|
email: string
|
||||||
|
username: string
|
||||||
|
displayName: string | null
|
||||||
|
avatarUrl: string | null
|
||||||
|
role: 'admin' | 'moderator' | 'user'
|
||||||
|
isActive: boolean
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Article {
|
||||||
|
id: number
|
||||||
|
slug: string
|
||||||
|
title: string
|
||||||
|
content: string | null
|
||||||
|
excerpt: string | null
|
||||||
|
coverImageUrl: string | null
|
||||||
|
status: 'draft' | 'published' | 'archived'
|
||||||
|
visibility: 'public' | 'unlisted' | 'private'
|
||||||
|
viewCount: number
|
||||||
|
publishedAt: string | null
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
author: Pick<User, 'id' | 'username' | 'displayName' | 'avatarUrl'>
|
||||||
|
comments?: Comment[]
|
||||||
|
reactions?: Reaction[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Comment {
|
||||||
|
id: number
|
||||||
|
articleId: number
|
||||||
|
parentId: number | null
|
||||||
|
content: string
|
||||||
|
status: 'pending' | 'approved' | 'rejected' | 'suspicious'
|
||||||
|
authorName: string | null
|
||||||
|
authorEmail: string | null
|
||||||
|
authorId: number | null
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
author?: User | null
|
||||||
|
replies?: Comment[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Reaction {
|
||||||
|
id: number
|
||||||
|
articleId: number
|
||||||
|
userId: number
|
||||||
|
type: string
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ArticleVersion {
|
||||||
|
id: number
|
||||||
|
articleId: number
|
||||||
|
content: string
|
||||||
|
title: string
|
||||||
|
version: number
|
||||||
|
createdAt: string
|
||||||
|
createdBy: Pick<User, 'id' | 'username' | 'displayName'>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CommentAudit {
|
||||||
|
id: number
|
||||||
|
commentId: number
|
||||||
|
action: string
|
||||||
|
reason: string
|
||||||
|
performedBy: string
|
||||||
|
performedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaginationResponse<T> {
|
||||||
|
items: T[]
|
||||||
|
total: number
|
||||||
|
page: number
|
||||||
|
limit: number
|
||||||
|
totalPages: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnalyticsSummary {
|
||||||
|
totalArticles: number
|
||||||
|
totalComments: number
|
||||||
|
totalUsers: number
|
||||||
|
totalViews: number
|
||||||
|
dailyViews: { date: string; views: number }[]
|
||||||
|
popularArticles: Article[]
|
||||||
|
commentStats: {
|
||||||
|
pending: number
|
||||||
|
approved: number
|
||||||
|
rejected: number
|
||||||
|
suspicious: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoginCredentials {
|
||||||
|
emailOrUsername: string
|
||||||
|
password: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateArticleInput {
|
||||||
|
title: string
|
||||||
|
content: string
|
||||||
|
excerpt?: string
|
||||||
|
coverImageUrl?: string
|
||||||
|
status: 'draft' | 'published' | 'archived'
|
||||||
|
visibility: 'public' | 'unlisted' | 'private'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateArticleInput extends Partial<CreateArticleInput> {
|
||||||
|
slug?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateCommentInput {
|
||||||
|
content: string
|
||||||
|
parentId?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiError {
|
||||||
|
message: string
|
||||||
|
code: string
|
||||||
|
details?: Record<string, string[]>
|
||||||
|
}
|
||||||
51
nuxt.config.ts
Normal file
51
nuxt.config.ts
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
import { defineNuxtConfig } from 'nuxt/config'
|
||||||
|
|
||||||
|
export default defineNuxtConfig({
|
||||||
|
compatibilityDate: '2025-07-15',
|
||||||
|
devtools: { enabled: true },
|
||||||
|
|
||||||
|
modules: [
|
||||||
|
'@nuxt/ui',
|
||||||
|
'@pinia/nuxt',
|
||||||
|
],
|
||||||
|
|
||||||
|
runtimeConfig: {
|
||||||
|
public: {
|
||||||
|
apiBase: process.env.NUXT_PUBLIC_API_BASE || 'http://localhost:3001',
|
||||||
|
siteUrl: process.env.NUXT_PUBLIC_SITE_URL || 'http://localhost:3000',
|
||||||
|
siteName: process.env.NUXT_PUBLIC_SITE_NAME || 'LinkShare Blog',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
nitro: {
|
||||||
|
routeRules: {
|
||||||
|
'/': { isr: 60 },
|
||||||
|
'/article/**': { isr: 300 },
|
||||||
|
'/admin/**': { ssr: false },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
app: {
|
||||||
|
head: {
|
||||||
|
title: 'LinkShare Blog',
|
||||||
|
meta: [
|
||||||
|
{ charset: 'utf-8' },
|
||||||
|
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
|
||||||
|
{ name: 'description', content: 'LinkShare Blog - 分享技术与生活' },
|
||||||
|
],
|
||||||
|
link: [
|
||||||
|
{ rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
css: [
|
||||||
|
'~/assets/css/main.css',
|
||||||
|
],
|
||||||
|
|
||||||
|
typescript: {
|
||||||
|
strict: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
compatibilityDate: '2025-07-15',
|
||||||
|
})
|
||||||
23
package.json
Normal file
23
package.json
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"name": "BingLogyBlog-Frontend",
|
||||||
|
"type": "module",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"build": "nuxt build",
|
||||||
|
"dev": "nuxt dev",
|
||||||
|
"generate": "nuxt generate",
|
||||||
|
"preview": "nuxt preview",
|
||||||
|
"postinstall": "nuxt prepare"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@nuxt/ui": "^4.6.0",
|
||||||
|
"@pinia/nuxt": "^0.11.3",
|
||||||
|
"marked": "^17.0.5",
|
||||||
|
"nuxt": "^4.4.2",
|
||||||
|
"ofetch": "^1.5.1",
|
||||||
|
"tailwindcss": "^4.2.2",
|
||||||
|
"vite": "^8.0.3",
|
||||||
|
"vue": "^3.5.30",
|
||||||
|
"vue-router": "^5.0.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
8994
pnpm-lock.yaml
generated
Normal file
8994
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
3
pnpm-workspace.yaml
Normal file
3
pnpm-workspace.yaml
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
allowBuilds:
|
||||||
|
'@parcel/watcher': true
|
||||||
|
esbuild: true
|
||||||
BIN
public/favicon.ico
Normal file
BIN
public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
2
public/robots.txt
Normal file
2
public/robots.txt
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
User-Agent: *
|
||||||
|
Disallow:
|
||||||
18
tsconfig.json
Normal file
18
tsconfig.json
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
// https://nuxt.com/docs/guide/concepts/typescript
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"path": "./.nuxt/tsconfig.app.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "./.nuxt/tsconfig.server.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "./.nuxt/tsconfig.shared.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "./.nuxt/tsconfig.node.json"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user