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:
烧瑚烙饼 2026-03-28 15:56:50 +08:00
parent b1864f9967
commit ce208df092
43 changed files with 13016 additions and 127 deletions

6
.env.example Normal file
View 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
View File

@ -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
View 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 字,可选)
- 封面图 URLUInput可选
- 状态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)
- 表格展示
- 用户信息(头像 + 名称 + 邮箱)
- 角色UBadgeadmin/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()
- 切换按钮UIconsun/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评论审核日志
- OAuth2TokenOAuth2 令牌)
- AnalyticsEvent分析事件
### 认证机制
- **Web 端**: Session CookieHttpOnlySecure
- **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
```

View File

@ -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
View 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
View 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);
}

View 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>

View 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>

View 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('![alt text](', ')')" 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>

View 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>

View 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>

View 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>

View 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">
&copy; {{ new Date().getFullYear() }} LinkShare Blog. All rights reserved.
</p>
</div>
</div>
</div>
</footer>
</template>
<script setup lang="ts">
const { isAuthenticated } = useAuth()
</script>

View 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>

View 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
View 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' }),
}
}

View 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
View 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,
}
}

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

View 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
View 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
View 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>

View 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>

View 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>

View 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
View 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>

View 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>

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

3
pnpm-workspace.yaml Normal file
View File

@ -0,0 +1,3 @@
allowBuilds:
'@parcel/watcher': true
esbuild: true

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

2
public/robots.txt Normal file
View File

@ -0,0 +1,2 @@
User-Agent: *
Disallow:

18
tsconfig.json Normal file
View 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"
}
]
}