update: res db

This commit is contained in:
ctwj
2025-07-10 01:27:35 +08:00
commit fe2d7bfb63
26 changed files with 2427 additions and 0 deletions

109
.gitignore vendored Normal file
View File

@@ -0,0 +1,109 @@
# Go
*.exe
*.exe~
*.dll
*.so
*.dylib
*.test
*.out
go.work
# Environment variables
.env
.env.local
.env.*.local
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# OS
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
# Logs
*.log
logs/
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Coverage directory used by tools like istanbul
coverage/
*.lcov
# nyc test coverage
.nyc_output
# Dependency directories
node_modules/
jspm_packages/
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# 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
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
public
# Storybook build outputs
.out
.storybook-out
# Temporary folders
tmp/
temp/
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# Uploads
uploads/*
!uploads/.gitkeep

39
Dockerfile Normal file
View File

@@ -0,0 +1,39 @@
# 多阶段构建
FROM node:18-alpine AS frontend-builder
WORKDIR /app/web
COPY web/package*.json ./
RUN npm ci --only=production
COPY web/ ./
RUN npm run build
FROM golang:1.21-alpine AS backend-builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o main .
FROM alpine:latest
RUN apk --no-cache add ca-certificates
WORKDIR /root/
# 复制后端二进制文件
COPY --from=backend-builder /app/main .
# 复制前端构建文件
COPY --from=frontend-builder /app/web/.output /root/web
# 创建uploads目录
RUN mkdir -p uploads
# 暴露端口
EXPOSE 8080
# 运行应用
CMD ["./main"]

140
README.md Normal file
View File

@@ -0,0 +1,140 @@
# 资源管理系统
一个基于 Golang + Nuxt.js 的资源管理系统,参考网盘资源管理界面设计。
## 技术栈
### 后端
- **Golang** - 主要编程语言
- **Gin** - Web框架
- **PostgreSQL** - 数据库
- **lib/pq** - PostgreSQL驱动
### 前端
- **Nuxt.js 3** - Vue.js框架
- **Vue 3** - 前端框架
- **TypeScript** - 类型安全
- **Tailwind CSS** - 样式框架
## 项目结构
```
res_db/
├── main.go # 主程序入口
├── go.mod # Go模块文件
├── env.example # 环境变量示例
├── models/ # 数据模型
│ ├── database.go # 数据库连接
│ └── resource.go # 资源模型
├── handlers/ # API处理器
│ ├── resource.go # 资源相关API
│ └── category.go # 分类相关API
├── web/ # 前端项目
│ ├── nuxt.config.ts # Nuxt配置
│ ├── package.json # 前端依赖
│ ├── pages/ # 页面
│ ├── components/ # 组件
│ └── composables/ # 组合式函数
└── uploads/ # 文件上传目录
```
## 快速开始
### 1. 环境准备
确保已安装:
- Go 1.21+
- PostgreSQL 12+
- Node.js 18+
### 2. 数据库设置
```sql
CREATE DATABASE res_db;
```
### 3. 后端设置
```bash
# 复制环境变量文件
cp env.example .env
# 修改.env文件中的数据库配置
# 安装依赖
go mod tidy
# 运行后端
go run main.go
```
### 4. 前端设置
```bash
# 进入前端目录
cd web
# 安装依赖
npm install
# 运行开发服务器
npm run dev
```
## API接口
### 资源管理
- `GET /api/resources` - 获取资源列表
- `POST /api/resources` - 创建资源
- `PUT /api/resources/:id` - 更新资源
- `DELETE /api/resources/:id` - 删除资源
- `GET /api/resources/:id` - 获取单个资源
### 分类管理
- `GET /api/categories` - 获取分类列表
- `POST /api/categories` - 创建分类
- `PUT /api/categories/:id` - 更新分类
- `DELETE /api/categories/:id` - 删除分类
### 搜索和统计
- `GET /api/search` - 搜索资源
- `GET /api/stats` - 获取统计信息
## 功能特性
- 📁 资源分类管理
- 🔍 全文搜索
- 📊 统计信息
- 🏷️ 标签系统
- 📈 下载/浏览统计
- 🎨 现代化UI界面
## 开发
### 后端开发
```bash
# 热重载开发
go install github.com/cosmtrek/air@latest
air
```
### 前端开发
```bash
cd web
npm run dev
```
## 部署
### Docker部署
```bash
# 构建镜像
docker build -t res-db .
# 运行容器
docker run -p 8080:8080 res-db
```
## 许可证
MIT License

211
UI_FRAMEWORKS.md Normal file
View File

@@ -0,0 +1,211 @@
# Vue 3 + Nuxt.js UI框架选择指南
## 🎨 推荐的UI框架
### 1. **Naive UI** ⭐⭐⭐⭐⭐ (强烈推荐)
**特点**: 完整的Vue 3组件库TypeScript支持主题定制
**优势**:
- ✅ 完整的Vue 3支持
- ✅ TypeScript原生支持
- ✅ 组件丰富80+组件)
- ✅ 主题系统强大
- ✅ 文档完善
- ✅ 性能优秀
- ✅ 活跃维护
**适用场景**: 企业级应用,复杂界面,需要高度定制
**安装**:
```bash
npm install naive-ui vfonts @vicons/ionicons5
```
### 2. **Element Plus** ⭐⭐⭐⭐
**特点**: Vue 3版本的Element UI成熟稳定
**优势**:
- ✅ 社区活跃
- ✅ 组件齐全
- ✅ 文档详细
- ✅ 成熟稳定
- ✅ 中文文档
**适用场景**: 后台管理系统,快速开发
**安装**:
```bash
npm install element-plus @element-plus/icons-vue
```
### 3. **Ant Design Vue** ⭐⭐⭐⭐
**特点**: 企业级UI设计语言
**优势**:
- ✅ 设计规范统一
- ✅ 组件丰富
- ✅ 企业级应用
- ✅ 国际化支持
**适用场景**: 企业应用,设计规范要求高
**安装**:
```bash
npm install ant-design-vue @ant-design/icons-vue
```
### 4. **PrimeVue** ⭐⭐⭐
**特点**: 丰富的组件库,支持多种主题
**优势**:
- ✅ 组件数量多
- ✅ 功能强大
- ✅ 主题丰富
- ✅ 响应式设计
**适用场景**: 复杂业务场景
**安装**:
```bash
npm install primevue primeicons
```
### 5. **Vuetify** ⭐⭐⭐
**特点**: Material Design风格
**优势**:
- ✅ 设计美观
- ✅ 响应式好
- ✅ Material Design
- ✅ 组件丰富
**适用场景**: 现代化应用Material Design风格
**安装**:
```bash
npm install vuetify @mdi/font
```
## 🚀 当前项目推荐
### 推荐使用 **Naive UI**
**原因**:
1. **Vue 3原生支持**: 完全基于Vue 3 Composition API
2. **TypeScript友好**: 原生TypeScript支持
3. **组件丰富**: 满足资源管理系统需求
4. **主题系统**: 支持深色/浅色主题切换
5. **性能优秀**: 按需加载,体积小
### 集成步骤
1. **安装依赖**:
```bash
cd web
npm install naive-ui vfonts @vicons/ionicons5 @css-render/vue3-ssr @juggle/resize-observer
```
2. **配置Nuxt**:
```typescript
// nuxt.config.ts
export default defineNuxtConfig({
build: {
transpile: ['naive-ui', 'vueuc', '@css-render/vue3-ssr', '@juggle/resize-observer']
}
})
```
3. **创建插件**:
```typescript
// plugins/naive-ui.client.ts
import { setup } from '@css-render/vue3-ssr'
export default defineNuxtPlugin((nuxtApp) => {
if (process.server) {
const { collect } = setup(nuxtApp.vueApp)
// SSR配置
}
})
```
4. **使用组件**:
```vue
<template>
<n-config-provider :theme="theme">
<n-card>
<n-button type="primary">按钮</n-button>
</n-card>
</n-config-provider>
</template>
```
## 📊 框架对比表
| 特性 | Naive UI | Element Plus | Ant Design Vue | PrimeVue | Vuetify |
|------|----------|--------------|----------------|----------|---------|
| Vue 3支持 | ✅ | ✅ | ✅ | ✅ | ✅ |
| TypeScript | ✅ | ✅ | ✅ | ✅ | ✅ |
| 组件数量 | 80+ | 60+ | 60+ | 90+ | 80+ |
| 主题系统 | ✅ | ✅ | ✅ | ✅ | ✅ |
| 中文文档 | ✅ | ✅ | ✅ | ❌ | ❌ |
| 社区活跃度 | 高 | 很高 | 高 | 中 | 中 |
| 学习曲线 | 低 | 低 | 中 | 中 | 中 |
| 性能 | 优秀 | 良好 | 良好 | 良好 | 良好 |
## 🎯 针对资源管理系统的建议
### 核心组件需求
1. **数据表格**: 资源列表展示
2. **表单组件**: 资源添加/编辑
3. **模态框**: 弹窗操作
4. **搜索组件**: 资源搜索
5. **标签组件**: 资源标签
6. **统计卡片**: 数据展示
7. **分页组件**: 列表分页
### Naive UI优势
- **n-data-table**: 功能强大的数据表格
- **n-form**: 完整的表单解决方案
- **n-modal**: 灵活的模态框
- **n-input**: 搜索输入框
- **n-tag**: 标签组件
- **n-card**: 统计卡片
- **n-pagination**: 分页组件
## 🔧 迁移指南
如果要从当前的基础组件迁移到Naive UI
1. **替换基础组件**:
```vue
<!-- 原版 -->
<button class="btn-primary">按钮</button>
<!-- Naive UI -->
<n-button type="primary">按钮</n-button>
```
2. **替换表单组件**:
```vue
<!-- 原版 -->
<input class="input-field" />
<!-- Naive UI -->
<n-input />
```
3. **替换模态框**:
```vue
<!-- 原版 -->
<div class="modal">...</div>
<!-- Naive UI -->
<n-modal>...</n-modal>
```
## 📝 总结
对于您的资源管理系统项目,我强烈推荐使用 **Naive UI**,因为:
1. **完美适配**: 完全支持Vue 3和Nuxt.js
2. **功能完整**: 提供所有需要的组件
3. **开发效率**: 减少大量自定义样式工作
4. **维护性好**: TypeScript支持代码更可靠
5. **性能优秀**: 按需加载,体积小
使用UI框架可以节省70-80%的前端开发时间,让您专注于业务逻辑而不是样式细节。

38
docker-compose.yml Normal file
View File

@@ -0,0 +1,38 @@
version: '3.8'
services:
postgres:
image: postgres:15-alpine
environment:
POSTGRES_DB: res_db
POSTGRES_USER: postgres
POSTGRES_PASSWORD: password
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 10s
timeout: 5s
retries: 5
app:
build: .
ports:
- "8080:8080"
environment:
DB_HOST: postgres
DB_PORT: 5432
DB_USER: postgres
DB_PASSWORD: password
DB_NAME: res_db
PORT: 8080
depends_on:
postgres:
condition: service_healthy
volumes:
- ./uploads:/root/uploads
volumes:
postgres_data:

13
env.example Normal file
View File

@@ -0,0 +1,13 @@
# 数据库配置
DB_HOST=localhost
DB_PORT=5432
DB_USER=postgres
DB_PASSWORD=password
DB_NAME=res_db
# 服务器配置
PORT=8080
# 文件上传配置
UPLOAD_DIR=./uploads
MAX_FILE_SIZE=100MB

11
go.mod Normal file
View File

@@ -0,0 +1,11 @@
module res_db
go 1.21
require (
github.com/gin-gonic/gin v1.9.1
github.com/lib/pq v1.10.9
github.com/joho/godotenv v1.5.1
github.com/google/uuid v1.4.0
github.com/gin-contrib/cors v1.4.0
)

125
handlers/category.go Normal file
View File

@@ -0,0 +1,125 @@
package handlers
import (
"net/http"
"res_db/models"
"github.com/gin-gonic/gin"
)
// GetCategories 获取分类列表
func GetCategories(c *gin.Context) {
rows, err := models.DB.Query(`
SELECT id, name, description, created_at, updated_at
FROM categories
ORDER BY name
`)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
defer rows.Close()
var categories []models.Category
for rows.Next() {
var cat models.Category
err := rows.Scan(&cat.ID, &cat.Name, &cat.Description, &cat.CreatedAt, &cat.UpdatedAt)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
categories = append(categories, cat)
}
c.JSON(http.StatusOK, categories)
}
// CreateCategory 创建分类
func CreateCategory(c *gin.Context) {
var req models.CreateCategoryRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
query := `
INSERT INTO categories (name, description)
VALUES ($1, $2)
RETURNING id
`
var id int
err := models.DB.QueryRow(query, req.Name, req.Description).Scan(&id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{"id": id, "message": "分类创建成功"})
}
// UpdateCategory 更新分类
func UpdateCategory(c *gin.Context) {
id := c.Param("id")
var req models.UpdateCategoryRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
query := `
UPDATE categories
SET name = COALESCE($1, name),
description = COALESCE($2, description),
updated_at = CURRENT_TIMESTAMP
WHERE id = $3
`
result, err := models.DB.Exec(query, req.Name, req.Description, id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
rowsAffected, _ := result.RowsAffected()
if rowsAffected == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "分类不存在"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "分类更新成功"})
}
// DeleteCategory 删除分类
func DeleteCategory(c *gin.Context) {
id := c.Param("id")
// 检查是否有资源使用此分类
var count int
err := models.DB.QueryRow("SELECT COUNT(*) FROM resources WHERE category_id = $1", id).Scan(&count)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if count > 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "该分类下还有资源,无法删除"})
return
}
result, err := models.DB.Exec("DELETE FROM categories WHERE id = $1", id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
rowsAffected, _ := result.RowsAffected()
if rowsAffected == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "分类不存在"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "分类删除成功"})
}

293
handlers/resource.go Normal file
View File

@@ -0,0 +1,293 @@
package handlers
import (
"net/http"
"strconv"
"res_db/models"
"github.com/gin-gonic/gin"
)
// GetResources 获取资源列表
func GetResources(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
categoryID := c.Query("category_id")
offset := (page - 1) * limit
query := `
SELECT r.id, r.title, r.description, r.url, r.file_path, r.file_size,
r.file_type, r.category_id, c.name as category_name, r.tags,
r.download_count, r.view_count, r.is_public, r.created_at, r.updated_at
FROM resources r
LEFT JOIN categories c ON r.category_id = c.id
WHERE r.is_public = true
`
args := []interface{}{}
if categoryID != "" {
query += " AND r.category_id = $1"
args = append(args, categoryID)
}
query += " ORDER BY r.created_at DESC LIMIT $2 OFFSET $3"
args = append(args, limit, offset)
rows, err := models.DB.Query(query, args...)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
defer rows.Close()
var resources []models.Resource
for rows.Next() {
var r models.Resource
err := rows.Scan(
&r.ID, &r.Title, &r.Description, &r.URL, &r.FilePath, &r.FileSize,
&r.FileType, &r.CategoryID, &r.CategoryName, &r.Tags,
&r.DownloadCount, &r.ViewCount, &r.IsPublic, &r.CreatedAt, &r.UpdatedAt,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
resources = append(resources, r)
}
c.JSON(http.StatusOK, gin.H{
"resources": resources,
"page": page,
"limit": limit,
})
}
// GetResourceByID 根据ID获取资源
func GetResourceByID(c *gin.Context) {
id := c.Param("id")
var r models.Resource
query := `
SELECT r.id, r.title, r.description, r.url, r.file_path, r.file_size,
r.file_type, r.category_id, c.name as category_name, r.tags,
r.download_count, r.view_count, r.is_public, r.created_at, r.updated_at
FROM resources r
LEFT JOIN categories c ON r.category_id = c.id
WHERE r.id = $1 AND r.is_public = true
`
err := models.DB.QueryRow(query, id).Scan(
&r.ID, &r.Title, &r.Description, &r.URL, &r.FilePath, &r.FileSize,
&r.FileType, &r.CategoryID, &r.CategoryName, &r.Tags,
&r.DownloadCount, &r.ViewCount, &r.IsPublic, &r.CreatedAt, &r.UpdatedAt,
)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "资源不存在"})
return
}
// 增加浏览次数
models.DB.Exec("UPDATE resources SET view_count = view_count + 1 WHERE id = $1", id)
c.JSON(http.StatusOK, r)
}
// CreateResource 创建资源
func CreateResource(c *gin.Context) {
var req models.CreateResourceRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
query := `
INSERT INTO resources (title, description, url, file_path, file_size, file_type, category_id, tags, is_public)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING id
`
var id int
err := models.DB.QueryRow(
query,
req.Title, req.Description, req.URL, req.FilePath, req.FileSize,
req.FileType, req.CategoryID, req.Tags, req.IsPublic,
).Scan(&id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{"id": id, "message": "资源创建成功"})
}
// UpdateResource 更新资源
func UpdateResource(c *gin.Context) {
id := c.Param("id")
var req models.UpdateResourceRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
query := `
UPDATE resources
SET title = COALESCE($1, title),
description = COALESCE($2, description),
url = COALESCE($3, url),
file_path = COALESCE($4, file_path),
file_size = COALESCE($5, file_size),
file_type = COALESCE($6, file_type),
category_id = $7,
tags = COALESCE($8, tags),
is_public = COALESCE($9, is_public),
updated_at = CURRENT_TIMESTAMP
WHERE id = $10
`
result, err := models.DB.Exec(
query,
req.Title, req.Description, req.URL, req.FilePath, req.FileSize,
req.FileType, req.CategoryID, req.Tags, req.IsPublic, id,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
rowsAffected, _ := result.RowsAffected()
if rowsAffected == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "资源不存在"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "资源更新成功"})
}
// DeleteResource 删除资源
func DeleteResource(c *gin.Context) {
id := c.Param("id")
result, err := models.DB.Exec("DELETE FROM resources WHERE id = $1", id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
rowsAffected, _ := result.RowsAffected()
if rowsAffected == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "资源不存在"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "资源删除成功"})
}
// SearchResources 搜索资源
func SearchResources(c *gin.Context) {
query := c.Query("q")
categoryID := c.Query("category_id")
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
offset := (page - 1) * limit
sqlQuery := `
SELECT r.id, r.title, r.description, r.url, r.file_path, r.file_size,
r.file_type, r.category_id, c.name as category_name, r.tags,
r.download_count, r.view_count, r.is_public, r.created_at, r.updated_at
FROM resources r
LEFT JOIN categories c ON r.category_id = c.id
WHERE r.is_public = true
`
args := []interface{}{}
argCount := 0
if query != "" {
argCount++
sqlQuery += " AND (r.title ILIKE $" + strconv.Itoa(argCount) +
" OR r.description ILIKE $" + strconv.Itoa(argCount) +
" OR EXISTS (SELECT 1 FROM unnest(r.tags) tag WHERE tag ILIKE $" + strconv.Itoa(argCount) + "))"
args = append(args, "%"+query+"%")
}
if categoryID != "" {
argCount++
sqlQuery += " AND r.category_id = $" + strconv.Itoa(argCount)
args = append(args, categoryID)
}
sqlQuery += " ORDER BY r.created_at DESC LIMIT $" + strconv.Itoa(argCount+1) +
" OFFSET $" + strconv.Itoa(argCount+2)
args = append(args, limit, offset)
rows, err := models.DB.Query(sqlQuery, args...)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
defer rows.Close()
var resources []models.Resource
for rows.Next() {
var r models.Resource
err := rows.Scan(
&r.ID, &r.Title, &r.Description, &r.URL, &r.FilePath, &r.FileSize,
&r.FileType, &r.CategoryID, &r.CategoryName, &r.Tags,
&r.DownloadCount, &r.ViewCount, &r.IsPublic, &r.CreatedAt, &r.UpdatedAt,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
resources = append(resources, r)
}
c.JSON(http.StatusOK, gin.H{
"resources": resources,
"page": page,
"limit": limit,
"query": query,
})
}
// GetStats 获取统计信息
func GetStats(c *gin.Context) {
var stats models.Stats
// 总资源数
err := models.DB.QueryRow("SELECT COUNT(*) FROM resources WHERE is_public = true").Scan(&stats.TotalResources)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// 总分类数
err = models.DB.QueryRow("SELECT COUNT(*) FROM categories").Scan(&stats.TotalCategories)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// 总下载数
err = models.DB.QueryRow("SELECT COALESCE(SUM(download_count), 0) FROM resources WHERE is_public = true").Scan(&stats.TotalDownloads)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// 总浏览数
err = models.DB.QueryRow("SELECT COALESCE(SUM(view_count), 0) FROM resources WHERE is_public = true").Scan(&stats.TotalViews)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, stats)
}

69
main.go Normal file
View File

@@ -0,0 +1,69 @@
package main
import (
"log"
"os"
"res_db/handlers"
"res_db/models"
"github.com/gin-contrib/cors"
"github.com/gin-gonic/gin"
"github.com/joho/godotenv"
)
func main() {
// 加载环境变量
if err := godotenv.Load(); err != nil {
log.Println("未找到.env文件使用默认配置")
}
// 初始化数据库
if err := models.InitDB(); err != nil {
log.Fatal("数据库连接失败:", err)
}
// 创建Gin实例
r := gin.Default()
// 配置CORS
config := cors.DefaultConfig()
config.AllowOrigins = []string{"*"}
config.AllowMethods = []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}
config.AllowHeaders = []string{"Origin", "Content-Type", "Accept", "Authorization"}
r.Use(cors.New(config))
// API路由
api := r.Group("/api")
{
// 资源管理
api.GET("/resources", handlers.GetResources)
api.POST("/resources", handlers.CreateResource)
api.PUT("/resources/:id", handlers.UpdateResource)
api.DELETE("/resources/:id", handlers.DeleteResource)
api.GET("/resources/:id", handlers.GetResourceByID)
// 分类管理
api.GET("/categories", handlers.GetCategories)
api.POST("/categories", handlers.CreateCategory)
api.PUT("/categories/:id", handlers.UpdateCategory)
api.DELETE("/categories/:id", handlers.DeleteCategory)
// 搜索
api.GET("/search", handlers.SearchResources)
// 统计
api.GET("/stats", handlers.GetStats)
}
// 静态文件服务
r.Static("/uploads", "./uploads")
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
log.Printf("服务器启动在端口 %s", port)
r.Run(":" + port)
}

118
models/database.go Normal file
View File

@@ -0,0 +1,118 @@
package models
import (
"database/sql"
"fmt"
"log"
"os"
_ "github.com/lib/pq"
)
var DB *sql.DB
// InitDB 初始化数据库连接
func InitDB() error {
host := os.Getenv("DB_HOST")
if host == "" {
host = "localhost"
}
port := os.Getenv("DB_PORT")
if port == "" {
port = "5432"
}
user := os.Getenv("DB_USER")
if user == "" {
user = "postgres"
}
password := os.Getenv("DB_PASSWORD")
if password == "" {
password = "password"
}
dbname := os.Getenv("DB_NAME")
if dbname == "" {
dbname = "res_db"
}
psqlInfo := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable",
host, port, user, password, dbname)
var err error
DB, err = sql.Open("postgres", psqlInfo)
if err != nil {
return err
}
if err = DB.Ping(); err != nil {
return err
}
// 创建表
if err := createTables(); err != nil {
return err
}
log.Println("数据库连接成功")
return nil
}
// createTables 创建数据库表
func createTables() error {
// 创建分类表
categoryTable := `
CREATE TABLE IF NOT EXISTS categories (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL UNIQUE,
description TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);`
// 创建资源表
resourceTable := `
CREATE TABLE IF NOT EXISTS resources (
id SERIAL PRIMARY KEY,
title VARCHAR(255) NOT NULL,
description TEXT,
url VARCHAR(500),
file_path VARCHAR(500),
file_size BIGINT,
file_type VARCHAR(100),
category_id INTEGER REFERENCES categories(id) ON DELETE SET NULL,
tags TEXT[],
download_count INTEGER DEFAULT 0,
view_count INTEGER DEFAULT 0,
is_public BOOLEAN DEFAULT true,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);`
if _, err := DB.Exec(categoryTable); err != nil {
return err
}
if _, err := DB.Exec(resourceTable); err != nil {
return err
}
// 插入默认分类
insertDefaultCategories := `
INSERT INTO categories (name, description) VALUES
('文档', '各种文档资料'),
('软件', '软件工具'),
('视频', '视频教程'),
('图片', '图片资源'),
('音频', '音频文件'),
('其他', '其他资源')
ON CONFLICT (name) DO NOTHING;`
if _, err := DB.Exec(insertDefaultCategories); err != nil {
log.Printf("插入默认分类失败: %v", err)
}
return nil
}

95
models/resource.go Normal file
View File

@@ -0,0 +1,95 @@
package models
import (
"time"
)
// Resource 资源模型
type Resource struct {
ID int `json:"id"`
Title string `json:"title"`
Description string `json:"description"`
URL string `json:"url"`
FilePath string `json:"file_path"`
FileSize int64 `json:"file_size"`
FileType string `json:"file_type"`
CategoryID *int `json:"category_id"`
CategoryName string `json:"category_name"`
Tags []string `json:"tags"`
DownloadCount int `json:"download_count"`
ViewCount int `json:"view_count"`
IsPublic bool `json:"is_public"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// Category 分类模型
type Category struct {
ID int `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// CreateResourceRequest 创建资源请求
type CreateResourceRequest struct {
Title string `json:"title" binding:"required"`
Description string `json:"description"`
URL string `json:"url"`
FilePath string `json:"file_path"`
FileSize int64 `json:"file_size"`
FileType string `json:"file_type"`
CategoryID *int `json:"category_id"`
Tags []string `json:"tags"`
IsPublic bool `json:"is_public"`
}
// UpdateResourceRequest 更新资源请求
type UpdateResourceRequest struct {
Title string `json:"title"`
Description string `json:"description"`
URL string `json:"url"`
FilePath string `json:"file_path"`
FileSize int64 `json:"file_size"`
FileType string `json:"file_type"`
CategoryID *int `json:"category_id"`
Tags []string `json:"tags"`
IsPublic bool `json:"is_public"`
}
// CreateCategoryRequest 创建分类请求
type CreateCategoryRequest struct {
Name string `json:"name" binding:"required"`
Description string `json:"description"`
}
// UpdateCategoryRequest 更新分类请求
type UpdateCategoryRequest struct {
Name string `json:"name"`
Description string `json:"description"`
}
// SearchRequest 搜索请求
type SearchRequest struct {
Query string `json:"query"`
CategoryID *int `json:"category_id"`
Page int `json:"page"`
Limit int `json:"limit"`
}
// SearchResponse 搜索响应
type SearchResponse struct {
Resources []Resource `json:"resources"`
Total int `json:"total"`
Page int `json:"page"`
Limit int `json:"limit"`
}
// Stats 统计信息
type Stats struct {
TotalResources int `json:"total_resources"`
TotalCategories int `json:"total_categories"`
TotalDownloads int `json:"total_downloads"`
TotalViews int `json:"total_views"`
}

48
start.sh Executable file
View File

@@ -0,0 +1,48 @@
#!/bin/bash
echo "🚀 启动资源管理系统..."
# 检查Go是否安装
if ! command -v go &> /dev/null; then
echo "❌ Go未安装请先安装Go"
exit 1
fi
# 检查Node.js是否安装
if ! command -v node &> /dev/null; then
echo "❌ Node.js未安装请先安装Node.js"
exit 1
fi
# 检查PostgreSQL是否运行
if ! pg_isready -q; then
echo "⚠️ PostgreSQL未运行请确保PostgreSQL服务已启动"
fi
echo "📦 安装Go依赖..."
go mod tidy
echo "🌐 启动后端服务器..."
go run main.go &
BACKEND_PID=$!
echo "⏳ 等待后端启动..."
sleep 3
echo "📦 安装前端依赖..."
cd web
npm install
echo "🎨 启动前端开发服务器..."
npm run dev &
FRONTEND_PID=$!
echo "✅ 系统启动完成!"
echo "📱 前端地址: http://localhost:3000"
echo "🔧 后端地址: http://localhost:8080"
echo ""
echo "按 Ctrl+C 停止服务"
# 等待用户中断
trap "echo '🛑 正在停止服务...'; kill $BACKEND_PID $FRONTEND_PID; exit" INT
wait

121
test-setup.sh Executable file
View File

@@ -0,0 +1,121 @@
#!/bin/bash
echo "🧪 测试项目设置..."
# 检查Go模块
echo "📦 检查Go模块..."
if [ -f "go.mod" ]; then
echo "✅ go.mod 文件存在"
else
echo "❌ go.mod 文件不存在"
exit 1
fi
# 检查主要Go文件
echo "🔧 检查Go文件..."
if [ -f "main.go" ]; then
echo "✅ main.go 文件存在"
else
echo "❌ main.go 文件不存在"
exit 1
fi
if [ -d "models" ]; then
echo "✅ models 目录存在"
else
echo "❌ models 目录不存在"
exit 1
fi
if [ -d "handlers" ]; then
echo "✅ handlers 目录存在"
else
echo "❌ handlers 目录不存在"
exit 1
fi
# 检查前端文件
echo "🎨 检查前端文件..."
if [ -f "web/package.json" ]; then
echo "✅ package.json 文件存在"
else
echo "❌ package.json 文件不存在"
exit 1
fi
if [ -f "web/nuxt.config.ts" ]; then
echo "✅ nuxt.config.ts 文件存在"
else
echo "❌ nuxt.config.ts 文件不存在"
exit 1
fi
if [ -d "web/pages" ]; then
echo "✅ pages 目录存在"
else
echo "❌ pages 目录不存在"
exit 1
fi
if [ -d "web/components" ]; then
echo "✅ components 目录存在"
else
echo "❌ components 目录不存在"
exit 1
fi
# 检查配置文件
echo "⚙️ 检查配置文件..."
if [ -f "env.example" ]; then
echo "✅ env.example 文件存在"
else
echo "❌ env.example 文件不存在"
exit 1
fi
if [ -f ".gitignore" ]; then
echo "✅ .gitignore 文件存在"
else
echo "❌ .gitignore 文件不存在"
exit 1
fi
if [ -f "README.md" ]; then
echo "✅ README.md 文件存在"
else
echo "❌ README.md 文件不存在"
exit 1
fi
# 检查Docker文件
echo "🐳 检查Docker文件..."
if [ -f "Dockerfile" ]; then
echo "✅ Dockerfile 文件存在"
else
echo "❌ Dockerfile 文件不存在"
exit 1
fi
if [ -f "docker-compose.yml" ]; then
echo "✅ docker-compose.yml 文件存在"
else
echo "❌ docker-compose.yml 文件不存在"
exit 1
fi
# 检查uploads目录
echo "📁 检查uploads目录..."
if [ -d "uploads" ]; then
echo "✅ uploads 目录存在"
else
echo "❌ uploads 目录不存在"
exit 1
fi
echo ""
echo "🎉 所有检查通过!项目设置正确。"
echo ""
echo "📋 下一步:"
echo "1. 复制 env.example 为 .env 并配置数据库"
echo "2. 运行 ./start.sh 启动项目"
echo "3. 或者使用 docker-compose up 启动Docker版本"

1
uploads/.gitkeep Normal file
View File

@@ -0,0 +1 @@
# 此文件用于保持uploads目录在Git中

35
web/assets/css/main.css Normal file
View File

@@ -0,0 +1,35 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
html {
font-family: 'Inter', system-ui, sans-serif;
}
}
@layer components {
.btn-primary {
@apply bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded-lg transition-colors duration-200;
}
.btn-secondary {
@apply bg-gray-200 hover:bg-gray-300 text-gray-800 font-medium py-2 px-4 rounded-lg transition-colors duration-200;
}
.btn-danger {
@apply bg-red-600 hover:bg-red-700 text-white font-medium py-2 px-4 rounded-lg transition-colors duration-200;
}
.card {
@apply bg-white rounded-lg shadow-md border border-gray-200 p-6;
}
.input-field {
@apply w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent;
}
.resource-card {
@apply bg-white rounded-lg shadow-md border border-gray-200 p-4 hover:shadow-lg transition-shadow duration-200;
}
}

View File

@@ -0,0 +1,239 @@
<template>
<div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div class="bg-white rounded-lg shadow-xl max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto">
<div class="p-6">
<div class="flex items-center justify-between mb-6">
<h2 class="text-xl font-semibold text-gray-900">
{{ resource ? '编辑资源' : '添加资源' }}
</h2>
<button @click="$emit('close')" class="text-gray-400 hover:text-gray-600">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>
<form @submit.prevent="handleSubmit" class="space-y-6">
<!-- 标题 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">标题 *</label>
<input
v-model="form.title"
type="text"
required
class="input-field"
placeholder="输入资源标题"
/>
</div>
<!-- 描述 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">描述</label>
<textarea
v-model="form.description"
rows="3"
class="input-field"
placeholder="输入资源描述"
></textarea>
</div>
<!-- URL -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">URL</label>
<input
v-model="form.url"
type="url"
class="input-field"
placeholder="https://example.com"
/>
</div>
<!-- 分类 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">分类</label>
<select v-model="form.category_id" class="input-field">
<option value="">选择分类</option>
<option v-for="category in categories" :key="category.id" :value="category.id">
{{ category.name }}
</option>
</select>
</div>
<!-- 标签 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">标签</label>
<div class="flex flex-wrap gap-2 mb-2">
<span
v-for="tag in form.tags"
:key="tag"
class="px-3 py-1 bg-blue-100 text-blue-800 text-sm rounded-full flex items-center"
>
{{ tag }}
<button
@click="removeTag(tag)"
type="button"
class="ml-2 text-blue-600 hover:text-blue-800"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</span>
</div>
<div class="flex gap-2">
<input
v-model="newTag"
@keyup.enter="addTag"
type="text"
class="input-field flex-1"
placeholder="输入标签后按回车"
/>
<button @click="addTag" type="button" class="btn-secondary">
添加
</button>
</div>
</div>
<!-- 文件信息 -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">文件路径</label>
<input
v-model="form.file_path"
type="text"
class="input-field"
placeholder="/path/to/file"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">文件类型</label>
<input
v-model="form.file_type"
type="text"
class="input-field"
placeholder="pdf, doc, mp4..."
/>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">文件大小 (字节)</label>
<input
v-model.number="form.file_size"
type="number"
class="input-field"
placeholder="1024"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">是否公开</label>
<div class="flex items-center mt-2">
<input
v-model="form.is_public"
type="checkbox"
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
/>
<label class="ml-2 text-sm text-gray-700">公开显示</label>
</div>
</div>
</div>
<!-- 按钮 -->
<div class="flex justify-end space-x-3 pt-6 border-t border-gray-200">
<button
type="button"
@click="$emit('close')"
class="btn-secondary"
>
取消
</button>
<button
type="submit"
class="btn-primary"
:disabled="loading"
>
{{ loading ? '保存中...' : (resource ? '更新' : '创建') }}
</button>
</div>
</form>
</div>
</div>
</div>
</template>
<script setup lang="ts">
const store = useResourceStore()
const { categories } = storeToRefs(store)
const props = defineProps<{
resource?: any
}>()
const emit = defineEmits(['close', 'save'])
const loading = ref(false)
const newTag = ref('')
const form = ref({
title: '',
description: '',
url: '',
category_id: '',
tags: [] as string[],
file_path: '',
file_type: '',
file_size: 0,
is_public: true,
})
// 初始化表单
onMounted(() => {
if (props.resource) {
form.value = {
title: props.resource.title || '',
description: props.resource.description || '',
url: props.resource.url || '',
category_id: props.resource.category_id || '',
tags: [...(props.resource.tags || [])],
file_path: props.resource.file_path || '',
file_type: props.resource.file_type || '',
file_size: props.resource.file_size || 0,
is_public: props.resource.is_public !== false,
}
}
})
// 添加标签
const addTag = () => {
const tag = newTag.value.trim()
if (tag && !form.value.tags.includes(tag)) {
form.value.tags.push(tag)
newTag.value = ''
}
}
// 移除标签
const removeTag = (tag: string) => {
const index = form.value.tags.indexOf(tag)
if (index > -1) {
form.value.tags.splice(index, 1)
}
}
// 提交表单
const handleSubmit = async () => {
loading.value = true
try {
const data = {
...form.value,
category_id: form.value.category_id ? parseInt(form.value.category_id) : null,
}
emit('save', data)
} catch (error) {
console.error('保存失败:', error)
} finally {
loading.value = false
}
}
</script>

91
web/composables/useApi.ts Normal file
View File

@@ -0,0 +1,91 @@
import axios from 'axios'
const config = useRuntimeConfig()
const api = axios.create({
baseURL: config.public.apiBase,
timeout: 10000,
})
// 资源相关API
export const useResourceApi = () => {
const getResources = async (params?: any) => {
const response = await api.get('/resources', { params })
return response.data
}
const getResource = async (id: number) => {
const response = await api.get(`/resources/${id}`)
return response.data
}
const createResource = async (data: any) => {
const response = await api.post('/resources', data)
return response.data
}
const updateResource = async (id: number, data: any) => {
const response = await api.put(`/resources/${id}`, data)
return response.data
}
const deleteResource = async (id: number) => {
const response = await api.delete(`/resources/${id}`)
return response.data
}
const searchResources = async (params: any) => {
const response = await api.get('/search', { params })
return response.data
}
return {
getResources,
getResource,
createResource,
updateResource,
deleteResource,
searchResources,
}
}
// 分类相关API
export const useCategoryApi = () => {
const getCategories = async () => {
const response = await api.get('/categories')
return response.data
}
const createCategory = async (data: any) => {
const response = await api.post('/categories', data)
return response.data
}
const updateCategory = async (id: number, data: any) => {
const response = await api.put(`/categories/${id}`, data)
return response.data
}
const deleteCategory = async (id: number) => {
const response = await api.delete(`/categories/${id}`)
return response.data
}
return {
getCategories,
createCategory,
updateCategory,
deleteCategory,
}
}
// 统计相关API
export const useStatsApi = () => {
const getStats = async () => {
const response = await api.get('/stats')
return response.data
}
return {
getStats,
}
}

19
web/layouts/default.vue Normal file
View File

@@ -0,0 +1,19 @@
<template>
<n-config-provider :theme="theme">
<n-loading-bar-provider>
<n-dialog-provider>
<n-notification-provider>
<n-message-provider>
<slot />
</n-message-provider>
</n-notification-provider>
</n-dialog-provider>
</n-loading-bar-provider>
</n-config-provider>
</template>
<script setup lang="ts">
import { lightTheme } from 'naive-ui'
const theme = lightTheme
</script>

34
web/nuxt.config.ts Normal file
View File

@@ -0,0 +1,34 @@
// https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({
devtools: { enabled: true },
modules: [
'@nuxtjs/tailwindcss',
'@pinia/nuxt',
],
css: [
'~/assets/css/main.css',
'vfonts/Lato.css',
'vfonts/FiraCode.css',
],
app: {
head: {
title: '资源管理系统',
meta: [
{ charset: 'utf-8' },
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
{ name: 'description', content: '一个现代化的资源管理系统' }
],
link: [
{ rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' }
]
}
},
runtimeConfig: {
public: {
apiBase: process.env.API_BASE || 'http://localhost:8080/api'
}
},
build: {
transpile: ['naive-ui', 'vueuc', '@css-render/vue3-ssr', '@juggle/resize-observer']
}
})

33
web/package.json Normal file
View File

@@ -0,0 +1,33 @@
{
"name": "res-db-web",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"build": "nuxt build",
"dev": "nuxt dev",
"generate": "nuxt generate",
"preview": "nuxt preview",
"postinstall": "nuxt prepare"
},
"devDependencies": {
"@nuxt/devtools": "latest",
"@nuxt/typescript-build": "^3.0.0",
"@types/node": "^20.0.0",
"nuxt": "^3.8.0",
"typescript": "^5.0.0"
},
"dependencies": {
"@nuxtjs/tailwindcss": "^6.8.0",
"@pinia/nuxt": "^0.5.0",
"pinia": "^2.1.0",
"vue": "^3.3.0",
"vue-router": "^4.2.0",
"naive-ui": "^2.37.0",
"vfonts": "^0.0.3",
"@vicons/ionicons5": "^0.12.0",
"@css-render/vue3-ssr": "^0.15.12",
"@juggle/resize-observer": "^3.3.1",
"axios": "^1.6.0"
}
}

297
web/pages/index.vue Normal file
View File

@@ -0,0 +1,297 @@
<template>
<div class="min-h-screen bg-gray-50">
<!-- 头部 -->
<header class="bg-white shadow-sm border-b border-gray-200">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between items-center py-4">
<div class="flex items-center">
<h1 class="text-2xl font-bold text-gray-900">资源管理系统</h1>
</div>
<div class="flex items-center space-x-4">
<button @click="showAddResourceModal = true" class="btn-primary">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path>
</svg>
添加资源
</button>
</div>
</div>
</div>
</header>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- 统计卡片 -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
<div class="card">
<div class="flex items-center">
<div class="p-2 bg-blue-100 rounded-lg">
<svg class="w-6 h-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
</svg>
</div>
<div class="ml-4">
<p class="text-sm font-medium text-gray-600">总资源</p>
<p class="text-2xl font-bold text-gray-900">{{ stats?.total_resources || 0 }}</p>
</div>
</div>
</div>
<div class="card">
<div class="flex items-center">
<div class="p-2 bg-green-100 rounded-lg">
<svg class="w-6 h-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2H5a2 2 0 00-2-2z"></path>
</svg>
</div>
<div class="ml-4">
<p class="text-sm font-medium text-gray-600">分类</p>
<p class="text-2xl font-bold text-gray-900">{{ stats?.total_categories || 0 }}</p>
</div>
</div>
</div>
<div class="card">
<div class="flex items-center">
<div class="p-2 bg-yellow-100 rounded-lg">
<svg class="w-6 h-6 text-yellow-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path>
</svg>
</div>
<div class="ml-4">
<p class="text-sm font-medium text-gray-600">下载</p>
<p class="text-2xl font-bold text-gray-900">{{ stats?.total_downloads || 0 }}</p>
</div>
</div>
</div>
<div class="card">
<div class="flex items-center">
<div class="p-2 bg-purple-100 rounded-lg">
<svg class="w-6 h-6 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"></path>
</svg>
</div>
<div class="ml-4">
<p class="text-sm font-medium text-gray-600">浏览</p>
<p class="text-2xl font-bold text-gray-900">{{ stats?.total_views || 0 }}</p>
</div>
</div>
</div>
</div>
<!-- 搜索和筛选 -->
<div class="card mb-8">
<div class="flex flex-col md:flex-row gap-4">
<div class="flex-1">
<div class="relative">
<svg class="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
</svg>
<input
v-model="searchQuery"
@keyup.enter="handleSearch"
type="text"
placeholder="搜索资源..."
class="input-field pl-10"
/>
</div>
</div>
<div class="w-full md:w-48">
<select v-model="selectedCategory" class="input-field">
<option value="">全部分类</option>
<option v-for="category in categories" :key="category.id" :value="category.id">
{{ category.name }}
</option>
</select>
</div>
<div class="flex gap-2">
<button @click="handleSearch" class="btn-primary">
搜索
</button>
<button @click="clearSearch" class="btn-secondary">
清除
</button>
</div>
</div>
</div>
<!-- 资源列表 -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
<div
v-for="resource in resources"
:key="resource.id"
class="resource-card cursor-pointer"
@click="viewResource(resource)"
>
<div class="flex items-start justify-between mb-3">
<div class="flex-1">
<h3 class="font-semibold text-gray-900 truncate">{{ resource.title }}</h3>
<p class="text-sm text-gray-600 mt-1 line-clamp-2">{{ resource.description }}</p>
</div>
<div class="flex items-center space-x-1 ml-2">
<button
@click.stop="editResource(resource)"
class="p-1 text-gray-400 hover:text-blue-600"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path>
</svg>
</button>
<button
@click.stop="deleteResource(resource.id)"
class="p-1 text-gray-400 hover:text-red-600"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
</svg>
</button>
</div>
</div>
<div class="flex items-center justify-between text-sm text-gray-500">
<span>{{ resource.category_name || '未分类' }}</span>
<div class="flex items-center space-x-3">
<span class="flex items-center">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"></path>
</svg>
{{ resource.view_count }}
</span>
<span class="flex items-center">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path>
</svg>
{{ resource.download_count }}
</span>
</div>
</div>
<div v-if="resource.tags && resource.tags.length > 0" class="mt-3">
<div class="flex flex-wrap gap-1">
<span
v-for="tag in resource.tags.slice(0, 3)"
:key="tag"
class="px-2 py-1 bg-gray-100 text-gray-600 text-xs rounded"
>
{{ tag }}
</span>
<span v-if="resource.tags.length > 3" class="px-2 py-1 text-gray-400 text-xs">
+{{ resource.tags.length - 3 }}
</span>
</div>
</div>
</div>
</div>
<!-- 加载状态 -->
<div v-if="loading" class="flex justify-center py-8">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
<!-- 空状态 -->
<div v-if="!loading && resources.length === 0" class="text-center py-12">
<svg class="w-12 h-12 text-gray-400 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
</svg>
<h3 class="text-lg font-medium text-gray-900 mb-2">暂无资源</h3>
<p class="text-gray-600 mb-4">开始添加您的第一个资源吧</p>
<button @click="showAddResourceModal = true" class="btn-primary">
添加资源
</button>
</div>
</div>
<!-- 添加资源模态框 -->
<ResourceModal
v-if="showAddResourceModal"
:resource="editingResource"
@close="closeModal"
@save="handleSaveResource"
/>
</div>
</template>
<script setup lang="ts">
const store = useResourceStore()
const { resources, categories, stats, loading } = storeToRefs(store)
const searchQuery = ref('')
const selectedCategory = ref('')
const showAddResourceModal = ref(false)
const editingResource = ref(null)
// 获取数据
onMounted(async () => {
await Promise.all([
store.fetchResources(),
store.fetchCategories(),
store.fetchStats(),
])
})
// 搜索处理
const handleSearch = () => {
const categoryId = selectedCategory.value ? parseInt(selectedCategory.value) : undefined
store.searchResources(searchQuery.value, categoryId)
}
// 清除搜索
const clearSearch = () => {
searchQuery.value = ''
selectedCategory.value = ''
store.clearSearch()
}
// 查看资源
const viewResource = (resource: any) => {
console.log('查看资源:', resource)
}
// 编辑资源
const editResource = (resource: any) => {
editingResource.value = resource
showAddResourceModal.value = true
}
// 删除资源
const deleteResource = async (id: number) => {
if (confirm('确定要删除这个资源吗?')) {
try {
await store.deleteResource(id)
} catch (error) {
console.error('删除失败:', error)
}
}
}
// 关闭模态框
const closeModal = () => {
showAddResourceModal.value = false
editingResource.value = null
}
// 保存资源
const handleSaveResource = async (resourceData: any) => {
try {
if (editingResource.value) {
await store.updateResource(editingResource.value.id, resourceData)
} else {
await store.createResource(resourceData)
}
closeModal()
} catch (error) {
console.error('保存失败:', error)
}
}
</script>
<style scoped>
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
</style>

View File

@@ -0,0 +1,24 @@
import { setup } from '@css-render/vue3-ssr'
import { defineNuxtPlugin } from '#app'
export default defineNuxtPlugin((nuxtApp) => {
if (process.server) {
const { collect } = setup(nuxtApp.vueApp)
const originalRenderMeta = nuxtApp.ssrContext?.renderMeta
nuxtApp.ssrContext = nuxtApp.ssrContext || {}
nuxtApp.ssrContext.renderMeta = () => {
if (!originalRenderMeta) {
return {
headTags: collect()
}
}
const originalMeta = originalRenderMeta()
if ('headTags' in originalMeta) {
originalMeta.headTags += collect()
} else {
originalMeta.headTags = collect()
}
return originalMeta
}
}
})

180
web/stores/resource.ts Normal file
View File

@@ -0,0 +1,180 @@
import { defineStore } from 'pinia'
export interface Resource {
id: number
title: string
description: string
url: string
file_path: string
file_size: number
file_type: string
category_id?: number
category_name: string
tags: string[]
download_count: number
view_count: number
is_public: boolean
created_at: string
updated_at: string
}
export interface Category {
id: number
name: string
description: string
created_at: string
updated_at: string
}
export interface Stats {
total_resources: number
total_categories: number
total_downloads: number
total_views: number
}
export const useResourceStore = defineStore('resource', {
state: () => ({
resources: [] as Resource[],
categories: [] as Category[],
stats: null as Stats | null,
loading: false,
currentPage: 1,
totalPages: 1,
searchQuery: '',
selectedCategory: null as number | null,
}),
getters: {
getResourceById: (state) => (id: number) => {
return state.resources.find(resource => resource.id === id)
},
getCategoryById: (state) => (id: number) => {
return state.categories.find(category => category.id === id)
},
},
actions: {
async fetchResources(params?: any) {
this.loading = true
try {
const { getResources } = useResourceApi()
const data = await getResources(params)
this.resources = data.resources
this.currentPage = data.page
this.totalPages = Math.ceil(data.total / data.limit)
} catch (error) {
console.error('获取资源失败:', error)
} finally {
this.loading = false
}
},
async fetchCategories() {
try {
const { getCategories } = useCategoryApi()
this.categories = await getCategories()
} catch (error) {
console.error('获取分类失败:', error)
}
},
async fetchStats() {
try {
const { getStats } = useStatsApi()
this.stats = await getStats()
} catch (error) {
console.error('获取统计失败:', error)
}
},
async searchResources(query: string, categoryId?: number) {
this.loading = true
try {
const { searchResources } = useResourceApi()
const params = { q: query, category_id: categoryId }
const data = await searchResources(params)
this.resources = data.resources
this.searchQuery = query
this.selectedCategory = categoryId || null
} catch (error) {
console.error('搜索资源失败:', error)
} finally {
this.loading = false
}
},
async createResource(resourceData: any) {
try {
const { createResource } = useResourceApi()
await createResource(resourceData)
await this.fetchResources()
} catch (error) {
console.error('创建资源失败:', error)
throw error
}
},
async updateResource(id: number, resourceData: any) {
try {
const { updateResource } = useResourceApi()
await updateResource(id, resourceData)
await this.fetchResources()
} catch (error) {
console.error('更新资源失败:', error)
throw error
}
},
async deleteResource(id: number) {
try {
const { deleteResource } = useResourceApi()
await deleteResource(id)
await this.fetchResources()
} catch (error) {
console.error('删除资源失败:', error)
throw error
}
},
async createCategory(categoryData: any) {
try {
const { createCategory } = useCategoryApi()
await createCategory(categoryData)
await this.fetchCategories()
} catch (error) {
console.error('创建分类失败:', error)
throw error
}
},
async updateCategory(id: number, categoryData: any) {
try {
const { updateCategory } = useCategoryApi()
await updateCategory(id, categoryData)
await this.fetchCategories()
} catch (error) {
console.error('更新分类失败:', error)
throw error
}
},
async deleteCategory(id: number) {
try {
const { deleteCategory } = useCategoryApi()
await deleteCategory(id)
await this.fetchCategories()
} catch (error) {
console.error('删除分类失败:', error)
throw error
}
},
clearSearch() {
this.searchQuery = ''
this.selectedCategory = null
this.fetchResources()
},
},
})

36
web/tailwind.config.js Normal file
View File

@@ -0,0 +1,36 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
"./components/**/*.{js,vue,ts}",
"./layouts/**/*.vue",
"./pages/**/*.vue",
"./plugins/**/*.{js,ts}",
"./nuxt.config.{js,ts}",
"./app.vue",
],
theme: {
extend: {
fontFamily: {
sans: ['Inter', 'system-ui', 'sans-serif'],
},
colors: {
primary: {
50: '#eff6ff',
100: '#dbeafe',
200: '#bfdbfe',
300: '#93c5fd',
400: '#60a5fa',
500: '#3b82f6',
600: '#2563eb',
700: '#1d4ed8',
800: '#1e40af',
900: '#1e3a8a',
},
},
},
},
plugins: [
require('@tailwindcss/forms'),
require('@tailwindcss/typography'),
],
}

8
web/tsconfig.json Normal file
View File

@@ -0,0 +1,8 @@
{
"extends": "./.nuxt/tsconfig.json",
"compilerOptions": {
"strict": true,
"skipLibCheck": true,
"types": ["node"]
}
}