mirror of
https://github.com/ctwj/urldb.git
synced 2025-11-25 19:37:33 +08:00
update: res db
This commit is contained in:
109
.gitignore
vendored
Normal file
109
.gitignore
vendored
Normal 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
39
Dockerfile
Normal 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
140
README.md
Normal 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
211
UI_FRAMEWORKS.md
Normal 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
38
docker-compose.yml
Normal 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
13
env.example
Normal 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
11
go.mod
Normal 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
125
handlers/category.go
Normal 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
293
handlers/resource.go
Normal 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
69
main.go
Normal 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
118
models/database.go
Normal 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
95
models/resource.go
Normal 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
48
start.sh
Executable 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
121
test-setup.sh
Executable 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
1
uploads/.gitkeep
Normal file
@@ -0,0 +1 @@
|
||||
# 此文件用于保持uploads目录在Git中
|
||||
35
web/assets/css/main.css
Normal file
35
web/assets/css/main.css
Normal 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;
|
||||
}
|
||||
}
|
||||
239
web/components/ResourceModal.vue
Normal file
239
web/components/ResourceModal.vue
Normal 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
91
web/composables/useApi.ts
Normal 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
19
web/layouts/default.vue
Normal 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
34
web/nuxt.config.ts
Normal 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
33
web/package.json
Normal 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
297
web/pages/index.vue
Normal 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>
|
||||
24
web/plugins/naive-ui.client.ts
Normal file
24
web/plugins/naive-ui.client.ts
Normal 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
180
web/stores/resource.ts
Normal 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
36
web/tailwind.config.js
Normal 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
8
web/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "./.nuxt/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"types": ["node"]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user