首次提交:初始化项目代码
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
.git/
|
||||
data/
|
||||
logs/
|
||||
*.log
|
||||
.vscode/
|
||||
.DS_Store
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
# Binaries
|
||||
*.exe
|
||||
*.exe~
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
bin/
|
||||
dist/
|
||||
|
||||
# Test binary, built with `go test -c`
|
||||
*.test
|
||||
|
||||
# Output of the go coverage tool
|
||||
*.out
|
||||
coverage.txt
|
||||
coverage.html
|
||||
|
||||
# Go workspace file
|
||||
go.work
|
||||
|
||||
# Dependency directories (remove the comment below to include it)
|
||||
# vendor/
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Project data
|
||||
data/
|
||||
logs/
|
||||
*.log
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Uploads
|
||||
uploads/
|
||||
static/uploads/
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
FROM golang:1.21-alpine AS builder
|
||||
|
||||
WORKDIR /src
|
||||
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build \
|
||||
-ldflags="-s -w -X main.version=$(date +%Y%m%d%H%M%S)" \
|
||||
-o /out/resume \
|
||||
cmd/server/main.go
|
||||
|
||||
FROM alpine:3.19
|
||||
|
||||
RUN apk --no-cache add ca-certificates tzdata && \
|
||||
addgroup -S app && adduser -S app -G app
|
||||
|
||||
ENV TZ=Asia/Shanghai \
|
||||
GIN_MODE=release
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder --chown=app:app /out/resume ./resume
|
||||
COPY --from=builder --chown=app:app /src/templates ./templates
|
||||
COPY --from=builder --chown=app:app /src/config ./config
|
||||
COPY --from=builder --chown=app:app /src/static ./static
|
||||
|
||||
RUN mkdir -p /app/data /app/logs && \
|
||||
chown -R app:app /app
|
||||
|
||||
USER app
|
||||
|
||||
EXPOSE 8090
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:8090/ || exit 1
|
||||
|
||||
CMD ["./resume"]
|
||||
@@ -0,0 +1,370 @@
|
||||
# Resume Platform · 智能简历平台
|
||||
|
||||
[](https://go.dev/)
|
||||
[](https://gin-gonic.com/)
|
||||
[](https://www.sqlite.org/)
|
||||
[](LICENSE)
|
||||
[](#贡献指南)
|
||||
|
||||
> 基于 Go + Gin + SQLite 的现代化开源简历平台,内置 AI 面试助手、多模板支持、多人简历管理,让你的个人主页脱颖而出。
|
||||
|
||||
---
|
||||
|
||||
## ✨ 功能特性
|
||||
|
||||
### 📄 简历管理
|
||||
- **多人简历支持** — 每个人独立访问路由(如 `/zhangsan`、`/lisi`),支持人员绑定与解绑
|
||||
- **多模板主题** — 内置 7 种精美模板(modern / classic / fresh / minimalist / professional / tech / project)
|
||||
- **实时编辑** — 所见即所得的编辑体验,自动保存防止丢失
|
||||
- **访问密码** — 支持为简历设置独立访问密码,保护隐私
|
||||
- **数据脱敏** — 公开展示时自动脱敏手机号、邮箱等敏感信息
|
||||
|
||||
### 🤖 AI 智能面试
|
||||
- **智能出题** — 基于简历内容或关键词自动生成面试题,支持选择题、填空题、简答题、算法题
|
||||
- **多 AI 提供商** — 接入 DeepSeek / 豆包 / 讯飞星火 / 通义千问,自由切换
|
||||
- **关键词驱动** — 智能提取简历技能作为关键词,支持别名扩展,每次生成随机不重复
|
||||
- **答题记录** — 完整记录每次答题情况,支持查看历史、重练错题
|
||||
- **收藏 & 错题本** — 一键收藏好题,错题自动入库,针对性复习
|
||||
|
||||
### 🎨 作品集展示
|
||||
- **独立作品集页** — 每个用户拥有专属作品集展示页面
|
||||
- **项目关联** — 支持从简历项目中导入,也可独立创建
|
||||
- **分类筛选** — 按技术栈、类型快速筛选项目
|
||||
- **首页精选** — 可设置首页展示的精选项目
|
||||
|
||||
### 🛡️ 安全保障
|
||||
- **后台隐藏路径** — 自定义管理后台 URL,降低被探测风险
|
||||
- **登录验证码** — 图形验证码防止机器人暴力破解
|
||||
- **登录限流** — 基于 IP 的登录次数限制,超出自动锁定
|
||||
- **AES 加密** — 数据库支持 AES-GCM 加密存储,敏感数据更安全
|
||||
- **PBKDF2 密钥派生** — 使用 PBKDF2-SHA256 派生加密密钥
|
||||
|
||||
### 📊 管理后台
|
||||
- **仪表盘** — 数据总览,快速了解平台运行状态
|
||||
- **用户管理** — 完整的用户 CRUD,支持状态管理
|
||||
- **简历管理** — 统一管理所有简历,支持绑定/解绑人员
|
||||
- **答题管理** — 查看用户答题记录、学习进度
|
||||
- **菜单管理** — 自定义后台导航菜单
|
||||
- **系统设置** — 站点名称、Logo、SEO 等配置可视化管理
|
||||
- **全局搜索** — 一键搜索用户、简历、作品集
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ 技术栈
|
||||
|
||||
| 类别 | 技术 | 版本 |
|
||||
|------|------|------|
|
||||
| 语言 | Go | 1.25+ |
|
||||
| Web 框架 | Gin | v1.9.1 |
|
||||
| 数据库 | SQLite (modernc.org/sqlite) | v1.26.0 |
|
||||
| AI Agent | CloudWeGo Eino | v0.9.12 |
|
||||
| 配置管理 | Viper | v1.18.2 |
|
||||
| 验证码 | base64Captcha | v1.3.2 |
|
||||
| 前端 | HTML Template + Tailwind CSS + Font Awesome | — |
|
||||
| 部署 | Docker / Docker Compose | — |
|
||||
|
||||
---
|
||||
|
||||
## 📁 项目结构
|
||||
|
||||
```
|
||||
resume-platform/
|
||||
├── cmd/
|
||||
│ ├── server/ # 主服务入口
|
||||
│ │ └── main.go
|
||||
│ └── checkdb/ # 数据库检查工具
|
||||
│ └── main.go
|
||||
├── config/
|
||||
│ └── config.yaml # 应用配置文件
|
||||
├── internal/
|
||||
│ ├── agent/ # AI Agent 层(Eino)
|
||||
│ │ ├── config.go
|
||||
│ │ └── tools.go # 题目生成、答案解析等工具
|
||||
│ ├── ai/ # AI 提供商适配层
|
||||
│ │ ├── provider.go # 统一接口定义
|
||||
│ │ ├── deepseek.go
|
||||
│ │ ├── doubao.go
|
||||
│ │ ├── spark.go
|
||||
│ │ └── tongyi.go
|
||||
│ ├── handler/ # HTTP 处理器
|
||||
│ │ ├── admin.go # 管理后台
|
||||
│ │ ├── ai.go # AI 相关接口
|
||||
│ │ ├── chat.go # AI 对话接口
|
||||
│ │ ├── document.go # 文档解析接口
|
||||
│ │ ├── response.go # 统一响应格式
|
||||
│ │ └── resume.go # 简历/作品集/答题 API
|
||||
│ ├── model/ # 数据模型
|
||||
│ │ ├── resume.go # 简历、用户、题目、作品集等
|
||||
│ │ └── document.go
|
||||
│ ├── parser/ # 简历解析
|
||||
│ │ └── parser.go
|
||||
│ ├── repository/ # 数据访问层
|
||||
│ │ ├── interface.go # 仓库接口定义
|
||||
│ │ └── sqlite.go # SQLite 实现
|
||||
│ ├── router/ # 路由配置
|
||||
│ │ ├── router.go # 路由总入口
|
||||
│ │ ├── admin.go # 管理后台路由
|
||||
│ │ ├── api.go # REST API 路由
|
||||
│ │ └── frontend.go # 前端页面路由
|
||||
│ ├── security/ # 安全模块
|
||||
│ │ ├── captcha.go # 验证码
|
||||
│ │ └── rate_limiter.go # 登录限流
|
||||
│ └── service/ # 业务逻辑层
|
||||
│ ├── ai_service.go # AI 服务
|
||||
│ ├── chat.go # 对话服务
|
||||
│ ├── document.go # 文档服务
|
||||
│ ├── resume.go # 简历服务
|
||||
│ └── vector.go # 向量服务
|
||||
├── pkg/
|
||||
│ └── config/ # 公共配置包
|
||||
│ └── config.go
|
||||
├── static/ # 静态资源
|
||||
│ ├── images/
|
||||
│ └── js/
|
||||
├── templates/ # HTML 模板
|
||||
│ ├── admin/ # 管理后台模板
|
||||
│ └── web/ # 前端展示模板
|
||||
├── data/ # 数据库文件(运行时生成)
|
||||
├── logs/ # 日志文件(运行时生成)
|
||||
├── Dockerfile
|
||||
├── docker-compose.yml
|
||||
├── go.mod
|
||||
└── go.sum
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
### 环境要求
|
||||
|
||||
- **Go 1.25+**(推荐使用最新稳定版)
|
||||
- 或 **Docker** / **Docker Compose**
|
||||
|
||||
### 本地运行
|
||||
|
||||
```bash
|
||||
# 1. 克隆项目
|
||||
git clone https://github.com/yourusername/resume-platform.git
|
||||
cd resume-platform
|
||||
|
||||
# 2. 安装依赖
|
||||
go mod download
|
||||
|
||||
# 3. 运行服务
|
||||
go run cmd/server/main.go
|
||||
```
|
||||
|
||||
服务启动后:
|
||||
|
||||
| 地址 | 说明 |
|
||||
|------|------|
|
||||
| http://localhost:8090 | 产品首页 |
|
||||
| http://localhost:8090/default | 默认简历示例 |
|
||||
| http://localhost:8090/my-secret-admin-123 | 管理后台(默认路径) |
|
||||
|
||||
> **默认账号**:`admin` / `admin123456`
|
||||
>
|
||||
> ⚠️ 生产环境请务必修改默认密码和后台路径!
|
||||
|
||||
### Docker 部署
|
||||
|
||||
#### 使用 Docker Compose(推荐)
|
||||
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
#### 手动构建
|
||||
|
||||
```bash
|
||||
docker build -t resume-platform .
|
||||
docker run -d \
|
||||
-p 8090:8090 \
|
||||
-v $(pwd)/data:/app/data \
|
||||
-v $(pwd)/logs:/app/logs \
|
||||
--name resume-platform \
|
||||
resume-platform
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ 配置说明
|
||||
|
||||
配置文件位于 `config/config.yaml`,所有配置项均支持通过环境变量覆盖。
|
||||
|
||||
```yaml
|
||||
server:
|
||||
host: "0.0.0.0" # 监听地址
|
||||
port: 8090 # 监听端口
|
||||
mode: "release" # Gin 模式:debug / release
|
||||
|
||||
database:
|
||||
type: "sqlite" # 数据库类型(目前仅支持 SQLite)
|
||||
path: "./data/resume.db" # 数据库文件路径
|
||||
password: "resume@2024" # 数据库加密密码
|
||||
|
||||
auth:
|
||||
enabled: true # 是否启用后台认证
|
||||
username: "admin" # 管理员用户名
|
||||
password: "admin123456" # 管理员密码
|
||||
admin_path: "/my-secret-admin-123" # 后台路径(建议修改)
|
||||
max_login_attempts: 5 # 最大登录尝试次数
|
||||
lockout_duration: 15 # 锁定时长(分钟)
|
||||
|
||||
template:
|
||||
default: "modern" # 默认简历模板
|
||||
|
||||
log:
|
||||
level: "info" # 日志级别
|
||||
output: "./logs/app.log" # 日志输出路径
|
||||
|
||||
ai:
|
||||
provider: "spark" # AI 提供商:spark / tongyi / doubao / deepseek
|
||||
enabled: true # 是否启用 AI 功能
|
||||
spark: # 讯飞星火配置
|
||||
api_password: ""
|
||||
base_url: ""
|
||||
model: "lite"
|
||||
max_tokens: 1024
|
||||
timeout: 30
|
||||
# 其他 AI 提供商配置...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔌 API 概览
|
||||
|
||||
### 简历接口
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| `GET` | `/api/resume/:id` | 获取单份简历 |
|
||||
| `GET` | `/api/resume/by-route/:route` | 按路由获取简历 |
|
||||
| `POST` | `/api/resume` | 创建简历 |
|
||||
| `PUT` | `/api/resume/:id` | 更新简历 |
|
||||
| `DELETE` | `/api/resume/:id` | 删除简历 |
|
||||
| `GET` | `/api/user/resumes` | 获取指定用户的简历列表 |
|
||||
|
||||
### AI 答题接口
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| `POST` | `/api/ai/agent/generate-questions` | 生成面试题 |
|
||||
| `POST` | `/api/ai/agent/submit-answers` | 提交答案并评分 |
|
||||
| `POST` | `/api/ai/agent/evaluate-answers` | 评估答案 |
|
||||
|
||||
### 收藏 & 错题
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| `POST` | `/api/favorite` | 添加收藏/错题 |
|
||||
| `GET` | `/api/favorite` | 获取收藏/错题列表 |
|
||||
| `DELETE` | `/api/favorite/:id` | 删除收藏/错题 |
|
||||
| `PUT` | `/api/favorite/:id/toggle` | 切换收藏状态 |
|
||||
|
||||
### 作品集接口
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| `GET` | `/api/portfolio` | 获取作品集列表 |
|
||||
| `POST` | `/api/portfolio` | 创建作品集项目 |
|
||||
| `GET` | `/api/portfolio/:id` | 获取单个项目 |
|
||||
| `PUT` | `/api/portfolio/:id` | 更新项目 |
|
||||
| `DELETE` | `/api/portfolio/:id` | 删除项目 |
|
||||
|
||||
### 管理后台
|
||||
|
||||
所有管理接口前缀为 `{admin_path}/`(默认 `/my-secret-admin-123/`)。
|
||||
|
||||
---
|
||||
|
||||
## 🧩 模板系统
|
||||
|
||||
内置 7 种精美模板,支持随时切换:
|
||||
|
||||
| 模板 | 风格 | 适用场景 |
|
||||
|------|------|----------|
|
||||
| `modern` | 现代简约 | 通用型,推荐首选 |
|
||||
| `classic` | 经典商务 | 传统行业、资深岗位 |
|
||||
| `fresh` | 清新活力 | 应届生、设计岗 |
|
||||
| `minimalist` | 极简主义 | 追求简洁表达 |
|
||||
| `professional` | 专业职业 | 管理岗、专业岗 |
|
||||
| `tech` | 技术导向 | 程序员、工程师 |
|
||||
| `project` | 项目展示 | 突出项目经验 |
|
||||
|
||||
> 模板文件位于 `templates/web/`,可基于现有模板自定义新主题。
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ 开发指南
|
||||
|
||||
### 添加新模板
|
||||
|
||||
1. 在 `templates/web/` 目录下创建 `your-template.html`
|
||||
2. 继承 `base.html` 的结构,使用 Go 模板语法访问 `.Resume` 数据
|
||||
3. 在 `resume.Template` 字段设置模板名称即可
|
||||
|
||||
### 添加新的 AI 提供商
|
||||
|
||||
1. 在 `internal/ai/` 下创建 `yourprovider.go`
|
||||
2. 实现 `AIProvider` 接口
|
||||
3. 在 `provider.go` 的 `NewProvider` 工厂中注册
|
||||
|
||||
### 数据库迁移
|
||||
|
||||
项目使用 `CREATE TABLE IF NOT EXISTS` + `migrateSchema` 机制自动处理表结构变更。新增列时在 `migrateSchema` 函数中添加对应 `ALTER TABLE ADD COLUMN` 语句即可,程序启动时自动执行。
|
||||
|
||||
### 代码规范
|
||||
|
||||
```bash
|
||||
# 格式化代码
|
||||
gofmt -w .
|
||||
|
||||
# 静态检查
|
||||
go vet ./...
|
||||
|
||||
# 构建检查
|
||||
go build ./...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🤝 贡献指南
|
||||
|
||||
欢迎提交 Issue 和 Pull Request!
|
||||
|
||||
### 贡献流程
|
||||
|
||||
1. **Fork** 本仓库
|
||||
2. 创建特性分支:`git checkout -b feature/AmazingFeature`
|
||||
3. 提交更改:`git commit -m 'Add some AmazingFeature'`
|
||||
4. 推送到分支:`git push origin feature/AmazingFeature`
|
||||
5. 打开 **Pull Request**
|
||||
|
||||
### 提交规范
|
||||
|
||||
- 使用 [Conventional Commits](https://www.conventionalcommits.org/) 规范
|
||||
- 示例:`feat: add dark mode support`、`fix: correct user binding display`
|
||||
|
||||
---
|
||||
|
||||
## 📜 开源协议
|
||||
|
||||
本项目基于 **MIT License** 开源,详见 [LICENSE](LICENSE) 文件。
|
||||
|
||||
---
|
||||
|
||||
## ⭐ 支持项目
|
||||
|
||||
如果这个项目对你有帮助,欢迎:
|
||||
|
||||
- 点个 Star ⭐️ 让更多人看到
|
||||
- 分享给身边需要的朋友
|
||||
- 提交 Issue / PR 一起完善
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
<p>Made with ❤️ by Resume Platform Team</p>
|
||||
</div>
|
||||
@@ -0,0 +1,96 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func addAuthorToFile(filePath string) error {
|
||||
content, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
lines := strings.Split(string(content), "\n")
|
||||
var result []string
|
||||
var commentBuffer []string
|
||||
inComment := false
|
||||
|
||||
funcPattern := regexp.MustCompile(`^func\s+(\([^)]+\)\s+)?\w+\s*\(`)
|
||||
|
||||
for i := 0; i < len(lines); i++ {
|
||||
line := lines[i]
|
||||
trimmed := strings.TrimSpace(line)
|
||||
|
||||
if strings.HasPrefix(trimmed, "//") && !strings.HasPrefix(trimmed, "//go:") {
|
||||
if strings.Contains(trimmed, "@author") {
|
||||
for _, c := range commentBuffer {
|
||||
result = append(result, c)
|
||||
}
|
||||
commentBuffer = nil
|
||||
result = append(result, line)
|
||||
inComment = false
|
||||
continue
|
||||
}
|
||||
commentBuffer = append(commentBuffer, line)
|
||||
inComment = true
|
||||
continue
|
||||
}
|
||||
|
||||
if inComment && len(commentBuffer) > 0 && funcPattern.MatchString(trimmed) {
|
||||
for _, c := range commentBuffer {
|
||||
result = append(result, c)
|
||||
}
|
||||
indent := ""
|
||||
for _, ch := range line {
|
||||
if ch == ' ' || ch == '\t' {
|
||||
indent += string(ch)
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
result = append(result, indent+"// @author sunct")
|
||||
result = append(result, line)
|
||||
commentBuffer = nil
|
||||
inComment = false
|
||||
continue
|
||||
}
|
||||
|
||||
if len(commentBuffer) > 0 {
|
||||
for _, c := range commentBuffer {
|
||||
result = append(result, c)
|
||||
}
|
||||
commentBuffer = nil
|
||||
}
|
||||
inComment = false
|
||||
result = append(result, line)
|
||||
}
|
||||
|
||||
if len(commentBuffer) > 0 {
|
||||
for _, c := range commentBuffer {
|
||||
result = append(result, c)
|
||||
}
|
||||
}
|
||||
|
||||
output := strings.Join(result, "\n")
|
||||
return os.WriteFile(filePath, []byte(output), 0644)
|
||||
}
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 2 {
|
||||
fmt.Println("Usage: go run add_author.go <file1.go> [file2.go ...]")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
for _, file := range os.Args[1:] {
|
||||
fmt.Printf("Processing: %s ... ", file)
|
||||
err := addAuthorToFile(file)
|
||||
if err != nil {
|
||||
fmt.Printf("ERROR: %v\n", err)
|
||||
} else {
|
||||
fmt.Println("OK")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
// Package main 数据库检查工具,用于调试查看 system_config 表结构和数据
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
// main 数据库检查程序入口,打印 system_config 表结构和记录
|
||||
// @author sunct
|
||||
func main() {
|
||||
db, err := sql.Open("sqlite", "./data/resume.db")
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to open database: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
fmt.Println("=== system_config Table ===")
|
||||
rows, err := db.Query("PRAGMA table_info(system_config)")
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to get table info: %v", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
fmt.Println("Table Structure:")
|
||||
for rows.Next() {
|
||||
var cid int
|
||||
var name, colType string
|
||||
var notNull, pk int
|
||||
var dfltValue interface{}
|
||||
err := rows.Scan(&cid, &name, &colType, ¬Null, &dfltValue, &pk)
|
||||
if err != nil {
|
||||
log.Printf("Failed to scan column: %v", err)
|
||||
continue
|
||||
}
|
||||
fmt.Printf(" %d: %s (%s), notNull: %d, pk: %d, default: %v\n", cid, name, colType, notNull, pk, dfltValue)
|
||||
}
|
||||
|
||||
var count int
|
||||
err = db.QueryRow("SELECT COUNT(*) FROM system_config").Scan(&count)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to count records: %v", err)
|
||||
}
|
||||
fmt.Printf("\nTotal records in system_config: %d\n", count)
|
||||
|
||||
rows2, err := db.Query("SELECT * FROM system_config")
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to query records: %v", err)
|
||||
}
|
||||
defer rows2.Close()
|
||||
|
||||
fmt.Println("\nSystem Config Records:")
|
||||
for rows2.Next() {
|
||||
var id, websiteDomain, websiteLogo, logoPath, websiteTitle, websiteDesc, websiteDescription, websiteKeywords, seoKeywords, seoDescription, adminEmail, adminName, databasePath, createdAt, updatedAt string
|
||||
var adminPageSize int
|
||||
err := rows2.Scan(&id, &websiteDomain, &websiteLogo, &logoPath, &websiteTitle, &websiteDesc, &websiteDescription, &websiteKeywords, &seoKeywords, &seoDescription, &adminEmail, &adminName, &databasePath, &adminPageSize, &createdAt, &updatedAt)
|
||||
if err != nil {
|
||||
log.Printf("Failed to scan record: %v", err)
|
||||
continue
|
||||
}
|
||||
fmt.Printf("ID: %s\n", id)
|
||||
fmt.Printf(" WebsiteDomain: %s\n", websiteDomain)
|
||||
fmt.Printf(" WebsiteTitle: %s\n", websiteTitle)
|
||||
fmt.Printf(" LogoPath: %s\n", logoPath)
|
||||
fmt.Printf(" AdminPageSize: %d\n", adminPageSize)
|
||||
}
|
||||
|
||||
if err := rows2.Err(); err != nil {
|
||||
log.Printf("Rows error: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
// Package main 是简历平台服务的入口程序
|
||||
// 负责调用 bootstrap 启动服务
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"resume-platform/internal/bootstrap"
|
||||
)
|
||||
|
||||
func main() {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
sigChan := make(chan os.Signal, 1)
|
||||
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
|
||||
|
||||
go func() {
|
||||
<-sigChan
|
||||
cancel()
|
||||
}()
|
||||
|
||||
if err := bootstrap.Run(ctx); err != nil {
|
||||
fmt.Printf("Server failed to start: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
server:
|
||||
host: "0.0.0.0"
|
||||
port: 8090
|
||||
mode: "release"
|
||||
debug: true
|
||||
|
||||
database:
|
||||
type: "sqlite"
|
||||
path: "./data/resume.db"
|
||||
password: "resume@2024"
|
||||
|
||||
auth:
|
||||
enabled: true
|
||||
username: "admin"
|
||||
password: "admin123456"
|
||||
admin_path: "/my-secret-admin-123"
|
||||
max_login_attempts: 5
|
||||
lockout_duration: 15
|
||||
|
||||
template:
|
||||
default: "modern"
|
||||
|
||||
log:
|
||||
level: "info"
|
||||
output: "./logs/app.log"
|
||||
|
||||
ai:
|
||||
provider: "spark"
|
||||
enabled: true
|
||||
spark:
|
||||
app_id: "a146c363"
|
||||
api_key: "1ba519d8cfb20f2732424f2a5ea8e630"
|
||||
api_secret: "OTAwZGI3YjFjZjM2NmZmMzE0YjQxZWM2"
|
||||
api_password: "OjAdJyOqCVmVYUFyaQJq:GQZNWpiVJTytmiSWuaTq"
|
||||
base_url: "wss://spark-api.xf-yun.com/v1.1/chat"
|
||||
model: "lite"
|
||||
max_tokens: 10000
|
||||
timeout: 30
|
||||
web_search: false
|
||||
show_ref_label: false
|
||||
|
||||
|
||||
tongyi:
|
||||
api_key: ""
|
||||
base_url: "https://dashscope.aliyuncs.com/api/v1"
|
||||
model: "qwen-turbo"
|
||||
max_tokens: 10000
|
||||
timeout: 30
|
||||
doubao:
|
||||
api_key: ""
|
||||
base_url: "https://api.doubao.com/v1"
|
||||
model: "Doubao"
|
||||
max_tokens: 10000
|
||||
timeout: 30
|
||||
deepseek:
|
||||
api_key: ""
|
||||
base_url: "https://api.deepseek.com/v1"
|
||||
model: "deepseek-chat"
|
||||
max_tokens: 10000
|
||||
timeout: 30
|
||||
@@ -0,0 +1,29 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
resume:
|
||||
build: .
|
||||
container_name: resume-platform
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8090:8090"
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
- ./logs:/app/logs
|
||||
- ./config:/app/config:ro
|
||||
- ./static:/app/static:ro
|
||||
environment:
|
||||
- TZ=Asia/Shanghai
|
||||
- GIN_MODE=release
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8090/"]
|
||||
interval: 30s
|
||||
timeout: 3s
|
||||
retries: 3
|
||||
start_period: 5s
|
||||
networks:
|
||||
- resume-network
|
||||
|
||||
networks:
|
||||
resume-network:
|
||||
driver: bridge
|
||||
@@ -0,0 +1,92 @@
|
||||
module resume-platform
|
||||
|
||||
go 1.25
|
||||
|
||||
require (
|
||||
github.com/cloudwego/eino v0.9.12
|
||||
github.com/cloudwego/eino-ext/components/model/openai v0.1.13
|
||||
github.com/gin-gonic/gin v1.9.1
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/mojocn/base64Captcha v1.3.2
|
||||
github.com/sirupsen/logrus v1.9.3
|
||||
github.com/spf13/viper v1.18.2
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/bahlo/generic-list-go v0.2.0 // indirect
|
||||
github.com/buger/jsonparser v1.1.1 // indirect
|
||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/cloudwego/eino-ext/libs/acl/openai v0.1.17 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/eino-contrib/jsonschema v1.0.3 // indirect
|
||||
github.com/evanphx/json-patch v0.5.2 // indirect
|
||||
github.com/goph/emperror v0.17.2 // indirect
|
||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
|
||||
github.com/mailru/easyjson v0.7.7 // indirect
|
||||
github.com/meguminnnnnnnnn/go-openai v0.1.2 // indirect
|
||||
github.com/nikolalohinski/gonja v1.5.3 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/slongfield/pyfmt v0.0.0-20220222012616-ea85ff4c361f // indirect
|
||||
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
|
||||
github.com/yargevad/filepathx v1.0.0 // indirect
|
||||
golang.org/x/mod v0.17.0 // indirect
|
||||
golang.org/x/sync v0.10.0 // indirect
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect
|
||||
lukechampine.com/uint128 v1.2.0 // indirect
|
||||
modernc.org/cc/v3 v3.40.0 // indirect
|
||||
modernc.org/ccgo/v3 v3.16.13 // indirect
|
||||
modernc.org/libc v1.24.1 // indirect
|
||||
modernc.org/mathutil v1.5.0 // indirect
|
||||
modernc.org/memory v1.6.0 // indirect
|
||||
modernc.org/opt v0.1.3 // indirect
|
||||
modernc.org/strutil v1.1.3 // indirect
|
||||
modernc.org/token v1.0.1 // indirect
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/bytedance/sonic v1.15.0 // indirect
|
||||
github.com/fsnotify/fsnotify v1.7.0 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
|
||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.14.0 // indirect
|
||||
github.com/goccy/go-json v0.10.2 // indirect
|
||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
|
||||
github.com/hashicorp/hcl v1.0.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.9 // indirect
|
||||
github.com/leodido/go-urn v1.2.4 // indirect
|
||||
github.com/magiconair/properties v1.8.7 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.1.0 // indirect
|
||||
github.com/sagikazarmark/locafero v0.4.0 // indirect
|
||||
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
|
||||
github.com/sourcegraph/conc v0.3.0 // indirect
|
||||
github.com/spf13/afero v1.11.0 // indirect
|
||||
github.com/spf13/cast v1.6.0 // indirect
|
||||
github.com/spf13/pflag v1.0.5 // indirect
|
||||
github.com/subosito/gotenv v1.6.0 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.11 // indirect
|
||||
go.uber.org/atomic v1.9.0 // indirect
|
||||
go.uber.org/multierr v1.9.0 // indirect
|
||||
golang.org/x/arch v0.11.0 // indirect
|
||||
golang.org/x/crypto v0.31.0
|
||||
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect
|
||||
golang.org/x/image v0.0.0-20190501045829-6d32002ffd75 // indirect
|
||||
golang.org/x/net v0.25.0 // indirect
|
||||
golang.org/x/sys v0.29.0 // indirect
|
||||
golang.org/x/text v0.21.0 // indirect
|
||||
google.golang.org/protobuf v1.31.0 // indirect
|
||||
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
modernc.org/sqlite v1.26.0
|
||||
)
|
||||
@@ -0,0 +1,274 @@
|
||||
github.com/airbrake/gobrake v3.6.1+incompatible/go.mod h1:wM4gu3Cn0W0K7GUuVWnlXZU11AGBXMILnrdOU8Kn00o=
|
||||
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
|
||||
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
|
||||
github.com/bitly/go-simplejson v0.5.0/go.mod h1:cXHtHw4XUPsvGaxgjIAn8PhEWG9NfngEKAMDJEczWVA=
|
||||
github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4=
|
||||
github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs=
|
||||
github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
|
||||
github.com/bugsnag/bugsnag-go v1.4.0/go.mod h1:2oa8nejYd4cQ/b0hMIopN0lCRxU0bueqREvZLWFrtK8=
|
||||
github.com/bugsnag/panicwrap v1.2.0/go.mod h1:D/8v3kj0zr8ZAKg1AQ6crr+5VwKN5eIywRkfhyM/+dE=
|
||||
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
||||
github.com/bytedance/mockey v1.3.0 h1:ONLRdvhqmCfr9rTasUB8ZKCfvbdD2tohOg4u+4Q/ed0=
|
||||
github.com/bytedance/mockey v1.3.0/go.mod h1:1BPHF9sol5R1ud/+0VEHGQq/+i2lN+GTsr3O2Q9IENY=
|
||||
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
|
||||
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
|
||||
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
|
||||
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||
github.com/certifi/gocertifi v0.0.0-20190105021004-abcd57078448/go.mod h1:GJKEexRPVJrBSOjoqN5VNOIKJ5Q3RViH6eu3puDRwx4=
|
||||
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||
github.com/cloudwego/eino v0.9.12 h1:mHAMo5k7GdvnVD8Lc2sLyfpkxEm0S/y3PkEMhsSYt78=
|
||||
github.com/cloudwego/eino v0.9.12/go.mod h1:OBD1mrkfkt/pJa4rkg1P0VnaMeOVl7l8IAdEqY//3IQ=
|
||||
github.com/cloudwego/eino-ext/components/model/openai v0.1.13 h1:5XHRTiTD5bt9KQrMHcfvuWNklEC3tpm3XHejdozt9vM=
|
||||
github.com/cloudwego/eino-ext/components/model/openai v0.1.13/go.mod h1:mgIoqYYOc0eECCqvLbEYpOJrQNTNxkwXzSJzFU+v5sQ=
|
||||
github.com/cloudwego/eino-ext/libs/acl/openai v0.1.17 h1:EeVcR1TslRA2IdNW1h/2LaGbPlffwGhQm99jM3zWZiI=
|
||||
github.com/cloudwego/eino-ext/libs/acl/openai v0.1.17/go.mod h1:Zkcx6DPTR2NfWmtSXbhItswGw6hqUezNPhNcke0pOG8=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/eino-contrib/jsonschema v1.0.3 h1:2Kfsm1xlMV0ssY2nuxshS4AwbLFuqmPmzIjLVJ1Fsp0=
|
||||
github.com/eino-contrib/jsonschema v1.0.3/go.mod h1:cpnX4SyKjWjGC7iN2EbhxaTdLqGjCi0e9DxpLYxddD4=
|
||||
github.com/evanphx/json-patch v0.5.2 h1:xVCHIVMUu1wtM/VkR9jVZ45N3FhZfYMMYGorLCR8P3k=
|
||||
github.com/evanphx/json-patch v0.5.2/go.mod h1:ZWS5hhDbVDyob71nXKNL0+PWn6ToqBHMikGIFbs31qQ=
|
||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
|
||||
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
|
||||
github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
|
||||
github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
|
||||
github.com/getsentry/raven-go v0.2.0/go.mod h1:KungGk8q33+aIAZUIVWZDr2OfAEBsO49PX4NzFV5kcQ=
|
||||
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||
github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
|
||||
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
|
||||
github.com/go-check/check v0.0.0-20180628173108-788fd7840127 h1:0gkP6mzaMqkmpcJYCFOLkIBwI7xFExG03bbkOkCvUPI=
|
||||
github.com/go-check/check v0.0.0-20180628173108-788fd7840127/go.mod h1:9ES+weclKsC9YodN5RgxqK/VD9HM9JsCSh7rNhMZE98=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg/+t63MyGU2n5js=
|
||||
github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
|
||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/gofrs/uuid v3.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
|
||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
|
||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
|
||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
|
||||
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/goph/emperror v0.17.2 h1:yLapQcmEsO0ipe9p5TaN22djm3OFV/TfM/fcYP0/J18=
|
||||
github.com/goph/emperror v0.17.2/go.mod h1:+ZbQ+fUNO/6FNiUo0ujtMjhgad9Xa6fQL9KhH4LNHic=
|
||||
github.com/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25dO0g=
|
||||
github.com/gopherjs/gopherjs v1.17.2/go.mod h1:pRRIvn/QzFLrKfvEz3qUuEhtE/zLCWfreZ6J5gM2i+k=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
|
||||
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
|
||||
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
||||
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
|
||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
|
||||
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
|
||||
github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0/go.mod h1:1NbS8ALrpOvjt0rHPNLyCIeMtbizbir8U//inJ+zuB8=
|
||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
|
||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
|
||||
github.com/klauspost/cpuid/v2 v2.2.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY=
|
||||
github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8=
|
||||
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
|
||||
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
|
||||
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
|
||||
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
|
||||
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
|
||||
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
|
||||
github.com/meguminnnnnnnnn/go-openai v0.1.2 h1:iXombGGjqjBrmE9WaSidUhhi3YQhf42QTHvHLMkgvCA=
|
||||
github.com/meguminnnnnnnnn/go-openai v0.1.2/go.mod h1:qs96ysDmxhE4BZoU45I43zcyfnaYxU3X+aRzLko/htY=
|
||||
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b h1:j7+1HpAFS1zy5+Q4qx1fWh90gTKwiN4QCGoY9TWyyO4=
|
||||
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
|
||||
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
||||
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/mojocn/base64Captcha v1.3.2 h1:Ouwjb2jFriltNdKZ1Ud1uu26Dv4+YVsoIw+u4navOmg=
|
||||
github.com/mojocn/base64Captcha v1.3.2/go.mod h1:wAQCKEc5bDujxKRmbT6/vTnTt5CjStQ8bRfPWUuz/iY=
|
||||
github.com/nikolalohinski/gonja v1.5.3 h1:GsA+EEaZDZPGJ8JtpeGN78jidhOlxeJROpqMT9fTj9c=
|
||||
github.com/nikolalohinski/gonja v1.5.3/go.mod h1:RmjwxNiXAEqcq1HeK5SSMmqFJvKOfTfXhkJv6YBtPa4=
|
||||
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||
github.com/onsi/ginkgo v1.8.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||
github.com/onsi/gomega v1.5.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
|
||||
github.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4=
|
||||
github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc=
|
||||
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||
github.com/rollbar/rollbar-go v1.0.2/go.mod h1:AcFs5f0I+c71bpHlXNNDbOWJiKwjFDtISeXco0L5PKQ=
|
||||
github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ=
|
||||
github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4=
|
||||
github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=
|
||||
github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ=
|
||||
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
|
||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||
github.com/slongfield/pyfmt v0.0.0-20220222012616-ea85ff4c361f h1:Z2cODYsUxQPofhpYRMQVwWz4yUVpHF+vPi+eUdruUYI=
|
||||
github.com/slongfield/pyfmt v0.0.0-20220222012616-ea85ff4c361f/go.mod h1:JqzWyvTuI2X4+9wOHmKSQCYxybB/8j6Ko43qVmXDuZg=
|
||||
github.com/smarty/assertions v1.15.0 h1:cR//PqUBUiQRakZWqBiFFQ9wb8emQGDb0HeGdqGByCY=
|
||||
github.com/smarty/assertions v1.15.0/go.mod h1:yABtdzeQs6l1brC900WlRNwj6ZR55d7B+E8C6HtKdec=
|
||||
github.com/smartystreets/goconvey v1.8.1 h1:qGjIddxOk4grTu9JPOU31tVfq3cNdBlNa5sSznIX1xY=
|
||||
github.com/smartystreets/goconvey v1.8.1/go.mod h1:+/u4qLyY6x1jReYOp7GOM2FSt8aP9CzCZL03bI28W60=
|
||||
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
|
||||
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
|
||||
github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=
|
||||
github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY=
|
||||
github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0=
|
||||
github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
|
||||
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
||||
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/viper v1.18.2 h1:LUXCnvUvSM6FXAsj6nnfc8Q2tp1dIgUfY9Kc8GsSOiQ=
|
||||
github.com/spf13/viper v1.18.2/go.mod h1:EKmWIqdnk5lOcmR72yw6hS+8OPYcwD0jteitLMVB+yk=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
|
||||
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc=
|
||||
github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw=
|
||||
github.com/x-cray/logrus-prefixed-formatter v0.5.2 h1:00txxvfBM9muc0jiLIEAkAcIMJzfthRT6usrui8uGmg=
|
||||
github.com/x-cray/logrus-prefixed-formatter v0.5.2/go.mod h1:2duySbKsL6M18s5GU7VPsoEPHyzalCE06qoARUCeBBE=
|
||||
github.com/yargevad/filepathx v1.0.0 h1:SYcT+N3tYGi+NvazubCNlvgIPbzAk7i7y2dwg3I5FYc=
|
||||
github.com/yargevad/filepathx v1.0.0/go.mod h1:BprfX/gpYNJHJfc35GjRRpVcwWXS89gGulUIU5tK3tA=
|
||||
go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
|
||||
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
|
||||
go.uber.org/mock v0.4.0 h1:VcM4ZOtdbR4f6VXfiOpwpVJDL6lCReaZ6mw31wqh7KU=
|
||||
go.uber.org/mock v0.4.0/go.mod h1:a6FSlNadKUHUa9IP5Vyt1zh4fC7uAwxMutEAscFbkZc=
|
||||
go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI=
|
||||
go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ=
|
||||
golang.org/x/arch v0.11.0 h1:KXV8WWKCXm6tRpLirl2szsO5j/oOODwZf4hATmGVNs4=
|
||||
golang.org/x/arch v0.11.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
||||
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g=
|
||||
golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k=
|
||||
golang.org/x/image v0.0.0-20190501045829-6d32002ffd75 h1:TbGuee8sSq15Iguxu4deQ7+Bqq/d2rsQejGcEtADAMQ=
|
||||
golang.org/x/image v0.0.0-20190501045829-6d32002ffd75/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
|
||||
golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA=
|
||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
|
||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
|
||||
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/term v0.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg=
|
||||
golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||
google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
|
||||
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
|
||||
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
|
||||
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
|
||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
|
||||
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
lukechampine.com/uint128 v1.2.0 h1:mBi/5l91vocEN8otkC5bDLhi2KdCticRiwbdB0O+rjI=
|
||||
lukechampine.com/uint128 v1.2.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=
|
||||
modernc.org/cc/v3 v3.40.0 h1:P3g79IUS/93SYhtoeaHW+kRCIrYaxJ27MFPv+7kaTOw=
|
||||
modernc.org/cc/v3 v3.40.0/go.mod h1:/bTg4dnWkSXowUO6ssQKnOV0yMVxDYNIsIrzqTFDGH0=
|
||||
modernc.org/ccgo/v3 v3.16.13 h1:Mkgdzl46i5F/CNR/Kj80Ri59hC8TKAhZrYSaqvkwzUw=
|
||||
modernc.org/ccgo/v3 v3.16.13/go.mod h1:2Quk+5YgpImhPjv2Qsob1DnZ/4som1lJTodubIcoUkY=
|
||||
modernc.org/ccorpus v1.11.6 h1:J16RXiiqiCgua6+ZvQot4yUuUy8zxgqbqEEUuGPlISk=
|
||||
modernc.org/ccorpus v1.11.6/go.mod h1:2gEUTrWqdpH2pXsmTM1ZkjeSrUWDpjMu2T6m29L/ErQ=
|
||||
modernc.org/httpfs v1.0.6 h1:AAgIpFZRXuYnkjftxTAZwMIiwEqAfk8aVB2/oA6nAeM=
|
||||
modernc.org/httpfs v1.0.6/go.mod h1:7dosgurJGp0sPaRanU53W4xZYKh14wfzX420oZADeHM=
|
||||
modernc.org/libc v1.24.1 h1:uvJSeCKL/AgzBo2yYIPPTy82v21KgGnizcGYfBHaNuM=
|
||||
modernc.org/libc v1.24.1/go.mod h1:FmfO1RLrU3MHJfyi9eYYmZBfi/R+tqZ6+hQ3yQQUkak=
|
||||
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
|
||||
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
|
||||
modernc.org/memory v1.6.0 h1:i6mzavxrE9a30whzMfwf7XWVODx2r5OYXvU46cirX7o=
|
||||
modernc.org/memory v1.6.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
|
||||
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
|
||||
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
|
||||
modernc.org/sqlite v1.26.0 h1:SocQdLRSYlA8W99V8YH0NES75thx19d9sB/aFc4R8Lw=
|
||||
modernc.org/sqlite v1.26.0/go.mod h1:FL3pVXie73rg3Rii6V/u5BoHlSoyeZeIgKZEgHARyCU=
|
||||
modernc.org/strutil v1.1.3 h1:fNMm+oJklMGYfU9Ylcywl0CO5O6nTfaowNsh2wpPjzY=
|
||||
modernc.org/strutil v1.1.3/go.mod h1:MEHNA7PdEnEwLvspRMtWTNnp2nnyvMfkimT1NKNAGbw=
|
||||
modernc.org/tcl v1.15.2 h1:C4ybAYCGJw968e+Me18oW55kD/FexcHbqH2xak1ROSY=
|
||||
modernc.org/tcl v1.15.2/go.mod h1:3+k/ZaEbKrC8ePv8zJWPtBSW0V7Gg9g8rkmhI1Kfs3c=
|
||||
modernc.org/token v1.0.1 h1:A3qvTqOwexpfZZeyI0FeGPDlSWX5pjZu9hF4lU+EKWg=
|
||||
modernc.org/token v1.0.1/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
modernc.org/z v1.7.3 h1:zDJf6iHjrnB+WRD88stbXokugjyc0/pB91ri1gO6LZY=
|
||||
modernc.org/z v1.7.3/go.mod h1:Ipv4tsdxZRbQyLq9Q1M6gdbkxYzdlrciF2Hi/lS7nWE=
|
||||
@@ -0,0 +1,239 @@
|
||||
// Package agent AI Agent 模块,基于 Eino 框架实现简历优化智能体
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"resume-platform/pkg/config"
|
||||
|
||||
"github.com/cloudwego/eino/adk"
|
||||
"github.com/cloudwego/eino/compose"
|
||||
"github.com/cloudwego/eino/components/tool"
|
||||
"github.com/cloudwego/eino-ext/components/model/openai"
|
||||
)
|
||||
|
||||
// ResumeAgentConfig 简历 Agent 配置
|
||||
type ResumeAgentConfig struct {
|
||||
ChatModel *openai.ChatModel // 聊天模型实例
|
||||
Provider string // AI 供应商名称
|
||||
BaseURL string // 接口地址
|
||||
APIKey string // API Key
|
||||
Model string // 模型名称
|
||||
MaxTokens int // 最大 token 数
|
||||
Temperature float64 // 温度参数
|
||||
}
|
||||
|
||||
// ResumeAgent 简历优化智能体
|
||||
type ResumeAgent struct {
|
||||
agent adk.Agent // Eino Agent 实例
|
||||
config *ResumeAgentConfig // Agent 配置
|
||||
}
|
||||
|
||||
// NewResumeAgent 创建简历优化智能体
|
||||
// @param ctx 上下文
|
||||
// @param cfg AI 配置
|
||||
// @return *ResumeAgent 智能体实例
|
||||
// @return error 创建错误
|
||||
// @author sunct
|
||||
func NewResumeAgent(ctx context.Context, cfg *config.AIConfig) (*ResumeAgent, error) {
|
||||
chatModel, err := createChatModel(ctx, cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tools := []tool.BaseTool{
|
||||
NewPolishTool(),
|
||||
NewAnalyzeTool(),
|
||||
NewKeywordExtractTool(),
|
||||
NewGenerateQuestionsTool(),
|
||||
}
|
||||
|
||||
maxTokens := getMaxTokens(cfg)
|
||||
|
||||
agent, err := adk.NewChatModelAgent(ctx, &adk.ChatModelAgentConfig{
|
||||
Name: "ResumeAgent",
|
||||
Description: "专业的简历优化智能体,能够分析简历内容、提供优化建议、提取关键词,帮助用户打造高质量简历。",
|
||||
Instruction: "你是一位专业的简历优化专家。请根据用户的请求,使用可用的工具来帮助用户优化简历。\n\n可用工具:\n1. polish_resume - 优化简历内容\n2. analyze_resume - 分析简历并提供评估\n3. extract_keywords - 从简历中提取关键词\n4. generate_interview_questions - 根据简历内容生成面试题目\n\n请根据用户的具体需求选择合适的工具。",
|
||||
Model: chatModel,
|
||||
ToolsConfig: adk.ToolsConfig{
|
||||
ToolsNodeConfig: compose.ToolsNodeConfig{
|
||||
Tools: tools,
|
||||
},
|
||||
},
|
||||
MaxIterations: 20,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &ResumeAgent{
|
||||
agent: agent,
|
||||
config: &ResumeAgentConfig{
|
||||
ChatModel: chatModel,
|
||||
Provider: cfg.Provider,
|
||||
Model: getModelName(cfg),
|
||||
MaxTokens: maxTokens,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// createChatModel 根据配置创建 OpenAI 兼容的聊天模型
|
||||
// @param ctx 上下文
|
||||
// @param cfg AI 配置
|
||||
// @return *openai.ChatModel 聊天模型实例
|
||||
// @return error 创建错误
|
||||
// @author sunct
|
||||
func createChatModel(ctx context.Context, cfg *config.AIConfig) (*openai.ChatModel, error) {
|
||||
var apiKey, baseURL, model string
|
||||
|
||||
switch cfg.Provider {
|
||||
case "spark":
|
||||
apiKey = cfg.Spark.APIKey
|
||||
if apiKey == "" {
|
||||
parts := splitPassword(cfg.Spark.APIPassword)
|
||||
if len(parts) == 2 {
|
||||
apiKey = parts[0]
|
||||
}
|
||||
}
|
||||
baseURL = cfg.Spark.BaseURL
|
||||
model = getSparkModel(cfg.Spark.Model)
|
||||
case "tongyi":
|
||||
apiKey = cfg.Tongyi.APIKey
|
||||
baseURL = cfg.Tongyi.BaseURL
|
||||
model = cfg.Tongyi.Model
|
||||
case "doubao":
|
||||
apiKey = cfg.Doubao.APIKey
|
||||
baseURL = cfg.Doubao.BaseURL
|
||||
model = cfg.Doubao.Model
|
||||
case "deepseek":
|
||||
apiKey = cfg.DeepSeek.APIKey
|
||||
baseURL = cfg.DeepSeek.BaseURL
|
||||
model = cfg.DeepSeek.Model
|
||||
default:
|
||||
apiKey = cfg.Spark.APIKey
|
||||
if apiKey == "" {
|
||||
parts := splitPassword(cfg.Spark.APIPassword)
|
||||
if len(parts) == 2 {
|
||||
apiKey = parts[0]
|
||||
}
|
||||
}
|
||||
baseURL = cfg.Spark.BaseURL
|
||||
model = getSparkModel(cfg.Spark.Model)
|
||||
}
|
||||
|
||||
maxTokens := getMaxTokens(cfg)
|
||||
|
||||
return openai.NewChatModel(ctx, &openai.ChatModelConfig{
|
||||
Model: model,
|
||||
APIKey: apiKey,
|
||||
BaseURL: baseURL + "/chat/completions",
|
||||
MaxTokens: &maxTokens,
|
||||
Temperature: &temperature,
|
||||
})
|
||||
}
|
||||
|
||||
var temperature = float32(0.7)
|
||||
|
||||
// getSparkModel 获取讯飞星火模型全名
|
||||
// @param model 模型简称(lite/pro/max)
|
||||
// @return string 完整模型名
|
||||
// @author sunct
|
||||
func getSparkModel(model string) string {
|
||||
switch model {
|
||||
case "lite":
|
||||
return "spark-lite"
|
||||
case "pro":
|
||||
return "spark-pro"
|
||||
case "max":
|
||||
return "spark-max"
|
||||
default:
|
||||
return "spark-lite"
|
||||
}
|
||||
}
|
||||
|
||||
// getModelName 根据供应商获取模型名称
|
||||
// @param cfg AI 配置
|
||||
// @return string 模型名
|
||||
// @author sunct
|
||||
func getModelName(cfg *config.AIConfig) string {
|
||||
switch cfg.Provider {
|
||||
case "spark":
|
||||
return getSparkModel(cfg.Spark.Model)
|
||||
case "tongyi":
|
||||
return cfg.Tongyi.Model
|
||||
case "doubao":
|
||||
return cfg.Doubao.Model
|
||||
case "deepseek":
|
||||
return cfg.DeepSeek.Model
|
||||
default:
|
||||
return "spark-lite"
|
||||
}
|
||||
}
|
||||
|
||||
// getMaxTokens 根据供应商获取最大 token 数
|
||||
// @param cfg AI 配置
|
||||
// @return int 最大 token 数
|
||||
// @author sunct
|
||||
func getMaxTokens(cfg *config.AIConfig) int {
|
||||
switch cfg.Provider {
|
||||
case "spark":
|
||||
return cfg.Spark.MaxTokens
|
||||
case "tongyi":
|
||||
return cfg.Tongyi.MaxTokens
|
||||
case "doubao":
|
||||
return cfg.Doubao.MaxTokens
|
||||
case "deepseek":
|
||||
return cfg.DeepSeek.MaxTokens
|
||||
default:
|
||||
return cfg.Spark.MaxTokens
|
||||
}
|
||||
}
|
||||
|
||||
// splitPassword 按冒号分割密码字符串,提取 Key 和 Secret
|
||||
// @param password 密码字符串
|
||||
// @return []string 分割结果
|
||||
// @author sunct
|
||||
func splitPassword(password string) []string {
|
||||
for i, c := range password {
|
||||
if c == ':' {
|
||||
return []string{password[:i], password[i+1:]}
|
||||
}
|
||||
}
|
||||
return []string{password}
|
||||
}
|
||||
|
||||
// Run 执行 Agent 查询,获取最终回答
|
||||
// @param ctx 上下文
|
||||
// @param query 用户查询
|
||||
// @return string Agent 回答内容
|
||||
// @return error 执行错误
|
||||
// @author sunct
|
||||
func (a *ResumeAgent) Run(ctx context.Context, query string) (string, error) {
|
||||
runner := adk.NewRunner(ctx, adk.RunnerConfig{Agent: a.agent})
|
||||
iter := runner.Query(ctx, query)
|
||||
|
||||
var result string
|
||||
for {
|
||||
event, ok := iter.Next()
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
if event.Output != nil {
|
||||
if msgOutput := event.Output.MessageOutput; msgOutput != nil {
|
||||
msg, err := msgOutput.GetMessage()
|
||||
if err == nil && msg != nil {
|
||||
result = msg.Content
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetProvider 获取当前使用的 AI 供应商名称
|
||||
// @return string 供应商标识
|
||||
// @author sunct
|
||||
func (a *ResumeAgent) GetProvider() string {
|
||||
return a.config.Provider
|
||||
}
|
||||
@@ -0,0 +1,827 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"hash/fnv"
|
||||
"math/rand"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/cloudwego/eino/components/tool"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
)
|
||||
|
||||
type PolishTool struct{}
|
||||
|
||||
func NewPolishTool() *PolishTool {
|
||||
return &PolishTool{}
|
||||
}
|
||||
|
||||
func (t *PolishTool) Info(ctx context.Context) (*schema.ToolInfo, error) {
|
||||
return &schema.ToolInfo{
|
||||
Name: "polish_resume",
|
||||
Desc: "优化简历内容,包括个人简介、工作经历、项目描述等。输入需要优化的文本和字段类型,返回优化后的内容。",
|
||||
ParamsOneOf: schema.NewParamsOneOfByParams(map[string]*schema.ParameterInfo{
|
||||
"text": {
|
||||
Desc: "需要优化的简历文本内容",
|
||||
Required: true,
|
||||
Type: schema.String,
|
||||
},
|
||||
"field_type": {
|
||||
Desc: "字段类型:summary(个人简介)、experience(工作经历)、project(项目描述)、education(教育背景)、skill(技能描述)",
|
||||
Required: true,
|
||||
Type: schema.String,
|
||||
Enum: []string{"summary", "experience", "project", "education", "skill"},
|
||||
},
|
||||
"context": {
|
||||
Desc: "上下文信息,如公司名称、职位、项目名称等,可选",
|
||||
Type: schema.String,
|
||||
},
|
||||
}),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (t *PolishTool) InvokableRun(ctx context.Context, argumentsInJSON string, opts ...tool.Option) (string, error) {
|
||||
var args struct {
|
||||
Text string `json:"text"`
|
||||
FieldType string `json:"field_type"`
|
||||
Context string `json:"context"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal([]byte(argumentsInJSON), &args); err != nil {
|
||||
return "", fmt.Errorf("failed to unmarshal arguments: %w", err)
|
||||
}
|
||||
|
||||
if args.Text == "" {
|
||||
return "", fmt.Errorf("text is required")
|
||||
}
|
||||
|
||||
prompt := buildPolishPrompt(args.Text, args.FieldType, args.Context)
|
||||
|
||||
result := fmt.Sprintf(`{"polished_text": "%s", "suggestions": ["使用了更专业的行业术语", "突出了量化成果", "优化了表达结构"]}`,
|
||||
escapeJSON(prompt))
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func escapeJSON(s string) string {
|
||||
s = strings.ReplaceAll(s, "\\", "\\\\")
|
||||
s = strings.ReplaceAll(s, "\"", "\\\"")
|
||||
s = strings.ReplaceAll(s, "\n", "\\n")
|
||||
s = strings.ReplaceAll(s, "\r", "\\r")
|
||||
return s
|
||||
}
|
||||
|
||||
func buildPolishPrompt(text, fieldType, context string) string {
|
||||
var fieldName string
|
||||
var requirements string
|
||||
|
||||
switch fieldType {
|
||||
case "summary":
|
||||
fieldName = "个人简介"
|
||||
requirements = `1. 突出核心竞争力和独特价值
|
||||
2. 使用行动动词和量化成果
|
||||
3. 保持专业但不过于生硬
|
||||
4. 控制在150字以内
|
||||
5. 使用STAR法则描述关键成就`
|
||||
case "experience":
|
||||
fieldName = "工作经历"
|
||||
requirements = `1. 使用STAR法则(情境-任务-行动-结果)
|
||||
2. 突出量化成果和数据指标
|
||||
3. 使用专业术语和行业关键词
|
||||
4. 突出个人贡献而非团队成果
|
||||
5. 强调技术能力和解决问题的能力`
|
||||
case "project":
|
||||
fieldName = "项目描述"
|
||||
requirements = `1. 清晰描述技术架构和技术栈
|
||||
2. 突出个人技术贡献和解决的难题
|
||||
3. 量化项目成果和影响
|
||||
4. 使用专业技术术语
|
||||
5. 说明项目复杂度和创新性`
|
||||
case "education":
|
||||
fieldName = "教育背景"
|
||||
requirements = `1. 突出专业相关性
|
||||
2. 强调学术成绩和荣誉
|
||||
3. 描述相关课程和项目
|
||||
4. 保持简洁专业`
|
||||
case "skill":
|
||||
fieldName = "技能描述"
|
||||
requirements = `1. 按技术栈分类整理
|
||||
2. 标注熟练程度
|
||||
3. 突出核心竞争力
|
||||
4. 与目标职位匹配`
|
||||
default:
|
||||
fieldName = "文本内容"
|
||||
requirements = `1. 语言更加专业、精炼
|
||||
2. 突出重点和亮点
|
||||
3. 使用适当的行业术语
|
||||
4. 格式清晰易读`
|
||||
}
|
||||
|
||||
prompt := fmt.Sprintf(`你是一位专业的简历优化专家。请优化以下%s内容:
|
||||
|
||||
原文:
|
||||
%s`, fieldName, text)
|
||||
|
||||
if context != "" {
|
||||
prompt += fmt.Sprintf(`
|
||||
|
||||
上下文信息:
|
||||
%s`, context)
|
||||
}
|
||||
|
||||
prompt += fmt.Sprintf(`
|
||||
|
||||
优化要求:
|
||||
%s
|
||||
|
||||
请直接输出优化后的内容,不要包含其他解释。`, requirements)
|
||||
|
||||
return prompt
|
||||
}
|
||||
|
||||
type AnalyzeTool struct{}
|
||||
|
||||
func NewAnalyzeTool() *AnalyzeTool {
|
||||
return &AnalyzeTool{}
|
||||
}
|
||||
|
||||
func (t *AnalyzeTool) Info(ctx context.Context) (*schema.ToolInfo, error) {
|
||||
return &schema.ToolInfo{
|
||||
Name: "analyze_resume",
|
||||
Desc: "分析简历内容,提供专业的评估和改进建议。输入简历文本,返回分析报告。",
|
||||
ParamsOneOf: schema.NewParamsOneOfByParams(map[string]*schema.ParameterInfo{
|
||||
"resume_text": {
|
||||
Desc: "简历文本内容",
|
||||
Required: true,
|
||||
Type: schema.String,
|
||||
},
|
||||
"target_position": {
|
||||
Desc: "目标职位,可选",
|
||||
Type: schema.String,
|
||||
},
|
||||
}),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (t *AnalyzeTool) InvokableRun(ctx context.Context, argumentsInJSON string, opts ...tool.Option) (string, error) {
|
||||
var args struct {
|
||||
ResumeText string `json:"resume_text"`
|
||||
TargetPosition string `json:"target_position"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal([]byte(argumentsInJSON), &args); err != nil {
|
||||
return "", fmt.Errorf("failed to unmarshal arguments: %w", err)
|
||||
}
|
||||
|
||||
if args.ResumeText == "" {
|
||||
return "", fmt.Errorf("resume_text is required")
|
||||
}
|
||||
|
||||
result := `{"score": 85.5, "strengths": ["项目经验丰富,技术栈匹配度高", "工作经历描述清晰", "有量化成果展示"], "weaknesses": ["个人简介不够突出", "缺少核心优势总结", "部分技术栈描述不够详细"], "suggestions": ["优化个人简介,突出核心竞争力", "增加核心优势板块", "补充技术细节描述"], "keyword_gaps": ["微服务", "云原生", "分布式系统"]}`
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
type KeywordExtractTool struct{}
|
||||
|
||||
func NewKeywordExtractTool() *KeywordExtractTool {
|
||||
return &KeywordExtractTool{}
|
||||
}
|
||||
|
||||
type GenerateQuestionsTool struct{}
|
||||
|
||||
func NewGenerateQuestionsTool() *GenerateQuestionsTool {
|
||||
return &GenerateQuestionsTool{}
|
||||
}
|
||||
|
||||
func (t *GenerateQuestionsTool) Info(ctx context.Context) (*schema.ToolInfo, error) {
|
||||
return &schema.ToolInfo{
|
||||
Name: "generate_interview_questions",
|
||||
Desc: "根据简历内容生成面试题目,包括选择题、填空题、简答题和算法设计题。输入简历文本,返回结构化的题目列表,每类5题,按类型排序。",
|
||||
ParamsOneOf: schema.NewParamsOneOfByParams(map[string]*schema.ParameterInfo{
|
||||
"resume_text": {
|
||||
Desc: "简历文本内容(JSON格式)",
|
||||
Required: true,
|
||||
Type: schema.String,
|
||||
},
|
||||
"keywords": {
|
||||
Desc: "指定关键词,逗号分隔,如:Go,MySQL,微服务",
|
||||
Type: schema.String,
|
||||
},
|
||||
"types": {
|
||||
Desc: "题目类型,逗号分隔:mcq(选择题)、fill(填空题)、sa(简答题)、algo(算法设计题),默认全部",
|
||||
Type: schema.String,
|
||||
},
|
||||
}),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (t *GenerateQuestionsTool) InvokableRun(ctx context.Context, argumentsInJSON string, opts ...tool.Option) (string, error) {
|
||||
var args struct {
|
||||
ResumeText string `json:"resume_text"`
|
||||
Keywords string `json:"keywords"`
|
||||
Types string `json:"types"`
|
||||
Seed string `json:"seed"`
|
||||
Random bool `json:"random"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal([]byte(argumentsInJSON), &args); err != nil {
|
||||
return "", fmt.Errorf("failed to unmarshal arguments: %w", err)
|
||||
}
|
||||
|
||||
if args.ResumeText == "" && args.Keywords == "" {
|
||||
return "", fmt.Errorf("resume_text or keywords is required")
|
||||
}
|
||||
|
||||
if args.ResumeText == "" {
|
||||
args.ResumeText = "基于关键词:" + args.Keywords
|
||||
}
|
||||
|
||||
questions := generateMockQuestions(args.ResumeText, args.Keywords, args.Types, args.Seed, args.Random)
|
||||
|
||||
resultJSON, err := json.Marshal(questions)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return string(resultJSON), nil
|
||||
}
|
||||
|
||||
func generateMockQuestions(resumeText string, keywordsStr, typesStr, seed string, random bool) []map[string]interface{} {
|
||||
var questions []map[string]interface{}
|
||||
|
||||
techKeywords := extractTechKeywords(resumeText)
|
||||
if len(techKeywords) == 0 {
|
||||
techKeywords = []string{"HTML", "CSS", "JavaScript", "Go", "MySQL", "Redis"}
|
||||
}
|
||||
|
||||
if keywordsStr != "" {
|
||||
techKeywords = strings.Split(keywordsStr, ",")
|
||||
}
|
||||
|
||||
selectedTypes := parseTypes(typesStr)
|
||||
|
||||
position := extractPosition(resumeText, techKeywords)
|
||||
difficultyLevel := getDifficultyByPosition(position)
|
||||
|
||||
// 始终使用随机种子,保证每次生成不同题目
|
||||
if seed != "" {
|
||||
h := fnv.New32a()
|
||||
h.Write([]byte(seed))
|
||||
rand.Seed(int64(h.Sum32()))
|
||||
} else {
|
||||
rand.Seed(time.Now().UnixNano())
|
||||
}
|
||||
|
||||
mcqQuestions := []questionItem{
|
||||
{"Go语言中,以下哪个关键字用于声明常量?", []string{"var", "const", "static", "final"}, "B",
|
||||
"**解析:** const是Go语言中用于声明常量的关键字。常量是在编译时确定的值,不可修改。", []string{"Go", "常量"}, "入门", "Go基础"},
|
||||
{"以下哪个不是Go语言的基本类型?", []string{"int", "string", "float64", "ArrayList"}, "D",
|
||||
"**解析:** Go语言的基本类型包括:bool、int系列、uint系列、float系列、complex系列、string。", []string{"Go", "类型"}, "入门", "Go基础"},
|
||||
{"Go语言中,make函数不能用于创建哪种类型?", []string{"slice", "map", "channel", "struct"}, "D",
|
||||
"**解析:** make函数用于创建slice、map和channel这三种引用类型。", []string{"Go", "make"}, "初级", "Go基础"},
|
||||
{"以下哪个方法可以安全地并发访问map?", []string{"直接读写", "使用sync.Mutex", "使用atomic包", "无需任何措施"}, "B",
|
||||
"**解析:** Go语言的map不是并发安全的,需要使用sync.Mutex进行保护。", []string{"Go", "并发", "map"}, "中级", "Go并发"},
|
||||
{"Go语言中,interface{}表示什么?", []string{"空接口", "无类型", "错误类型", "指针类型"}, "A",
|
||||
"**解析:** interface{}是空接口,任何类型都实现了空接口。", []string{"Go", "接口"}, "初级", "Go基础"},
|
||||
{"Go语言中,defer语句的执行顺序是?", []string{"先进先出", "先进后出", "按顺序执行", "随机执行"}, "B",
|
||||
"**解析:** defer语句采用栈式管理,遵循先进后出(LIFO)原则。", []string{"Go", "defer"}, "初级", "Go基础"},
|
||||
{"以下哪种情况会导致goroutine泄漏?", []string{"channel发送后关闭", "channel接收但无发送者", "使用sync.WaitGroup", "使用context取消"}, "B",
|
||||
"**解析:** 如果goroutine在等待从channel接收数据,但没有发送者,将永远阻塞。", []string{"Go", "goroutine", "并发"}, "进阶", "Go并发"},
|
||||
{"Go语言中,GOMAXPROCS的作用是?", []string{"限制内存使用", "限制CPU核心数", "限制goroutine数量", "限制线程栈大小"}, "B",
|
||||
"**解析:** GOMAXPROCS控制Go程序可以同时使用的操作系统线程数。", []string{"Go", "GOMAXPROCS", "并发"}, "中级", "Go并发"},
|
||||
{"MySQL中,以下哪个索引类型适合全文搜索?", []string{"B-Tree", "HASH", "FULLTEXT", "SPATIAL"}, "C",
|
||||
"**解析:** FULLTEXT索引专门用于全文搜索。", []string{"MySQL", "索引"}, "初级", "MySQL"},
|
||||
{"Redis中,哪个命令可以原子性地设置键值并设置过期时间?", []string{"SET和EXPIRE", "SETNX", "SETEX", "MSET"}, "C",
|
||||
"**解析:** SETEX命令可以原子性地完成设置值和过期时间两个操作。", []string{"Redis", "命令"}, "初级", "Redis"},
|
||||
{"HTTP协议中,哪个方法不是幂等的?", []string{"GET", "PUT", "DELETE", "POST"}, "D",
|
||||
"**解析:** POST方法不是幂等的,多次执行可能产生不同的结果。", []string{"HTTP", "REST"}, "入门", "网络协议"},
|
||||
{"微服务架构中,服务发现的作用是?", []string{"负载均衡", "服务注册与查找", "数据加密", "日志收集"}, "B",
|
||||
"**解析:** 服务发现允许服务动态注册和查找其他服务的网络位置。", []string{"微服务", "服务发现"}, "初级", "架构"},
|
||||
{"Go语言中,以下哪个是值类型?", []string{"slice", "map", "string", "channel"}, "C",
|
||||
"**解析:** string在Go中是值类型,虽然底层是指针+长度,但赋值时会复制。", []string{"Go", "类型"}, "初级", "Go基础"},
|
||||
{"Go语言中,channel默认是?", []string{"有缓冲", "无缓冲", "双向", "单向"}, "B",
|
||||
"**解析:** Go语言中make(chan T)创建的是无缓冲channel,发送和接收会阻塞直到对方就绪。", []string{"Go", "channel"}, "初级", "Go并发"},
|
||||
{"MySQL中,InnoDB的默认隔离级别是?", []string{"READ UNCOMMITTED", "READ COMMITTED", "REPEATABLE READ", "SERIALIZABLE"}, "C",
|
||||
"**解析:** InnoDB默认隔离级别是REPEATABLE READ,可以防止不可重复读。", []string{"MySQL", "事务"}, "中级", "MySQL"},
|
||||
{"Redis中,哪种数据结构最适合实现排行榜?", []string{"String", "List", "Hash", "Sorted Set"}, "D",
|
||||
"**解析:** Sorted Set(有序集合)支持分数排序,非常适合实现排行榜功能。", []string{"Redis", "数据结构"}, "初级", "Redis"},
|
||||
{"Docker中,COPY和ADD命令的主要区别是?", []string{"无区别", "ADD支持URL和自动解压", "COPY支持URL", "ADD更快"}, "B",
|
||||
"**解析:** ADD比COPY功能更丰富,支持从URL下载文件和自动解压tar.gz。", []string{"Docker", "镜像"}, "初级", "DevOps"},
|
||||
{"Kubernetes中,Pod的最小运行实例数是?", []string{"0", "1", "2", "3"}, "A",
|
||||
"**解析:** Pod的replicas可以设置为0,表示不运行任何实例。", []string{"Kubernetes", "Pod"}, "初级", "DevOps"},
|
||||
{"TCP协议工作在OSI模型的哪一层?", []string{"网络层", "传输层", "应用层", "数据链路层"}, "B",
|
||||
"**解析:** TCP是传输层协议,负责端到端的可靠数据传输。", []string{"TCP", "网络"}, "入门", "网络协议"},
|
||||
{"HTTPS默认使用的端口号是?", []string{"80", "8080", "443", "8443"}, "C",
|
||||
"**解析:** HTTPS默认使用443端口,HTTP默认使用80端口。", []string{"HTTP", "HTTPS"}, "入门", "网络协议"},
|
||||
{"Git中,git merge和git rebase的主要区别是?", []string{"无区别", "merge保留历史,rebase线性化", "rebase保留历史", "merge更快"}, "B",
|
||||
"**解析:** merge保留完整提交历史,rebase将提交线性化到目标分支上。", []string{"Git", "版本控制"}, "初级", "工具"},
|
||||
{"RESTful API中,删除资源应该使用哪个HTTP方法?", []string{"GET", "POST", "PUT", "DELETE"}, "D",
|
||||
"**解析:** DELETE方法用于删除指定的资源,是幂等操作。", []string{"API", "REST"}, "入门", "架构"},
|
||||
{"HTML中,哪个标签用于定义无序列表?", []string{"<ol>", "<ul>", "<li>", "<dl>"}, "B",
|
||||
"**解析:** <ul>标签用于定义无序列表,<ol>用于有序列表,<li>是列表项,<dl>是定义列表。", []string{"HTML", "列表"}, "入门", "HTML"},
|
||||
{"HTML5中,哪个标签用于定义导航链接?", []string{"<nav>", "<header>", "<section>", "<article>"}, "A",
|
||||
"**解析:** <nav>标签定义导航链接的部分,用于页面的主要导航区域。", []string{"HTML5", "语义化"}, "入门", "HTML"},
|
||||
{"CSS中,哪个属性用于设置元素的边框?", []string{"margin", "padding", "border", "outline"}, "C",
|
||||
"**解析:** border属性用于设置元素的边框样式、宽度和颜色。", []string{"CSS", "边框"}, "入门", "CSS"},
|
||||
{"CSS中,flex-direction: column的作用是?", []string{"水平排列", "垂直排列", "反向排列", "换行"}, "B",
|
||||
"**解析:** flex-direction: column使flex容器的子元素垂直排列。", []string{"CSS", "Flexbox"}, "初级", "CSS"},
|
||||
{"JavaScript中,typeof null的返回值是?", []string{"'null'", "'undefined'", "'object'", "'number'"}, "C",
|
||||
"**解析:** typeof null返回'object',这是JavaScript的一个历史遗留bug。", []string{"JavaScript", "类型"}, "初级", "JavaScript"},
|
||||
{"JavaScript中,哪个方法用于阻止事件冒泡?", []string{"preventDefault()", "stopPropagation()", "stopImmediatePropagation()", "cancelBubble()"}, "B",
|
||||
"**解析:** stopPropagation()方法阻止事件继续传播到父元素。", []string{"JavaScript", "事件"}, "初级", "JavaScript"},
|
||||
{"CSS中,哪个选择器的优先级最高?", []string{"类选择器", "ID选择器", "标签选择器", "伪类选择器"}, "B",
|
||||
"**解析:** ID选择器的优先级最高,其次是类选择器和伪类选择器,最后是标签选择器。", []string{"CSS", "选择器"}, "初级", "CSS"},
|
||||
{"HTML中,哪个属性用于指定表单提交的URL?", []string{"method", "action", "enctype", "target"}, "B",
|
||||
"**解析:** action属性指定表单提交的目标URL。", []string{"HTML", "表单"}, "入门", "HTML"},
|
||||
{"JavaScript中,Promise的三种状态不包括?", []string{"pending", "resolved", "rejected", "fulfilled"}, "B",
|
||||
"**解析:** Promise的三种状态是pending(等待中)、fulfilled(已成功)、rejected(已失败)。resolved不是标准状态。", []string{"JavaScript", "Promise"}, "中级", "JavaScript"},
|
||||
}
|
||||
|
||||
fillQuestions := []questionItem{
|
||||
{"Go语言中,___关键字用于启动一个goroutine。", nil, "go",
|
||||
"**解析:** go关键字用于启动goroutine,它会在新的goroutine中执行指定的函数。", []string{"Go", "goroutine"}, "入门", "Go基础"},
|
||||
{"Go语言的包管理工具是___。", nil, "go mod",
|
||||
"**解析:** go mod是Go 1.11引入的官方包管理工具,用于管理项目依赖。", []string{"Go", "包管理"}, "入门", "Go基础"},
|
||||
{"HTTP状态码___表示请求成功。", nil, "200",
|
||||
"**解析:** HTTP 200 OK表示请求成功。", []string{"HTTP", "状态码"}, "入门", "网络协议"},
|
||||
{"JSON序列化使用的包是___。", nil, "encoding/json",
|
||||
"**解析:** encoding/json包提供了JSON序列化和反序列化功能。", []string{"Go", "JSON"}, "初级", "Go基础"},
|
||||
{"Go语言中,___关键字用于错误处理。", nil, "error",
|
||||
"**解析:** error是Go语言的错误类型,通常作为函数的返回值返回。", []string{"Go", "错误处理"}, "入门", "Go基础"},
|
||||
{"MySQL事务的ACID特性中,A代表___。", nil, "Atomicity/原子性",
|
||||
"**解析:** ACID分别代表:Atomicity(原子性)、Consistency(一致性)、Isolation(隔离性)、Durability(持久性)。", []string{"MySQL", "事务"}, "初级", "MySQL"},
|
||||
{"Redis中,___命令用于查看键的剩余过期时间。", nil, "TTL",
|
||||
"**解析:** TTL命令返回键的剩余生存时间(秒)。", []string{"Redis", "命令"}, "初级", "Redis"},
|
||||
{"TCP三次握手过程中,第二次握手服务器发送的报文包含SYN和___标志位。", nil, "ACK",
|
||||
"**解析:** 三次握手:1.客户端发送SYN;2.服务器回复SYN+ACK;3.客户端发送ACK。", []string{"TCP", "网络"}, "中级", "网络协议"},
|
||||
{"Go语言中,___函数用于创建切片、map和channel。", nil, "make",
|
||||
"**解析:** make函数专门用于创建slice、map和channel这三种引用类型。", []string{"Go", "make"}, "入门", "Go基础"},
|
||||
{"Go语言中,___函数用于分配内存并返回指针。", nil, "new",
|
||||
"**解析:** new函数分配内存,返回指向该类型零值的指针。", []string{"Go", "new"}, "入门", "Go基础"},
|
||||
{"MySQL中,___约束确保列中的值唯一。", nil, "UNIQUE",
|
||||
"**解析:** UNIQUE约束确保列中的所有值都是不同的。", []string{"MySQL", "约束"}, "初级", "MySQL"},
|
||||
{"Redis中,___数据结构适合实现消息队列。", nil, "List",
|
||||
"**解析:** List(列表)支持先进先出(FIFO)操作,适合实现消息队列。", []string{"Redis", "数据结构"}, "初级", "Redis"},
|
||||
{"Docker中,___命令用于构建镜像。", nil, "docker build",
|
||||
"**解析:** docker build命令根据Dockerfile构建Docker镜像。", []string{"Docker", "镜像"}, "初级", "DevOps"},
|
||||
{"Kubernetes中,___用于管理Pod的副本数。", nil, "ReplicaSet",
|
||||
"**解析:** ReplicaSet确保指定数量的Pod副本在运行。", []string{"Kubernetes", "ReplicaSet"}, "初级", "DevOps"},
|
||||
{"HTTP状态码___表示服务器内部错误。", nil, "500",
|
||||
"**解析:** HTTP 500 Internal Server Error表示服务器遇到了意外情况。", []string{"HTTP", "状态码"}, "入门", "网络协议"},
|
||||
{"Git中,___命令用于创建新分支。", nil, "git branch",
|
||||
"**解析:** git branch命令用于创建、列出和删除分支。", []string{"Git", "分支"}, "入门", "工具"},
|
||||
{"HTML中,___标签用于定义表格的表头单元格。", nil, "th",
|
||||
"**解析:** <th>标签定义表头单元格,通常显示为粗体居中。", []string{"HTML", "表格"}, "入门", "HTML"},
|
||||
{"CSS中,___属性用于设置元素的背景颜色。", nil, "background-color",
|
||||
"**解析:** background-color属性设置元素的背景颜色。", []string{"CSS", "背景"}, "入门", "CSS"},
|
||||
{"JavaScript中,___关键字用于声明常量。", nil, "const",
|
||||
"**解析:** const声明的常量不能重新赋值。", []string{"JavaScript", "变量"}, "入门", "JavaScript"},
|
||||
{"HTML5中,___标签用于播放视频。", nil, "video",
|
||||
"**解析:** <video>标签定义视频播放器。", []string{"HTML5", "媒体"}, "入门", "HTML"},
|
||||
{"CSS中,___单位是相对于根元素字体大小的。", nil, "rem",
|
||||
"**解析:** rem(root em)是相对于<html>元素的字体大小。", []string{"CSS", "单位"}, "初级", "CSS"},
|
||||
}
|
||||
|
||||
saQuestions := []questionItem{
|
||||
{"请简述Go语言的并发模型。", nil,
|
||||
"Go语言采用goroutine和channel实现并发。goroutine是轻量级线程,由Go运行时管理。channel用于goroutine之间的通信,支持同步和异步模式。select语句可以同时等待多个channel操作。",
|
||||
"**深入解析:**\n\n**1. Goroutine原理:**\n- Goroutine是用户态线程,由Go运行时调度\n- 创建开销约2KB栈空间,远小于操作系统线程\n- GOMAXPROCS控制同时运行的OS线程数\n\n**2. Channel类型:**\n- 无缓冲channel:发送和接收同步\n- 有缓冲channel:异步,缓冲区满时阻塞\n- 双向/单向channel:控制数据流向\n\n**3. 调度模型(M:N):**\n- M个OS线程对应N个goroutine\n- P(Processor)负责调度goroutine\n- G(Goroutine)等待被调度执行\n\n**4. 并发同步原语:**\n- sync.Mutex/RWMutex:互斥锁\n- sync.WaitGroup:等待多个goroutine完成\n- sync.Cond:条件变量\n- atomic包:原子操作",
|
||||
[]string{"Go", "并发", "goroutine", "channel"}, "中级", "Go并发"},
|
||||
{"请描述RESTful API的设计原则。", nil,
|
||||
"RESTful API设计原则包括:使用HTTP方法表示操作(GET/POST/PUT/DELETE);无状态通信;统一接口;资源通过URL标识;支持多种数据格式;使用HTTP状态码表示结果。",
|
||||
"**深入解析:**\n\n**1. HTTP方法语义:**\n- GET:获取资源(幂等)\n- POST:创建资源\n- PUT:更新资源(幂等,完整替换)\n- PATCH:部分更新\n- DELETE:删除资源(幂等)\n\n**2. 无状态原则:**\n- 服务器不存储客户端状态\n- 每次请求包含所有必要信息\n- 便于水平扩展和缓存\n\n**3. 统一接口约束:**\n- 资源标识:每个资源有唯一URI\n- 资源操作:通过HTTP方法操作\n- 自描述消息:媒体类型描述内容\n- 超媒体驱动:响应包含链接\n\n**4. URL设计规范:**\n- 使用名词而非动词:/users 而非 /getUsers\n- 使用复数形式:/users 而非 /user\n- 使用嵌套表示关系:/users/{id}/posts\n- 避免冗余:/api/v1/users 而非 /api/v1/get_users",
|
||||
[]string{"API", "REST", "HTTP"}, "中级", "架构"},
|
||||
{"请解释什么是微服务架构及其优缺点。", nil,
|
||||
"微服务架构是将应用拆分为多个独立的小型服务。优点:独立部署、技术栈灵活、高可用、团队自治。缺点:分布式复杂性、服务间通信开销、数据一致性挑战、运维成本增加。",
|
||||
"**深入解析:**\n\n**1. 架构特点:**\n- 单一职责:每个服务专注一个业务领域\n- 独立部署:服务间互不影响\n- 松耦合:通过API进行通信\n- 去中心化:数据和治理分散\n\n**2. 核心组件:**\n- 服务发现:Consul、Eureka、Nacos\n- API网关:Zuul、Gateway、Kong\n- 配置中心:Spring Cloud Config、Nacos\n- 消息队列:Kafka、RabbitMQ、RocketMQ\n\n**3. 数据一致性模式:**\n- Saga模式:分布式事务协调\n- 事件溯源:通过事件重建状态\n- CQRS:命令查询职责分离\n- 最终一致性:异步保证一致性\n\n**4. 适用场景:**\n- 大型复杂系统\n- 多团队协作开发\n- 需要快速迭代部署\n- 需要技术栈多样性",
|
||||
[]string{"微服务", "架构"}, "进阶", "架构"},
|
||||
{"请说明MySQL索引的工作原理。", nil,
|
||||
"MySQL索引主要使用B-Tree结构。索引通过排序和分层存储实现快速查找。查询时从根节点开始,通过比较键值向下遍历到叶子节点,找到对应的数据行。索引可以显著提升查询性能,但会降低写入性能。",
|
||||
"**深入解析:**\n\n**1. B-Tree结构:**\n- 平衡树,所有叶子节点在同一层\n- 每个节点存储多个键值\n- 节点内有序,便于二分查找\n\n**2. B+Tree优化:**\n- 叶子节点存储数据或指向数据的指针\n- 叶子节点形成双向链表\n- 支持范围查询和全表扫描\n\n**3. 索引类型:**\n- 主键索引:唯一且非空\n- 唯一索引:值唯一但可空\n- 普通索引:无限制\n- 联合索引:多列组合\n- 全文索引:文本搜索\n\n**4. 索引失效场景:**\n- 使用!=或NOT IN\n- 使用函数或表达式\n- 使用LIKE '%xxx'\n- 类型不匹配\n- OR条件未全部命中索引\n\n**5. 覆盖索引:**\n- 查询所需字段都在索引中\n- 无需回表查询\n- 显著提升性能",
|
||||
[]string{"MySQL", "索引", "B-Tree"}, "进阶", "MySQL"},
|
||||
{"请描述Redis的持久化机制。", nil,
|
||||
"Redis提供两种持久化方式:RDB(快照)和AOF(追加日志)。RDB定期将内存数据写入磁盘,适合备份和灾难恢复。AOF记录所有写操作,重启时重放恢复数据,数据安全性更高。",
|
||||
"**深入解析:**\n\n**1. RDB持久化:**\n- 触发方式:定时自动、手动SAVE/BGSAVE\n- 优点:文件体积小、恢复速度快\n- 缺点:可能丢失最近数据\n- 适用场景:备份、灾难恢复\n\n**2. AOF持久化:**\n- 记录所有写命令到日志\n- 同步策略:always/everysec/no\n- 重写机制:压缩日志文件\n- 优点:数据安全,最多丢失1秒数据\n- 缺点:文件体积大、恢复慢\n\n**3. 混合持久化(Redis 4.0+):**\n- RDB作为基础快照\n- AOF记录增量变化\n- 兼顾速度和安全性\n\n**4. 恢复策略:**\n- 优先加载AOF\n- AOF不存在时加载RDB\n- 同时开启时混合加载",
|
||||
[]string{"Redis", "持久化", "RDB", "AOF"}, "进阶", "Redis"},
|
||||
{"请说明Go语言中interface的实现原理。", nil,
|
||||
"Go语言的interface采用鸭子类型,不需要显式声明实现。底层使用iface结构存储类型信息和数据指针。空接口interface{}可以存储任何类型的值。",
|
||||
"**深入解析:**\n\n**1. iface结构:**\n- tab:指向itab,包含类型信息和方法表\n- data:指向实际数据的指针\n\n**2. itab结构:**\n- inter:接口类型信息\n- _type:实际类型信息\n- fun:方法指针数组\n\n**3. 类型断言:**\n- 安全断言:value, ok := x.(Type)\n- 类型选择:switch v := x.(type) {}\n\n**4. 性能优化:**\n- 避免频繁类型断言\n- 优先使用具体类型而非接口",
|
||||
[]string{"Go", "接口", "底层原理"}, "高级", "Go基础"},
|
||||
{"请描述分布式系统中的CAP理论。", nil,
|
||||
"CAP理论指出分布式系统不可能同时满足一致性(Consistency)、可用性(Availability)和分区容错性(Partition tolerance)三个特性。在网络分区发生时,必须在一致性和可用性之间做出选择。",
|
||||
"**深入解析:**\n\n**1. CAP三要素:**\n- C:一致性,所有节点看到相同的数据\n- A:可用性,任何请求都能获得响应\n- P:分区容错性,网络分区时系统继续运行\n\n**2. 权衡选择:**\n- CP:牺牲可用性保证一致性(如ZooKeeper)\n- AP:牺牲一致性保证可用性(如Redis集群)\n\n**3. BASE理论:**\n- 基本可用(Basically Available)\n- 软状态(Soft State)\n- 最终一致性(Eventually Consistent)",
|
||||
[]string{"分布式", "CAP", "理论"}, "高级", "架构"},
|
||||
}
|
||||
|
||||
algoQuestions := []questionItem{
|
||||
{"实现一个函数,反转单链表。要求时间复杂度O(n),空间复杂度O(1)。", nil,
|
||||
"```go\nfunc reverseList(head *ListNode) *ListNode {\n var prev *ListNode\n curr := head\n for curr != nil {\n nextTemp := curr.Next\n curr.Next = prev\n prev = curr\n curr = nextTemp\n }\n return prev\n}\n```",
|
||||
"**深入解析:**\n\n**1. 算法思路:**\n- 使用三个指针:prev、curr、nextTemp\n- 遍历链表,逐个反转节点指向\n- 时间复杂度:O(n)\n- 空间复杂度:O(1)\n\n**2. 递归解法:**\n```go\nfunc reverseList(head *ListNode) *ListNode {\n if head == nil || head.Next == nil {\n return head\n }\n p := reverseList(head.Next)\n head.Next.Next = head\n head.Next = nil\n return p\n}\n```\n\n**3. 关键点:**\n- 保存下一节点的引用\n- 防止链表断裂\n- 返回新的头节点\n\n**4. 扩展问题:**\n- 反转链表前N个节点\n- 反转链表的一部分\n- K个一组反转链表",
|
||||
[]string{"算法", "链表", "反转"}, "初级", "算法"},
|
||||
{"实现一个函数,判断两个字符串是否是异位词。", nil,
|
||||
"```go\nfunc isAnagram(s, t string) bool {\n if len(s) != len(t) {\n return false\n }\n count := make([]int, 26)\n for i := 0; i < len(s); i++ {\n count[s[i]-'a']++\n count[t[i]-'a']--\n }\n for _, c := range count {\n if c != 0 {\n return false\n }\n }\n return true\n}\n```",
|
||||
"**深入解析:**\n\n**1. 算法思路:**\n- 统计字符频率\n- 使用数组或哈希表\n- 比较两个字符串的频率\n\n**2. 复杂度分析:**\n- 时间:O(n)\n- 空间:O(1)(固定大小数组)\n\n**3. 扩展情况:**\n- 包含Unicode字符:使用map[rune]int\n- 大小写敏感/不敏感处理\n- 包含空格或特殊字符\n\n**4. 其他解法:**\n- 排序后比较:O(n log n)时间,O(1)空间\n- 使用哈希表:通用但空间稍大\n\n**5. 相关问题:**\n- 找到字符串中第一个不重复的字符\n- 最长无重复子串\n- 字符串排列",
|
||||
[]string{"算法", "字符串", "哈希表"}, "入门", "算法"},
|
||||
{"实现快速排序算法。要求平均时间复杂度O(n log n)。", nil,
|
||||
"```go\nfunc quickSort(nums []int) []int {\n if len(nums) <= 1 {\n return nums\n }\n pivot := nums[len(nums)/2]\n var left, right, middle []int\n for _, num := range nums {\n if num < pivot {\n left = append(left, num)\n } else if num > pivot {\n right = append(right, num)\n } else {\n middle = append(middle, num)\n }\n }\n return append(append(quickSort(left), middle...), quickSort(right)...)\n}\n```",
|
||||
"**深入解析:**\n\n**1. 算法思想:**\n- 分治策略\n- 选择基准元素\n- 分区:小于/等于/大于基准\n- 递归处理左右分区\n\n**2. 复杂度分析:**\n- 平均时间:O(n log n)\n- 最坏时间:O(n²)(已排序数组)\n- 空间复杂度:O(log n)(递归栈)\n\n**3. 优化策略:**\n- 随机选择基准\n- 三数取中选择基准\n- 小数组使用插入排序\n- 尾递归优化\n\n**4. 原地排序实现:**\n```go\nfunc quickSortInPlace(nums []int, low, high int) {\n if low < high {\n pivot := partition(nums, low, high)\n quickSortInPlace(nums, low, pivot-1)\n quickSortInPlace(nums, pivot+1, high)\n }\n}\n```",
|
||||
[]string{"算法", "排序", "快速排序"}, "中级", "算法"},
|
||||
{"实现一个LRU缓存。要求get和put操作时间复杂度O(1)。", nil,
|
||||
"```go\ntype LRUCache struct {\n capacity int\n cache map[int]*Node\n head *Node\n tail *Node\n}\n\ntype Node struct {\n key, val int\n prev, next *Node\n}\n\nfunc (c *LRUCache) Get(key int) int {\n if node, ok := c.cache[key]; ok {\n c.remove(node)\n c.addToFront(node)\n return node.val\n }\n return -1\n}\n\nfunc (c *LRUCache) Put(key, value int) {\n if node, ok := c.cache[key]; ok {\n node.val = value\n c.remove(node)\n c.addToFront(node)\n return\n }\n if len(c.cache) == c.capacity {\n delete(c.cache, c.tail.key)\n c.remove(c.tail)\n }\n newNode := &Node{key: key, val: value}\n c.cache[key] = newNode\n c.addToFront(newNode)\n}\n```",
|
||||
"**深入解析:**\n\n**1. 数据结构:**\n- 哈希表:O(1)查找\n- 双向链表:O(1)删除和插入\n- 链表头部:最近使用\n- 链表尾部:最久未使用\n\n**2. 核心操作:**\n- Get:查找并移到头部\n- Put:插入头部,满时删除尾部\n- Remove:从链表中移除\n- AddToFront:添加到头部\n\n**3. 复杂度分析:**\n- Get:O(1)\n- Put:O(1)\n- 空间:O(capacity)\n\n**4. 扩展:LFU缓存**\n- 最少使用频率淘汰\n- 需要维护频率计数\n- 同频率按LRU淘汰",
|
||||
[]string{"算法", "缓存", "LRU"}, "进阶", "算法"},
|
||||
{"实现二叉树的层序遍历。", nil,
|
||||
"```go\nfunc levelOrder(root *TreeNode) [][]int {\n var result [][]int\n if root == nil {\n return result\n }\n queue := []*TreeNode{root}\n for len(queue) > 0 {\n levelSize := len(queue)\n var currentLevel []int\n for i := 0; i < levelSize; i++ {\n node := queue[0]\n queue = queue[1:]\n currentLevel = append(currentLevel, node.Val)\n if node.Left != nil {\n queue = append(queue, node.Left)\n }\n if node.Right != nil {\n queue = append(queue, node.Right)\n }\n }\n result = append(result, currentLevel)\n }\n return result\n}\n```",
|
||||
"**深入解析:**\n\n**1. 算法思路:**\n- 使用队列进行广度优先遍历\n- 按层处理节点\n- 记录每层节点数\n- 逐层收集结果\n\n**2. 复杂度分析:**\n- 时间:O(n),每个节点访问一次\n- 空间:O(n),最坏情况队列存储所有节点\n\n**3. 变体问题:**\n- 锯齿形层序遍历\n- 层序遍历从底向上\n- 每层最大值\n- 二叉树的右视图\n\n**4. 递归实现:**\n```go\nfunc levelOrderRecursive(root *TreeNode) [][]int {\n var result [][]int\n dfs(root, 0, &result)\n return result\n}\n```",
|
||||
[]string{"算法", "二叉树", "层序遍历"}, "初级", "算法"},
|
||||
{"实现二分查找算法。", nil,
|
||||
"```go\nfunc binarySearch(nums []int, target int) int {\n left, right := 0, len(nums)-1\n for left <= right {\n mid := left + (right-left)/2\n if nums[mid] == target {\n return mid\n } else if nums[mid] < target {\n left = mid + 1\n } else {\n right = mid - 1\n }\n }\n return -1\n}\n```",
|
||||
"**深入解析:**\n\n**1. 算法思想:**\n- 分治策略\n- 有序数组前提\n- 每次排除一半元素\n\n**2. 复杂度分析:**\n- 时间:O(log n)\n- 空间:O(1)\n\n**3. 边界处理:**\n- 避免整数溢出:mid = left + (right-left)/2\n- 循环条件:left <= right\n\n**4. 变体问题:**\n- 查找第一个等于target的元素\n- 查找最后一个等于target的元素\n- 查找第一个大于等于target的元素",
|
||||
[]string{"算法", "二分查找"}, "入门", "算法"},
|
||||
{"实现合并两个有序链表。", nil,
|
||||
"```go\nfunc mergeTwoLists(l1, l2 *ListNode) *ListNode {\n dummy := &ListNode{}\n curr := dummy\n for l1 != nil && l2 != nil {\n if l1.Val < l2.Val {\n curr.Next = l1\n l1 = l1.Next\n } else {\n curr.Next = l2\n l2 = l2.Next\n }\n curr = curr.Next\n }\n if l1 != nil {\n curr.Next = l1\n } else {\n curr.Next = l2\n }\n return dummy.Next\n}\n```",
|
||||
"**深入解析:**\n\n**1. 算法思路:**\n- 虚拟头节点简化边界处理\n- 双指针遍历两个链表\n- 比较节点值,选择较小者\n\n**2. 复杂度分析:**\n- 时间:O(n+m)\n- 空间:O(1)\n\n**3. 递归解法:**\n```go\nfunc mergeTwoLists(l1, l2 *ListNode) *ListNode {\n if l1 == nil { return l2 }\n if l2 == nil { return l1 }\n if l1.Val < l2.Val {\n l1.Next = mergeTwoLists(l1.Next, l2)\n return l1\n }\n l2.Next = mergeTwoLists(l1, l2.Next)\n return l2\n}\n```",
|
||||
[]string{"算法", "链表", "合并"}, "初级", "算法"},
|
||||
}
|
||||
|
||||
shuffleSlice := func(slice interface{}) {
|
||||
switch s := slice.(type) {
|
||||
case []questionItem:
|
||||
for i := len(s) - 1; i > 0; i-- {
|
||||
j := rand.Intn(i + 1)
|
||||
s[i], s[j] = s[j], s[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if containsType(selectedTypes, "mcq") {
|
||||
mcqFiltered := filterQuestionsByDifficulty(filterQuestionsByKeywords(mcqQuestions, techKeywords), difficultyLevel)
|
||||
shuffleSlice(mcqFiltered)
|
||||
for i := 0; i < 5 && i < len(mcqFiltered); i++ {
|
||||
mcq := mcqFiltered[i]
|
||||
questions = append(questions, map[string]interface{}{
|
||||
"type": "mcq",
|
||||
"text": mcq.question,
|
||||
"options": mcq.options,
|
||||
"answer": mcq.answer,
|
||||
"analysis": mcq.analysis,
|
||||
"score": getScoreByDifficulty(mcq.difficulty),
|
||||
"keywords": mcq.keywords,
|
||||
"difficulty": mcq.difficulty,
|
||||
"category": mcq.category,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if containsType(selectedTypes, "fill") {
|
||||
fillFiltered := filterQuestionsByDifficulty(filterQuestionsByKeywords(fillQuestions, techKeywords), difficultyLevel)
|
||||
shuffleSlice(fillFiltered)
|
||||
for i := 0; i < 5 && i < len(fillFiltered); i++ {
|
||||
fill := fillFiltered[i]
|
||||
questions = append(questions, map[string]interface{}{
|
||||
"type": "fill",
|
||||
"text": fill.question,
|
||||
"answer": fill.answer,
|
||||
"analysis": fill.analysis,
|
||||
"score": getScoreByDifficulty(fill.difficulty),
|
||||
"keywords": fill.keywords,
|
||||
"difficulty": fill.difficulty,
|
||||
"category": fill.category,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if containsType(selectedTypes, "sa") {
|
||||
saFiltered := filterQuestionsByDifficulty(filterQuestionsByKeywords(saQuestions, techKeywords), difficultyLevel)
|
||||
shuffleSlice(saFiltered)
|
||||
for i := 0; i < 5 && i < len(saFiltered); i++ {
|
||||
sa := saFiltered[i]
|
||||
questions = append(questions, map[string]interface{}{
|
||||
"type": "sa",
|
||||
"text": sa.question,
|
||||
"answer": sa.answer,
|
||||
"analysis": sa.analysis,
|
||||
"score": getScoreByDifficulty(sa.difficulty),
|
||||
"keywords": sa.keywords,
|
||||
"difficulty": sa.difficulty,
|
||||
"category": sa.category,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if containsType(selectedTypes, "algo") {
|
||||
algoFiltered := filterQuestionsByDifficulty(filterQuestionsByKeywords(algoQuestions, techKeywords), difficultyLevel)
|
||||
shuffleSlice(algoFiltered)
|
||||
for i := 0; i < 5 && i < len(algoFiltered); i++ {
|
||||
algo := algoFiltered[i]
|
||||
questions = append(questions, map[string]interface{}{
|
||||
"type": "algo",
|
||||
"text": algo.question,
|
||||
"answer": algo.answer,
|
||||
"analysis": algo.analysis,
|
||||
"score": getScoreByDifficulty(algo.difficulty),
|
||||
"keywords": algo.keywords,
|
||||
"difficulty": algo.difficulty,
|
||||
"category": algo.category,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return questions
|
||||
}
|
||||
|
||||
func getScoreByDifficulty(difficulty string) int {
|
||||
switch difficulty {
|
||||
case "入门":
|
||||
return 5
|
||||
case "初级":
|
||||
return 10
|
||||
case "中级":
|
||||
return 15
|
||||
case "进阶":
|
||||
return 20
|
||||
case "高级":
|
||||
return 25
|
||||
default:
|
||||
return 10
|
||||
}
|
||||
}
|
||||
|
||||
func parseTypes(typesStr string) []string {
|
||||
if typesStr == "" {
|
||||
return []string{"mcq", "fill", "sa", "algo"}
|
||||
}
|
||||
return strings.Split(typesStr, ",")
|
||||
}
|
||||
|
||||
func containsType(types []string, t string) bool {
|
||||
for _, tp := range types {
|
||||
if tp == t {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type questionItem struct {
|
||||
question string
|
||||
options []string
|
||||
answer string
|
||||
analysis string
|
||||
keywords []string
|
||||
difficulty string
|
||||
category string
|
||||
}
|
||||
|
||||
func extractPosition(resumeText string, keywords []string) string {
|
||||
positionKeywords := map[string][]string{
|
||||
"高级工程师": {"高级", "资深", "architect", "架构师", "技术负责人", "tech lead", "lead"},
|
||||
"中级工程师": {"中级", "工程师", "developer", "software engineer", "全栈"},
|
||||
"初级工程师": {"初级", "助理", "实习生", "junior", "graduate", "entry"},
|
||||
}
|
||||
|
||||
resumeLower := strings.ToLower(resumeText)
|
||||
|
||||
for position, keywords := range positionKeywords {
|
||||
for _, kw := range keywords {
|
||||
if strings.Contains(resumeLower, strings.ToLower(kw)) {
|
||||
return position
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, kw := range keywords {
|
||||
if strings.Contains(strings.ToLower(kw), "架构") || strings.Contains(strings.ToLower(kw), "架构师") {
|
||||
return "高级工程师"
|
||||
}
|
||||
}
|
||||
|
||||
return "中级工程师"
|
||||
}
|
||||
|
||||
func getDifficultyByPosition(position string) string {
|
||||
switch position {
|
||||
case "高级工程师":
|
||||
return "高级"
|
||||
case "中级工程师":
|
||||
return "中级"
|
||||
case "初级工程师":
|
||||
return "初级"
|
||||
default:
|
||||
return "中级"
|
||||
}
|
||||
}
|
||||
|
||||
func filterQuestionsByDifficulty(questions []questionItem, minDifficulty string) []questionItem {
|
||||
difficultyOrder := map[string]int{
|
||||
"入门": 0,
|
||||
"初级": 1,
|
||||
"中级": 2,
|
||||
"进阶": 3,
|
||||
"高级": 4,
|
||||
}
|
||||
|
||||
minLevel := difficultyOrder[minDifficulty]
|
||||
var filtered []questionItem
|
||||
|
||||
for _, q := range questions {
|
||||
if difficultyOrder[q.difficulty] >= minLevel {
|
||||
filtered = append(filtered, q)
|
||||
}
|
||||
}
|
||||
|
||||
if len(filtered) == 0 {
|
||||
return questions
|
||||
}
|
||||
|
||||
return filtered
|
||||
}
|
||||
|
||||
// keywordAliases 关键词别名映射表,扩展用户输入的关键词以提高匹配命中率
|
||||
// 例如:用户输入 "React" 时,同时匹配 "前端"、"JavaScript" 等相关关键词
|
||||
var keywordAliases = map[string][]string{
|
||||
"go": {"go", "golang", "goroutine", "channel", "defer", "make", "const", "var", "interface", "gomaxprocs", "panic", "recover", "new"},
|
||||
"golang": {"go", "golang", "goroutine", "channel", "defer", "make", "interface"},
|
||||
"java": {"java", "jvm", "spring", "maven", "gradle", "jdk", "jre"},
|
||||
"python": {"python", "django", "flask", "pandas", "numpy"},
|
||||
"javascript": {"javascript", "js", "es6", "promise", "async", "await"},
|
||||
"typescript": {"typescript", "ts", "javascript", "js"},
|
||||
"react": {"react", "jsx", "tsx", "hooks", "redux", "前端", "javascript"},
|
||||
"vue": {"vue", "vuex", "vuejs", "前端", "javascript"},
|
||||
"mysql": {"mysql", "sql", "数据库", "innodb", "事务", "索引", "acid"},
|
||||
"redis": {"redis", "缓存", "rdb", "aof", "sorted set", "ttl"},
|
||||
"docker": {"docker", "容器", "镜像", "dockerfile"},
|
||||
"kubernetes": {"kubernetes", "k8s", "pod", "replicaset", "container"},
|
||||
"k8s": {"kubernetes", "k8s", "pod", "replicaset"},
|
||||
"微服务": {"微服务", "架构", "rest", "api", "服务发现", "分布式"},
|
||||
"分布式": {"分布式", "cap", "微服务", "一致性", "raft", "paxos"},
|
||||
"网络": {"网络", "tcp", "http", "https", "tcp/ip", "osi"},
|
||||
"http": {"http", "https", "rest", "api", "状态码"},
|
||||
"https": {"https", "http", "ssl", "tls"},
|
||||
"算法": {"算法", "链表", "二叉树", "排序", "查找", "二分查找", "快速排序", "lru"},
|
||||
"html": {"html", "html5", "前端", "表单"},
|
||||
"html5": {"html5", "html", "前端"},
|
||||
"css": {"css", "flexbox", "rem", "选择器", "前端"},
|
||||
"git": {"git", "版本控制", "分支"},
|
||||
"api": {"api", "rest", "restful", "接口"},
|
||||
"rest": {"rest", "restful", "api", "http"},
|
||||
"tcp": {"tcp", "网络", "osi", "三次握手"},
|
||||
}
|
||||
|
||||
// expandKeywords 扩展关键词列表,加入别名
|
||||
func expandKeywords(keywords []string) []string {
|
||||
seen := make(map[string]bool)
|
||||
var expanded []string
|
||||
for _, kw := range keywords {
|
||||
kwLower := strings.ToLower(strings.TrimSpace(kw))
|
||||
if kwLower == "" {
|
||||
continue
|
||||
}
|
||||
if !seen[kwLower] {
|
||||
seen[kwLower] = true
|
||||
expanded = append(expanded, kwLower)
|
||||
}
|
||||
if aliases, ok := keywordAliases[kwLower]; ok {
|
||||
for _, alias := range aliases {
|
||||
if !seen[alias] {
|
||||
seen[alias] = true
|
||||
expanded = append(expanded, alias)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return expanded
|
||||
}
|
||||
|
||||
func filterQuestionsByKeywords(questions []questionItem, keywords []string) []questionItem {
|
||||
if len(keywords) == 0 {
|
||||
return questions
|
||||
}
|
||||
|
||||
expandedKeywords := expandKeywords(keywords)
|
||||
if len(expandedKeywords) == 0 {
|
||||
return questions
|
||||
}
|
||||
|
||||
var filtered []questionItem
|
||||
for _, q := range questions {
|
||||
matched := false
|
||||
for _, qkw := range q.keywords {
|
||||
qkwLower := strings.ToLower(qkw)
|
||||
for _, kw := range expandedKeywords {
|
||||
if qkwLower == kw || strings.Contains(qkwLower, kw) || strings.Contains(kw, qkwLower) {
|
||||
filtered = append(filtered, q)
|
||||
matched = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if matched {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 严格过滤:未匹配时返回空切片,由调用方处理(GenerateQuestions 会返回"未找到与关键词匹配的题目")
|
||||
return filtered
|
||||
}
|
||||
|
||||
func (t *KeywordExtractTool) Info(ctx context.Context) (*schema.ToolInfo, error) {
|
||||
return &schema.ToolInfo{
|
||||
Name: "extract_keywords",
|
||||
Desc: "从简历文本中提取关键词,包括技术栈、技能、行业术语等。输入简历文本,返回结构化的关键词列表。",
|
||||
ParamsOneOf: schema.NewParamsOneOfByParams(map[string]*schema.ParameterInfo{
|
||||
"text": {
|
||||
Desc: "简历文本内容",
|
||||
Required: true,
|
||||
Type: schema.String,
|
||||
},
|
||||
"category": {
|
||||
Desc: "提取类别:all(全部)、tech(技术栈)、skill(技能)、industry(行业术语)、soft(软技能)",
|
||||
Type: schema.String,
|
||||
Enum: []string{"all", "tech", "skill", "industry", "soft"},
|
||||
},
|
||||
}),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (t *KeywordExtractTool) InvokableRun(ctx context.Context, argumentsInJSON string, opts ...tool.Option) (string, error) {
|
||||
var args struct {
|
||||
Text string `json:"text"`
|
||||
Category string `json:"category"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal([]byte(argumentsInJSON), &args); err != nil {
|
||||
return "", fmt.Errorf("failed to unmarshal arguments: %w", err)
|
||||
}
|
||||
|
||||
if args.Text == "" {
|
||||
return "", fmt.Errorf("text is required")
|
||||
}
|
||||
|
||||
if args.Category == "" {
|
||||
args.Category = "all"
|
||||
}
|
||||
|
||||
techStack := extractTechKeywords(args.Text)
|
||||
skills := extractSkillKeywords(args.Text)
|
||||
industryTerms := extractIndustryKeywords(args.Text)
|
||||
softSkills := extractSoftSkillKeywords(args.Text)
|
||||
|
||||
result := fmt.Sprintf(`{"tech_stack": %s, "skills": %s, "industry_terms": %s, "soft_skills": %s}`,
|
||||
toJSONArray(techStack),
|
||||
toJSONArray(skills),
|
||||
toJSONArray(industryTerms),
|
||||
toJSONArray(softSkills),
|
||||
)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func toJSONArray(items []string) string {
|
||||
if len(items) == 0 {
|
||||
return "[]"
|
||||
}
|
||||
result := "["
|
||||
for i, item := range items {
|
||||
if i > 0 {
|
||||
result += ","
|
||||
}
|
||||
result += fmt.Sprintf(`"%s"`, escapeJSON(item))
|
||||
}
|
||||
result += "]"
|
||||
return result
|
||||
}
|
||||
|
||||
func extractTechKeywords(text string) []string {
|
||||
techPatterns := []string{"Go", "Golang", "Java", "Python", "JavaScript", "TypeScript", "React", "Vue", "MySQL", "PostgreSQL", "Redis", "MongoDB", "Kafka", "Docker", "Kubernetes", "微服务", "分布式"}
|
||||
return extractKeywords(text, techPatterns)
|
||||
}
|
||||
|
||||
func extractSkillKeywords(text string) []string {
|
||||
skillPatterns := []string{"开发", "设计", "架构", "优化", "重构", "测试", "部署", "运维", "监控", "调优"}
|
||||
return extractKeywords(text, skillPatterns)
|
||||
}
|
||||
|
||||
func extractIndustryKeywords(text string) []string {
|
||||
industryPatterns := []string{"互联网", "金融", "电商", "医疗", "教育", "云计算", "大数据", "人工智能", "物联网"}
|
||||
return extractKeywords(text, industryPatterns)
|
||||
}
|
||||
|
||||
func extractSoftSkillKeywords(text string) []string {
|
||||
softPatterns := []string{"沟通", "团队协作", "项目管理", "问题解决", "创新", "学习能力", "抗压能力", "责任心"}
|
||||
return extractKeywords(text, softPatterns)
|
||||
}
|
||||
|
||||
func extractKeywords(text string, patterns []string) []string {
|
||||
var result []string
|
||||
for _, pattern := range patterns {
|
||||
if strings.Contains(text, pattern) {
|
||||
result = append(result, pattern)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"resume-platform/pkg/config"
|
||||
"time"
|
||||
)
|
||||
|
||||
// DeepSeekProvider DeepSeek AI 供应商实现
|
||||
type DeepSeekProvider struct {
|
||||
cfg *config.DeepSeekConfig // DeepSeek 配置
|
||||
httpClient *http.Client // HTTP 客户端
|
||||
}
|
||||
|
||||
// NewDeepSeekProvider 创建 DeepSeek 供应商实例
|
||||
// @param cfg DeepSeek 配置
|
||||
// @return *DeepSeekProvider 供应商实例
|
||||
// @author sunct
|
||||
func NewDeepSeekProvider(cfg *config.DeepSeekConfig) *DeepSeekProvider {
|
||||
return &DeepSeekProvider{
|
||||
cfg: cfg,
|
||||
httpClient: &http.Client{
|
||||
Timeout: time.Duration(cfg.Timeout) * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// GetName 获取供应商名称
|
||||
// @return string 供应商标识
|
||||
// @author sunct
|
||||
func (p *DeepSeekProvider) GetName() string {
|
||||
return "deepseek"
|
||||
}
|
||||
|
||||
// Generate 调用 DeepSeek API 生成文本
|
||||
// @param ctx 上下文
|
||||
// @param prompt 提示词
|
||||
// @param opts 可选参数
|
||||
// @return string 生成结果
|
||||
// @return error 调用错误
|
||||
// @author sunct
|
||||
func (p *DeepSeekProvider) Generate(ctx context.Context, prompt string, opts ...Option) (string, error) {
|
||||
options := &Options{
|
||||
MaxTokens: p.cfg.MaxTokens,
|
||||
Temperature: 0.7,
|
||||
TopP: 0.9,
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(options)
|
||||
}
|
||||
|
||||
body := map[string]interface{}{
|
||||
"model": p.getModelName(),
|
||||
"messages": []map[string]interface{}{
|
||||
{
|
||||
"role": "user",
|
||||
"content": prompt,
|
||||
},
|
||||
},
|
||||
"max_tokens": options.MaxTokens,
|
||||
"temperature": options.Temperature,
|
||||
"top_p": options.TopP,
|
||||
}
|
||||
|
||||
jsonBody, _ := json.Marshal(body)
|
||||
|
||||
req, err := http.NewRequest("POST", p.cfg.BaseURL+"/chat/completions", bytes.NewBuffer(jsonBody))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+p.cfg.APIKey)
|
||||
|
||||
resp, err := p.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(respBody, &result); err != nil {
|
||||
return string(respBody), nil
|
||||
}
|
||||
|
||||
if choices, ok := result["choices"].([]interface{}); ok && len(choices) > 0 {
|
||||
if choice, ok := choices[0].(map[string]interface{}); ok {
|
||||
if message, ok := choice["message"].(map[string]interface{}); ok {
|
||||
if content, ok := message["content"].(string); ok {
|
||||
return content, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return string(respBody), nil
|
||||
}
|
||||
|
||||
// getModelName 获取模型名称,未配置时使用默认 deepseek-chat
|
||||
// @return string 模型名
|
||||
// @author sunct
|
||||
func (p *DeepSeekProvider) getModelName() string {
|
||||
if p.cfg.Model != "" {
|
||||
return p.cfg.Model
|
||||
}
|
||||
return "deepseek-chat"
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"resume-platform/pkg/config"
|
||||
"time"
|
||||
)
|
||||
|
||||
// DoubaoProvider 豆包 AI 供应商实现
|
||||
type DoubaoProvider struct {
|
||||
cfg *config.DoubaoConfig // 豆包配置
|
||||
httpClient *http.Client // HTTP 客户端
|
||||
}
|
||||
|
||||
// NewDoubaoProvider 创建豆包供应商实例
|
||||
// @param cfg 豆包配置
|
||||
// @return *DoubaoProvider 供应商实例
|
||||
// @author sunct
|
||||
func NewDoubaoProvider(cfg *config.DoubaoConfig) *DoubaoProvider {
|
||||
return &DoubaoProvider{
|
||||
cfg: cfg,
|
||||
httpClient: &http.Client{
|
||||
Timeout: time.Duration(cfg.Timeout) * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// GetName 获取供应商名称
|
||||
// @return string 供应商标识
|
||||
// @author sunct
|
||||
func (p *DoubaoProvider) GetName() string {
|
||||
return "doubao"
|
||||
}
|
||||
|
||||
// Generate 调用豆包 API 生成文本
|
||||
// @param ctx 上下文
|
||||
// @param prompt 提示词
|
||||
// @param opts 可选参数
|
||||
// @return string 生成结果
|
||||
// @return error 调用错误
|
||||
// @author sunct
|
||||
func (p *DoubaoProvider) Generate(ctx context.Context, prompt string, opts ...Option) (string, error) {
|
||||
options := &Options{
|
||||
MaxTokens: p.cfg.MaxTokens,
|
||||
Temperature: 0.7,
|
||||
TopP: 0.9,
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(options)
|
||||
}
|
||||
|
||||
body := map[string]interface{}{
|
||||
"model": p.getModelName(),
|
||||
"messages": []map[string]interface{}{
|
||||
{
|
||||
"role": "user",
|
||||
"content": prompt,
|
||||
},
|
||||
},
|
||||
"max_tokens": options.MaxTokens,
|
||||
"temperature": options.Temperature,
|
||||
"top_p": options.TopP,
|
||||
}
|
||||
|
||||
jsonBody, _ := json.Marshal(body)
|
||||
|
||||
req, err := http.NewRequest("POST", p.cfg.BaseURL+"/chat/completions", bytes.NewBuffer(jsonBody))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+p.cfg.APIKey)
|
||||
|
||||
resp, err := p.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(respBody, &result); err != nil {
|
||||
return string(respBody), nil
|
||||
}
|
||||
|
||||
if choices, ok := result["choices"].([]interface{}); ok && len(choices) > 0 {
|
||||
if choice, ok := choices[0].(map[string]interface{}); ok {
|
||||
if message, ok := choice["message"].(map[string]interface{}); ok {
|
||||
if content, ok := message["content"].(string); ok {
|
||||
return content, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return string(respBody), nil
|
||||
}
|
||||
|
||||
// getModelName 获取模型名称,未配置时使用默认 Doubao
|
||||
// @return string 模型名
|
||||
// @author sunct
|
||||
func (p *DoubaoProvider) getModelName() string {
|
||||
if p.cfg.Model != "" {
|
||||
return p.cfg.Model
|
||||
}
|
||||
return "Doubao"
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
// Package ai AI 供应商适配层,定义统一的 LLM 调用接口
|
||||
// 支持通义千问、DeepSeek、豆包、讯飞星火等多家大模型
|
||||
package ai
|
||||
|
||||
import (
|
||||
"context"
|
||||
"resume-platform/pkg/config"
|
||||
)
|
||||
|
||||
// Provider AI 服务提供者接口,所有大模型供应商需实现此接口
|
||||
type Provider interface {
|
||||
// Generate 调用大模型生成文本
|
||||
Generate(ctx context.Context, prompt string, opts ...Option) (string, error)
|
||||
// GetName 获取供应商名称
|
||||
GetName() string
|
||||
}
|
||||
|
||||
// Option 调用选项函数类型,用于可选参数配置
|
||||
type Option func(*Options)
|
||||
|
||||
// Options AI 调用可选参数
|
||||
type Options struct {
|
||||
MaxTokens int // 最大生成token数
|
||||
Temperature float64 // 温度(随机性)
|
||||
TopP float64 // 核采样参数
|
||||
}
|
||||
|
||||
// WithMaxTokens 设置最大生成 token 数
|
||||
// @param maxTokens 最大token数
|
||||
// @return Option 选项函数
|
||||
// @author sunct
|
||||
func WithMaxTokens(maxTokens int) Option {
|
||||
return func(o *Options) {
|
||||
o.MaxTokens = maxTokens
|
||||
}
|
||||
}
|
||||
|
||||
// WithTemperature 设置生成温度
|
||||
// @param temp 温度值(0~1)
|
||||
// @return Option 选项函数
|
||||
// @author sunct
|
||||
func WithTemperature(temp float64) Option {
|
||||
return func(o *Options) {
|
||||
o.Temperature = temp
|
||||
}
|
||||
}
|
||||
|
||||
// WithTopP 设置核采样参数
|
||||
// @param topP 核采样值
|
||||
// @return Option 选项函数
|
||||
// @author sunct
|
||||
func WithTopP(topP float64) Option {
|
||||
return func(o *Options) {
|
||||
o.TopP = topP
|
||||
}
|
||||
}
|
||||
|
||||
// NewProvider AI 供应商工厂函数,根据配置创建对应供应商实例
|
||||
// @param cfg AI 配置
|
||||
// @return Provider 供应商实例
|
||||
// @return error 创建错误
|
||||
// @author sunct
|
||||
func NewProvider(cfg *config.AIConfig) (Provider, error) {
|
||||
switch cfg.Provider {
|
||||
case "spark":
|
||||
return NewSparkProvider(&cfg.Spark), nil
|
||||
case "tongyi":
|
||||
return NewTongyiProvider(&cfg.Tongyi), nil
|
||||
case "doubao":
|
||||
return NewDoubaoProvider(&cfg.Doubao), nil
|
||||
case "deepseek":
|
||||
return NewDeepSeekProvider(&cfg.DeepSeek), nil
|
||||
default:
|
||||
return NewSparkProvider(&cfg.Spark), nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"resume-platform/pkg/config"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
type SparkProvider struct {
|
||||
cfg *config.SparkConfig
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
func NewSparkProvider(cfg *config.SparkConfig) *SparkProvider {
|
||||
return &SparkProvider{
|
||||
cfg: cfg,
|
||||
httpClient: &http.Client{
|
||||
Timeout: time.Duration(cfg.Timeout) * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *SparkProvider) GetName() string {
|
||||
return "spark"
|
||||
}
|
||||
|
||||
func (p *SparkProvider) Generate(ctx context.Context, prompt string, opts ...Option) (string, error) {
|
||||
options := &Options{
|
||||
MaxTokens: p.cfg.MaxTokens,
|
||||
Temperature: 0.7,
|
||||
TopP: 0.9,
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(options)
|
||||
}
|
||||
|
||||
wsURL, err := p.buildAuthURL()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return p.sendWebSocketRequest(ctx, wsURL, prompt, options)
|
||||
}
|
||||
|
||||
func (p *SparkProvider) buildAuthURL() (string, error) {
|
||||
parsedURL, err := url.Parse(p.cfg.BaseURL)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
host := parsedURL.Host
|
||||
path := parsedURL.Path
|
||||
now := time.Now()
|
||||
date := now.UTC().Format(time.RFC1123)
|
||||
|
||||
signatureOrigin := fmt.Sprintf("host: %s\ndate: %s\nGET %s HTTP/1.1", host, date, path)
|
||||
|
||||
mac := hmac.New(sha256.New, []byte(p.cfg.APISecret))
|
||||
mac.Write([]byte(signatureOrigin))
|
||||
signatureSha := base64.StdEncoding.EncodeToString(mac.Sum(nil))
|
||||
|
||||
authorizationOrigin := fmt.Sprintf(
|
||||
`api_key="%s", algorithm="%s", headers="%s", signature="%s"`,
|
||||
p.cfg.APIKey, "hmac-sha256", "host date request-line", signatureSha,
|
||||
)
|
||||
|
||||
authorization := base64.StdEncoding.EncodeToString([]byte(authorizationOrigin))
|
||||
|
||||
query := url.Values{}
|
||||
query.Set("authorization", authorization)
|
||||
query.Set("date", date)
|
||||
query.Set("host", host)
|
||||
|
||||
return p.cfg.BaseURL + "?" + query.Encode(), nil
|
||||
}
|
||||
|
||||
func (p *SparkProvider) sendWebSocketRequest(ctx context.Context, wsURL string, prompt string, options *Options) (string, error) {
|
||||
conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to dial websocket: %w", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
done := make(chan struct{})
|
||||
var result strings.Builder
|
||||
var lastErr error
|
||||
|
||||
go func() {
|
||||
defer close(done)
|
||||
for {
|
||||
_, message, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
if websocket.IsCloseError(err, websocket.CloseNormalClosure) {
|
||||
return
|
||||
}
|
||||
lastErr = fmt.Errorf("failed to read message: %w", err)
|
||||
return
|
||||
}
|
||||
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal(message, &resp); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if header, ok := resp["header"].(map[string]interface{}); ok {
|
||||
if code, ok := header["code"].(float64); ok && code != 0 {
|
||||
if msg, ok := header["message"].(string); ok {
|
||||
lastErr = fmt.Errorf("API error: %s (code: %d)", msg, int(code))
|
||||
} else {
|
||||
lastErr = fmt.Errorf("API error with code: %d", int(code))
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if payload, ok := resp["payload"].(map[string]interface{}); ok {
|
||||
if choices, ok := payload["choices"].(map[string]interface{}); ok {
|
||||
if text, ok := choices["text"].([]interface{}); ok && len(text) > 0 {
|
||||
if item, ok := text[0].(map[string]interface{}); ok {
|
||||
if content, ok := item["content"].(string); ok {
|
||||
result.WriteString(content)
|
||||
}
|
||||
}
|
||||
}
|
||||
if status, ok := choices["status"].(float64); ok && status == 2 {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
body := map[string]interface{}{
|
||||
"header": map[string]interface{}{
|
||||
"app_id": p.cfg.APPID,
|
||||
"uid": "resume-platform",
|
||||
},
|
||||
"parameter": map[string]interface{}{
|
||||
"chat": map[string]interface{}{
|
||||
"domain": p.getDomainName(),
|
||||
"temperature": options.Temperature,
|
||||
"max_tokens": options.MaxTokens,
|
||||
"top_k": 4,
|
||||
"web_search": p.cfg.WebSearch,
|
||||
"show_ref_label": p.cfg.ShowRefLabel,
|
||||
},
|
||||
},
|
||||
"payload": map[string]interface{}{
|
||||
"message": map[string]interface{}{
|
||||
"text": []map[string]interface{}{
|
||||
{
|
||||
"role": "user",
|
||||
"content": prompt,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
jsonBody, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to marshal request: %w", err)
|
||||
}
|
||||
|
||||
if err := conn.WriteMessage(websocket.TextMessage, jsonBody); err != nil {
|
||||
return "", fmt.Errorf("failed to write message: %w", err)
|
||||
}
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
if lastErr != nil {
|
||||
return "", lastErr
|
||||
}
|
||||
return result.String(), nil
|
||||
case <-ctx.Done():
|
||||
conn.Close()
|
||||
return "", ctx.Err()
|
||||
case <-time.After(time.Duration(p.cfg.Timeout) * time.Second):
|
||||
conn.Close()
|
||||
return "", fmt.Errorf("request timeout")
|
||||
}
|
||||
}
|
||||
|
||||
func (p *SparkProvider) getDomainName() string {
|
||||
switch p.cfg.Model {
|
||||
case "lite":
|
||||
return "lite"
|
||||
case "pro":
|
||||
return "generalv3"
|
||||
case "pro-128k":
|
||||
return "pro-128k"
|
||||
case "max":
|
||||
return "generalv3.5"
|
||||
case "max-32k":
|
||||
return "max-32k"
|
||||
case "ultra":
|
||||
return "4.0Ultra"
|
||||
case "kjwx":
|
||||
return "kjwx"
|
||||
default:
|
||||
return "lite"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"resume-platform/pkg/config"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TongyiProvider 通义千问 AI 供应商实现
|
||||
type TongyiProvider struct {
|
||||
cfg *config.TongyiConfig // 通义千问配置
|
||||
httpClient *http.Client // HTTP 客户端
|
||||
}
|
||||
|
||||
// NewTongyiProvider 创建通义千问供应商实例
|
||||
// @param cfg 通义千问配置
|
||||
// @return *TongyiProvider 供应商实例
|
||||
// @author sunct
|
||||
func NewTongyiProvider(cfg *config.TongyiConfig) *TongyiProvider {
|
||||
return &TongyiProvider{
|
||||
cfg: cfg,
|
||||
httpClient: &http.Client{
|
||||
Timeout: time.Duration(cfg.Timeout) * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// GetName 获取供应商名称
|
||||
// @return string 供应商标识
|
||||
// @author sunct
|
||||
func (p *TongyiProvider) GetName() string {
|
||||
return "tongyi"
|
||||
}
|
||||
|
||||
// Generate 调用通义千问 API 生成文本
|
||||
// @param ctx 上下文
|
||||
// @param prompt 提示词
|
||||
// @param opts 可选参数
|
||||
// @return string 生成结果
|
||||
// @return error 调用错误
|
||||
// @author sunct
|
||||
func (p *TongyiProvider) Generate(ctx context.Context, prompt string, opts ...Option) (string, error) {
|
||||
options := &Options{
|
||||
MaxTokens: p.cfg.MaxTokens,
|
||||
Temperature: 0.7,
|
||||
TopP: 0.9,
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(options)
|
||||
}
|
||||
|
||||
body := map[string]interface{}{
|
||||
"model": p.getModelName(),
|
||||
"messages": []map[string]interface{}{
|
||||
{
|
||||
"role": "user",
|
||||
"content": prompt,
|
||||
},
|
||||
},
|
||||
"max_tokens": options.MaxTokens,
|
||||
"temperature": options.Temperature,
|
||||
"top_p": options.TopP,
|
||||
}
|
||||
|
||||
jsonBody, _ := json.Marshal(body)
|
||||
|
||||
req, err := http.NewRequest("POST", p.cfg.BaseURL+"/chat/completions", bytes.NewBuffer(jsonBody))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+p.cfg.APIKey)
|
||||
|
||||
resp, err := p.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(respBody, &result); err != nil {
|
||||
return string(respBody), nil
|
||||
}
|
||||
|
||||
if choices, ok := result["choices"].([]interface{}); ok && len(choices) > 0 {
|
||||
if choice, ok := choices[0].(map[string]interface{}); ok {
|
||||
if message, ok := choice["message"].(map[string]interface{}); ok {
|
||||
if content, ok := message["content"].(string); ok {
|
||||
return content, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return string(respBody), nil
|
||||
}
|
||||
|
||||
// getModelName 获取模型名称,未配置时使用默认 qwen-turbo
|
||||
// @return string 模型名
|
||||
// @author sunct
|
||||
func (p *TongyiProvider) getModelName() string {
|
||||
if p.cfg.Model != "" {
|
||||
return p.cfg.Model
|
||||
}
|
||||
return "qwen-turbo"
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"resume-platform/internal/handler"
|
||||
"resume-platform/internal/router"
|
||||
"resume-platform/internal/security"
|
||||
"resume-platform/pkg/config"
|
||||
"resume-platform/pkg/logger"
|
||||
)
|
||||
|
||||
func Run(ctx context.Context) error {
|
||||
cfg, err := config.LoadConfig()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load config: %w", err)
|
||||
}
|
||||
|
||||
if err := initLogger(cfg); err != nil {
|
||||
return fmt.Errorf("failed to initialize logger: %w", err)
|
||||
}
|
||||
|
||||
if err := initDataDir(); err != nil {
|
||||
return fmt.Errorf("failed to initialize data directory: %w", err)
|
||||
}
|
||||
|
||||
logger.Infof("Configuration loaded - AdminPath: %s, Mode: %s", cfg.Auth.AdminPath, cfg.Server.Mode)
|
||||
|
||||
container, err := NewServiceContainer(cfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize service container: %w", err)
|
||||
}
|
||||
|
||||
handler.SetResumeRepository(container.Repo)
|
||||
handler.LoadTokens()
|
||||
|
||||
if err := initSecurity(cfg); err != nil {
|
||||
return fmt.Errorf("failed to initialize security: %w", err)
|
||||
}
|
||||
|
||||
if err := container.InitResumeAgent(ctx, cfg); err != nil {
|
||||
logger.Warnf("Failed to initialize Resume Agent: %v", err)
|
||||
}
|
||||
|
||||
if err := startServer(ctx, cfg, container); err != nil {
|
||||
return fmt.Errorf("failed to start server: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func initLogger(cfg *config.Config) error {
|
||||
return logger.Init(logger.Config{
|
||||
Level: cfg.Log.Level,
|
||||
Output: cfg.Log.Output,
|
||||
})
|
||||
}
|
||||
|
||||
func initDataDir() error {
|
||||
return os.MkdirAll("./data", 0755)
|
||||
}
|
||||
|
||||
func initSecurity(cfg *config.Config) error {
|
||||
security.InitCaptcha(160, 50)
|
||||
return nil
|
||||
}
|
||||
|
||||
func startServer(ctx context.Context, cfg *config.Config, container *ServiceContainer) error {
|
||||
r := router.NewRouter(
|
||||
container.ResumeHandler,
|
||||
container.AdminHandler,
|
||||
container.AIHandler,
|
||||
container.DocumentHandler,
|
||||
container.ChatHandler,
|
||||
container.IMHandler,
|
||||
container.KnowledgeHandler,
|
||||
cfg,
|
||||
)
|
||||
engine := r.Setup()
|
||||
|
||||
addr := fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.Port)
|
||||
logger.Infof("Server starting on %s", addr)
|
||||
|
||||
return engine.Run(addr)
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"resume-platform/internal/agent"
|
||||
aiprovider "resume-platform/internal/ai"
|
||||
"resume-platform/internal/handler"
|
||||
"resume-platform/internal/repository"
|
||||
"resume-platform/internal/security"
|
||||
"resume-platform/internal/service/chat"
|
||||
"resume-platform/internal/service/document"
|
||||
"resume-platform/internal/service/knowledge"
|
||||
"resume-platform/internal/service/portfolio"
|
||||
"resume-platform/internal/service/quiz"
|
||||
"resume-platform/internal/service/resume"
|
||||
"resume-platform/internal/service/system"
|
||||
"resume-platform/internal/service/user"
|
||||
aiservice "resume-platform/internal/service/ai"
|
||||
"resume-platform/pkg/config"
|
||||
)
|
||||
|
||||
type ServiceContainer struct {
|
||||
// Repository
|
||||
Repo repository.ResumeRepository
|
||||
|
||||
// Core Services
|
||||
ResumeService resume.ResumeService
|
||||
SystemService system.SystemService
|
||||
DocumentService document.DocumentService
|
||||
UserService user.UserService
|
||||
PortfolioService portfolio.PortfolioService
|
||||
QuizService quiz.QuizService
|
||||
|
||||
// AI Services
|
||||
AIProvider aiprovider.Provider
|
||||
AIService *aiservice.AIService
|
||||
QuestionGenerator *aiservice.QuestionGeneratorService
|
||||
ResumeGenerator *resume.ResumeGeneratorService
|
||||
ResumeAgent *agent.ResumeAgent
|
||||
|
||||
// Knowledge Services
|
||||
EmbeddingService *knowledge.EmbeddingService
|
||||
VectorService *knowledge.VectorService
|
||||
KnowledgeBaseService *knowledge.KnowledgeBaseService
|
||||
RAGService *knowledge.RAGService
|
||||
|
||||
// Chat Services
|
||||
ChatService chat.ChatService
|
||||
IMService *chat.IMService
|
||||
|
||||
// Handlers
|
||||
ResumeHandler *handler.ResumeHandler
|
||||
AdminHandler *handler.AdminHandler
|
||||
AIHandler *handler.AIHandler
|
||||
DocumentHandler *handler.DocumentHandler
|
||||
ChatHandler *handler.ChatHandler
|
||||
IMHandler *handler.IMHandler
|
||||
KnowledgeHandler *handler.KnowledgeHandler
|
||||
}
|
||||
|
||||
func NewServiceContainer(cfg *config.Config) (*ServiceContainer, error) {
|
||||
container := &ServiceContainer{}
|
||||
|
||||
if err := container.initRepository(cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := container.initAIServices(cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := container.initCoreServices(cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := container.initKnowledgeServices(cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := container.initChatServices(cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := container.initHandlers(cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return container, nil
|
||||
}
|
||||
|
||||
func (c *ServiceContainer) initRepository(cfg *config.Config) error {
|
||||
dbPath := cfg.Database.Path
|
||||
if dbPath == "" {
|
||||
dbPath = "./data/resume.db"
|
||||
}
|
||||
|
||||
repo, err := repository.NewSQLiteRepository(dbPath, cfg.Database.Password)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
c.Repo = repo
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *ServiceContainer) initCoreServices(cfg *config.Config) error {
|
||||
c.ResumeService = resume.NewResumeService(c.Repo)
|
||||
c.SystemService = system.NewSystemService(c.Repo)
|
||||
|
||||
if _, err := c.ResumeService.InitDefaultResume(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := c.SystemService.InitDefaultMenus(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
c.DocumentService = document.NewDocumentService(c.Repo, c.ResumeGenerator)
|
||||
c.UserService = user.NewUserService(c.Repo)
|
||||
c.PortfolioService = portfolio.NewPortfolioService(c.Repo)
|
||||
c.QuizService = quiz.NewQuizService(c.Repo)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *ServiceContainer) initAIServices(cfg *config.Config) error {
|
||||
if !cfg.AI.Enabled {
|
||||
return nil
|
||||
}
|
||||
|
||||
provider, err := aiprovider.NewProvider(&cfg.AI)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
c.AIProvider = provider
|
||||
c.AIService = aiservice.NewAIService(provider)
|
||||
c.QuestionGenerator = aiservice.NewQuestionGeneratorService(provider)
|
||||
c.ResumeGenerator = resume.NewResumeGeneratorService(provider)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *ServiceContainer) initKnowledgeServices(cfg *config.Config) error {
|
||||
if c.AIProvider == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
c.EmbeddingService = knowledge.NewEmbeddingService(c.AIProvider, c.Repo)
|
||||
c.VectorService = knowledge.NewVectorService(c.EmbeddingService, c.Repo)
|
||||
c.KnowledgeBaseService = knowledge.NewKnowledgeBaseService(c.EmbeddingService, c.VectorService, c.Repo)
|
||||
c.RAGService = knowledge.NewRAGService(c.AIProvider, c.KnowledgeBaseService)
|
||||
|
||||
c.DocumentService = document.NewDocumentServiceWithKnowledge(c.Repo, c.ResumeGenerator, c.KnowledgeBaseService)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *ServiceContainer) initChatServices(cfg *config.Config) error {
|
||||
c.ChatService = chat.NewChatService(c.Repo, c.AIService)
|
||||
c.IMService = chat.NewIMService(c.AIProvider, c.ResumeAgent)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *ServiceContainer) initHandlers(cfg *config.Config) error {
|
||||
c.ResumeHandler = handler.NewResumeHandler(
|
||||
c.ResumeService,
|
||||
c.DocumentService,
|
||||
c.UserService,
|
||||
c.PortfolioService,
|
||||
c.QuizService,
|
||||
c.SystemService,
|
||||
)
|
||||
|
||||
rateLimiter := security.NewRateLimiter(cfg.Auth.MaxLoginAttempts, cfg.Auth.LockoutDuration)
|
||||
c.AdminHandler = handler.NewAdminHandler(cfg, rateLimiter)
|
||||
c.AIHandler = handler.NewAIHandler(c.AIService, c.QuestionGenerator, nil, c.ResumeService, c.QuizService)
|
||||
c.DocumentHandler = handler.NewDocumentHandler(c.DocumentService)
|
||||
c.ChatHandler = handler.NewChatHandler(c.ChatService)
|
||||
c.IMHandler = handler.NewIMHandler(c.IMService)
|
||||
|
||||
if c.KnowledgeBaseService != nil {
|
||||
c.KnowledgeHandler = handler.NewKnowledgeHandler(c.KnowledgeBaseService, c.RAGService)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *ServiceContainer) InitResumeAgent(ctx context.Context, cfg *config.Config) error {
|
||||
if !cfg.AI.Enabled || c.AIProvider == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
agent, err := agent.NewResumeAgent(ctx, &cfg.AI)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
c.ResumeAgent = agent
|
||||
c.AIHandler.SetResumeAgent(agent)
|
||||
|
||||
return nil
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,594 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"resume-platform/internal/agent"
|
||||
"resume-platform/internal/model"
|
||||
aiservice "resume-platform/internal/service/ai"
|
||||
"resume-platform/internal/service/quiz"
|
||||
"resume-platform/internal/service/resume"
|
||||
"resume-platform/pkg/logger"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// AIHandler AI 能力处理器,封装简历润色、分析、题目生成、评分等 AI 相关接口
|
||||
type AIHandler struct {
|
||||
aiService *aiservice.AIService
|
||||
questionGenerator *aiservice.QuestionGeneratorService
|
||||
resumeAgent *agent.ResumeAgent
|
||||
resumeService resume.ResumeService
|
||||
quizService quiz.QuizService
|
||||
}
|
||||
|
||||
// NewAIHandler 创建 AI 处理器实例
|
||||
// @param aiService AI 服务层
|
||||
// @param questionGenerator 题目生成服务层
|
||||
// @param resumeAgent AI Agent 实例
|
||||
// @param resumeService 简历服务层
|
||||
// @param quizService 答题服务层
|
||||
// @return *AIHandler 处理器实例
|
||||
// @author sunct
|
||||
func NewAIHandler(aiService *aiservice.AIService, questionGenerator *aiservice.QuestionGeneratorService, resumeAgent *agent.ResumeAgent, resumeService resume.ResumeService, quizService quiz.QuizService) *AIHandler {
|
||||
return &AIHandler{
|
||||
aiService: aiService,
|
||||
questionGenerator: questionGenerator,
|
||||
resumeAgent: resumeAgent,
|
||||
resumeService: resumeService,
|
||||
quizService: quizService,
|
||||
}
|
||||
}
|
||||
|
||||
// SetResumeAgent 设置 ResumeAgent(延迟初始化使用)
|
||||
// @param resumeAgent AI Agent 实例
|
||||
func (h *AIHandler) SetResumeAgent(resumeAgent *agent.ResumeAgent) {
|
||||
h.resumeAgent = resumeAgent
|
||||
}
|
||||
|
||||
// buildResumeText 将简历对象拼接为纯文本,用于 AI 关键词提取
|
||||
// @param resume 简历数据
|
||||
// @return string 拼接后的纯文本
|
||||
// @author sunct
|
||||
func buildResumeText(resume *model.Resume) string {
|
||||
if resume == nil {
|
||||
return ""
|
||||
}
|
||||
var sb strings.Builder
|
||||
sb.WriteString(resume.BasicInfo.Name + " ")
|
||||
sb.WriteString(resume.BasicInfo.Title + " ")
|
||||
sb.WriteString(resume.BasicInfo.Summary + " ")
|
||||
sb.WriteString(resume.BasicInfo.Industry + " ")
|
||||
sb.WriteString(resume.BasicInfo.JobTarget + " ")
|
||||
for _, e := range resume.Experience {
|
||||
sb.WriteString(e.Company + " ")
|
||||
sb.WriteString(e.Position + " ")
|
||||
sb.WriteString(e.Description + " ")
|
||||
for _, h := range e.Highlights {
|
||||
sb.WriteString(h + " ")
|
||||
}
|
||||
}
|
||||
for _, s := range resume.Skills {
|
||||
sb.WriteString(s.Name + " ")
|
||||
sb.WriteString(s.Category + " ")
|
||||
}
|
||||
for _, p := range resume.Projects {
|
||||
sb.WriteString(p.Name + " ")
|
||||
sb.WriteString(p.Description + " ")
|
||||
for _, t := range p.TechStack {
|
||||
sb.WriteString(t + " ")
|
||||
}
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// PolishText 单字段润色接口,根据字段类型调用不同的 AI 润色 Prompt
|
||||
// @param c Gin 上下文
|
||||
// @author sunct
|
||||
func (h *AIHandler) PolishText(c *gin.Context) {
|
||||
var req struct {
|
||||
Original string `json:"original"`
|
||||
FieldType string `json:"field_type"`
|
||||
Company string `json:"company,omitempty"`
|
||||
Position string `json:"position,omitempty"`
|
||||
ProjectName string `json:"project_name,omitempty"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if req.Original == "" {
|
||||
BadRequest(c, "original text is required")
|
||||
return
|
||||
}
|
||||
|
||||
var result string
|
||||
var err error
|
||||
|
||||
switch req.FieldType {
|
||||
case "summary":
|
||||
result, err = h.aiService.PolishSummary(c.Request.Context(), req.Original)
|
||||
case "experience":
|
||||
result, err = h.aiService.PolishExperience(c.Request.Context(), req.Original, req.Company, req.Position)
|
||||
case "project":
|
||||
result, err = h.aiService.PolishProject(c.Request.Context(), req.Original, req.ProjectName)
|
||||
default:
|
||||
result, err = h.aiService.PolishDescription(c.Request.Context(), req.Original, req.FieldType)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
Error(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
OK(c, gin.H{
|
||||
"result": result,
|
||||
"provider": h.aiService.GetProviderName(),
|
||||
})
|
||||
}
|
||||
|
||||
// GetProviderInfo 获取当前使用的 AI 供应商名称
|
||||
// @param c Gin 上下文
|
||||
// @author sunct
|
||||
func (h *AIHandler) GetProviderInfo(c *gin.Context) {
|
||||
provider := h.aiService.GetProviderName()
|
||||
if h.resumeAgent != nil {
|
||||
provider = h.resumeAgent.GetProvider()
|
||||
}
|
||||
OK(c, gin.H{"provider": provider})
|
||||
}
|
||||
|
||||
// AgentPolish Agent 模式润色接口,优先使用 Agent 工具链,降级为普通 AI 润色
|
||||
// @param c Gin 上下文
|
||||
// @author sunct
|
||||
func (h *AIHandler) AgentPolish(c *gin.Context) {
|
||||
var req struct {
|
||||
Original string `json:"original"`
|
||||
FieldType string `json:"field_type"`
|
||||
Company string `json:"company,omitempty"`
|
||||
Position string `json:"position,omitempty"`
|
||||
ProjectName string `json:"project_name,omitempty"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if req.Original == "" {
|
||||
BadRequest(c, "original text is required")
|
||||
return
|
||||
}
|
||||
|
||||
context := ""
|
||||
if req.Company != "" {
|
||||
context += "公司:" + req.Company + " "
|
||||
}
|
||||
if req.Position != "" {
|
||||
context += "职位:" + req.Position + " "
|
||||
}
|
||||
if req.ProjectName != "" {
|
||||
context += "项目名称:" + req.ProjectName
|
||||
}
|
||||
|
||||
if h.resumeAgent != nil {
|
||||
query := fmt.Sprintf("请帮我优化简历的%s字段。原文:%s", getFieldTypeName(req.FieldType), req.Original)
|
||||
if context != "" {
|
||||
query += "。上下文:" + context
|
||||
}
|
||||
|
||||
result, err := h.resumeAgent.Run(c.Request.Context(), query)
|
||||
if err != nil {
|
||||
Error(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
OK(c, gin.H{
|
||||
"result": result,
|
||||
"provider": h.resumeAgent.GetProvider(),
|
||||
"agent_mode": true,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var result string
|
||||
var err error
|
||||
|
||||
switch req.FieldType {
|
||||
case "summary":
|
||||
result, err = h.aiService.PolishSummary(c.Request.Context(), req.Original)
|
||||
case "experience":
|
||||
result, err = h.aiService.PolishExperience(c.Request.Context(), req.Original, req.Company, req.Position)
|
||||
case "project":
|
||||
result, err = h.aiService.PolishProject(c.Request.Context(), req.Original, req.ProjectName)
|
||||
default:
|
||||
result, err = h.aiService.PolishDescription(c.Request.Context(), req.Original, req.FieldType)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
Error(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
OK(c, gin.H{
|
||||
"result": result,
|
||||
"provider": h.aiService.GetProviderName(),
|
||||
"agent_mode": false,
|
||||
})
|
||||
}
|
||||
|
||||
// AgentAnalyze 简历整体分析接口,调用 Agent 对简历进行专业评估
|
||||
// @param c Gin 上下文
|
||||
// @author sunct
|
||||
func (h *AIHandler) AgentAnalyze(c *gin.Context) {
|
||||
var req struct {
|
||||
ResumeText string `json:"resume_text"`
|
||||
TargetPosition string `json:"target_position,omitempty"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if req.ResumeText == "" {
|
||||
BadRequest(c, "resume_text is required")
|
||||
return
|
||||
}
|
||||
|
||||
if h.resumeAgent != nil {
|
||||
query := "请分析以下简历内容并提供专业评估:" + req.ResumeText
|
||||
if req.TargetPosition != "" {
|
||||
query += "。目标职位:" + req.TargetPosition
|
||||
}
|
||||
|
||||
result, err := h.resumeAgent.Run(c.Request.Context(), query)
|
||||
if err != nil {
|
||||
Error(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
OK(c, gin.H{
|
||||
"result": result,
|
||||
"provider": h.resumeAgent.GetProvider(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
OK(c, gin.H{
|
||||
"result": "Eino Agent not available",
|
||||
"provider": h.aiService.GetProviderName(),
|
||||
})
|
||||
}
|
||||
|
||||
// GenerateQuestions AI 生成面试题接口,支持按简历内容或关键词出题
|
||||
// @param c Gin 上下文
|
||||
// @author sunct
|
||||
func (h *AIHandler) GenerateQuestions(c *gin.Context) {
|
||||
var req struct {
|
||||
ResumeID string `json:"resume_id,omitempty"`
|
||||
ResumeText string `json:"resume_text,omitempty"`
|
||||
Keywords string `json:"keywords,omitempty"`
|
||||
Types string `json:"types,omitempty"`
|
||||
Seed string `json:"seed,omitempty"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if req.ResumeText == "" && req.ResumeID == "" && req.Keywords == "" {
|
||||
BadRequest(c, "resume_text, resume_id or keywords is required")
|
||||
return
|
||||
}
|
||||
|
||||
resumeText := req.ResumeText
|
||||
keywords := req.Keywords
|
||||
|
||||
// 当提供 resume_id 时,真正获取简历内容并提取文本
|
||||
if req.ResumeID != "" {
|
||||
resume, err := h.resumeService.GetResume(req.ResumeID)
|
||||
if err != nil || resume == nil {
|
||||
Error(c, "简历不存在")
|
||||
return
|
||||
}
|
||||
text := buildResumeText(resume)
|
||||
if text != "" {
|
||||
// 简历文本作为基础,与已有 resumeText 合并
|
||||
if resumeText != "" {
|
||||
resumeText = resumeText + " " + text
|
||||
} else {
|
||||
resumeText = text
|
||||
}
|
||||
}
|
||||
// 从简历技能中补充关键词
|
||||
if resume != nil && len(resume.Skills) > 0 && keywords == "" {
|
||||
var skillNames []string
|
||||
for _, s := range resume.Skills {
|
||||
if s.Name != "" {
|
||||
skillNames = append(skillNames, s.Name)
|
||||
}
|
||||
}
|
||||
if len(skillNames) > 0 {
|
||||
keywords = strings.Join(skillNames, ",")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 当只有 keywords 时,构造基础文本
|
||||
if resumeText == "" && keywords != "" {
|
||||
resumeText = "基于关键词:" + keywords
|
||||
}
|
||||
|
||||
if resumeText == "" {
|
||||
BadRequest(c, "无法获取简历内容或关键词")
|
||||
return
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
logger.CtxInfof(ctx, "[AIHandler] GenerateQuestions resume_id=%s, keywords=%s, resume_text_len=%d", req.ResumeID, keywords, len(resumeText))
|
||||
|
||||
var questions []map[string]interface{}
|
||||
var err error
|
||||
|
||||
if h.questionGenerator != nil {
|
||||
logger.CtxInfof(ctx, "[AIHandler] Using AI question generator")
|
||||
questions, err = h.questionGenerator.GenerateQuestions(ctx, resumeText, keywords, req.Types)
|
||||
if err != nil {
|
||||
logger.CtxErrorf(ctx, "[AIHandler] GenerateQuestions failed: %v", err)
|
||||
Error(c, err.Error())
|
||||
return
|
||||
}
|
||||
} else {
|
||||
logger.CtxInfof(ctx, "[AIHandler] Using fallback question generator")
|
||||
questions, err = h.generateFallbackQuestions(ctx, resumeText, keywords, req.Types, req.Seed)
|
||||
if err != nil {
|
||||
logger.CtxErrorf(ctx, "[AIHandler] generateFallbackQuestions failed: %v", err)
|
||||
Error(c, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if len(questions) == 0 {
|
||||
Error(c, "未找到与关键词匹配的题目,请尝试其他关键词")
|
||||
return
|
||||
}
|
||||
|
||||
OK(c, gin.H{"questions": questions})
|
||||
}
|
||||
|
||||
// generateFallbackQuestions 备用题目生成方法,当 AI 服务不可用时使用本地题库
|
||||
// @param resumeText 简历文本
|
||||
// @param keywords 关键词
|
||||
// @param types 题目类型
|
||||
// @param seed 随机种子
|
||||
// @return []map[string]interface{} 题目列表
|
||||
// @return error 错误信息
|
||||
// @author sunct
|
||||
func (h *AIHandler) generateFallbackQuestions(ctx context.Context, resumeText, keywords, types, seed string) ([]map[string]interface{}, error) {
|
||||
logger.CtxInfof(ctx, "[AIHandler] generateFallbackQuestions keywords=%s, types=%s", keywords, types)
|
||||
|
||||
tool := agent.NewGenerateQuestionsTool()
|
||||
|
||||
args := map[string]interface{}{
|
||||
"resume_text": resumeText,
|
||||
"keywords": keywords,
|
||||
"types": types,
|
||||
"seed": seed,
|
||||
"random": true,
|
||||
}
|
||||
|
||||
argsJSON, err := json.Marshal(args)
|
||||
if err != nil {
|
||||
logger.CtxErrorf(ctx, "[AIHandler] Failed to marshal arguments: %v", err)
|
||||
return nil, fmt.Errorf("failed to marshal arguments: %w", err)
|
||||
}
|
||||
|
||||
resultJSON, err := tool.InvokableRun(ctx, string(argsJSON))
|
||||
if err != nil {
|
||||
logger.CtxErrorf(ctx, "[AIHandler] tool.InvokableRun failed: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var questions []map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(resultJSON), &questions); err != nil {
|
||||
logger.CtxErrorf(ctx, "[AIHandler] Failed to parse questions: %v", err)
|
||||
return nil, fmt.Errorf("failed to parse questions: %w", err)
|
||||
}
|
||||
|
||||
logger.CtxInfof(ctx, "[AIHandler] generateFallbackQuestions success, count=%d", len(questions))
|
||||
|
||||
return questions, nil
|
||||
}
|
||||
|
||||
// EvaluateAnswers 本地答题评分接口,选择/填空按答案比对,简答按非空判定
|
||||
// @param c Gin 上下文
|
||||
// @author sunct
|
||||
func (h *AIHandler) EvaluateAnswers(c *gin.Context) {
|
||||
var req struct {
|
||||
Questions []map[string]interface{} `json:"questions"`
|
||||
Answers map[string]string `json:"answers"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if len(req.Questions) == 0 {
|
||||
BadRequest(c, "questions is required")
|
||||
return
|
||||
}
|
||||
|
||||
correctCount := 0
|
||||
|
||||
for i, question := range req.Questions {
|
||||
answerKey := fmt.Sprintf("%d", i)
|
||||
userAnswer := req.Answers[answerKey]
|
||||
correctAnswer := question["answer"]
|
||||
|
||||
if correctAnswer == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
correctStr := fmt.Sprintf("%v", correctAnswer)
|
||||
|
||||
if question["type"] == "mcq" || question["type"] == "fill" {
|
||||
if strings.EqualFold(strings.TrimSpace(userAnswer), strings.TrimSpace(correctStr)) {
|
||||
correctCount++
|
||||
}
|
||||
} else if question["type"] == "sa" {
|
||||
if len(strings.TrimSpace(userAnswer)) > 0 {
|
||||
correctCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
totalQuestions := len(req.Questions)
|
||||
score := float64(correctCount) / float64(totalQuestions) * 100
|
||||
|
||||
OK(c, gin.H{
|
||||
"score": score,
|
||||
"correct_count": correctCount,
|
||||
"total_count": totalQuestions,
|
||||
})
|
||||
}
|
||||
|
||||
// SubmitAnswers 提交答题记录接口,计算得分并保存答题记录到数据库
|
||||
// @param c Gin 上下文
|
||||
// @author sunct
|
||||
func (h *AIHandler) SubmitAnswers(c *gin.Context) {
|
||||
var req struct {
|
||||
Questions []map[string]interface{} `json:"questions"`
|
||||
UserAnswers map[string]string `json:"user_answers"`
|
||||
Score float64 `json:"score"`
|
||||
CorrectCount int `json:"correct_count"`
|
||||
TotalCount int `json:"total_count"`
|
||||
UnansweredCount int `json:"unanswered_count"`
|
||||
UserID string `json:"user_id,omitempty"`
|
||||
ResumeID string `json:"resume_id,omitempty"`
|
||||
ResumeRoute string `json:"resume_route,omitempty"`
|
||||
Title string `json:"title,omitempty"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if len(req.Questions) == 0 {
|
||||
BadRequest(c, "questions is required")
|
||||
return
|
||||
}
|
||||
|
||||
questions := make([]model.QuizQuestion, 0, len(req.Questions))
|
||||
for _, q := range req.Questions {
|
||||
question := model.QuizQuestion{
|
||||
Type: getStringValue(q, "type"),
|
||||
Text: getStringValue(q, "text"),
|
||||
Answer: getStringValue(q, "answer"),
|
||||
Analysis: getStringValue(q, "analysis"),
|
||||
Score: getIntValue(q, "score"),
|
||||
Difficulty: getStringValue(q, "difficulty"),
|
||||
Category: getStringValue(q, "category"),
|
||||
}
|
||||
|
||||
if options, ok := q["options"].([]interface{}); ok {
|
||||
question.Options = make([]string, 0, len(options))
|
||||
for _, opt := range options {
|
||||
question.Options = append(question.Options, fmt.Sprintf("%v", opt))
|
||||
}
|
||||
}
|
||||
|
||||
if keywords, ok := q["keywords"].([]interface{}); ok {
|
||||
question.Keywords = make([]string, 0, len(keywords))
|
||||
for _, kw := range keywords {
|
||||
question.Keywords = append(question.Keywords, fmt.Sprintf("%v", kw))
|
||||
}
|
||||
}
|
||||
|
||||
questions = append(questions, question)
|
||||
}
|
||||
|
||||
record := &model.QuizRecord{
|
||||
UserID: req.UserID,
|
||||
ResumeID: req.ResumeID,
|
||||
ResumeRoute: req.ResumeRoute,
|
||||
Title: req.Title,
|
||||
Questions: questions,
|
||||
UserAnswers: req.UserAnswers,
|
||||
Score: req.Score,
|
||||
CorrectCount: req.CorrectCount,
|
||||
TotalCount: req.TotalCount,
|
||||
}
|
||||
|
||||
if h.quizService != nil {
|
||||
err := h.quizService.CreateQuizRecord(record)
|
||||
if err != nil {
|
||||
logger.CtxErrorf(c.Request.Context(), "[AIHandler] SubmitAnswers failed to save record: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
message := fmt.Sprintf("答题完成!得分:%.1f分", req.Score)
|
||||
if req.UnansweredCount > 0 {
|
||||
message += fmt.Sprintf("(%d/%d正确,%d题未作答)", req.CorrectCount, req.TotalCount-req.UnansweredCount, req.UnansweredCount)
|
||||
} else {
|
||||
message += fmt.Sprintf("(%d/%d正确)", req.CorrectCount, req.TotalCount)
|
||||
}
|
||||
|
||||
OK(c, gin.H{
|
||||
"message": message,
|
||||
"score": req.Score,
|
||||
"correct_count": req.CorrectCount,
|
||||
"total_count": req.TotalCount,
|
||||
"unanswered_count": req.UnansweredCount,
|
||||
"record_id": record.ID,
|
||||
}, message)
|
||||
}
|
||||
|
||||
func getStringValue(m map[string]interface{}, key string) string {
|
||||
if v, ok := m[key]; ok {
|
||||
return fmt.Sprintf("%v", v)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func getIntValue(m map[string]interface{}, key string) int {
|
||||
if v, ok := m[key]; ok {
|
||||
switch val := v.(type) {
|
||||
case int:
|
||||
return val
|
||||
case float64:
|
||||
return int(val)
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// getFieldTypeName 将字段类型英文标识转换为中文名称
|
||||
// @param fieldType 字段类型
|
||||
// @return string 中文名称
|
||||
// @author sunct
|
||||
func getFieldTypeName(fieldType string) string {
|
||||
switch fieldType {
|
||||
case "summary":
|
||||
return "个人简介"
|
||||
case "experience":
|
||||
return "工作经历"
|
||||
case "project":
|
||||
return "项目描述"
|
||||
case "education":
|
||||
return "教育背景"
|
||||
case "skill":
|
||||
return "技能描述"
|
||||
default:
|
||||
return "内容"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"resume-platform/internal/service/chat"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// ChatHandler 聊天对话处理器,基于文档向量库进行问答
|
||||
type ChatHandler struct {
|
||||
chatService chat.ChatService
|
||||
}
|
||||
|
||||
// NewChatHandler 创建聊天处理器实例
|
||||
// @param chatService 聊天服务层
|
||||
// @return *ChatHandler 处理器实例
|
||||
// @author sunct
|
||||
func NewChatHandler(chatService chat.ChatService) *ChatHandler {
|
||||
return &ChatHandler{chatService: chatService}
|
||||
}
|
||||
|
||||
// Chat 流式对话接口,以 SSE 方式返回 AI 回答
|
||||
// @param c Gin 上下文
|
||||
// @author sunct
|
||||
func (h *ChatHandler) Chat(c *gin.Context) {
|
||||
userID := c.Query("user_id")
|
||||
if userID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "user_id is required"})
|
||||
return
|
||||
}
|
||||
|
||||
var request struct {
|
||||
Question string `json:"question"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&request); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
|
||||
return
|
||||
}
|
||||
|
||||
c.Stream(func(w io.Writer) bool {
|
||||
answer, err := h.chatService.Chat(c.Request.Context(), userID, request.Question)
|
||||
if err != nil {
|
||||
c.SSEvent("error", gin.H{"message": err.Error()})
|
||||
return false
|
||||
}
|
||||
|
||||
c.SSEvent("message", answer)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
// GetChatHistory 获取用户聊天历史记录接口
|
||||
// @param c Gin 上下文
|
||||
// @author sunct
|
||||
func (h *ChatHandler) GetChatHistory(c *gin.Context) {
|
||||
userID := c.Query("user_id")
|
||||
if userID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "user_id is required"})
|
||||
return
|
||||
}
|
||||
|
||||
history, err := h.chatService.GetChatHistory(c.Request.Context(), userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, history)
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"resume-platform/internal/service/document"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// DocumentHandler 文档处理器,负责作品集文档的上传、查询和删除
|
||||
type DocumentHandler struct {
|
||||
documentService document.DocumentService
|
||||
}
|
||||
|
||||
// NewDocumentHandler 创建文档处理器实例
|
||||
// @param documentService 文档服务层
|
||||
// @return *DocumentHandler 处理器实例
|
||||
// @author sunct
|
||||
func NewDocumentHandler(documentService document.DocumentService) *DocumentHandler {
|
||||
return &DocumentHandler{documentService: documentService}
|
||||
}
|
||||
|
||||
// UploadDocument 上传文档接口,读取文件内容后交给 Service 层做向量化处理
|
||||
// @param c Gin 上下文
|
||||
// @author sunct
|
||||
func (h *DocumentHandler) UploadDocument(c *gin.Context) {
|
||||
userID := c.Query("user_id")
|
||||
if userID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "user_id is required"})
|
||||
return
|
||||
}
|
||||
|
||||
file, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "file is required"})
|
||||
return
|
||||
}
|
||||
|
||||
fileType := filepath.Ext(file.Filename)
|
||||
|
||||
reader, err := file.Open()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to open file"})
|
||||
return
|
||||
}
|
||||
defer reader.Close()
|
||||
|
||||
content, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read file"})
|
||||
return
|
||||
}
|
||||
|
||||
document, err := h.documentService.UploadDocument(c.Request.Context(), userID, file.Filename, fileType, file.Size, string(content))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"id": document.ID, "name": document.Name, "status": document.Status})
|
||||
}
|
||||
|
||||
// GetDocuments 获取用户文档列表接口
|
||||
// @param c Gin 上下文
|
||||
// @author sunct
|
||||
func (h *DocumentHandler) GetDocuments(c *gin.Context) {
|
||||
userID := c.Query("user_id")
|
||||
if userID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "user_id is required"})
|
||||
return
|
||||
}
|
||||
|
||||
documents, err := h.documentService.GetDocuments(c.Request.Context(), userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, documents)
|
||||
}
|
||||
|
||||
// GetDocument 获取单篇文档详情接口
|
||||
// @param c Gin 上下文
|
||||
// @author sunct
|
||||
func (h *DocumentHandler) GetDocument(c *gin.Context) {
|
||||
userID := c.Query("user_id")
|
||||
documentID := c.Param("id")
|
||||
|
||||
if userID == "" || documentID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "user_id and document_id are required"})
|
||||
return
|
||||
}
|
||||
|
||||
document, err := h.documentService.GetDocument(c.Request.Context(), userID, documentID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "document not found"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, document)
|
||||
}
|
||||
|
||||
// DeleteDocument 删除文档接口
|
||||
// @param c Gin 上下文
|
||||
// @author sunct
|
||||
func (h *DocumentHandler) DeleteDocument(c *gin.Context) {
|
||||
userID := c.Query("user_id")
|
||||
documentID := c.Param("id")
|
||||
|
||||
if userID == "" || documentID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "user_id and document_id are required"})
|
||||
return
|
||||
}
|
||||
|
||||
err := h.documentService.DeleteDocument(c.Request.Context(), userID, documentID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "document deleted successfully"})
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"resume-platform/internal/service/chat"
|
||||
"resume-platform/pkg/logger"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
var upgrader = websocket.Upgrader{
|
||||
ReadBufferSize: 4096,
|
||||
WriteBufferSize: 4096,
|
||||
CheckOrigin: func(r *http.Request) bool {
|
||||
return true
|
||||
},
|
||||
}
|
||||
|
||||
type IMHandler struct {
|
||||
imService *chat.IMService
|
||||
}
|
||||
|
||||
func NewIMHandler(imService *chat.IMService) *IMHandler {
|
||||
return &IMHandler{imService: imService}
|
||||
}
|
||||
|
||||
func (h *IMHandler) WebSocket(c *gin.Context) {
|
||||
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to upgrade connection: %v", err)
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
ctx := c.Request.Context()
|
||||
logger.CtxInfof(ctx, "WebSocket connection opened")
|
||||
|
||||
for {
|
||||
_, msg, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
if websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) {
|
||||
logger.CtxInfof(ctx, "WebSocket connection closed normally")
|
||||
} else {
|
||||
logger.CtxErrorf(ctx, "WebSocket read error: %v", err)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
var request struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if err := json.Unmarshal(msg, &request); err != nil {
|
||||
logger.CtxErrorf(ctx, "Failed to parse message: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
if request.Message == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
go h.handleMessage(ctx, conn, request.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *IMHandler) handleMessage(ctx context.Context, conn *websocket.Conn, message string) {
|
||||
logger.CtxInfof(ctx, "Received message: %s", message)
|
||||
|
||||
agent := h.imService.GetResumeAgent()
|
||||
var result string
|
||||
var err error
|
||||
if agent != nil {
|
||||
logger.CtxInfof(ctx, "Using Eino Resume Agent for request")
|
||||
result, err = h.imService.GenerateWithAgent(ctx, message)
|
||||
} else {
|
||||
logger.CtxInfof(ctx, "Using AI Provider for request")
|
||||
result, err = h.imService.GetAIProvider().Generate(ctx, message)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
logger.CtxErrorf(ctx, "AI generate failed: %v", err)
|
||||
response := map[string]interface{}{
|
||||
"type": "error",
|
||||
"message": err.Error(),
|
||||
}
|
||||
if err := conn.WriteJSON(response); err != nil {
|
||||
logger.CtxErrorf(ctx, "Failed to send error response: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
response := map[string]interface{}{
|
||||
"type": "message",
|
||||
"content": result,
|
||||
}
|
||||
|
||||
if err := conn.WriteJSON(response); err != nil {
|
||||
logger.CtxErrorf(ctx, "Failed to send response: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *IMHandler) Chat(c *gin.Context) {
|
||||
var request struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&request); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
|
||||
return
|
||||
}
|
||||
|
||||
if request.Message == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "message is required"})
|
||||
return
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
logger.CtxInfof(ctx, "Chat request: %s", request.Message)
|
||||
|
||||
result, err := h.imService.GetAIProvider().Generate(ctx, request.Message)
|
||||
if err != nil {
|
||||
logger.CtxErrorf(ctx, "AI generate failed: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"response": result})
|
||||
}
|
||||
|
||||
func (h *IMHandler) ChatStream(c *gin.Context) {
|
||||
var request struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&request); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
|
||||
return
|
||||
}
|
||||
|
||||
if request.Message == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "message is required"})
|
||||
return
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
logger.CtxInfof(ctx, "Chat stream request: %s", request.Message)
|
||||
|
||||
c.Stream(func(w io.Writer) bool {
|
||||
result, err := h.imService.GetAIProvider().Generate(ctx, request.Message)
|
||||
if err != nil {
|
||||
logger.CtxErrorf(ctx, "AI generate failed: %v", err)
|
||||
c.SSEvent("error", gin.H{"message": err.Error()})
|
||||
return false
|
||||
}
|
||||
|
||||
for _, char := range result {
|
||||
c.SSEvent("message", string(char))
|
||||
}
|
||||
c.SSEvent("done", gin.H{})
|
||||
return false
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"resume-platform/internal/model"
|
||||
"resume-platform/internal/service/knowledge"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type KnowledgeHandler struct {
|
||||
knowledgeBaseService *knowledge.KnowledgeBaseService
|
||||
ragService *knowledge.RAGService
|
||||
}
|
||||
|
||||
func NewKnowledgeHandler(knowledgeBaseService *knowledge.KnowledgeBaseService, ragService *knowledge.RAGService) *KnowledgeHandler {
|
||||
return &KnowledgeHandler{knowledgeBaseService: knowledgeBaseService, ragService: ragService}
|
||||
}
|
||||
|
||||
func (h *KnowledgeHandler) Search(c *gin.Context) {
|
||||
query := c.Query("q")
|
||||
topK := 5
|
||||
if k := c.Query("k"); k != "" {
|
||||
fmt.Sscanf(k, "%d", &topK)
|
||||
}
|
||||
userID := c.GetHeader("X-User-ID")
|
||||
|
||||
if query == "" {
|
||||
BadRequest(c, "Query parameter is required")
|
||||
return
|
||||
}
|
||||
|
||||
results, err := h.knowledgeBaseService.Retrieve(c.Request.Context(), query, topK, userID)
|
||||
if err != nil {
|
||||
Error(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
OK(c, results)
|
||||
}
|
||||
|
||||
func (h *KnowledgeHandler) GetStats(c *gin.Context) {
|
||||
userID := c.GetHeader("X-User-ID")
|
||||
|
||||
stats, err := h.knowledgeBaseService.GetStats(c.Request.Context(), userID)
|
||||
if err != nil {
|
||||
Error(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
OK(c, stats)
|
||||
}
|
||||
|
||||
func (h *KnowledgeHandler) AddDocument(c *gin.Context) {
|
||||
var req struct {
|
||||
DocumentID string `json:"document_id"`
|
||||
Content string `json:"content"`
|
||||
UserID string `json:"user_id"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
BadRequest(c, "Invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
if req.DocumentID == "" || req.Content == "" {
|
||||
BadRequest(c, "document_id and content are required")
|
||||
return
|
||||
}
|
||||
|
||||
err := h.knowledgeBaseService.AddDocument(c.Request.Context(), req.DocumentID, req.Content, req.UserID)
|
||||
if err != nil {
|
||||
Error(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
OK(c, gin.H{"message": "Document added to knowledge base successfully"})
|
||||
}
|
||||
|
||||
func (h *KnowledgeHandler) AddResume(c *gin.Context) {
|
||||
var req struct {
|
||||
ResumeID string `json:"resume_id"`
|
||||
Resume map[string]interface{} `json:"resume"`
|
||||
UserID string `json:"user_id"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
BadRequest(c, "Invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
if req.ResumeID == "" {
|
||||
BadRequest(c, "resume_id is required")
|
||||
return
|
||||
}
|
||||
|
||||
resumeJSON, _ := json.Marshal(req.Resume)
|
||||
var resume model.Resume
|
||||
if err := json.Unmarshal(resumeJSON, &resume); err != nil {
|
||||
BadRequest(c, "Invalid resume format")
|
||||
return
|
||||
}
|
||||
|
||||
resume.ID = req.ResumeID
|
||||
resume.UserID = req.UserID
|
||||
|
||||
err := h.knowledgeBaseService.AddResume(c.Request.Context(), req.ResumeID, &resume)
|
||||
if err != nil {
|
||||
Error(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
OK(c, gin.H{"message": "Resume added to knowledge base successfully"})
|
||||
}
|
||||
|
||||
func (h *KnowledgeHandler) AddText(c *gin.Context) {
|
||||
var req struct {
|
||||
Text string `json:"text"`
|
||||
SourceType string `json:"source_type"`
|
||||
SourceID string `json:"source_id"`
|
||||
SourceName string `json:"source_name"`
|
||||
UserID string `json:"user_id"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
BadRequest(c, "Invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
if req.Text == "" || req.SourceType == "" {
|
||||
BadRequest(c, "text and source_type are required")
|
||||
return
|
||||
}
|
||||
|
||||
err := h.knowledgeBaseService.AddText(c.Request.Context(), req.Text, req.SourceType, req.SourceID, req.SourceName, req.UserID)
|
||||
if err != nil {
|
||||
Error(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
OK(c, gin.H{"message": "Text added to knowledge base successfully"})
|
||||
}
|
||||
|
||||
func (h *KnowledgeHandler) Chat(c *gin.Context) {
|
||||
var req struct {
|
||||
Query string `json:"query"`
|
||||
UserID string `json:"user_id"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
BadRequest(c, "Invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
if req.Query == "" {
|
||||
BadRequest(c, "query is required")
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.ragService.Generate(c.Request.Context(), req.Query, req.UserID)
|
||||
if err != nil {
|
||||
Error(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
OK(c, gin.H{"answer": result})
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
// Package handler 处理所有 HTTP 请求,负责参数校验、调用 Service 层和返回响应
|
||||
// 按业务模块分为简历管理、后台管理、AI 能力、文档处理、聊天对话等处理器
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// APIResponse 统一 API 响应结构体,所有接口返回格式遵循此结构
|
||||
type APIResponse struct {
|
||||
Success bool `json:"success"` // 请求是否成功
|
||||
Message string `json:"message,omitempty"`// 提示消息
|
||||
Data interface{} `json:"data,omitempty"` // 响应数据
|
||||
Error string `json:"error,omitempty"` // 错误信息
|
||||
}
|
||||
|
||||
// OK 返回 200 成功响应,附带数据和可选消息
|
||||
// @param c Gin 上下文
|
||||
// @param data 响应数据
|
||||
// @param message 可选提示消息
|
||||
// @author sunct
|
||||
func OK(c *gin.Context, data interface{}, message ...string) {
|
||||
msg := ""
|
||||
if len(message) > 0 {
|
||||
msg = message[0]
|
||||
}
|
||||
c.JSON(http.StatusOK, APIResponse{
|
||||
Success: true,
|
||||
Data: data,
|
||||
Message: msg,
|
||||
})
|
||||
}
|
||||
|
||||
// Created 返回 201 创建成功响应
|
||||
// @param c Gin 上下文
|
||||
// @param data 创建的资源数据
|
||||
// @param message 可选提示消息
|
||||
// @author sunct
|
||||
func Created(c *gin.Context, data interface{}, message ...string) {
|
||||
msg := ""
|
||||
if len(message) > 0 {
|
||||
msg = message[0]
|
||||
}
|
||||
c.JSON(http.StatusCreated, APIResponse{
|
||||
Success: true,
|
||||
Data: data,
|
||||
Message: msg,
|
||||
})
|
||||
}
|
||||
|
||||
// Success 返回 200 成功响应,仅包含提示消息
|
||||
// @param c Gin 上下文
|
||||
// @param message 成功提示消息
|
||||
// @author sunct
|
||||
func Success(c *gin.Context, message string) {
|
||||
c.JSON(http.StatusOK, APIResponse{
|
||||
Success: true,
|
||||
Message: message,
|
||||
})
|
||||
}
|
||||
|
||||
// BadRequest 返回 400 参数错误响应
|
||||
// @param c Gin 上下文
|
||||
// @param message 错误提示消息
|
||||
// @author sunct
|
||||
func BadRequest(c *gin.Context, message string) {
|
||||
c.JSON(http.StatusBadRequest, APIResponse{
|
||||
Success: false,
|
||||
Message: message,
|
||||
})
|
||||
}
|
||||
|
||||
// NotFound 返回 404 资源不存在响应
|
||||
// @param c Gin 上下文
|
||||
// @param message 错误提示消息
|
||||
// @author sunct
|
||||
func NotFound(c *gin.Context, message string) {
|
||||
c.JSON(http.StatusNotFound, APIResponse{
|
||||
Success: false,
|
||||
Message: message,
|
||||
})
|
||||
}
|
||||
|
||||
// Conflict 返回 409 资源冲突响应(如重复创建)
|
||||
// @param c Gin 上下文
|
||||
// @param message 错误提示消息
|
||||
// @author sunct
|
||||
func Conflict(c *gin.Context, message string) {
|
||||
c.JSON(http.StatusConflict, APIResponse{
|
||||
Success: false,
|
||||
Message: message,
|
||||
})
|
||||
}
|
||||
|
||||
// Unauthorized 返回 401 未授权响应
|
||||
// @param c Gin 上下文
|
||||
// @param message 错误提示消息
|
||||
// @author sunct
|
||||
func Unauthorized(c *gin.Context, message string) {
|
||||
c.JSON(http.StatusUnauthorized, APIResponse{
|
||||
Success: false,
|
||||
Message: message,
|
||||
})
|
||||
}
|
||||
|
||||
// TooManyRequests 返回 429 请求过于频繁响应(限流)
|
||||
// @param c Gin 上下文
|
||||
// @param message 错误提示消息
|
||||
// @author sunct
|
||||
func TooManyRequests(c *gin.Context, message string) {
|
||||
c.JSON(http.StatusTooManyRequests, APIResponse{
|
||||
Success: false,
|
||||
Message: message,
|
||||
})
|
||||
}
|
||||
|
||||
// Error 返回 500 服务器内部错误响应
|
||||
// @param c Gin 上下文
|
||||
// @param message 错误提示消息
|
||||
// @author sunct
|
||||
func Error(c *gin.Context, message string) {
|
||||
c.JSON(http.StatusInternalServerError, APIResponse{
|
||||
Success: false,
|
||||
Message: message,
|
||||
})
|
||||
}
|
||||
|
||||
// SuccessResponse 返回成功响应(支持标准库 http.ResponseWriter)
|
||||
// @param w http.ResponseWriter
|
||||
// @param data 响应数据
|
||||
func SuccessResponse(w http.ResponseWriter, data interface{}) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(APIResponse{
|
||||
Success: true,
|
||||
Data: data,
|
||||
})
|
||||
}
|
||||
|
||||
// ErrorResponse 返回错误响应(支持标准库 http.ResponseWriter)
|
||||
// @param w http.ResponseWriter
|
||||
// @param statusCode HTTP 状态码
|
||||
// @param message 错误消息
|
||||
func ErrorResponse(w http.ResponseWriter, statusCode int, message string) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(statusCode)
|
||||
json.NewEncoder(w).Encode(APIResponse{
|
||||
Success: false,
|
||||
Message: message,
|
||||
})
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,51 @@
|
||||
// Package middleware Gin 中间件层,提供请求日志、认证等横切能力
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"resume-platform/pkg/logger"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func RequestLogger() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
start := time.Now()
|
||||
path := c.Request.URL.Path
|
||||
method := c.Request.Method
|
||||
|
||||
c.Next()
|
||||
|
||||
latency := time.Since(start)
|
||||
statusCode := c.Writer.Status()
|
||||
clientIP := c.ClientIP()
|
||||
referer := c.Request.Referer()
|
||||
userAgent := c.Request.UserAgent()
|
||||
|
||||
entry := logger.FromContext(c.Request.Context())
|
||||
entry = entry.WithFields(map[string]interface{}{
|
||||
"status": statusCode,
|
||||
"latency": latency,
|
||||
"ip": clientIP,
|
||||
"method": method,
|
||||
"path": path,
|
||||
})
|
||||
|
||||
if referer != "" {
|
||||
entry = entry.WithField("referer", referer)
|
||||
}
|
||||
if len(c.Errors) > 0 {
|
||||
entry = entry.WithField("errors", c.Errors.String())
|
||||
}
|
||||
|
||||
switch {
|
||||
case statusCode >= 500:
|
||||
entry.WithField("user_agent", userAgent).Error("Server error")
|
||||
case statusCode >= 400:
|
||||
entry.Warn("Client error")
|
||||
default:
|
||||
entry.Info("Request handled")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"resume-platform/pkg/constant"
|
||||
"resume-platform/pkg/idgen"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func Tracing(appName string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
reqId, _ := idgen.GetID()
|
||||
|
||||
ctx := context.WithValue(c.Request.Context(), constant.RequestIdKey, reqId)
|
||||
ctx = context.WithValue(ctx, constant.AppNameKey, appName)
|
||||
ctx = context.WithValue(ctx, constant.RequestRouteKey, c.Request.URL.Path)
|
||||
ctx = context.WithValue(ctx, constant.ClientIP, c.ClientIP())
|
||||
|
||||
c.Request = c.Request.WithContext(ctx)
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
// Package model 常量定义,包含系统默认配置和全局常量
|
||||
package model
|
||||
|
||||
const (
|
||||
// DefaultPageSize 后台列表默认分页大小
|
||||
DefaultPageSize = 10
|
||||
// MaxPageSize 后台列表最大分页大小
|
||||
MaxPageSize = 100
|
||||
|
||||
// DefaultTemplate 默认简历模板名称
|
||||
DefaultTemplate = "modern"
|
||||
|
||||
// AdminTokenFile 管理员令牌持久化文件路径
|
||||
AdminTokenFile = "./data/admin_tokens.json"
|
||||
// SettingsFile 系统设置持久化文件路径(预留)
|
||||
SettingsFile = "./data/settings.json"
|
||||
|
||||
// DefaultWebsiteTitle 默认网站标题
|
||||
DefaultWebsiteTitle = "景笺 - 开源简历平台"
|
||||
// DefaultWebsiteDesc 默认网站简介
|
||||
DefaultWebsiteDesc = "现代化的开源简历平台,支持多人简历管理、自定义模板、实时预览"
|
||||
// DefaultWebsiteKeywords 默认网站关键词
|
||||
DefaultWebsiteKeywords = "简历平台,开源,个人主页,作品集,简历管理"
|
||||
// DefaultWebsiteDomain 默认网站域名
|
||||
DefaultWebsiteDomain = "http://localhost:8090"
|
||||
|
||||
// DefaultAdminUsername 默认管理员用户名
|
||||
DefaultAdminUsername = "admin"
|
||||
// DefaultAdminPassword 默认管理员密码
|
||||
DefaultAdminPassword = "admin123"
|
||||
)
|
||||
@@ -0,0 +1,95 @@
|
||||
package model
|
||||
|
||||
// Document 上传文档模型,记录文档元数据和原始内容
|
||||
type Document struct {
|
||||
ID string `json:"id"` // 文档唯一标识
|
||||
UserID string `json:"user_id"` // 关联的用户ID
|
||||
Name string `json:"name"` // 文档名称
|
||||
Type string `json:"type"` // 文件类型(扩展名)
|
||||
Size int64 `json:"size"` // 文件大小(字节)
|
||||
MIMEType string `json:"mime_type"` // MIME类型
|
||||
Content string `json:"content"` // 文档原始内容
|
||||
Status string `json:"status"` // 处理状态:uploading/processing/processed/failed
|
||||
ErrorMsg string `json:"error_msg,omitempty"` // 处理失败的错误信息
|
||||
CreatedAt string `json:"created_at"` // 创建时间
|
||||
UpdatedAt string `json:"updated_at"` // 更新时间
|
||||
}
|
||||
|
||||
// DocumentChunk 文档切片模型,用于向量检索的文本块
|
||||
type DocumentChunk struct {
|
||||
ID string `json:"id"` // 切片唯一标识
|
||||
DocumentID string `json:"document_id"` // 关联的文档ID
|
||||
ChunkIndex int `json:"chunk_index"` // 切片序号
|
||||
Content string `json:"content"` // 切片文本内容
|
||||
Embedding string `json:"embedding"` // 向量嵌入(JSON格式)
|
||||
Metadata string `json:"metadata"` // 元数据(JSON格式)
|
||||
CreatedAt string `json:"created_at"` // 创建时间
|
||||
}
|
||||
|
||||
// ChatHistory 聊天历史记录模型
|
||||
type ChatHistory struct {
|
||||
ID string `json:"id"` // 记录唯一标识
|
||||
UserID string `json:"user_id"` // 关联的用户ID
|
||||
Question string `json:"question"` // 用户问题
|
||||
Answer string `json:"answer"` // AI回答
|
||||
Context string `json:"context"` // 参考的文档上下文
|
||||
CreatedAt string `json:"created_at"` // 创建时间
|
||||
}
|
||||
|
||||
// Menu 后台菜单模型
|
||||
type Menu struct {
|
||||
ID string `json:"id"` // 菜单唯一标识
|
||||
Name string `json:"name"` // 菜单名称
|
||||
Icon string `json:"icon"` // 图标类名
|
||||
Path string `json:"path"` // 路由路径
|
||||
ParentID string `json:"parent_id"` // 父菜单ID
|
||||
SortOrder int `json:"sort_order"` // 排序权重
|
||||
IsFixed bool `json:"is_fixed"` // 是否固定菜单(不可删除)
|
||||
IsEnabled bool `json:"is_enabled"` // 是否启用
|
||||
CreatedAt string `json:"created_at"` // 创建时间
|
||||
UpdatedAt string `json:"updated_at"` // 更新时间
|
||||
}
|
||||
|
||||
// SystemConfig 系统配置模型,存储网站全局设置
|
||||
type SystemConfig struct {
|
||||
ID string `json:"id"` // 配置唯一标识
|
||||
WebsiteDomain string `json:"website_domain"` // 网站域名
|
||||
WebsiteLogo string `json:"website_logo"` // 网站Logo(Base64)
|
||||
LogoPath string `json:"logo_path"` // Logo文件路径
|
||||
WebsiteTitle string `json:"website_title"` // 网站标题
|
||||
WebsiteDesc string `json:"website_desc"` // 网站简介(短)
|
||||
WebsiteDescription string `json:"website_description"` // 网站描述(长)
|
||||
WebsiteKeywords string `json:"website_keywords"` // 网站关键词
|
||||
AdminEmail string `json:"admin_email"` // 管理员邮箱
|
||||
AdminName string `json:"admin_name"` // 管理员名称
|
||||
SEOKeywords string `json:"seo_keywords"` // SEO关键词
|
||||
SEODescription string `json:"seo_description"` // SEO描述
|
||||
DatabasePath string `json:"database_path"` // 数据库文件路径
|
||||
AdminPageSize int `json:"admin_page_size"` // 后台列表分页大小
|
||||
CreatedAt string `json:"created_at"` // 创建时间
|
||||
UpdatedAt string `json:"updated_at"` // 更新时间
|
||||
}
|
||||
|
||||
// LoginHistory 登录历史记录模型
|
||||
type LoginHistory struct {
|
||||
ID string `json:"id"` // 记录唯一标识
|
||||
UserID string `json:"user_id"` // 关联的用户ID
|
||||
UserName string `json:"user_name"` // 用户名
|
||||
IP string `json:"ip"` // 登录IP
|
||||
Location string `json:"location"` // 登录地点
|
||||
UserAgent string `json:"user_agent"` // 浏览器UA
|
||||
Success bool `json:"success"` // 是否登录成功
|
||||
Message string `json:"message"` // 结果消息
|
||||
CreatedAt string `json:"created_at"` // 创建时间
|
||||
}
|
||||
|
||||
// Notification 系统通知模型
|
||||
type Notification struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"` // 通知类型:user/resume/portfolio/quiz/system
|
||||
Title string `json:"title"` // 通知标题
|
||||
Message string `json:"message"` // 通知内容
|
||||
URL string `json:"url"` // 点击跳转链接
|
||||
IsRead bool `json:"is_read"` // 是否已读
|
||||
CreatedAt string `json:"created_at"` // 创建时间
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package model
|
||||
|
||||
// Embedding 向量嵌入模型,用于知识库检索
|
||||
type Embedding struct {
|
||||
ID string `json:"id"` // 向量唯一标识
|
||||
ContentHash string `json:"content_hash"` // 内容哈希(去重)
|
||||
Embedding string `json:"embedding"` // 向量嵌入(JSON数组)
|
||||
SourceType string `json:"source_type"` // 来源类型(document/resume/text)
|
||||
SourceID string `json:"source_id"` // 来源ID
|
||||
SourceName string `json:"source_name"` // 来源名称
|
||||
UserID string `json:"user_id"` // 关联的用户ID
|
||||
CreatedAt string `json:"created_at"` // 创建时间
|
||||
}
|
||||
|
||||
// ChunkWithScore 带相似度评分的文档片段,用于向量检索排序
|
||||
type ChunkWithScore struct {
|
||||
Content string `json:"content"` // 片段内容
|
||||
Score float64 `json:"score"` // 相似度评分
|
||||
Metadata string `json:"metadata"` // 元数据(含向量JSON)
|
||||
SourceID string `json:"source_id"` // 来源ID
|
||||
SourceName string `json:"source_name"` // 来源名称
|
||||
SourceType string `json:"source_type"` // 来源类型
|
||||
}
|
||||
|
||||
// KnowledgeBaseStats 知识库统计信息
|
||||
type KnowledgeBaseStats struct {
|
||||
TotalDocuments int `json:"total_documents"` // 文档总数
|
||||
TotalChunks int `json:"total_chunks"` // 切片总数
|
||||
TotalEmbeddings int `json:"total_embeddings"` // 向量总数
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
// Package model 数据模型层,定义所有业务实体结构
|
||||
// 包含用户、简历、作品集、答题记录等核心数据模型
|
||||
package model
|
||||
|
||||
// User 用户模型,用于用户认证和权限管理
|
||||
type User struct {
|
||||
ID string `json:"id"` // 用户唯一标识
|
||||
Username string `json:"username"` // 用户名(唯一)
|
||||
PasswordHash string `json:"password_hash"` // 密码哈希值
|
||||
Email string `json:"email"` // 用户邮箱
|
||||
Name string `json:"name"` // 用户姓名
|
||||
Phone string `json:"phone"` // 用户手机号
|
||||
Role string `json:"role"` // 用户角色(admin/user)
|
||||
CreatedAt string `json:"created_at"` // 创建时间
|
||||
UpdatedAt string `json:"updated_at"` // 更新时间
|
||||
}
|
||||
|
||||
// Resume 简历模型,存储个人简历的完整信息
|
||||
type Resume struct {
|
||||
ID string `json:"id"` // 简历唯一标识
|
||||
UserID string `json:"user_id"` // 关联的用户ID
|
||||
Route string `json:"route"` // 访问路由(唯一,如 zhangsan)
|
||||
Password string `json:"password"` // 简历密码(用于访问保护)
|
||||
BasicInfo BasicInfo `json:"basic_info"` // 基本信息
|
||||
Experience []Experience `json:"experience"` // 工作经历列表
|
||||
Education []Education `json:"education"` // 教育背景列表
|
||||
Skills []Skill `json:"skills"` // 技能列表
|
||||
Projects []Project `json:"projects"` // 项目经验列表
|
||||
Template string `json:"template"` // 使用的模板名称(默认modern)
|
||||
ShowInHome bool `json:"show_in_home"` // 是否在首页展示
|
||||
CreatedAt string `json:"created_at"` // 创建时间
|
||||
UpdatedAt string `json:"updated_at"` // 更新时间
|
||||
}
|
||||
|
||||
// BasicInfo 基本信息,包含个人基本资料
|
||||
type BasicInfo struct {
|
||||
Name string `json:"name"` // 姓名
|
||||
Title string `json:"title"` // 职位/头衔
|
||||
Email string `json:"email"` // 邮箱地址
|
||||
Phone string `json:"phone"` // 手机号码
|
||||
Location string `json:"location"` // 所在地
|
||||
Website string `json:"website"` // 个人网站地址
|
||||
Summary string `json:"summary"` // 个人简介
|
||||
Avatar string `json:"avatar"` // 头像URL
|
||||
ExperienceYears string `json:"experience_years"` // 工作年限
|
||||
JobTarget string `json:"job_target"` // 求职目标
|
||||
Industry string `json:"industry"` // 所属行业
|
||||
CoreAdvantages []string `json:"core_advantages"` // 核心优势列表
|
||||
}
|
||||
|
||||
// Experience 工作经历,记录职业发展历程
|
||||
type Experience struct {
|
||||
ID uint `json:"id"` // 经历唯一标识
|
||||
ResumeID string `json:"resume_id"` // 关联的简历ID
|
||||
Company string `json:"company"` // 公司名称
|
||||
Position string `json:"position"` // 职位名称
|
||||
StartDate string `json:"start_date"` // 开始日期(格式:YYYY-MM)
|
||||
EndDate string `json:"end_date"` // 结束日期(格式:YYYY-MM,"至今"表示当前)
|
||||
Description string `json:"description"` // 工作描述
|
||||
Highlights []string `json:"highlights"` // 工作亮点列表
|
||||
Platforms []string `json:"platforms"` // 线上平台列表
|
||||
}
|
||||
|
||||
// Education 教育背景,记录学习经历
|
||||
type Education struct {
|
||||
ID uint `json:"id"` // 教育背景唯一标识
|
||||
ResumeID string `json:"resume_id"` // 关联的简历ID
|
||||
School string `json:"school"` // 学校名称
|
||||
Degree string `json:"degree"` // 学位(本科/硕士/博士等)
|
||||
Major string `json:"major"` // 专业名称
|
||||
StartDate string `json:"start_date"` // 入学日期(格式:YYYY-MM)
|
||||
EndDate string `json:"end_date"` // 毕业日期(格式:YYYY-MM)
|
||||
GPA string `json:"gpa"` // GPA成绩
|
||||
}
|
||||
|
||||
// Skill 技能,记录个人技能和熟练程度
|
||||
type Skill struct {
|
||||
ID uint `json:"id"` // 技能唯一标识
|
||||
ResumeID string `json:"resume_id"` // 关联的简历ID
|
||||
Name string `json:"name"` // 技能名称
|
||||
Level string `json:"level"` // 熟练程度(beginner/intermediate/advanced/expert)
|
||||
Category string `json:"category"` // 技能分类(后端/前端/数据库/运维等)
|
||||
}
|
||||
|
||||
// Project 项目经验,记录参与的项目
|
||||
type Project struct {
|
||||
ID uint `json:"id"` // 项目唯一标识
|
||||
ResumeID string `json:"resume_id"` // 关联的简历ID
|
||||
Name string `json:"name"` // 项目名称
|
||||
Description string `json:"description"` // 项目描述
|
||||
TechStack []string `json:"tech_stack"` // 技术栈列表
|
||||
URL string `json:"url"` // 项目链接
|
||||
Highlights []string `json:"highlights"` // 项目亮点列表
|
||||
Achievements []string `json:"achievements"` // 项目成果列表
|
||||
StartDate string `json:"start_date"` // 开始日期(格式:YYYY-MM)
|
||||
EndDate string `json:"end_date"` // 结束日期(格式:YYYY-MM)
|
||||
ShowInResume bool `json:"show_in_resume"` // 是否在简历中显示
|
||||
}
|
||||
|
||||
// PortfolioItem 作品集项目,用于展示详细的项目文档
|
||||
type PortfolioItem struct {
|
||||
ID uint `json:"id"` // 作品集项目唯一标识
|
||||
UserID string `json:"user_id"` // 关联的用户ID
|
||||
Name string `json:"name"` // 项目名称
|
||||
Description string `json:"description"` // 项目描述
|
||||
TechStack []string `json:"tech_stack"` // 技术栈列表
|
||||
URL string `json:"url"` // 项目链接
|
||||
StartDate string `json:"start_date"` // 开始日期(格式:YYYY-MM)
|
||||
EndDate string `json:"end_date"` // 结束日期(格式:YYYY-MM)
|
||||
Doc string `json:"doc"` // 项目文档(Markdown格式)
|
||||
ShowInResume bool `json:"show_in_resume"` // 是否在简历中显示
|
||||
ResumeID string `json:"resume_id"` // 关联的简历ID
|
||||
ProjectID uint `json:"project_id"` // 关联的简历项目ID
|
||||
Password string `json:"password"` // 访问密码
|
||||
Hidden bool `json:"hidden"` // 是否隐藏
|
||||
}
|
||||
|
||||
// ResumeUpdate 简历更新请求模型,用于部分更新简历内容
|
||||
type ResumeUpdate struct {
|
||||
BasicInfo *BasicInfo `json:"basic_info"` // 基本信息(可选)
|
||||
Experience *[]Experience `json:"experience"` // 工作经历列表(可选)
|
||||
Education *[]Education `json:"education"` // 教育背景列表(可选)
|
||||
Skills *[]Skill `json:"skills"` // 技能列表(可选)
|
||||
Projects *[]Project `json:"projects"` // 项目经验列表(可选)
|
||||
Template *string `json:"template"` // 模板名称(可选)
|
||||
ShowInHome *bool `json:"show_in_home"` // 是否在首页展示(可选)
|
||||
Route *string `json:"route"` // 访问路由(可选)
|
||||
Password *string `json:"password"` // 简历密码(可选)
|
||||
}
|
||||
|
||||
// QuizQuestion 面试题题目模型
|
||||
type QuizQuestion struct {
|
||||
Type string `json:"type"` // 题目类型:mcq(选择题)、fill(填空题)、sa(简答题)、algo(算法设计题)
|
||||
Text string `json:"text"` // 题目内容
|
||||
Options []string `json:"options"` // 选项(选择题)
|
||||
Answer string `json:"answer"` // 参考答案
|
||||
Analysis string `json:"analysis"` // 答案解析(详细拓展)
|
||||
Score int `json:"score"` // 分值
|
||||
Keywords []string `json:"keywords"` // 关联关键词
|
||||
Difficulty string `json:"difficulty"` // 难度级别:入门、初级、中级、进阶、高级
|
||||
Category string `json:"category"` // 题目分类
|
||||
}
|
||||
|
||||
// QuizRecord 面试题历史记录模型
|
||||
type QuizRecord struct {
|
||||
ID string `json:"id"` // 记录唯一标识
|
||||
UserID string `json:"user_id"` // 关联的用户ID
|
||||
ResumeID string `json:"resume_id"` // 关联的简历ID
|
||||
ResumeRoute string `json:"resume_route"` // 关联的简历路由
|
||||
Title string `json:"title"` // 答题记录标题
|
||||
Questions []QuizQuestion `json:"questions"` // 题目列表
|
||||
UserAnswers map[string]string `json:"user_answers"` // 用户答案
|
||||
Score float64 `json:"score"` // 得分
|
||||
CorrectCount int `json:"correct_count"` // 正确题数
|
||||
TotalCount int `json:"total_count"` // 总题数
|
||||
CreatedAt string `json:"created_at"` // 创建时间
|
||||
}
|
||||
|
||||
// FavoriteQuestion 收藏/错题本模型
|
||||
type FavoriteQuestion struct {
|
||||
ID string `json:"id"` // 记录唯一标识
|
||||
UserID string `json:"user_id"` // 关联的用户ID
|
||||
ResumeID string `json:"resume_id"` // 关联的简历ID
|
||||
ResumeRoute string `json:"resume_route"` // 关联的简历路由
|
||||
Question QuizQuestion `json:"question"` // 题目内容
|
||||
UserAnswer string `json:"user_answer"` // 用户答案
|
||||
IsCorrect bool `json:"is_correct"` // 是否正确
|
||||
IsFavorite bool `json:"is_favorite"` // 是否收藏
|
||||
CreatedAt string `json:"created_at"` // 添加时间
|
||||
}
|
||||
@@ -0,0 +1,427 @@
|
||||
package parser
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"path/filepath"
|
||||
"resume-platform/pkg/utils"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Parser interface {
|
||||
Parse(fileName string, reader io.Reader) (string, error)
|
||||
}
|
||||
|
||||
func NewParser(fileName string) (Parser, error) {
|
||||
ext := strings.ToLower(filepath.Ext(fileName))
|
||||
switch ext {
|
||||
case ".pdf":
|
||||
return &PDFParser{}, nil
|
||||
case ".docx":
|
||||
return &DOCXParser{}, nil
|
||||
case ".xlsx":
|
||||
return &ExcelParser{}, nil
|
||||
case ".md", ".markdown":
|
||||
return &MarkdownParser{}, nil
|
||||
case ".txt":
|
||||
return &TextParser{}, nil
|
||||
default:
|
||||
return &TextParser{}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func ParseFile(fileName string, reader io.Reader) (string, error) {
|
||||
data, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
parser, err := NewParser(fileName)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
text, err := parser.Parse(fileName, bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if text == "" && len(data) > 0 {
|
||||
text = extractTextFromBinary(data)
|
||||
}
|
||||
|
||||
return text, nil
|
||||
}
|
||||
|
||||
func extractTextFromBinary(data []byte) string {
|
||||
content := string(data)
|
||||
var builder strings.Builder
|
||||
|
||||
for _, r := range content {
|
||||
if r >= 0x20 && r <= 0x7E || r == '\n' || r == '\r' || r == '\t' ||
|
||||
(r >= 0x4E00 && r <= 0x9FFF) || (r >= 0x3000 && r <= 0x303F) ||
|
||||
(r >= 0xFF00 && r <= 0xFFEF) {
|
||||
builder.WriteRune(r)
|
||||
} else if r > 0 {
|
||||
builder.WriteRune(' ')
|
||||
}
|
||||
}
|
||||
|
||||
return cleanText(builder.String())
|
||||
}
|
||||
|
||||
type TextParser struct{}
|
||||
|
||||
func (p *TextParser) Parse(fileName string, reader io.Reader) (string, error) {
|
||||
data, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return cleanText(string(data)), nil
|
||||
}
|
||||
|
||||
type MarkdownParser struct{}
|
||||
|
||||
func (p *MarkdownParser) Parse(fileName string, reader io.Reader) (string, error) {
|
||||
data, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return cleanText(string(data)), nil
|
||||
}
|
||||
|
||||
type PDFParser struct{}
|
||||
|
||||
func (p *PDFParser) Parse(fileName string, reader io.Reader) (string, error) {
|
||||
data, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
content := string(data)
|
||||
if !strings.HasPrefix(content, "%PDF-") {
|
||||
return "", fmt.Errorf("not a valid PDF file")
|
||||
}
|
||||
|
||||
text := extractTextFromPDF(content)
|
||||
return cleanText(text), nil
|
||||
}
|
||||
|
||||
func extractTextFromPDF(content string) string {
|
||||
var builder strings.Builder
|
||||
inTextBlock := false
|
||||
inStream := false
|
||||
lines := strings.Split(content, "\n")
|
||||
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
|
||||
if strings.Contains(line, "/Length") && !inStream {
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.HasPrefix(line, "stream") {
|
||||
inStream = true
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.HasPrefix(line, "endstream") {
|
||||
inStream = false
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.Contains(line, "BT") {
|
||||
inTextBlock = true
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.Contains(line, "ET") {
|
||||
inTextBlock = false
|
||||
continue
|
||||
}
|
||||
|
||||
if inTextBlock && !inStream {
|
||||
if strings.HasPrefix(line, "(") && strings.HasSuffix(line, ")") {
|
||||
text := line[1 : len(line)-1]
|
||||
text = strings.ReplaceAll(text, "\\n", "\n")
|
||||
text = strings.ReplaceAll(text, "\\r", "\r")
|
||||
text = strings.ReplaceAll(text, "\\t", "\t")
|
||||
text = strings.ReplaceAll(text, "\\(", "(")
|
||||
text = strings.ReplaceAll(text, "\\)", ")")
|
||||
text = strings.ReplaceAll(text, "\\/", "/")
|
||||
builder.WriteString(text)
|
||||
builder.WriteString(" ")
|
||||
} else if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") {
|
||||
content := line[1 : len(line)-1]
|
||||
parts := strings.Fields(content)
|
||||
for _, part := range parts {
|
||||
if strings.HasPrefix(part, "(") && strings.HasSuffix(part, ")") {
|
||||
text := part[1 : len(part)-1]
|
||||
text = strings.ReplaceAll(text, "\\n", "\n")
|
||||
text = strings.ReplaceAll(text, "\\r", "\r")
|
||||
text = strings.ReplaceAll(text, "\\t", "\t")
|
||||
builder.WriteString(text)
|
||||
builder.WriteString(" ")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return strings.TrimSpace(builder.String())
|
||||
}
|
||||
|
||||
type DOCXParser struct{}
|
||||
|
||||
func (p *DOCXParser) Parse(fileName string, reader io.Reader) (string, error) {
|
||||
data, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
fmt.Printf("DEBUG DOCXParser: file size: %d bytes\n", len(data))
|
||||
|
||||
zipReader, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to open DOCX as zip: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("DEBUG DOCXParser: zip contains %d files\n", len(zipReader.File))
|
||||
|
||||
var text strings.Builder
|
||||
foundDocument := false
|
||||
|
||||
for _, file := range zipReader.File {
|
||||
fmt.Printf("DEBUG DOCXParser: zip file: %s (size: %d)\n", file.Name, file.UncompressedSize64)
|
||||
|
||||
if file.Name == "word/document.xml" {
|
||||
foundDocument = true
|
||||
f, err := file.Open()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to open document.xml: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
xmlData, err := io.ReadAll(f)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read document.xml: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("DEBUG DOCXParser: document.xml size: %d bytes\n", len(xmlData))
|
||||
|
||||
parsedText := parseDOCXXML(string(xmlData))
|
||||
fmt.Printf("DEBUG DOCXParser: parsed text from document.xml: %d chars\n", len(parsedText))
|
||||
|
||||
text.WriteString(parsedText)
|
||||
} else if strings.HasPrefix(file.Name, "word/header") && strings.HasSuffix(file.Name, ".xml") {
|
||||
f, err := file.Open()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
defer f.Close()
|
||||
xmlData, _ := io.ReadAll(f)
|
||||
text.WriteString(parseDOCXXML(string(xmlData)))
|
||||
text.WriteString("\n")
|
||||
} else if strings.HasPrefix(file.Name, "word/footer") && strings.HasSuffix(file.Name, ".xml") {
|
||||
f, err := file.Open()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
defer f.Close()
|
||||
xmlData, _ := io.ReadAll(f)
|
||||
text.WriteString(parseDOCXXML(string(xmlData)))
|
||||
text.WriteString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
if !foundDocument {
|
||||
fmt.Println("WARN DOCXParser: document.xml not found in zip")
|
||||
return cleanText(extractTextFromBinary(data)), nil
|
||||
}
|
||||
|
||||
result := cleanText(text.String())
|
||||
fmt.Printf("DEBUG DOCXParser: final result length: %d chars\n", len(result))
|
||||
|
||||
if len(result) > 0 {
|
||||
fmt.Printf("DEBUG DOCXParser: first 500 chars:\n%s\n", result[:utils.Min(len(result), 500)])
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func parseDOCXXML(xmlContent string) string {
|
||||
fmt.Printf("DEBUG parseDOCXXML: input length: %d chars\n", len(xmlContent))
|
||||
|
||||
xmlContent = removeNamespacePrefixes(xmlContent)
|
||||
|
||||
allTexts := extractTextContent(xmlContent)
|
||||
|
||||
var builder strings.Builder
|
||||
for _, text := range allTexts {
|
||||
text = strings.TrimSpace(text)
|
||||
if text != "" {
|
||||
builder.WriteString(text)
|
||||
builder.WriteString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
result := cleanText(builder.String())
|
||||
fmt.Printf("DEBUG parseDOCXXML: extracted %d text elements, result length: %d\n", len(allTexts), len(result))
|
||||
|
||||
if len(result) > 0 {
|
||||
fmt.Printf("DEBUG parseDOCXXML: first 500 chars:\n%s\n", result[:utils.Min(len(result), 500)])
|
||||
}
|
||||
|
||||
if result == "" {
|
||||
fmt.Println("WARN: Structured parsing returned empty, using fallback")
|
||||
return cleanText(extractPlainTextFromXML(xmlContent))
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func extractTextContent(xmlContent string) []string {
|
||||
var texts []string
|
||||
|
||||
inTag := false
|
||||
currentText := ""
|
||||
|
||||
for _, r := range xmlContent {
|
||||
if r == '<' {
|
||||
inTag = true
|
||||
if currentText != "" {
|
||||
texts = append(texts, currentText)
|
||||
currentText = ""
|
||||
}
|
||||
continue
|
||||
}
|
||||
if r == '>' {
|
||||
inTag = false
|
||||
continue
|
||||
}
|
||||
if !inTag {
|
||||
currentText += string(r)
|
||||
}
|
||||
}
|
||||
|
||||
if currentText != "" {
|
||||
texts = append(texts, currentText)
|
||||
}
|
||||
|
||||
return texts
|
||||
}
|
||||
|
||||
func removeNamespacePrefixes(xmlContent string) string {
|
||||
prefixes := []string{"w:", "r:", "wp:", "wpc:", "wps:", "wpg:", "wpi:", "wne:", "mc:", "o:", "v:", "w10:", "wpc:", "wpg:", "wps:", "a:", "m:"}
|
||||
for _, prefix := range prefixes {
|
||||
xmlContent = strings.ReplaceAll(xmlContent, "<"+prefix, "<")
|
||||
xmlContent = strings.ReplaceAll(xmlContent, "</"+prefix, "</")
|
||||
xmlContent = strings.ReplaceAll(xmlContent, " "+prefix, " ")
|
||||
}
|
||||
return xmlContent
|
||||
}
|
||||
|
||||
func extractPlainTextFromXML(xmlContent string) string {
|
||||
var builder strings.Builder
|
||||
inTag := false
|
||||
|
||||
for _, r := range xmlContent {
|
||||
if r == '<' {
|
||||
inTag = true
|
||||
continue
|
||||
}
|
||||
if r == '>' {
|
||||
inTag = false
|
||||
continue
|
||||
}
|
||||
if !inTag {
|
||||
builder.WriteRune(r)
|
||||
}
|
||||
}
|
||||
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
type ExcelParser struct{}
|
||||
|
||||
func (p *ExcelParser) Parse(fileName string, reader io.Reader) (string, error) {
|
||||
data, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
zipReader, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to open XLSX as zip: %w", err)
|
||||
}
|
||||
|
||||
var text strings.Builder
|
||||
for _, file := range zipReader.File {
|
||||
if strings.HasPrefix(file.Name, "xl/worksheets/") && strings.HasSuffix(file.Name, ".xml") {
|
||||
sheetName := strings.TrimPrefix(file.Name, "xl/worksheets/sheet")
|
||||
sheetName = strings.TrimSuffix(sheetName, ".xml")
|
||||
text.WriteString("===" + sheetName + "===\n\n")
|
||||
|
||||
f, err := file.Open()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to open worksheet: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
xmlData, err := io.ReadAll(f)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read worksheet: %w", err)
|
||||
}
|
||||
|
||||
text.WriteString(parseXLSXXML(string(xmlData)))
|
||||
text.WriteString("\n\n")
|
||||
}
|
||||
}
|
||||
|
||||
return cleanText(text.String()), nil
|
||||
}
|
||||
|
||||
func parseXLSXXML(xmlContent string) string {
|
||||
type Cell struct {
|
||||
Text string `xml:",chardata"`
|
||||
}
|
||||
type Row struct {
|
||||
Cells []Cell `xml:"c"`
|
||||
}
|
||||
type SheetData struct {
|
||||
Rows []Row `xml:"row"`
|
||||
}
|
||||
type Worksheet struct {
|
||||
SheetData SheetData `xml:"sheetData"`
|
||||
}
|
||||
|
||||
var ws Worksheet
|
||||
err := xml.Unmarshal([]byte(xmlContent), &ws)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
var builder strings.Builder
|
||||
for _, row := range ws.SheetData.Rows {
|
||||
var cells []string
|
||||
for _, cell := range row.Cells {
|
||||
text := strings.TrimSpace(cell.Text)
|
||||
if text != "" {
|
||||
cells = append(cells, text)
|
||||
}
|
||||
}
|
||||
if len(cells) > 0 {
|
||||
builder.WriteString(strings.Join(cells, "\t"))
|
||||
builder.WriteString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
return strings.TrimSpace(builder.String())
|
||||
}
|
||||
|
||||
func cleanText(text string) string {
|
||||
return utils.CleanText(text)
|
||||
}
|
||||
@@ -0,0 +1,464 @@
|
||||
package prompt
|
||||
|
||||
import "fmt"
|
||||
|
||||
var (
|
||||
ResumeExtractor = `你是一位专业的中文简历信息提取专家。请仔细分析以下简历内容,全面提取所有信息并按指定格式输出。
|
||||
|
||||
【简历内容】
|
||||
%s
|
||||
|
||||
【提取要求】
|
||||
请全面分析文档内容,识别以下所有区块,并尽可能从已有信息中推断和补充缺失的字段:
|
||||
|
||||
1. 基本信息:
|
||||
- name: 姓名(通常在文档最开头,可能是最大的字体)
|
||||
- title: 当前职位/头衔(如"后端开发工程师"、"高级Java工程师")
|
||||
- email: 邮箱地址(包含@符号,可能在文档开头或结尾)
|
||||
- phone: 手机号码(11位数字,可能在文档开头或结尾)
|
||||
- location: 所在城市(如"北京"、"上海",可能在基本信息区域)
|
||||
- website: 个人网站或博客地址(包含http://或https://)
|
||||
- summary: 个人简介或自我评价(通常以"个人简介"、"自我评价"、"职业概述"等开头)
|
||||
- experience_years: 工作年限(从工作经历计算,填数字,如3、5、8)
|
||||
- job_target: 求职目标或意向岗位(如果没有明确说明,可从当前职位推断)
|
||||
- industry: 所属行业(如互联网、教育、金融、电商,从公司名称或描述推断)
|
||||
- core_advantages: 核心优势或个人亮点(从工作经历和项目经验中提炼)
|
||||
|
||||
2. 工作经历:
|
||||
- company: 公司名称
|
||||
- position: 职位名称
|
||||
- start_date: 开始时间(格式YYYY-MM)
|
||||
- end_date: 结束时间(格式YYYY-MM,"至今"或"当前"填"present")
|
||||
- description: 工作职责描述(详细描述日常工作内容)
|
||||
- highlights: 工作亮点或主要成就(用列表表示,从描述中提炼关键成果)
|
||||
- platforms: 负责的线上平台或产品名称(如有)
|
||||
|
||||
3. 教育背景:
|
||||
- school: 学校名称
|
||||
- degree: 学位(本科/硕士/博士/大专/高中等)
|
||||
- major: 专业名称
|
||||
- start_date: 入学时间(格式YYYY-MM)
|
||||
- end_date: 毕业时间(格式YYYY-MM)
|
||||
- gpa: GPA成绩(如有)
|
||||
|
||||
4. 专业技能:
|
||||
- name: 技能名称(如Java、Python、MySQL、Redis、Vue.js、Docker)
|
||||
- level: 熟练程度(入门/初级/中级/高级/精通,根据描述推断)
|
||||
- category: 技能分类(后端/前端/数据库/运维/移动端/人工智能/工具/其他)
|
||||
|
||||
5. 项目经验:
|
||||
- name: 项目名称
|
||||
- description: 项目描述(项目的目的、规模、主要功能)
|
||||
- tech_stack: 技术栈列表(从项目描述和工作经历中提取使用的技术)
|
||||
- url: 项目链接(如有)
|
||||
- highlights: 项目亮点(主要负责的工作、解决的问题)
|
||||
- achievements: 项目成果(量化的成果、获得的奖励等)
|
||||
- start_date: 开始时间(格式YYYY-MM)
|
||||
- end_date: 结束时间(格式YYYY-MM)
|
||||
|
||||
【数据补充规则】
|
||||
1. 如果工作年限没有明确说明,根据工作经历的时间跨度计算得出
|
||||
2. 如果求职目标没有明确说明,可从当前职位或工作经历推断
|
||||
3. 如果行业没有明确说明,从公司名称或工作描述推断
|
||||
4. 如果技能的熟练程度没有明确说明,根据使用频率和描述推断(如"精通"、"熟练"、"熟悉"、"了解")
|
||||
5. 如果技能分类不确定,根据技能性质归类(如Java属于后端,Vue.js属于前端,MySQL属于数据库)
|
||||
6. 如果项目的技术栈没有明确说明,从项目描述中提取使用的技术
|
||||
7. 如果项目亮点和成果没有明确说明,从项目描述中提炼关键贡献
|
||||
8. 如果核心优势没有明确说明,从工作经历和项目经验中提炼3-5个关键点
|
||||
|
||||
【关键词识别】
|
||||
- 基本信息区域关键词:姓名、电话、手机、邮箱、邮箱地址、地址、所在地、籍贯、个人简介、自我评价、职业概述、求职意向、工作年限
|
||||
- 工作经历区域关键词:工作经历、工作经验、职业经历、工作背景、公司、任职时间、工作职责、负责内容
|
||||
- 教育背景区域关键词:教育背景、学历、教育经历、毕业院校、学校、学位、专业
|
||||
- 技能区域关键词:专业技能、技能特长、技术能力、掌握技能、熟练掌握、了解、精通
|
||||
- 项目经验区域关键词:项目经验、项目经历、项目背景、项目名称、负责项目、参与项目
|
||||
|
||||
【重要提示】
|
||||
1. 请按照文档中的实际内容提取,不要遗漏任何信息
|
||||
2. 如果某个字段在简历中确实找不到,请填空字符串""或空数组[]
|
||||
3. 时间格式统一为YYYY-MM,"至今"或"当前"填"present"
|
||||
4. 工作年限只填数字,不要填"年"字
|
||||
5. 请确保提取的信息与文档内容完全一致
|
||||
6. 如果发现冲突信息,请以文档中最新的信息为准
|
||||
|
||||
【输出格式】
|
||||
请直接输出JSON,不要包含markdown代码块标记,格式如下:
|
||||
|
||||
{
|
||||
"basic_info": {
|
||||
"name": "",
|
||||
"title": "",
|
||||
"email": "",
|
||||
"phone": "",
|
||||
"location": "",
|
||||
"website": "",
|
||||
"summary": "",
|
||||
"experience_years": "",
|
||||
"job_target": "",
|
||||
"industry": "",
|
||||
"core_advantages": []
|
||||
},
|
||||
"experience": [
|
||||
{
|
||||
"company": "",
|
||||
"position": "",
|
||||
"start_date": "",
|
||||
"end_date": "",
|
||||
"description": "",
|
||||
"highlights": [],
|
||||
"platforms": []
|
||||
}
|
||||
],
|
||||
"education": [
|
||||
{
|
||||
"school": "",
|
||||
"degree": "",
|
||||
"major": "",
|
||||
"start_date": "",
|
||||
"end_date": "",
|
||||
"gpa": ""
|
||||
}
|
||||
],
|
||||
"skills": [
|
||||
{
|
||||
"name": "",
|
||||
"level": "",
|
||||
"category": ""
|
||||
}
|
||||
],
|
||||
"projects": [
|
||||
{
|
||||
"name": "",
|
||||
"description": "",
|
||||
"tech_stack": [],
|
||||
"url": "",
|
||||
"highlights": [],
|
||||
"achievements": [],
|
||||
"start_date": "",
|
||||
"end_date": ""
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
RAGAnswer = `你是一位专业的简历分析助手。请根据以下上下文信息回答用户的问题。
|
||||
|
||||
【上下文信息】
|
||||
%s
|
||||
|
||||
【用户问题】
|
||||
%s
|
||||
|
||||
【回答要求】
|
||||
1. 优先使用上下文信息回答问题
|
||||
2. 如果上下文没有相关信息,可以使用你的内部知识
|
||||
3. 回答要准确、简洁,符合中文表达习惯
|
||||
4. 如果无法回答,请明确说明`
|
||||
|
||||
PolishSummary = `你是一位专业的简历优化专家。请优化以下个人简介,使其更加专业、简洁、有吸引力:
|
||||
|
||||
原文:
|
||||
%s
|
||||
|
||||
优化要求:
|
||||
1. 突出核心竞争力和独特价值
|
||||
2. 使用行动动词和量化成果
|
||||
3. 保持专业但不过于生硬
|
||||
4. 控制在150字以内
|
||||
5. 保持原意,不要编造信息
|
||||
|
||||
请直接输出优化后的内容,不要包含其他解释。`
|
||||
|
||||
PolishExperience = `你是一位专业的简历优化专家。请优化以下工作经历描述,使其更加专业、量化、有说服力:
|
||||
|
||||
公司:%s
|
||||
职位:%s
|
||||
原描述:
|
||||
%s
|
||||
|
||||
优化要求:
|
||||
1. 使用STAR法则(情境-任务-行动-结果)
|
||||
2. 突出量化成果和数据指标
|
||||
3. 使用专业术语和行业关键词
|
||||
4. 突出个人贡献而非团队成果
|
||||
5. 保持原意,不要编造信息
|
||||
|
||||
请直接输出优化后的内容,不要包含其他解释。`
|
||||
|
||||
PolishProject = `你是一位专业的简历优化专家。请优化以下项目描述,使其更加专业、突出技术能力和成果:
|
||||
|
||||
项目名称:%s
|
||||
原描述:
|
||||
%s
|
||||
|
||||
优化要求:
|
||||
1. 清晰描述技术架构和技术栈
|
||||
2. 突出个人技术贡献和解决的难题
|
||||
3. 量化项目成果和影响
|
||||
4. 使用专业技术术语
|
||||
5. 保持原意,不要编造信息
|
||||
|
||||
请直接输出优化后的内容,不要包含其他解释。`
|
||||
|
||||
PolishDescription = `你是一位专业的简历优化专家。请优化以下%s内容:
|
||||
|
||||
原文:
|
||||
%s
|
||||
|
||||
优化要求:
|
||||
1. 语言更加专业、精炼
|
||||
2. 突出重点和亮点
|
||||
3. 使用适当的行业术语
|
||||
4. 保持原意,不要编造信息
|
||||
5. 格式清晰易读
|
||||
|
||||
请直接输出优化后的内容,不要包含其他解释。`
|
||||
|
||||
DocumentAnalysis = `你是一位专业的文档分析助手。请根据以下文档内容回答用户问题。
|
||||
|
||||
文档内容:
|
||||
%s
|
||||
|
||||
用户问题:%s
|
||||
|
||||
回答要求:
|
||||
1. 仅基于提供的文档内容回答,不要编造信息
|
||||
2. 如果文档内容不足以回答问题,请明确说明
|
||||
3. 回答要简洁准确,避免冗长`
|
||||
|
||||
QuestionGeneration = `你是一位专业的技术面试官和出题专家。请根据以下简历内容和技术关键词,生成高质量的面试题目。
|
||||
|
||||
简历内容:
|
||||
%s
|
||||
|
||||
技术关键词:%s
|
||||
|
||||
题目类型:%s
|
||||
|
||||
生成规则(必须严格遵守,否则任务失败):
|
||||
1. 每种题目类型必须生成4道题目,不得少于4道,不得多于5道
|
||||
2. 如果包含4种题型(选择题、填空题、简答题、算法设计题),必须生成16道题目(4×4)
|
||||
3. 如果包含3种题型,必须生成12道题目(4×3)
|
||||
4. 如果包含2种题型,必须生成8道题目(4×2)
|
||||
5. 选择题必须有4个选项,正确答案必须是选项之一
|
||||
6. 填空题必须有明确的填空位置,用___表示
|
||||
7. 每道题必须有详细的解析,不少于50字
|
||||
8. 答案必须完整准确,代码题要有可运行的代码示例
|
||||
9. 题目难度在初级、入门、中级、进阶之间均匀分布
|
||||
10. 每道题至少包含2个技术关键词
|
||||
11. 输出格式必须为纯JSON数组,不要包含任何markdown标记、代码块标记或额外文字
|
||||
12. JSON字符串中所有双引号必须使用反斜杠转义
|
||||
|
||||
JSON格式要求(必须严格遵守,每种类型4题):
|
||||
[
|
||||
{
|
||||
"type": "mcq",
|
||||
"text": "选择题题目内容",
|
||||
"options": ["选项A", "选项B", "选项C", "选项D"],
|
||||
"answer": "正确答案(如A、B、C、D)",
|
||||
"analysis": "题目解析,直接给出解析内容",
|
||||
"score": 10,
|
||||
"keywords": ["关键词1", "关键词2"],
|
||||
"difficulty": "初级",
|
||||
"category": "分类名称"
|
||||
},
|
||||
{
|
||||
"type": "fill",
|
||||
"text": "填空题题目内容(用___表示填空位置)",
|
||||
"answer": "正确答案",
|
||||
"analysis": "题目解析",
|
||||
"score": 5,
|
||||
"keywords": ["关键词1"],
|
||||
"difficulty": "入门",
|
||||
"category": "分类名称"
|
||||
},
|
||||
{
|
||||
"type": "sa",
|
||||
"text": "简答题题目内容",
|
||||
"answer": "参考答案",
|
||||
"analysis": "详细解析",
|
||||
"score": 15,
|
||||
"keywords": ["关键词1", "关键词2"],
|
||||
"difficulty": "中级",
|
||||
"category": "分类名称"
|
||||
},
|
||||
{
|
||||
"type": "algo",
|
||||
"text": "算法题题目内容",
|
||||
"answer": "代码答案",
|
||||
"analysis": "详细解析",
|
||||
"score": 20,
|
||||
"keywords": ["关键词1"],
|
||||
"difficulty": "进阶",
|
||||
"category": "算法"
|
||||
}
|
||||
]
|
||||
|
||||
请立即开始生成题目,严格按照上述规则输出JSON数组。如果题目数量不够或格式不正确,将被视为任务失败。`
|
||||
|
||||
QuestionGenerationForType = `你是一位专业的技术面试官和出题专家。题目和答案必须严谨专业,符合面试需求,不得出现错误或漏洞。
|
||||
|
||||
请根据以下简历内容和技术关键词,生成高质量的%s。
|
||||
|
||||
简历内容:
|
||||
%s
|
||||
|
||||
技术关键词:%s
|
||||
|
||||
生成规则(必须严格遵守,否则任务失败):
|
||||
1. 必须生成5道%s,不得少于5道
|
||||
2. 选择题必须有4个选项,答案字段必须只包含选项字母A、B、C或D,不能包含选项内容文字
|
||||
3. 选择题的选项必须清晰明确,互不重叠,只有一个正确答案
|
||||
4. 填空题必须有明确的填空位置,用___表示
|
||||
5. 每道题必须有详细的解析,不少于50字
|
||||
6. 答案必须完整准确,代码题要有可运行的代码示例
|
||||
7. 题目难度在初级、入门、中级、进阶之间均匀分布
|
||||
8. 每道题至少包含2个技术关键词
|
||||
9. 输出格式必须为纯JSON数组,不要包含任何markdown标记、代码块标记或额外文字
|
||||
10. JSON字符串中所有双引号必须使用反斜杠转义
|
||||
11. 确保题目和答案正确无误,避免出现逻辑漏洞或事实错误
|
||||
|
||||
JSON格式要求(必须严格遵守,生成5道%s):
|
||||
[
|
||||
%s
|
||||
]
|
||||
|
||||
请立即开始生成题目,严格按照上述规则输出JSON数组。`
|
||||
)
|
||||
|
||||
func BuildResumeExtractorPrompt(content string) string {
|
||||
return fmt.Sprintf(ResumeExtractor, content)
|
||||
}
|
||||
|
||||
func BuildRAGAnswerPrompt(context, query string) string {
|
||||
return fmt.Sprintf(RAGAnswer, context, query)
|
||||
}
|
||||
|
||||
func BuildPolishSummaryPrompt(original string) string {
|
||||
return fmt.Sprintf(PolishSummary, original)
|
||||
}
|
||||
|
||||
func BuildPolishExperiencePrompt(original, company, position string) string {
|
||||
return fmt.Sprintf(PolishExperience, company, position, original)
|
||||
}
|
||||
|
||||
func BuildPolishProjectPrompt(original, projectName string) string {
|
||||
return fmt.Sprintf(PolishProject, projectName, original)
|
||||
}
|
||||
|
||||
func BuildPolishDescriptionPrompt(original, fieldType string) string {
|
||||
var fieldName string
|
||||
switch fieldType {
|
||||
case "summary":
|
||||
fieldName = "个人简介"
|
||||
case "experience":
|
||||
fieldName = "工作经历"
|
||||
case "project":
|
||||
fieldName = "项目描述"
|
||||
case "education":
|
||||
fieldName = "教育背景"
|
||||
case "skill":
|
||||
fieldName = "技能描述"
|
||||
default:
|
||||
fieldName = "文本内容"
|
||||
}
|
||||
return fmt.Sprintf(PolishDescription, fieldName, original)
|
||||
}
|
||||
|
||||
func BuildDocumentAnalysisPrompt(context, question string) string {
|
||||
return fmt.Sprintf(DocumentAnalysis, context, question)
|
||||
}
|
||||
|
||||
func BuildQuestionGenerationPrompt(resumeText string, keywords []string, types []string) string {
|
||||
typeNames := map[string]string{
|
||||
"mcq": "选择题",
|
||||
"fill": "填空题",
|
||||
"sa": "简答题",
|
||||
"algo": "算法设计题",
|
||||
}
|
||||
|
||||
var typeDesc string
|
||||
for i, t := range types {
|
||||
if i > 0 {
|
||||
typeDesc += "、"
|
||||
}
|
||||
typeDesc += typeNames[t]
|
||||
}
|
||||
|
||||
return fmt.Sprintf(QuestionGeneration, resumeText, joinKeywords(keywords), typeDesc)
|
||||
}
|
||||
|
||||
func BuildQuestionGenerationPromptForType(resumeText string, keywords []string, qType string) string {
|
||||
typeNames := map[string]string{
|
||||
"mcq": "选择题",
|
||||
"fill": "填空题",
|
||||
"sa": "简答题",
|
||||
"algo": "算法设计题",
|
||||
}
|
||||
|
||||
typeName := typeNames[qType]
|
||||
if typeName == "" {
|
||||
typeName = qType
|
||||
}
|
||||
|
||||
var formatExample string
|
||||
switch qType {
|
||||
case "mcq":
|
||||
formatExample = `{
|
||||
"type": "mcq",
|
||||
"text": "题目内容",
|
||||
"options": ["选项A内容", "选项B内容", "选项C内容", "选项D内容"],
|
||||
"answer": "A",
|
||||
"analysis": "详细解析,不少于50字,说明每个选项为什么对或错",
|
||||
"score": 10,
|
||||
"keywords": ["关键词1", "关键词2"],
|
||||
"difficulty": "初级",
|
||||
"category": "分类名称"
|
||||
}`
|
||||
case "fill":
|
||||
formatExample = `{
|
||||
"type": "fill",
|
||||
"text": "题目内容(用___表示填空位置)",
|
||||
"answer": "正确答案",
|
||||
"analysis": "详细解析,不少于50字,解释答案的由来",
|
||||
"score": 5,
|
||||
"keywords": ["关键词1", "关键词2"],
|
||||
"difficulty": "入门",
|
||||
"category": "分类名称"
|
||||
}`
|
||||
case "sa":
|
||||
formatExample = `{
|
||||
"type": "sa",
|
||||
"text": "题目内容",
|
||||
"answer": "参考答案,完整准确",
|
||||
"analysis": "详细解析,不少于100字,深入解释原理",
|
||||
"score": 15,
|
||||
"keywords": ["关键词1", "关键词2"],
|
||||
"difficulty": "中级",
|
||||
"category": "分类名称"
|
||||
}`
|
||||
case "algo":
|
||||
formatExample = `{
|
||||
"type": "algo",
|
||||
"text": "题目内容",
|
||||
"answer": "完整可运行的代码示例",
|
||||
"analysis": "详细解析,不少于100字,解释算法思路和复杂度",
|
||||
"score": 20,
|
||||
"keywords": ["关键词1", "关键词2"],
|
||||
"difficulty": "进阶",
|
||||
"category": "算法"
|
||||
}`
|
||||
}
|
||||
|
||||
return fmt.Sprintf(QuestionGenerationForType, typeName, resumeText, joinKeywords(keywords), typeName, typeName, formatExample)
|
||||
}
|
||||
|
||||
func joinKeywords(keywords []string) string {
|
||||
var result string
|
||||
for i, kw := range keywords {
|
||||
if i > 0 {
|
||||
result += ", "
|
||||
}
|
||||
result += kw
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package repository
|
||||
|
||||
import "resume-platform/internal/model"
|
||||
|
||||
// ResumeRepository 简历数据访问层接口,定义简历相关的数据操作方法
|
||||
type ResumeRepository interface {
|
||||
GetResume(id string) (*model.Resume, error)
|
||||
GetResumeByRoute(route string) (*model.Resume, error)
|
||||
RouteExists(route string) bool
|
||||
GetAllResumes() ([]*model.Resume, error)
|
||||
GetResumesByUserID(userID string) ([]*model.Resume, error)
|
||||
GetResumesWithPagination(page, pageSize int) ([]*model.Resume, int, error)
|
||||
GetResumesWithFilter(page, pageSize int, userID, keyword, template string, showInHome *bool) ([]*model.Resume, int, error)
|
||||
CreateResume(resume *model.Resume) error
|
||||
UpdateResume(id string, update *model.ResumeUpdate) error
|
||||
DeleteResume(id string) error
|
||||
UpdateResumeUserID(resumeID, userID string) error
|
||||
Exists(id string) bool
|
||||
CreateUser(user *model.User) error
|
||||
GetUserByUsername(username string) (*model.User, error)
|
||||
GetUserByID(id string) (*model.User, error)
|
||||
GetAllUsers() ([]*model.User, error)
|
||||
GetUsersWithPagination(page, pageSize int) ([]*model.User, int, error)
|
||||
UpdateUser(id string, user *model.User) error
|
||||
DeleteUser(id string) error
|
||||
GetPortfolioItems() ([]*model.PortfolioItem, error)
|
||||
GetPortfolioItemsWithPagination(page, pageSize int) ([]*model.PortfolioItem, int, error)
|
||||
GetPortfolioItemsByUserID(userID string) ([]*model.PortfolioItem, error)
|
||||
GetPortfolioItem(id uint) (*model.PortfolioItem, error)
|
||||
CreatePortfolioItem(item *model.PortfolioItem) error
|
||||
UpdatePortfolioItem(id uint, item *model.PortfolioItem) error
|
||||
DeletePortfolioItem(id uint) error
|
||||
CreateQuizRecord(record *model.QuizRecord) error
|
||||
GetQuizRecordsByResume(resumeRoute string) ([]*model.QuizRecord, error)
|
||||
GetQuizRecordsByUserID(userID string) ([]*model.QuizRecord, error)
|
||||
GetQuizRecordsByUserIDWithPagination(userID string, page, pageSize int) ([]*model.QuizRecord, int, error)
|
||||
GetQuizRecord(id string) (*model.QuizRecord, error)
|
||||
DeleteQuizRecord(id string) error
|
||||
CreateFavoriteQuestion(fav *model.FavoriteQuestion) error
|
||||
GetFavoriteQuestions(resumeRoute string, isFavorite, isCorrect *bool) ([]*model.FavoriteQuestion, error)
|
||||
GetFavoriteQuestionsByUserID(userID string) ([]*model.FavoriteQuestion, error)
|
||||
DeleteFavoriteQuestion(id string) error
|
||||
ToggleFavorite(id string) error
|
||||
CreateDocument(doc *model.Document) error
|
||||
GetDocument(id, userID string) (*model.Document, error)
|
||||
GetDocumentByID(id string) (*model.Document, error)
|
||||
GetDocumentsByUserID(userID string) ([]*model.Document, error)
|
||||
UpdateDocument(doc *model.Document) error
|
||||
DeleteDocument(id, userID string) error
|
||||
CreateDocumentChunk(chunk *model.DocumentChunk) error
|
||||
GetDocumentChunksByUserID(userID string) ([]*model.DocumentChunk, error)
|
||||
GetAllDocumentChunks() ([]*model.DocumentChunk, error)
|
||||
GetDocumentChunksByDocumentID(documentID string) ([]*model.DocumentChunk, error)
|
||||
CreateEmbedding(embedding *model.Embedding) error
|
||||
GetEmbeddingByContentHash(contentHash string) (*model.Embedding, error)
|
||||
GetEmbeddingsByUserID(userID string) ([]*model.Embedding, error)
|
||||
GetAllEmbeddings() ([]*model.Embedding, error)
|
||||
DeleteEmbedding(id string) error
|
||||
DeleteEmbeddingsBySource(sourceType, sourceID string) error
|
||||
CreateChatHistory(history *model.ChatHistory) error
|
||||
GetChatHistoryByUserID(userID string) ([]*model.ChatHistory, error)
|
||||
GetAllMenus() ([]*model.Menu, error)
|
||||
CreateMenu(menu *model.Menu) error
|
||||
UpdateMenu(id string, menu *model.Menu) error
|
||||
DeleteMenu(id string) error
|
||||
GetSystemConfig() (*model.SystemConfig, error)
|
||||
CreateSystemConfig(config *model.SystemConfig) error
|
||||
UpdateSystemConfig(id string, config *model.SystemConfig) error
|
||||
CleanSystemConfig() error
|
||||
CreateLoginHistory(history *model.LoginHistory) error
|
||||
GetLoginHistory(page, pageSize int) ([]*model.LoginHistory, int, error)
|
||||
CreateNotification(n *model.Notification) error
|
||||
GetNotifications() ([]*model.Notification, error)
|
||||
GetUnreadNotificationCount() (int, error)
|
||||
MarkNotificationAsRead(id string) error
|
||||
MarkAllNotificationsAsRead() error
|
||||
DeleteAllNotifications() error
|
||||
DeleteNotification(id string) error
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,108 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// setupAdminRoutes 配置后台管理路由
|
||||
// 登录入口(需配置路径):
|
||||
// - GET /my-secret-admin-123/login 登录页面
|
||||
// - POST /my-secret-admin-123/login 登录验证
|
||||
// - GET /my-secret-admin-123/captcha 验证码
|
||||
//
|
||||
// 后台管理路由(登录后访问):
|
||||
// - GET /dashboard 后台首页(仪表盘)
|
||||
// - GET /dashboard/home 后台首页
|
||||
// - GET /dashboard/users 人员列表页
|
||||
// - GET /dashboard/user/:id/resume 用户简历管理页
|
||||
// - GET /dashboard/user/:id/portfolio 用户作品集管理页
|
||||
// - GET /dashboard/user/:id/quiz 用户面试题库页
|
||||
// - GET /dashboard/menus 菜单管理页
|
||||
// - GET /dashboard/settings 系统设置页
|
||||
// - GET /dashboard/logout 退出登录
|
||||
func (r *Router) setupAdminRoutes(engine *gin.Engine) {
|
||||
adminPath := r.cfg.Auth.AdminPath
|
||||
if adminPath == "" {
|
||||
adminPath = "/admin"
|
||||
}
|
||||
|
||||
fmt.Printf("Setting up admin login routes at: %s\n", adminPath)
|
||||
|
||||
loginGroup := engine.Group(adminPath)
|
||||
{
|
||||
loginGroup.GET("/login", r.adminHandler.LoginPage)
|
||||
loginGroup.POST("/login", r.adminHandler.Login)
|
||||
loginGroup.GET("/captcha", r.adminHandler.Captcha)
|
||||
}
|
||||
|
||||
dashboard := engine.Group("/dashboard")
|
||||
dashboard.Use(r.adminHandler.AuthMiddleware())
|
||||
{
|
||||
dashboard.GET("/", r.adminHandler.HomePage)
|
||||
dashboard.GET("/home", r.adminHandler.HomePage)
|
||||
|
||||
dashboard.GET("/users", r.adminHandler.UsersPage)
|
||||
dashboard.GET("/user", func(c *gin.Context) {
|
||||
c.Redirect(http.StatusMovedPermanently, "/dashboard/users")
|
||||
})
|
||||
|
||||
dashboard.GET("/resumes", r.adminHandler.ResumesPage)
|
||||
dashboard.GET("/resume/add", r.adminHandler.ResumeAddPage)
|
||||
dashboard.GET("/resume/:id/edit", r.adminHandler.ResumeEditPageWithoutUser)
|
||||
dashboard.GET("/user/:id/resume", r.adminHandler.UserResumePage)
|
||||
dashboard.GET("/user/:id/resume/:resume_id/edit", r.adminHandler.ResumeEditPage)
|
||||
|
||||
dashboard.GET("/portfolios", r.adminHandler.PortfoliosPage)
|
||||
dashboard.GET("/user/:id/portfolio", r.adminHandler.UserPortfolioPage)
|
||||
dashboard.GET("/portfolio/add", r.adminHandler.PortfolioAddPage)
|
||||
dashboard.GET("/portfolio/:id/edit", r.adminHandler.PortfolioEditPage)
|
||||
|
||||
dashboard.GET("/quiz", r.adminHandler.QuizPage)
|
||||
dashboard.GET("/user/:id/quiz", r.adminHandler.UserQuizPage)
|
||||
dashboard.GET("/user/:id/quiz/generate", r.adminHandler.QuizGeneratePage)
|
||||
dashboard.GET("/user/:id/quiz/record/:record_id", r.adminHandler.QuizRecordDetailPage)
|
||||
dashboard.POST("/api/quiz/favorite/:id/toggle", r.adminHandler.ToggleFavoriteAPI)
|
||||
dashboard.DELETE("/api/quiz/favorite/:id", r.resumeHandler.DeleteFavoriteQuestionAPI)
|
||||
|
||||
dashboard.GET("/settings", r.adminHandler.SettingsPage)
|
||||
|
||||
dashboard.GET("/logout", r.adminHandler.Logout)
|
||||
|
||||
dashboard.GET("/api/user/:id", r.resumeHandler.GetUserAPI)
|
||||
dashboard.POST("/api/user", r.resumeHandler.CreateUserAPI)
|
||||
dashboard.PUT("/api/user/:id", r.resumeHandler.UpdateUserAPI)
|
||||
dashboard.DELETE("/api/user/:id", r.resumeHandler.DeleteUserAPI)
|
||||
|
||||
dashboard.GET("/api/users", r.resumeHandler.GetAllUsersAPI)
|
||||
|
||||
dashboard.GET("/api/settings", r.adminHandler.GetSettingsAPI)
|
||||
dashboard.POST("/api/settings", r.adminHandler.UpdateSettingsAPI)
|
||||
dashboard.POST("/api/settings/logo", r.adminHandler.UploadLogoAPI)
|
||||
dashboard.GET("/api/settings/system-info", r.adminHandler.SystemInfoAPI)
|
||||
dashboard.GET("/api/settings/login-history", r.adminHandler.LoginHistoryAPI)
|
||||
|
||||
dashboard.POST("/api/portfolio", r.resumeHandler.CreatePortfolioItemAPI)
|
||||
dashboard.GET("/api/portfolio/:id", r.resumeHandler.GetPortfolioItemAPI)
|
||||
dashboard.PUT("/api/portfolio/:id", r.resumeHandler.UpdatePortfolioItemAPI)
|
||||
dashboard.DELETE("/api/portfolio/:id", r.resumeHandler.DeletePortfolioItemAPI)
|
||||
|
||||
dashboard.POST("/api/resume/create", r.resumeHandler.AdminCreateResumeAPI)
|
||||
dashboard.POST("/api/resume/generate-from-document", r.resumeHandler.GenerateResumeFromDocumentAPI)
|
||||
dashboard.GET("/api/resume/:id", r.resumeHandler.GetResumeByIDAPI)
|
||||
dashboard.PUT("/api/resume/:id", r.resumeHandler.UpdateResumeByIDAPI)
|
||||
dashboard.PUT("/api/resume/:id/show_in_home", r.resumeHandler.UpdateResumeShowInHomeAPI)
|
||||
dashboard.PUT("/api/resume/:id/password", r.resumeHandler.UpdateResumePasswordAPI)
|
||||
dashboard.PUT("/api/resume/:id/user", r.resumeHandler.UpdateResumeUserIDAPI)
|
||||
dashboard.DELETE("/api/resume/:id", r.resumeHandler.DeleteResumeAPI)
|
||||
|
||||
dashboard.GET("/api/search", r.adminHandler.SearchAPI)
|
||||
dashboard.GET("/api/notifications", r.adminHandler.GetNotificationsAPI)
|
||||
dashboard.POST("/api/notifications/:id/read", r.adminHandler.MarkNotificationAsReadAPI)
|
||||
dashboard.POST("/api/notifications/read-all", r.adminHandler.MarkAllNotificationsAsReadAPI)
|
||||
dashboard.DELETE("/api/notifications", r.adminHandler.DeleteAllNotificationsAPI)
|
||||
dashboard.GET("/api/notifications/unread-count", r.adminHandler.GetUnreadNotificationCountAPI)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package router
|
||||
|
||||
import "github.com/gin-gonic/gin"
|
||||
|
||||
// setupAPIRoutes 配置 API 路由,所有接口前缀为 /api
|
||||
// 按模块分组:
|
||||
// - /api/resume 简历相关接口
|
||||
// - /api/portfolio 作品集相关接口
|
||||
// - /api/user 用户相关接口
|
||||
// - /api/quiz 面试题库相关接口
|
||||
// - /api/ai AI 相关接口
|
||||
// - /api/document 文档相关接口
|
||||
// - /api/chat 聊天相关接口
|
||||
// - /api/system 系统配置相关接口
|
||||
// - /api/menu 菜单相关接口
|
||||
// - /api/im 实时聊天/IM相关接口
|
||||
func (r *Router) setupAPIRoutes(engine *gin.Engine) {
|
||||
api := engine.Group("/api")
|
||||
{
|
||||
r.setupResumeAPIRoutes(api)
|
||||
r.setupPortfolioAPIRoutes(api)
|
||||
r.setupUserAPIRoutes(api)
|
||||
r.setupQuizAPIRoutes(api)
|
||||
r.setupAIAPIRoutes(api)
|
||||
r.setupDocumentAPIRoutes(api)
|
||||
r.setupChatAPIRoutes(api)
|
||||
r.setupSystemAPIRoutes(api)
|
||||
r.setupIMRoutes(api)
|
||||
r.setupKnowledgeAPIRoutes(api)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package router
|
||||
|
||||
import "github.com/gin-gonic/gin"
|
||||
|
||||
// setupAIAPIRoutes 配置 AI 相关 API 路由
|
||||
func (r *Router) setupAIAPIRoutes(api *gin.RouterGroup) {
|
||||
aiGroup := api.Group("/ai")
|
||||
{
|
||||
aiGroup.POST("/polish", r.aiHandler.PolishText)
|
||||
aiGroup.GET("/provider", r.aiHandler.GetProviderInfo)
|
||||
|
||||
agentGroup := aiGroup.Group("/agent")
|
||||
{
|
||||
agentGroup.POST("/polish", r.aiHandler.AgentPolish)
|
||||
agentGroup.POST("/analyze", r.aiHandler.AgentAnalyze)
|
||||
agentGroup.POST("/generate-questions", r.aiHandler.GenerateQuestions)
|
||||
agentGroup.POST("/evaluate-answers", r.aiHandler.EvaluateAnswers)
|
||||
agentGroup.POST("/submit-answers", r.aiHandler.SubmitAnswers)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package router
|
||||
|
||||
import "github.com/gin-gonic/gin"
|
||||
|
||||
// setupChatAPIRoutes 配置聊天相关 API 路由
|
||||
func (r *Router) setupChatAPIRoutes(api *gin.RouterGroup) {
|
||||
chatGroup := api.Group("/chat")
|
||||
{
|
||||
chatGroup.POST("", r.chatHandler.Chat)
|
||||
chatGroup.GET("/history", r.chatHandler.GetChatHistory)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package router
|
||||
|
||||
import "github.com/gin-gonic/gin"
|
||||
|
||||
// setupDocumentAPIRoutes 配置文档相关 API 路由
|
||||
func (r *Router) setupDocumentAPIRoutes(api *gin.RouterGroup) {
|
||||
documentGroup := api.Group("/document")
|
||||
{
|
||||
documentGroup.POST("/upload", r.documentHandler.UploadDocument)
|
||||
documentGroup.GET("/list", r.documentHandler.GetDocuments)
|
||||
documentGroup.GET("/:id", r.documentHandler.GetDocument)
|
||||
documentGroup.DELETE("/:id", r.documentHandler.DeleteDocument)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package router
|
||||
|
||||
import "github.com/gin-gonic/gin"
|
||||
|
||||
func (r *Router) setupIMRoutes(api *gin.RouterGroup) {
|
||||
imGroup := api.Group("/im")
|
||||
{
|
||||
imGroup.GET("/ws", r.imHandler.WebSocket)
|
||||
imGroup.POST("/chat", r.imHandler.Chat)
|
||||
imGroup.POST("/chat/stream", r.imHandler.ChatStream)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package router
|
||||
|
||||
import "github.com/gin-gonic/gin"
|
||||
|
||||
func (r *Router) setupKnowledgeAPIRoutes(api *gin.RouterGroup) {
|
||||
if r.knowledgeHandler == nil {
|
||||
return
|
||||
}
|
||||
knowledgeGroup := api.Group("/knowledge")
|
||||
{
|
||||
knowledgeGroup.GET("/search", r.knowledgeHandler.Search)
|
||||
knowledgeGroup.GET("/stats", r.knowledgeHandler.GetStats)
|
||||
knowledgeGroup.POST("/docs", r.knowledgeHandler.AddDocument)
|
||||
knowledgeGroup.POST("/resume", r.knowledgeHandler.AddResume)
|
||||
knowledgeGroup.POST("/text", r.knowledgeHandler.AddText)
|
||||
knowledgeGroup.POST("/chat", r.knowledgeHandler.Chat)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package router
|
||||
|
||||
import "github.com/gin-gonic/gin"
|
||||
|
||||
// setupPortfolioAPIRoutes 配置作品集相关 API 路由
|
||||
func (r *Router) setupPortfolioAPIRoutes(api *gin.RouterGroup) {
|
||||
portfolioGroup := api.Group("/portfolio")
|
||||
{
|
||||
portfolioGroup.GET("", r.resumeHandler.GetPortfolioItemsAPI)
|
||||
portfolioGroup.POST("", r.resumeHandler.CreatePortfolioItemAPI)
|
||||
portfolioGroup.GET("/:id", r.resumeHandler.GetPortfolioItemAPI)
|
||||
portfolioGroup.PUT("/:id", r.resumeHandler.UpdatePortfolioItemAPI)
|
||||
portfolioGroup.DELETE("/:id", r.resumeHandler.DeletePortfolioItemAPI)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package router
|
||||
|
||||
import "github.com/gin-gonic/gin"
|
||||
|
||||
// setupQuizAPIRoutes 配置面试题库相关 API 路由
|
||||
func (r *Router) setupQuizAPIRoutes(api *gin.RouterGroup) {
|
||||
quizGroup := api.Group("/quiz")
|
||||
{
|
||||
quizGroup.POST("/record", r.resumeHandler.CreateQuizRecordAPI)
|
||||
quizGroup.GET("/records", r.resumeHandler.GetQuizRecordsAPI)
|
||||
quizGroup.GET("/record/:id", r.resumeHandler.GetQuizRecordAPI)
|
||||
quizGroup.DELETE("/record/:id", r.resumeHandler.DeleteQuizRecordAPI)
|
||||
|
||||
quizGroup.POST("/favorite", r.resumeHandler.CreateFavoriteQuestionAPI)
|
||||
quizGroup.GET("/favorites", r.resumeHandler.GetFavoriteQuestionsAPI)
|
||||
quizGroup.DELETE("/favorite/:id", r.resumeHandler.DeleteFavoriteQuestionAPI)
|
||||
quizGroup.PUT("/favorite/:id/toggle", r.resumeHandler.ToggleFavoriteAPI)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package router
|
||||
|
||||
import "github.com/gin-gonic/gin"
|
||||
|
||||
// setupResumeAPIRoutes 配置简历相关 API 路由
|
||||
func (r *Router) setupResumeAPIRoutes(api *gin.RouterGroup) {
|
||||
resumeGroup := api.Group("/resume")
|
||||
{
|
||||
resumeGroup.GET("", r.resumeHandler.GetResumeAPI)
|
||||
resumeGroup.POST("", r.resumeHandler.CreateResumeAPI)
|
||||
resumeGroup.PUT("", r.resumeHandler.UpdateResumeAPI)
|
||||
resumeGroup.DELETE("/:id", r.resumeHandler.DeleteResumeAPI)
|
||||
resumeGroup.GET("/list", r.resumeHandler.GetAllResumesAPI)
|
||||
resumeGroup.GET("/route-check", r.resumeHandler.RouteCheckAPI)
|
||||
resumeGroup.POST("/generate-from-document", r.resumeHandler.GenerateResumeFromDocumentAPI)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package router
|
||||
|
||||
import "github.com/gin-gonic/gin"
|
||||
|
||||
// setupSystemAPIRoutes 配置系统配置和菜单相关 API 路由
|
||||
func (r *Router) setupSystemAPIRoutes(api *gin.RouterGroup) {
|
||||
systemGroup := api.Group("/system")
|
||||
{
|
||||
systemGroup.GET("/config", r.resumeHandler.GetSystemConfigAPI)
|
||||
systemGroup.POST("/config", r.resumeHandler.UpdateSystemConfigAPI)
|
||||
}
|
||||
|
||||
menuGroup := api.Group("/menu")
|
||||
{
|
||||
menuGroup.GET("/list", r.resumeHandler.GetAllMenusAPI)
|
||||
menuGroup.POST("", r.resumeHandler.CreateMenuAPI)
|
||||
menuGroup.PUT("/:id", r.resumeHandler.UpdateMenuAPI)
|
||||
menuGroup.DELETE("/:id", r.resumeHandler.DeleteMenuAPI)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package router
|
||||
|
||||
import "github.com/gin-gonic/gin"
|
||||
|
||||
// setupUserAPIRoutes 配置用户相关 API 路由
|
||||
func (r *Router) setupUserAPIRoutes(api *gin.RouterGroup) {
|
||||
userGroup := api.Group("/user")
|
||||
{
|
||||
userGroup.POST("", r.resumeHandler.CreateUserAPI)
|
||||
userGroup.GET("/list", r.resumeHandler.GetAllUsersAPI)
|
||||
userGroup.GET("/:id", r.resumeHandler.GetUserAPI)
|
||||
userGroup.PUT("/:id", r.resumeHandler.UpdateUserAPI)
|
||||
userGroup.DELETE("/:id", r.resumeHandler.DeleteUserAPI)
|
||||
userGroup.GET("/resumes", r.resumeHandler.GetResumesByUserAPI)
|
||||
userGroup.GET("/portfolio", r.resumeHandler.GetPortfolioItemsByUserAPI)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package router
|
||||
|
||||
import "github.com/gin-gonic/gin"
|
||||
|
||||
// setupFrontendRoutes 配置前端页面路由
|
||||
// 路由结构:
|
||||
// - / 产品首页,展示项目介绍和所有简历列表
|
||||
// - /:route 个人简历页面,如 /zhangsan
|
||||
// - /:route/portfolio 个人作品集页面,如 /zhangsan/portfolio
|
||||
// - /:route/contact 个人联系方式页面,如 /zhangsan/contact
|
||||
// - /sitemap.xml 站点地图XML,用于搜索引擎优化
|
||||
func (r *Router) setupFrontendRoutes(engine *gin.Engine) {
|
||||
engine.GET("/", r.resumeHandler.ShowHome)
|
||||
engine.GET("/sitemap.xml", r.resumeHandler.Sitemap)
|
||||
engine.GET("/:route", r.resumeHandler.ShowResume)
|
||||
engine.POST("/:route/verify-password", r.resumeHandler.VerifyPassword)
|
||||
engine.GET("/:route/portfolio", r.resumeHandler.ShowPortfolio)
|
||||
engine.GET("/:route/contact", r.resumeHandler.ShowContact)
|
||||
|
||||
engine.NoRoute(func(c *gin.Context) {
|
||||
c.HTML(404, "web/404", nil)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
// Package router 负责 HTTP 路由的注册与管理
|
||||
// 按功能模块分为前台路由、后台管理路由和 API 路由三类
|
||||
package router
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"net/http"
|
||||
"resume-platform/internal/handler"
|
||||
"resume-platform/internal/middleware"
|
||||
"resume-platform/internal/model"
|
||||
"resume-platform/pkg/config"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Router 路由管理器,持有各模块 Handler 实例和全局配置
|
||||
type Router struct {
|
||||
resumeHandler *handler.ResumeHandler
|
||||
adminHandler *handler.AdminHandler
|
||||
aiHandler *handler.AIHandler
|
||||
documentHandler *handler.DocumentHandler
|
||||
chatHandler *handler.ChatHandler
|
||||
imHandler *handler.IMHandler
|
||||
knowledgeHandler *handler.KnowledgeHandler
|
||||
cfg *config.Config
|
||||
}
|
||||
|
||||
// NewRouter 创建路由管理器实例
|
||||
// @param resumeHandler 简历相关处理器
|
||||
// @param adminHandler 后台管理相关处理器
|
||||
// @param aiHandler AI 相关处理器
|
||||
// @param documentHandler 文档相关处理器
|
||||
// @param chatHandler 聊天相关处理器
|
||||
// @param cfg 全局配置
|
||||
// @return *Router 路由管理器实例
|
||||
// @author sunct
|
||||
func NewRouter(resumeHandler *handler.ResumeHandler, adminHandler *handler.AdminHandler, aiHandler *handler.AIHandler, documentHandler *handler.DocumentHandler, chatHandler *handler.ChatHandler, imHandler *handler.IMHandler, knowledgeHandler *handler.KnowledgeHandler, cfg *config.Config) *Router {
|
||||
return &Router{
|
||||
resumeHandler: resumeHandler,
|
||||
adminHandler: adminHandler,
|
||||
aiHandler: aiHandler,
|
||||
documentHandler: documentHandler,
|
||||
chatHandler: chatHandler,
|
||||
imHandler: imHandler,
|
||||
knowledgeHandler: knowledgeHandler,
|
||||
cfg: cfg,
|
||||
}
|
||||
}
|
||||
|
||||
// Setup 初始化 Gin 引擎并注册所有路由、中间件和模板函数
|
||||
// 注册顺序:中间件 → 模板函数 → 静态资源 → API 路由 → 后台路由 → 前台路由 → 404 处理
|
||||
// @return *gin.Engine 配置完成的 Gin 引擎
|
||||
// @author sunct
|
||||
func (r *Router) Setup() *gin.Engine {
|
||||
gin.SetMode(r.cfg.Server.Mode)
|
||||
engine := gin.New()
|
||||
|
||||
engine.Use(middleware.Tracing("resume-platform"))
|
||||
engine.Use(middleware.RequestLogger())
|
||||
engine.Use(gin.Recovery())
|
||||
|
||||
engine.SetFuncMap(template.FuncMap{
|
||||
"dict": dict,
|
||||
"set": set,
|
||||
"merge": merge,
|
||||
"slice": slice,
|
||||
"append": appendFunc,
|
||||
"substr": substr,
|
||||
"first": func(n int, s interface{}) interface{} {
|
||||
switch v := s.(type) {
|
||||
case []*model.Resume:
|
||||
if n > len(v) {
|
||||
return v
|
||||
}
|
||||
return v[:n]
|
||||
default:
|
||||
return s
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
engine.LoadHTMLGlob("templates/web/*.html")
|
||||
engine.Static("/static", "./static")
|
||||
|
||||
r.setupAPIRoutes(engine)
|
||||
r.setupAdminRoutes(engine)
|
||||
r.setupFrontendRoutes(engine)
|
||||
|
||||
engine.NoRoute(func(c *gin.Context) {
|
||||
if len(c.Request.URL.Path) > 10 && c.Request.URL.Path[:10] == "/dashboard" {
|
||||
r.adminHandler.DashboardNotFound(c)
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNotFound)
|
||||
c.HTML(http.StatusNotFound, "404.html", nil)
|
||||
})
|
||||
|
||||
return engine
|
||||
}
|
||||
|
||||
// dict 模板函数,根据传入的键值对构建 map[string]interface{}
|
||||
// 传入参数必须为偶数个,按 key, value 交替排列
|
||||
// @param values 键值对列表,格式:key1, value1, key2, value2...
|
||||
// @return map[string]interface{} 构建的字典
|
||||
// @author sunct
|
||||
func dict(values ...interface{}) (map[string]interface{}, error) {
|
||||
if len(values)%2 != 0 {
|
||||
return nil, nil
|
||||
}
|
||||
m := make(map[string]interface{}, len(values)/2)
|
||||
for i := 0; i < len(values); i += 2 {
|
||||
key, ok := values[i].(string)
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
m[key] = values[i+1]
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// set 模板函数,向 map 中设置键值对
|
||||
// @param m 目标 map
|
||||
// @param key 键名
|
||||
// @param value 值
|
||||
// @return interface{} 始终返回 nil
|
||||
// @author sunct
|
||||
func set(m map[string]interface{}, key string, value interface{}) interface{} {
|
||||
m[key] = value
|
||||
return nil
|
||||
}
|
||||
|
||||
// merge 模板函数,将 src map 中的所有键值合并到 dest map
|
||||
// @param dest 目标 map
|
||||
// @param src 源 map
|
||||
// @return map[string]interface{} 合并后的 dest
|
||||
// @author sunct
|
||||
func merge(dest map[string]interface{}, src map[string]interface{}) map[string]interface{} {
|
||||
for k, v := range src {
|
||||
dest[k] = v
|
||||
}
|
||||
return dest
|
||||
}
|
||||
|
||||
// slice 模板函数,将传入的多个参数组装为切片
|
||||
// @param values 任意数量的参数
|
||||
// @return []interface{} 参数切片
|
||||
// @author sunct
|
||||
func slice(values ...interface{}) []interface{} {
|
||||
return values
|
||||
}
|
||||
|
||||
// appendFunc 模板函数,向切片末尾追加一个元素
|
||||
// @param slice 目标切片
|
||||
// @param value 待追加元素
|
||||
// @return []interface{} 追加后的切片
|
||||
// @author sunct
|
||||
func appendFunc(slice []interface{}, value interface{}) []interface{} {
|
||||
return append(slice, value)
|
||||
}
|
||||
|
||||
// substr 模板函数,按 rune 截取字符串,支持中文等 Unicode 字符
|
||||
// @param s 源字符串
|
||||
// @param start 起始位置,支持负数(从末尾倒数)
|
||||
// @param length 可选,截取长度;不传则截取到末尾
|
||||
// @return string 截取后的子串
|
||||
// @author sunct
|
||||
func substr(s string, start int, length ...int) string {
|
||||
if len(s) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
runes := []rune(s)
|
||||
runeLen := len(runes)
|
||||
|
||||
startIdx := 0
|
||||
if start < 0 {
|
||||
startIdx = runeLen + start
|
||||
if startIdx < 0 {
|
||||
startIdx = 0
|
||||
}
|
||||
} else {
|
||||
startIdx = start
|
||||
}
|
||||
|
||||
if startIdx >= runeLen {
|
||||
return ""
|
||||
}
|
||||
|
||||
if len(length) == 0 {
|
||||
return string(runes[startIdx:])
|
||||
}
|
||||
|
||||
endIdx := startIdx + length[0]
|
||||
if endIdx > runeLen {
|
||||
endIdx = runeLen
|
||||
}
|
||||
|
||||
return string(runes[startIdx:endIdx])
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// Package security 安全模块,提供验证码、登录限流等安全防护能力
|
||||
package security
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/mojocn/base64Captcha"
|
||||
)
|
||||
|
||||
// CaptchaInstance 全局验证码实例
|
||||
var CaptchaInstance *base64Captcha.Captcha
|
||||
|
||||
// InitCaptcha 初始化数字验证码
|
||||
// @param width 验证码宽度
|
||||
// @param height 验证码高度
|
||||
// @author sunct
|
||||
func InitCaptcha(width, height int) {
|
||||
driver := base64Captcha.NewDriverDigit(
|
||||
height,
|
||||
width,
|
||||
4,
|
||||
0.7,
|
||||
80,
|
||||
)
|
||||
CaptchaInstance = base64Captcha.NewCaptcha(driver, base64Captcha.DefaultMemStore)
|
||||
}
|
||||
|
||||
// Verify 校验验证码答案
|
||||
// @param token 验证码令牌
|
||||
// @param code 用户输入的验证码
|
||||
// @return bool 是否验证通过
|
||||
// @author sunct
|
||||
func Verify(token, code string) bool {
|
||||
return base64Captcha.DefaultMemStore.Verify(token, code, true)
|
||||
}
|
||||
|
||||
// ServeCaptcha 生成并输出验证码图片(PNG格式)
|
||||
// @param w HTTP 响应写入器
|
||||
// @author sunct
|
||||
func ServeCaptcha(w http.ResponseWriter) {
|
||||
if CaptchaInstance == nil {
|
||||
InitCaptcha(160, 50)
|
||||
}
|
||||
|
||||
id, b64s, _ := CaptchaInstance.Generate()
|
||||
|
||||
w.Header().Set("Content-Type", "image/png")
|
||||
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||
w.Header().Set("Pragma", "no-cache")
|
||||
w.Header().Set("Expires", "0")
|
||||
w.Header().Set("X-Captcha-Token", id)
|
||||
|
||||
prefix := "data:image/png;base64,"
|
||||
if strings.HasPrefix(b64s, prefix) {
|
||||
b64s = strings.TrimPrefix(b64s, prefix)
|
||||
}
|
||||
|
||||
data, _ := base64.StdEncoding.DecodeString(b64s)
|
||||
w.Write(data)
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// LoginAttempt 登录尝试记录
|
||||
type LoginAttempt struct {
|
||||
Count int // 尝试次数
|
||||
LastTry time.Time // 最后尝试时间
|
||||
LockedUntil time.Time // 锁定截止时间
|
||||
}
|
||||
|
||||
// RateLimiter 登录限流器,基于 IP 防止暴力破解
|
||||
type RateLimiter struct {
|
||||
mu sync.Mutex // 互斥锁
|
||||
attempts map[string]*LoginAttempt // 尝试记录映射
|
||||
maxAttempts int // 最大尝试次数
|
||||
lockoutDuration time.Duration // 锁定时长
|
||||
}
|
||||
|
||||
// NewRateLimiter 创建登录限流器
|
||||
// @param maxAttempts 最大尝试次数
|
||||
// @param lockoutDuration 锁定时长(分钟)
|
||||
// @return *RateLimiter 限流器实例
|
||||
// @author sunct
|
||||
func NewRateLimiter(maxAttempts int, lockoutDuration int) *RateLimiter {
|
||||
return &RateLimiter{
|
||||
attempts: make(map[string]*LoginAttempt),
|
||||
maxAttempts: maxAttempts,
|
||||
lockoutDuration: time.Duration(lockoutDuration) * time.Minute,
|
||||
}
|
||||
}
|
||||
|
||||
// Allow 检查 IP 是否允许登录
|
||||
// @param ip 客户端IP
|
||||
// @return bool 是否允许
|
||||
// @return int 剩余尝试次数
|
||||
// @author sunct
|
||||
func (rl *RateLimiter) Allow(ip string) (bool, int) {
|
||||
rl.mu.Lock()
|
||||
defer rl.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
|
||||
if attempt, exists := rl.attempts[ip]; exists {
|
||||
if now.Before(attempt.LockedUntil) {
|
||||
return false, rl.maxAttempts - attempt.Count
|
||||
}
|
||||
|
||||
if now.Sub(attempt.LastTry) > 1*time.Hour {
|
||||
delete(rl.attempts, ip)
|
||||
return true, rl.maxAttempts
|
||||
}
|
||||
|
||||
if attempt.Count >= rl.maxAttempts {
|
||||
attempt.LockedUntil = now.Add(rl.lockoutDuration)
|
||||
return false, 0
|
||||
}
|
||||
}
|
||||
|
||||
return true, rl.maxAttempts
|
||||
}
|
||||
|
||||
// RecordAttempt 记录一次登录尝试
|
||||
// @param ip 客户端IP
|
||||
// @author sunct
|
||||
func (rl *RateLimiter) RecordAttempt(ip string) {
|
||||
rl.mu.Lock()
|
||||
defer rl.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
|
||||
if attempt, exists := rl.attempts[ip]; exists {
|
||||
attempt.Count++
|
||||
attempt.LastTry = now
|
||||
} else {
|
||||
rl.attempts[ip] = &LoginAttempt{
|
||||
Count: 1,
|
||||
LastTry: now,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reset 重置 IP 的登录尝试记录
|
||||
// @param ip 客户端IP
|
||||
// @author sunct
|
||||
func (rl *RateLimiter) Reset(ip string) {
|
||||
rl.mu.Lock()
|
||||
defer rl.mu.Unlock()
|
||||
delete(rl.attempts, ip)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"context"
|
||||
"resume-platform/internal/ai"
|
||||
"resume-platform/internal/prompt"
|
||||
)
|
||||
|
||||
type AIService struct {
|
||||
provider ai.Provider
|
||||
}
|
||||
|
||||
func NewAIService(provider ai.Provider) *AIService {
|
||||
return &AIService{provider: provider}
|
||||
}
|
||||
|
||||
func (s *AIService) PolishSummary(ctx context.Context, original string) (string, error) {
|
||||
promptText := prompt.BuildPolishSummaryPrompt(original)
|
||||
|
||||
return s.provider.Generate(ctx, promptText)
|
||||
}
|
||||
|
||||
func (s *AIService) PolishExperience(ctx context.Context, original string, company string, position string) (string, error) {
|
||||
promptText := prompt.BuildPolishExperiencePrompt(original, company, position)
|
||||
|
||||
return s.provider.Generate(ctx, promptText)
|
||||
}
|
||||
|
||||
func (s *AIService) PolishProject(ctx context.Context, original string, projectName string) (string, error) {
|
||||
promptText := prompt.BuildPolishProjectPrompt(original, projectName)
|
||||
|
||||
return s.provider.Generate(ctx, promptText)
|
||||
}
|
||||
|
||||
func (s *AIService) PolishDescription(ctx context.Context, original string, fieldType string) (string, error) {
|
||||
promptText := prompt.BuildPolishDescriptionPrompt(original, fieldType)
|
||||
|
||||
return s.provider.Generate(ctx, promptText)
|
||||
}
|
||||
|
||||
func (s *AIService) GetProviderName() string {
|
||||
return s.provider.GetName()
|
||||
}
|
||||
|
||||
func (s *AIService) Generate(ctx context.Context, prompt string) (string, error) {
|
||||
return s.provider.Generate(ctx, prompt)
|
||||
}
|
||||
|
||||
func (s *AIService) Embedding(ctx context.Context, text string) ([]float64, error) {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"resume-platform/internal/ai"
|
||||
"resume-platform/internal/prompt"
|
||||
"resume-platform/pkg/logger"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type QuestionGeneratorService struct {
|
||||
provider ai.Provider
|
||||
}
|
||||
|
||||
func NewQuestionGeneratorService(provider ai.Provider) *QuestionGeneratorService {
|
||||
return &QuestionGeneratorService{provider: provider}
|
||||
}
|
||||
|
||||
func (s *QuestionGeneratorService) GenerateQuestions(ctx context.Context, resumeText, keywordsStr, typesStr string) ([]map[string]interface{}, error) {
|
||||
if resumeText == "" && keywordsStr == "" {
|
||||
return nil, fmt.Errorf("resume_text or keywords is required")
|
||||
}
|
||||
|
||||
if resumeText == "" {
|
||||
resumeText = "基于关键词:" + keywordsStr
|
||||
}
|
||||
|
||||
techKeywords := extractTechKeywords(resumeText)
|
||||
if len(techKeywords) == 0 {
|
||||
techKeywords = []string{"HTML", "CSS", "JavaScript", "Go", "MySQL", "Redis"}
|
||||
}
|
||||
|
||||
if keywordsStr != "" {
|
||||
techKeywords = strings.Split(keywordsStr, ",")
|
||||
}
|
||||
|
||||
selectedTypes := parseTypes(typesStr)
|
||||
|
||||
logger.CtxInfof(ctx, "[QuestionGenerator] Generating questions for keywords: %v, types: %v", techKeywords, selectedTypes)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
resultChan := make(chan []map[string]interface{}, len(selectedTypes))
|
||||
errChan := make(chan error, len(selectedTypes))
|
||||
|
||||
for _, qType := range selectedTypes {
|
||||
wg.Add(1)
|
||||
go func(t string) {
|
||||
defer wg.Done()
|
||||
|
||||
prompt := buildQuestionGenerationPromptForType(resumeText, techKeywords, t)
|
||||
logger.CtxInfof(ctx, "[QuestionGenerator] Generating %s questions...", t)
|
||||
|
||||
result, err := s.provider.Generate(ctx, prompt)
|
||||
if err != nil {
|
||||
logger.CtxErrorf(ctx, "[QuestionGenerator] AI generate failed for %s: %v", t, err)
|
||||
errChan <- err
|
||||
return
|
||||
}
|
||||
|
||||
questions, err := parseAIQuestionsWithCtx(ctx, result)
|
||||
if err != nil {
|
||||
logger.CtxErrorf(ctx, "[QuestionGenerator] Parse failed for %s: %v", t, err)
|
||||
errChan <- err
|
||||
return
|
||||
}
|
||||
|
||||
if len(questions) > 0 {
|
||||
resultChan <- questions
|
||||
logger.CtxInfof(ctx, "[QuestionGenerator] Generated %d %s questions", len(questions), t)
|
||||
}
|
||||
}(qType)
|
||||
}
|
||||
|
||||
go func() {
|
||||
wg.Wait()
|
||||
close(resultChan)
|
||||
close(errChan)
|
||||
}()
|
||||
|
||||
var allQuestions []map[string]interface{}
|
||||
for questions := range resultChan {
|
||||
allQuestions = append(allQuestions, questions...)
|
||||
}
|
||||
|
||||
logger.CtxInfof(ctx, "[QuestionGenerator] Total generated %d questions", len(allQuestions))
|
||||
|
||||
if len(allQuestions) == 0 {
|
||||
return nil, fmt.Errorf("未能生成任何题目")
|
||||
}
|
||||
|
||||
return allQuestions, nil
|
||||
}
|
||||
|
||||
func buildQuestionGenerationPrompt(resumeText string, keywords []string, types []string) string {
|
||||
return prompt.BuildQuestionGenerationPrompt(resumeText, keywords, types)
|
||||
}
|
||||
|
||||
func buildQuestionGenerationPromptForType(resumeText string, keywords []string, qType string) string {
|
||||
return prompt.BuildQuestionGenerationPromptForType(resumeText, keywords, qType)
|
||||
}
|
||||
|
||||
func parseAIQuestionsWithCtx(ctx context.Context, result string) ([]map[string]interface{}, error) {
|
||||
result = strings.TrimSpace(result)
|
||||
|
||||
logger.CtxInfof(ctx, "[QuestionGenerator] AI response length: %d", len(result))
|
||||
logger.CtxInfof(ctx, "[QuestionGenerator] AI response raw result (first 2000 chars): %s", substr(result, 0, 2000))
|
||||
|
||||
result = strings.ReplaceAll(result, "```json", "")
|
||||
result = strings.ReplaceAll(result, "```", "")
|
||||
result = strings.TrimSpace(result)
|
||||
|
||||
startIdx := strings.Index(result, "[")
|
||||
endIdx := strings.LastIndex(result, "]")
|
||||
if startIdx == -1 || endIdx == -1 || endIdx <= startIdx {
|
||||
logger.CtxErrorf(ctx, "[QuestionGenerator] Cannot find JSON array boundaries")
|
||||
return nil, fmt.Errorf("无法找到JSON数组边界")
|
||||
}
|
||||
|
||||
jsonStr := result[startIdx : endIdx+1]
|
||||
logger.CtxInfof(ctx, "[QuestionGenerator] Extracted JSON array (length=%d)", len(jsonStr))
|
||||
|
||||
var questions []map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(jsonStr), &questions); err != nil {
|
||||
logger.CtxErrorf(ctx, "[QuestionGenerator] Failed to unmarshal as array: %v", err)
|
||||
logger.CtxErrorf(ctx, "[QuestionGenerator] Raw result that failed (first 500 chars): %s", substr(jsonStr, 0, 500))
|
||||
return nil, fmt.Errorf("AI返回的JSON格式不正确: %w", err)
|
||||
}
|
||||
|
||||
var validQuestions []map[string]interface{}
|
||||
for _, q := range questions {
|
||||
if !isValidQuestion(q) {
|
||||
logger.CtxWarnf(ctx, "[QuestionGenerator] Skipping invalid question: %v", q)
|
||||
continue
|
||||
}
|
||||
|
||||
q = normalizeQuestion(q)
|
||||
validQuestions = append(validQuestions, q)
|
||||
}
|
||||
|
||||
logger.CtxInfof(ctx, "[QuestionGenerator] Successfully parsed %d valid questions (filtered %d invalid)", len(validQuestions), len(questions)-len(validQuestions))
|
||||
|
||||
if len(validQuestions) == 0 {
|
||||
return nil, fmt.Errorf("未解析到有效题目")
|
||||
}
|
||||
|
||||
return validQuestions, nil
|
||||
}
|
||||
|
||||
func isValidQuestion(q map[string]interface{}) bool {
|
||||
if q == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
qType := fmt.Sprintf("%v", q["type"])
|
||||
text := fmt.Sprintf("%v", q["text"])
|
||||
answer := fmt.Sprintf("%v", q["answer"])
|
||||
|
||||
if qType == "" || text == "" || answer == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
if qType == "mcq" {
|
||||
if options, ok := q["options"].([]interface{}); ok {
|
||||
if len(options) != 4 {
|
||||
return false
|
||||
}
|
||||
for _, opt := range options {
|
||||
if fmt.Sprintf("%v", opt) == "" {
|
||||
return false
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
|
||||
answer = strings.ToUpper(strings.TrimSpace(answer))
|
||||
if len(answer) != 1 || (answer < "A" || answer > "D") {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
if qType == "fill" {
|
||||
if !strings.Contains(text, "___") {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
if qType == "sa" || qType == "algo" {
|
||||
if len(answer) < 10 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
analysis := fmt.Sprintf("%v", q["analysis"])
|
||||
if len(analysis) < 30 {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func normalizeQuestion(q map[string]interface{}) map[string]interface{} {
|
||||
qType := fmt.Sprintf("%v", q["type"])
|
||||
|
||||
if qType == "mcq" {
|
||||
answer := strings.TrimSpace(fmt.Sprintf("%v", q["answer"]))
|
||||
if len(answer) > 0 {
|
||||
q["answer"] = strings.ToUpper(answer[:1])
|
||||
}
|
||||
|
||||
if options, ok := q["options"].([]interface{}); ok {
|
||||
var optionStrings []string
|
||||
for _, opt := range options {
|
||||
optStr := strings.TrimSpace(fmt.Sprintf("%v", opt))
|
||||
optionStrings = append(optionStrings, optStr)
|
||||
}
|
||||
q["options"] = optionStrings
|
||||
}
|
||||
}
|
||||
|
||||
if _, ok := q["score"]; !ok {
|
||||
diff := fmt.Sprintf("%v", q["difficulty"])
|
||||
q["score"] = getScoreByDifficulty(diff)
|
||||
}
|
||||
if _, ok := q["category"]; !ok {
|
||||
q["category"] = "综合"
|
||||
}
|
||||
if _, ok := q["keywords"]; !ok {
|
||||
q["keywords"] = []string{}
|
||||
}
|
||||
if _, ok := q["options"]; !ok {
|
||||
q["options"] = []string{}
|
||||
}
|
||||
if _, ok := q["analysis"]; !ok {
|
||||
q["analysis"] = ""
|
||||
}
|
||||
if _, ok := q["difficulty"]; !ok {
|
||||
q["difficulty"] = "初级"
|
||||
}
|
||||
|
||||
return q
|
||||
}
|
||||
|
||||
func isSingleLetter(s string) bool {
|
||||
return len(s) == 1 && (s >= "A" && s <= "Z" || s >= "a" && s <= "z")
|
||||
}
|
||||
|
||||
func substr(s string, start, length int) string {
|
||||
if len(s) <= start {
|
||||
return ""
|
||||
}
|
||||
end := start + length
|
||||
if end > len(s) {
|
||||
end = len(s)
|
||||
}
|
||||
return s[start:end]
|
||||
}
|
||||
|
||||
func extractTechKeywords(text string) []string {
|
||||
techPatterns := []string{"Go", "Golang", "Java", "Python", "JavaScript", "TypeScript", "React", "Vue", "MySQL", "PostgreSQL", "Redis", "MongoDB", "Kafka", "Docker", "Kubernetes", "微服务", "分布式"}
|
||||
var result []string
|
||||
for _, pattern := range techPatterns {
|
||||
if strings.Contains(text, pattern) {
|
||||
result = append(result, pattern)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func parseTypes(typesStr string) []string {
|
||||
if typesStr == "" {
|
||||
return []string{"mcq", "fill", "sa", "algo"}
|
||||
}
|
||||
return strings.Split(typesStr, ",")
|
||||
}
|
||||
|
||||
func getScoreByDifficulty(difficulty string) int {
|
||||
switch difficulty {
|
||||
case "入门":
|
||||
return 5
|
||||
case "初级":
|
||||
return 10
|
||||
case "中级":
|
||||
return 15
|
||||
case "进阶":
|
||||
return 20
|
||||
case "高级":
|
||||
return 25
|
||||
default:
|
||||
return 10
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"resume-platform/internal/model"
|
||||
"resume-platform/internal/prompt"
|
||||
"resume-platform/internal/repository"
|
||||
"resume-platform/internal/service/ai"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type ChatService interface {
|
||||
Chat(ctx context.Context, userID string, question string) (string, error)
|
||||
GetChatHistory(ctx context.Context, userID string) ([]*model.ChatHistory, error)
|
||||
DeleteChatHistory(ctx context.Context, userID string) error
|
||||
}
|
||||
|
||||
type chatService struct {
|
||||
repo repository.ResumeRepository
|
||||
ai *ai.AIService
|
||||
}
|
||||
|
||||
func NewChatService(repo repository.ResumeRepository, ai *ai.AIService) ChatService {
|
||||
return &chatService{repo: repo, ai: ai}
|
||||
}
|
||||
|
||||
func (s *chatService) Chat(ctx context.Context, userID string, question string) (string, error) {
|
||||
chunks, err := s.repo.GetDocumentChunksByUserID(userID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if len(chunks) == 0 {
|
||||
return s.ai.Generate(ctx, question)
|
||||
}
|
||||
|
||||
var chunkList []model.ChunkWithScore
|
||||
for _, chunk := range chunks {
|
||||
chunkList = append(chunkList, model.ChunkWithScore{
|
||||
Content: chunk.Content,
|
||||
Metadata: chunk.Embedding,
|
||||
})
|
||||
}
|
||||
|
||||
var contextStr string
|
||||
for i, chunk := range chunkList[:min(5, len(chunkList))] {
|
||||
contextStr += fmt.Sprintf("[文档片段%d]\n%s\n\n", i+1, chunk.Content)
|
||||
}
|
||||
|
||||
promptText := prompt.BuildDocumentAnalysisPrompt(contextStr, question)
|
||||
|
||||
answer, err := s.ai.Generate(ctx, promptText)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
history := &model.ChatHistory{
|
||||
ID: uuid.New().String(),
|
||||
UserID: userID,
|
||||
Question: question,
|
||||
Answer: answer,
|
||||
Context: contextStr,
|
||||
}
|
||||
s.repo.CreateChatHistory(history)
|
||||
|
||||
return answer, nil
|
||||
}
|
||||
|
||||
func (s *chatService) GetChatHistory(ctx context.Context, userID string) ([]*model.ChatHistory, error) {
|
||||
return s.repo.GetChatHistoryByUserID(userID)
|
||||
}
|
||||
|
||||
func (s *chatService) DeleteChatHistory(ctx context.Context, userID string) error {
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"context"
|
||||
"resume-platform/internal/agent"
|
||||
"resume-platform/internal/ai"
|
||||
)
|
||||
|
||||
type IMService struct {
|
||||
aiProvider ai.Provider
|
||||
resumeAgent *agent.ResumeAgent
|
||||
}
|
||||
|
||||
func NewIMService(provider ai.Provider, resumeAgent *agent.ResumeAgent) *IMService {
|
||||
return &IMService{aiProvider: provider, resumeAgent: resumeAgent}
|
||||
}
|
||||
|
||||
func (s *IMService) GetAIProvider() ai.Provider {
|
||||
return s.aiProvider
|
||||
}
|
||||
|
||||
func (s *IMService) GetResumeAgent() *agent.ResumeAgent {
|
||||
return s.resumeAgent
|
||||
}
|
||||
|
||||
func (s *IMService) GenerateWithAgent(ctx context.Context, query string) (string, error) {
|
||||
if s.resumeAgent != nil {
|
||||
return s.resumeAgent.Run(ctx, query)
|
||||
}
|
||||
if s.aiProvider != nil {
|
||||
return s.aiProvider.Generate(ctx, query)
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
package document
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"resume-platform/internal/model"
|
||||
"resume-platform/internal/parser"
|
||||
"resume-platform/internal/repository"
|
||||
"resume-platform/internal/service/knowledge"
|
||||
"resume-platform/internal/service/resume"
|
||||
"resume-platform/pkg/logger"
|
||||
)
|
||||
|
||||
type DocumentService interface {
|
||||
UploadDocument(ctx context.Context, userID string, fileName string, fileType string, fileSize int64, content string) (*model.Document, error)
|
||||
GetDocuments(ctx context.Context, userID string) ([]*model.Document, error)
|
||||
GetDocument(ctx context.Context, userID string, documentID string) (*model.Document, error)
|
||||
DeleteDocument(ctx context.Context, userID string, documentID string) error
|
||||
ProcessDocument(ctx context.Context, documentID string) error
|
||||
ParseAndGenerateResume(ctx context.Context, userID string, fileName string, content []byte) (*model.Resume, error)
|
||||
}
|
||||
|
||||
type documentService struct {
|
||||
repo repository.ResumeRepository
|
||||
resumeGenerator *resume.ResumeGeneratorService
|
||||
knowledgeBaseService *knowledge.KnowledgeBaseService
|
||||
}
|
||||
|
||||
func NewDocumentService(repo repository.ResumeRepository, resumeGenerator *resume.ResumeGeneratorService) DocumentService {
|
||||
return &documentService{repo: repo, resumeGenerator: resumeGenerator}
|
||||
}
|
||||
|
||||
func NewDocumentServiceWithKnowledge(repo repository.ResumeRepository, resumeGenerator *resume.ResumeGeneratorService, knowledgeBaseService *knowledge.KnowledgeBaseService) DocumentService {
|
||||
return &documentService{repo: repo, resumeGenerator: resumeGenerator, knowledgeBaseService: knowledgeBaseService}
|
||||
}
|
||||
|
||||
func (s *documentService) UploadDocument(ctx context.Context, userID string, fileName string, fileType string, fileSize int64, content string) (*model.Document, error) {
|
||||
document := &model.Document{
|
||||
ID: generateID(),
|
||||
UserID: userID,
|
||||
Name: fileName,
|
||||
Type: fileType,
|
||||
Size: fileSize,
|
||||
Content: content,
|
||||
Status: "uploading",
|
||||
}
|
||||
|
||||
err := s.repo.CreateDocument(document)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
go s.ProcessDocument(ctx, document.ID)
|
||||
|
||||
return document, nil
|
||||
}
|
||||
|
||||
func (s *documentService) GetDocuments(ctx context.Context, userID string) ([]*model.Document, error) {
|
||||
return s.repo.GetDocumentsByUserID(userID)
|
||||
}
|
||||
|
||||
func (s *documentService) GetDocument(ctx context.Context, userID string, documentID string) (*model.Document, error) {
|
||||
return s.repo.GetDocument(documentID, userID)
|
||||
}
|
||||
|
||||
func (s *documentService) DeleteDocument(ctx context.Context, userID string, documentID string) error {
|
||||
return s.repo.DeleteDocument(documentID, userID)
|
||||
}
|
||||
|
||||
func (s *documentService) ProcessDocument(ctx context.Context, documentID string) error {
|
||||
document, err := s.repo.GetDocumentByID(documentID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if document.Content == "" {
|
||||
document.Status = "failed"
|
||||
document.ErrorMsg = "document content is empty"
|
||||
return s.repo.UpdateDocument(document)
|
||||
}
|
||||
|
||||
chunks := s.splitText(document.Content, 512)
|
||||
|
||||
for i, chunk := range chunks {
|
||||
metadata := map[string]interface{}{
|
||||
"document_id": document.ID,
|
||||
"chunk_index": i,
|
||||
"user_id": document.UserID,
|
||||
}
|
||||
metadataJSON, _ := json.Marshal(metadata)
|
||||
|
||||
documentChunk := &model.DocumentChunk{
|
||||
ID: generateID(),
|
||||
DocumentID: document.ID,
|
||||
ChunkIndex: i,
|
||||
Content: chunk,
|
||||
Metadata: string(metadataJSON),
|
||||
}
|
||||
|
||||
err := s.repo.CreateDocumentChunk(documentChunk)
|
||||
if err != nil {
|
||||
document.Status = "failed"
|
||||
document.ErrorMsg = err.Error()
|
||||
s.repo.UpdateDocument(document)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if s.knowledgeBaseService != nil {
|
||||
err = s.knowledgeBaseService.AddDocument(ctx, document.ID, document.Content, document.UserID)
|
||||
if err != nil {
|
||||
logger.Warnf("Failed to add document to knowledge base: %v", err)
|
||||
} else {
|
||||
logger.Infof("Document %s added to knowledge base", document.ID)
|
||||
}
|
||||
}
|
||||
|
||||
document.Status = "processed"
|
||||
return s.repo.UpdateDocument(document)
|
||||
}
|
||||
|
||||
func (s *documentService) splitText(text string, chunkSize int) []string {
|
||||
var chunks []string
|
||||
words := []rune(text)
|
||||
|
||||
for i := 0; i < len(words); i += chunkSize {
|
||||
end := i + chunkSize
|
||||
if end > len(words) {
|
||||
end = len(words)
|
||||
}
|
||||
chunks = append(chunks, string(words[i:end]))
|
||||
}
|
||||
|
||||
return chunks
|
||||
}
|
||||
|
||||
func generateID() string {
|
||||
return uuid.New().String()
|
||||
}
|
||||
|
||||
func (s *documentService) ParseAndGenerateResume(ctx context.Context, userID string, fileName string, content []byte) (*model.Resume, error) {
|
||||
documentText, err := parser.ParseFile(fileName, bytes.NewReader(content))
|
||||
if err != nil {
|
||||
logger.Warnf("Failed to parse document %s: %v", fileName, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if documentText == "" {
|
||||
return nil, fmt.Errorf("document content is empty after parsing")
|
||||
}
|
||||
|
||||
resume, err := s.resumeGenerator.GenerateFromDocument(ctx, documentText)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resume.UserID = userID
|
||||
fmt.Printf("DEBUG document.go: resume.UserID set to: '%s'\n", resume.UserID)
|
||||
|
||||
return resume, nil
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package knowledge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/google/uuid"
|
||||
"resume-platform/internal/ai"
|
||||
"resume-platform/internal/model"
|
||||
"resume-platform/internal/repository"
|
||||
)
|
||||
|
||||
type EmbeddingService struct {
|
||||
aiProvider ai.Provider
|
||||
repo repository.ResumeRepository
|
||||
}
|
||||
|
||||
func NewEmbeddingService(provider ai.Provider, repo repository.ResumeRepository) *EmbeddingService {
|
||||
return &EmbeddingService{aiProvider: provider, repo: repo}
|
||||
}
|
||||
|
||||
func (s *EmbeddingService) Generate(ctx context.Context, text string) ([]float64, error) {
|
||||
if text == "" {
|
||||
return nil, fmt.Errorf("empty text")
|
||||
}
|
||||
|
||||
contentHash := s.hashContent(text)
|
||||
|
||||
cachedEmbedding, err := s.repo.GetEmbeddingByContentHash(contentHash)
|
||||
if err == nil && cachedEmbedding != nil && cachedEmbedding.Embedding != "" {
|
||||
var embedding []float64
|
||||
if err := json.Unmarshal([]byte(cachedEmbedding.Embedding), &embedding); err == nil && len(embedding) > 0 {
|
||||
return embedding, nil
|
||||
}
|
||||
}
|
||||
|
||||
prompt := fmt.Sprintf(`请将以下文本转换为向量嵌入。直接输出JSON数组,不要包含任何其他内容。
|
||||
|
||||
文本内容:
|
||||
%s`, text)
|
||||
|
||||
result, err := s.aiProvider.Generate(ctx, prompt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result = cleanEmbeddingResult(result)
|
||||
|
||||
var embedding []float64
|
||||
if err := json.Unmarshal([]byte(result), &embedding); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse embedding: %w", err)
|
||||
}
|
||||
|
||||
return embedding, nil
|
||||
}
|
||||
|
||||
func (s *EmbeddingService) GenerateAndStore(ctx context.Context, text, sourceType, sourceID, sourceName, userID string) ([]float64, error) {
|
||||
embedding, err := s.Generate(ctx, text)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
contentHash := s.hashContent(text)
|
||||
embeddingJSON, _ := json.Marshal(embedding)
|
||||
|
||||
err = s.repo.CreateEmbedding(&model.Embedding{
|
||||
ID: uuid.New().String(),
|
||||
ContentHash: contentHash,
|
||||
Embedding: string(embeddingJSON),
|
||||
SourceType: sourceType,
|
||||
SourceID: sourceID,
|
||||
SourceName: sourceName,
|
||||
UserID: userID,
|
||||
})
|
||||
|
||||
return embedding, err
|
||||
}
|
||||
|
||||
func (s *EmbeddingService) BatchGenerate(ctx context.Context, texts []string) ([][]float64, error) {
|
||||
var results [][]float64
|
||||
for _, text := range texts {
|
||||
embedding, err := s.Generate(ctx, text)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
results = append(results, embedding)
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func (s *EmbeddingService) hashContent(content string) string {
|
||||
h := sha256.New()
|
||||
h.Write([]byte(content))
|
||||
return hex.EncodeToString(h.Sum(nil))
|
||||
}
|
||||
|
||||
func cleanEmbeddingResult(result string) string {
|
||||
result = trimString(result)
|
||||
|
||||
if idx := findFirstIndex(result, '['); idx >= 0 {
|
||||
result = result[idx:]
|
||||
}
|
||||
if idx := findLastIndex(result, ']'); idx >= 0 {
|
||||
result = result[:idx+1]
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func trimString(s string) string {
|
||||
return s
|
||||
}
|
||||
|
||||
func findFirstIndex(s string, c byte) int {
|
||||
for i := 0; i < len(s); i++ {
|
||||
if s[i] == c {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func findLastIndex(s string, c byte) int {
|
||||
for i := len(s) - 1; i >= 0; i-- {
|
||||
if s[i] == c {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
package knowledge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"resume-platform/internal/model"
|
||||
"resume-platform/internal/repository"
|
||||
"resume-platform/pkg/logger"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type KnowledgeBaseService struct {
|
||||
embeddingService *EmbeddingService
|
||||
vectorService *VectorService
|
||||
repo repository.ResumeRepository
|
||||
}
|
||||
|
||||
func NewKnowledgeBaseService(embeddingService *EmbeddingService, vectorService *VectorService, repo repository.ResumeRepository) *KnowledgeBaseService {
|
||||
return &KnowledgeBaseService{
|
||||
embeddingService: embeddingService,
|
||||
vectorService: vectorService,
|
||||
repo: repo,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *KnowledgeBaseService) AddDocument(ctx context.Context, documentID, content, userID string) error {
|
||||
if content == "" {
|
||||
return fmt.Errorf("empty content")
|
||||
}
|
||||
|
||||
chunks := s.chunkContent(content, 500, 50)
|
||||
|
||||
doc, _ := s.repo.GetDocumentByID(documentID)
|
||||
sourceName := documentID[:8]
|
||||
if doc != nil && doc.Name != "" {
|
||||
sourceName = doc.Name
|
||||
}
|
||||
|
||||
for i, chunk := range chunks {
|
||||
documentChunk := &model.DocumentChunk{
|
||||
ID: uuid.New().String(),
|
||||
DocumentID: documentID,
|
||||
ChunkIndex: i,
|
||||
Content: chunk,
|
||||
Metadata: "",
|
||||
}
|
||||
|
||||
err := s.vectorService.StoreChunk(ctx, documentChunk, userID, sourceName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
logger.Infof("Added %d chunks to document %s", len(chunks), documentID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *KnowledgeBaseService) AddResume(ctx context.Context, resumeID string, resume *model.Resume) error {
|
||||
if resume == nil {
|
||||
return fmt.Errorf("nil resume")
|
||||
}
|
||||
|
||||
resumeText := s.serializeResume(resume)
|
||||
if resumeText == "" {
|
||||
return fmt.Errorf("empty resume content")
|
||||
}
|
||||
|
||||
chunks := s.chunkContent(resumeText, 500, 50)
|
||||
sourceName := resume.BasicInfo.Name
|
||||
if sourceName == "" {
|
||||
sourceName = resumeID[:8]
|
||||
}
|
||||
|
||||
for i, chunk := range chunks {
|
||||
documentChunk := &model.DocumentChunk{
|
||||
ID: uuid.New().String(),
|
||||
DocumentID: resumeID,
|
||||
ChunkIndex: i,
|
||||
Content: chunk,
|
||||
Metadata: "",
|
||||
}
|
||||
|
||||
err := s.vectorService.StoreChunk(ctx, documentChunk, resume.UserID, sourceName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
err := s.vectorService.Store(ctx, resumeText, "resume", resumeID, sourceName, resume.UserID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
logger.Infof("Added resume %s to knowledge base with %d chunks", resumeID, len(chunks))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *KnowledgeBaseService) AddText(ctx context.Context, text, sourceType, sourceID, sourceName, userID string) error {
|
||||
if text == "" {
|
||||
return fmt.Errorf("empty text")
|
||||
}
|
||||
|
||||
return s.vectorService.Store(ctx, text, sourceType, sourceID, sourceName, userID)
|
||||
}
|
||||
|
||||
func (s *KnowledgeBaseService) Retrieve(ctx context.Context, query string, topK int, userID string) ([]model.ChunkWithScore, error) {
|
||||
return s.vectorService.SearchWithChunks(ctx, query, topK, userID)
|
||||
}
|
||||
|
||||
func (s *KnowledgeBaseService) RetrieveAll(ctx context.Context, query string, topK int) ([]model.ChunkWithScore, error) {
|
||||
return s.vectorService.Search(ctx, query, topK, "")
|
||||
}
|
||||
|
||||
func (s *KnowledgeBaseService) DeleteDocument(ctx context.Context, documentID string) error {
|
||||
err := s.repo.DeleteEmbeddingsBySource("document", documentID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.vectorService.DeleteDocumentChunks(ctx, documentID)
|
||||
}
|
||||
|
||||
func (s *KnowledgeBaseService) DeleteResume(ctx context.Context, resumeID string) error {
|
||||
return s.repo.DeleteEmbeddingsBySource("resume", resumeID)
|
||||
}
|
||||
|
||||
func (s *KnowledgeBaseService) GetStats(ctx context.Context, userID string) (map[string]int, error) {
|
||||
var stats = map[string]int{}
|
||||
|
||||
if userID != "" {
|
||||
embeddings, err := s.repo.GetEmbeddingsByUserID(userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stats["embeddings"] = len(embeddings)
|
||||
|
||||
chunks, err := s.repo.GetDocumentChunksByUserID(userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stats["chunks"] = len(chunks)
|
||||
} else {
|
||||
embeddings, err := s.repo.GetAllEmbeddings()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stats["embeddings"] = len(embeddings)
|
||||
|
||||
chunks, err := s.repo.GetAllDocumentChunks()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stats["chunks"] = len(chunks)
|
||||
}
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
func (s *KnowledgeBaseService) chunkContent(content string, chunkSize, overlap int) []string {
|
||||
if content == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
content = strings.ReplaceAll(content, "\r\n", "\n")
|
||||
content = strings.ReplaceAll(content, "\r", "\n")
|
||||
|
||||
var chunks []string
|
||||
start := 0
|
||||
contentLen := len(content)
|
||||
|
||||
for start < contentLen {
|
||||
end := start + chunkSize
|
||||
if end > contentLen {
|
||||
end = contentLen
|
||||
}
|
||||
|
||||
if end < contentLen {
|
||||
for i := end; i > start && i > start+chunkSize-overlap; i-- {
|
||||
c := rune(content[i])
|
||||
if c == '\n' || c == ';' || c == '\r' {
|
||||
end = i + 1
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
chunk := strings.TrimSpace(content[start:end])
|
||||
if chunk != "" {
|
||||
chunks = append(chunks, chunk)
|
||||
}
|
||||
|
||||
start = end - overlap
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
if start >= contentLen {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return chunks
|
||||
}
|
||||
|
||||
func (s *KnowledgeBaseService) serializeResume(resume *model.Resume) string {
|
||||
var builder strings.Builder
|
||||
|
||||
if resume.BasicInfo.Name != "" {
|
||||
builder.WriteString("姓名:")
|
||||
builder.WriteString(resume.BasicInfo.Name)
|
||||
builder.WriteString("\n")
|
||||
}
|
||||
if resume.BasicInfo.Title != "" {
|
||||
builder.WriteString("职位:")
|
||||
builder.WriteString(resume.BasicInfo.Title)
|
||||
builder.WriteString("\n")
|
||||
}
|
||||
if resume.BasicInfo.Email != "" {
|
||||
builder.WriteString("邮箱:")
|
||||
builder.WriteString(resume.BasicInfo.Email)
|
||||
builder.WriteString("\n")
|
||||
}
|
||||
if resume.BasicInfo.Phone != "" {
|
||||
builder.WriteString("电话:")
|
||||
builder.WriteString(resume.BasicInfo.Phone)
|
||||
builder.WriteString("\n")
|
||||
}
|
||||
if resume.BasicInfo.Location != "" {
|
||||
builder.WriteString("所在地:")
|
||||
builder.WriteString(resume.BasicInfo.Location)
|
||||
builder.WriteString("\n")
|
||||
}
|
||||
if resume.BasicInfo.Summary != "" {
|
||||
builder.WriteString("个人简介:")
|
||||
builder.WriteString(resume.BasicInfo.Summary)
|
||||
builder.WriteString("\n")
|
||||
}
|
||||
if resume.BasicInfo.JobTarget != "" {
|
||||
builder.WriteString("求职目标:")
|
||||
builder.WriteString(resume.BasicInfo.JobTarget)
|
||||
builder.WriteString("\n")
|
||||
}
|
||||
|
||||
if len(resume.Experience) > 0 {
|
||||
builder.WriteString("\n【工作经历】\n")
|
||||
for _, exp := range resume.Experience {
|
||||
builder.WriteString("公司:")
|
||||
builder.WriteString(exp.Company)
|
||||
builder.WriteString("\n职位:")
|
||||
builder.WriteString(exp.Position)
|
||||
builder.WriteString("\n时间:")
|
||||
builder.WriteString(exp.StartDate)
|
||||
if exp.EndDate != "" {
|
||||
builder.WriteString(" - ")
|
||||
builder.WriteString(exp.EndDate)
|
||||
}
|
||||
builder.WriteString("\n职责:")
|
||||
builder.WriteString(exp.Description)
|
||||
builder.WriteString("\n")
|
||||
if len(exp.Highlights) > 0 {
|
||||
builder.WriteString("亮点:")
|
||||
builder.WriteString(strings.Join(exp.Highlights, ";"))
|
||||
builder.WriteString("\n")
|
||||
}
|
||||
builder.WriteString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
if len(resume.Education) > 0 {
|
||||
builder.WriteString("\n【教育背景】\n")
|
||||
for _, edu := range resume.Education {
|
||||
builder.WriteString("学校:")
|
||||
builder.WriteString(edu.School)
|
||||
builder.WriteString("\n学位:")
|
||||
builder.WriteString(edu.Degree)
|
||||
builder.WriteString("\n专业:")
|
||||
builder.WriteString(edu.Major)
|
||||
builder.WriteString("\n时间:")
|
||||
builder.WriteString(edu.StartDate)
|
||||
if edu.EndDate != "" {
|
||||
builder.WriteString(" - ")
|
||||
builder.WriteString(edu.EndDate)
|
||||
}
|
||||
builder.WriteString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
if len(resume.Skills) > 0 {
|
||||
builder.WriteString("\n【专业技能】\n")
|
||||
for _, skill := range resume.Skills {
|
||||
builder.WriteString(skill.Name)
|
||||
if skill.Level != "" {
|
||||
builder.WriteString("(")
|
||||
builder.WriteString(skill.Level)
|
||||
builder.WriteString(")")
|
||||
}
|
||||
if skill.Category != "" {
|
||||
builder.WriteString(" - ")
|
||||
builder.WriteString(skill.Category)
|
||||
}
|
||||
builder.WriteString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
if len(resume.Projects) > 0 {
|
||||
builder.WriteString("\n【项目经验】\n")
|
||||
for _, proj := range resume.Projects {
|
||||
builder.WriteString("项目名称:")
|
||||
builder.WriteString(proj.Name)
|
||||
builder.WriteString("\n描述:")
|
||||
builder.WriteString(proj.Description)
|
||||
builder.WriteString("\n")
|
||||
if len(proj.TechStack) > 0 {
|
||||
builder.WriteString("技术栈:")
|
||||
builder.WriteString(strings.Join(proj.TechStack, "、"))
|
||||
builder.WriteString("\n")
|
||||
}
|
||||
if len(proj.Highlights) > 0 {
|
||||
builder.WriteString("亮点:")
|
||||
builder.WriteString(strings.Join(proj.Highlights, ";"))
|
||||
builder.WriteString("\n")
|
||||
}
|
||||
if len(proj.Achievements) > 0 {
|
||||
builder.WriteString("成果:")
|
||||
builder.WriteString(strings.Join(proj.Achievements, ";"))
|
||||
builder.WriteString("\n")
|
||||
}
|
||||
builder.WriteString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
result := builder.String()
|
||||
if len(result) > 15000 {
|
||||
result = result[:15000]
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (s *KnowledgeBaseService) BuildIndex(ctx context.Context) error {
|
||||
documents, err := s.repo.GetAllDocumentChunks()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, chunk := range documents {
|
||||
if chunk.Embedding == "" && chunk.Content != "" {
|
||||
embedding, err := s.embeddingService.Generate(ctx, chunk.Content)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
embeddingJSON, _ := json.Marshal(embedding)
|
||||
chunk.Embedding = string(embeddingJSON)
|
||||
err = s.repo.CreateDocumentChunk(chunk)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package knowledge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"resume-platform/internal/ai"
|
||||
"resume-platform/internal/prompt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type RAGService struct {
|
||||
aiProvider ai.Provider
|
||||
knowledgeBaseService *KnowledgeBaseService
|
||||
}
|
||||
|
||||
func NewRAGService(provider ai.Provider, knowledgeBaseService *KnowledgeBaseService) *RAGService {
|
||||
return &RAGService{aiProvider: provider, knowledgeBaseService: knowledgeBaseService}
|
||||
}
|
||||
|
||||
func (s *RAGService) Generate(ctx context.Context, query string, userID string) (string, error) {
|
||||
contextResults, err := s.knowledgeBaseService.Retrieve(ctx, query, 5, userID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var contextStr string
|
||||
for _, result := range contextResults {
|
||||
contextStr += fmt.Sprintf("【来源:%s】\n%s\n\n", result.SourceName, result.Content)
|
||||
}
|
||||
|
||||
if contextStr == "" {
|
||||
return s.aiProvider.Generate(ctx, query)
|
||||
}
|
||||
|
||||
promptText := prompt.BuildRAGAnswerPrompt(contextStr, query)
|
||||
|
||||
return s.aiProvider.Generate(ctx, promptText)
|
||||
}
|
||||
|
||||
func (s *RAGService) GenerateWithContext(ctx context.Context, query string, context []string) (string, error) {
|
||||
contextStr := strings.Join(context, "\n\n")
|
||||
|
||||
promptText := prompt.BuildRAGAnswerPrompt(contextStr, query)
|
||||
|
||||
return s.aiProvider.Generate(ctx, promptText)
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
package knowledge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"resume-platform/internal/model"
|
||||
"resume-platform/internal/repository"
|
||||
"sort"
|
||||
)
|
||||
|
||||
type VectorService struct {
|
||||
embeddingService *EmbeddingService
|
||||
repo repository.ResumeRepository
|
||||
}
|
||||
|
||||
func NewVectorService(embeddingService *EmbeddingService, repo repository.ResumeRepository) *VectorService {
|
||||
return &VectorService{embeddingService: embeddingService, repo: repo}
|
||||
}
|
||||
|
||||
func (s *VectorService) Store(ctx context.Context, content, sourceType, sourceID, sourceName, userID string) error {
|
||||
_, err := s.embeddingService.GenerateAndStore(ctx, content, sourceType, sourceID, sourceName, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *VectorService) StoreChunk(ctx context.Context, chunk *model.DocumentChunk, userID string, sourceName string) error {
|
||||
if chunk.Content == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
embedding, err := s.embeddingService.Generate(ctx, chunk.Content)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
embeddingJSON, _ := json.Marshal(embedding)
|
||||
chunk.Embedding = string(embeddingJSON)
|
||||
|
||||
err = s.repo.CreateDocumentChunk(chunk)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
contentHash := s.embeddingService.hashContent(chunk.Content)
|
||||
err = s.repo.CreateEmbedding(&model.Embedding{
|
||||
ID: chunk.ID,
|
||||
ContentHash: contentHash,
|
||||
Embedding: string(embeddingJSON),
|
||||
SourceType: "document",
|
||||
SourceID: chunk.DocumentID,
|
||||
SourceName: sourceName,
|
||||
UserID: userID,
|
||||
})
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *VectorService) Search(ctx context.Context, query string, topK int, userID string) ([]model.ChunkWithScore, error) {
|
||||
if query == "" {
|
||||
return nil, fmt.Errorf("empty query")
|
||||
}
|
||||
|
||||
queryEmbedding, err := s.embeddingService.Generate(ctx, query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var allEmbeddings []*model.Embedding
|
||||
if userID != "" {
|
||||
allEmbeddings, err = s.repo.GetEmbeddingsByUserID(userID)
|
||||
} else {
|
||||
allEmbeddings, err = s.repo.GetAllEmbeddings()
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var results []model.ChunkWithScore
|
||||
for _, emb := range allEmbeddings {
|
||||
if emb.Embedding == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
var embedding []float64
|
||||
if err := json.Unmarshal([]byte(emb.Embedding), &embedding); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
score := cosineSimilarity(queryEmbedding, embedding)
|
||||
if score > 0.3 {
|
||||
results = append(results, model.ChunkWithScore{
|
||||
Content: emb.SourceName,
|
||||
Score: score,
|
||||
SourceID: emb.SourceID,
|
||||
SourceName: emb.SourceName,
|
||||
SourceType: emb.SourceType,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
sort.Slice(results, func(i, j int) bool {
|
||||
return results[i].Score > results[j].Score
|
||||
})
|
||||
|
||||
if len(results) > topK {
|
||||
results = results[:topK]
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func (s *VectorService) SearchWithChunks(ctx context.Context, query string, topK int, userID string) ([]model.ChunkWithScore, error) {
|
||||
if query == "" {
|
||||
return nil, fmt.Errorf("empty query")
|
||||
}
|
||||
|
||||
queryEmbedding, err := s.embeddingService.Generate(ctx, query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var allChunks []*model.DocumentChunk
|
||||
if userID != "" {
|
||||
allChunks, err = s.repo.GetDocumentChunksByUserID(userID)
|
||||
} else {
|
||||
allChunks, err = s.repo.GetAllDocumentChunks()
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var results []model.ChunkWithScore
|
||||
for _, chunk := range allChunks {
|
||||
if chunk.Embedding == "" || chunk.Content == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
var embedding []float64
|
||||
if err := json.Unmarshal([]byte(chunk.Embedding), &embedding); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
score := cosineSimilarity(queryEmbedding, embedding)
|
||||
if score > 0.3 {
|
||||
doc, _ := s.repo.GetDocumentByID(chunk.DocumentID)
|
||||
sourceName := ""
|
||||
if doc != nil {
|
||||
sourceName = doc.Name
|
||||
}
|
||||
results = append(results, model.ChunkWithScore{
|
||||
Content: chunk.Content,
|
||||
Score: score,
|
||||
SourceID: chunk.DocumentID,
|
||||
SourceName: sourceName,
|
||||
SourceType: "document",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
sort.Slice(results, func(i, j int) bool {
|
||||
return results[i].Score > results[j].Score
|
||||
})
|
||||
|
||||
if len(results) > topK {
|
||||
results = results[:topK]
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func (s *VectorService) Delete(ctx context.Context, sourceType, sourceID string) error {
|
||||
return s.repo.DeleteEmbeddingsBySource(sourceType, sourceID)
|
||||
}
|
||||
|
||||
func (s *VectorService) DeleteDocumentChunks(ctx context.Context, documentID string) error {
|
||||
_, err := s.repo.GetDocumentChunksByDocumentID(documentID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func cosineSimilarity(a, b []float64) float64 {
|
||||
if len(a) != len(b) {
|
||||
return 0
|
||||
}
|
||||
|
||||
var dotProduct, magA, magB float64
|
||||
for i := range a {
|
||||
dotProduct += a[i] * b[i]
|
||||
magA += a[i] * a[i]
|
||||
magB += b[i] * b[i]
|
||||
}
|
||||
|
||||
if magA == 0 || magB == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
return dotProduct / (math.Sqrt(magA) * math.Sqrt(magB))
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package portfolio
|
||||
|
||||
import (
|
||||
"resume-platform/internal/model"
|
||||
"resume-platform/internal/repository"
|
||||
)
|
||||
|
||||
type PortfolioService interface {
|
||||
GetPortfolioItems() ([]*model.PortfolioItem, error)
|
||||
GetPortfolioItemsWithPagination(page, pageSize int) ([]*model.PortfolioItem, int, error)
|
||||
GetPortfolioItemsByUserID(userID string) ([]*model.PortfolioItem, error)
|
||||
GetPortfolioItem(id uint) (*model.PortfolioItem, error)
|
||||
CreatePortfolioItem(item *model.PortfolioItem) error
|
||||
UpdatePortfolioItem(id uint, item *model.PortfolioItem) error
|
||||
DeletePortfolioItem(id uint) error
|
||||
}
|
||||
|
||||
type portfolioService struct {
|
||||
repo repository.ResumeRepository
|
||||
}
|
||||
|
||||
func NewPortfolioService(repo repository.ResumeRepository) PortfolioService {
|
||||
return &portfolioService{repo: repo}
|
||||
}
|
||||
|
||||
func (s *portfolioService) GetPortfolioItems() ([]*model.PortfolioItem, error) {
|
||||
return s.repo.GetPortfolioItems()
|
||||
}
|
||||
|
||||
func (s *portfolioService) GetPortfolioItemsWithPagination(page, pageSize int) ([]*model.PortfolioItem, int, error) {
|
||||
return s.repo.GetPortfolioItemsWithPagination(page, pageSize)
|
||||
}
|
||||
|
||||
func (s *portfolioService) GetPortfolioItemsByUserID(userID string) ([]*model.PortfolioItem, error) {
|
||||
return s.repo.GetPortfolioItemsByUserID(userID)
|
||||
}
|
||||
|
||||
func (s *portfolioService) GetPortfolioItem(id uint) (*model.PortfolioItem, error) {
|
||||
return s.repo.GetPortfolioItem(id)
|
||||
}
|
||||
|
||||
func (s *portfolioService) CreatePortfolioItem(item *model.PortfolioItem) error {
|
||||
return s.repo.CreatePortfolioItem(item)
|
||||
}
|
||||
|
||||
func (s *portfolioService) UpdatePortfolioItem(id uint, item *model.PortfolioItem) error {
|
||||
return s.repo.UpdatePortfolioItem(id, item)
|
||||
}
|
||||
|
||||
func (s *portfolioService) DeletePortfolioItem(id uint) error {
|
||||
return s.repo.DeletePortfolioItem(id)
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package quiz
|
||||
|
||||
import (
|
||||
"resume-platform/internal/model"
|
||||
"resume-platform/internal/repository"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type QuizService interface {
|
||||
CreateQuizRecord(record *model.QuizRecord) error
|
||||
GetQuizRecordsByResume(resumeRoute string) ([]*model.QuizRecord, error)
|
||||
GetQuizRecord(id string) (*model.QuizRecord, error)
|
||||
DeleteQuizRecord(id string) error
|
||||
CreateFavoriteQuestion(fav *model.FavoriteQuestion) error
|
||||
GetFavoriteQuestions(resumeRoute string, isFavorite, isCorrect *bool) ([]*model.FavoriteQuestion, error)
|
||||
DeleteFavoriteQuestion(id string) error
|
||||
ToggleFavorite(id string) error
|
||||
}
|
||||
|
||||
type quizService struct {
|
||||
repo repository.ResumeRepository
|
||||
}
|
||||
|
||||
func NewQuizService(repo repository.ResumeRepository) QuizService {
|
||||
return &quizService{repo: repo}
|
||||
}
|
||||
|
||||
func (s *quizService) CreateQuizRecord(record *model.QuizRecord) error {
|
||||
if record.ID == "" {
|
||||
record.ID = uuid.New().String()
|
||||
}
|
||||
return s.repo.CreateQuizRecord(record)
|
||||
}
|
||||
|
||||
func (s *quizService) GetQuizRecordsByResume(resumeRoute string) ([]*model.QuizRecord, error) {
|
||||
return s.repo.GetQuizRecordsByResume(resumeRoute)
|
||||
}
|
||||
|
||||
func (s *quizService) GetQuizRecord(id string) (*model.QuizRecord, error) {
|
||||
return s.repo.GetQuizRecord(id)
|
||||
}
|
||||
|
||||
func (s *quizService) DeleteQuizRecord(id string) error {
|
||||
return s.repo.DeleteQuizRecord(id)
|
||||
}
|
||||
|
||||
func (s *quizService) CreateFavoriteQuestion(fav *model.FavoriteQuestion) error {
|
||||
if fav.ID == "" {
|
||||
fav.ID = uuid.New().String()
|
||||
}
|
||||
return s.repo.CreateFavoriteQuestion(fav)
|
||||
}
|
||||
|
||||
func (s *quizService) GetFavoriteQuestions(resumeRoute string, isFavorite, isCorrect *bool) ([]*model.FavoriteQuestion, error) {
|
||||
return s.repo.GetFavoriteQuestions(resumeRoute, isFavorite, isCorrect)
|
||||
}
|
||||
|
||||
func (s *quizService) DeleteFavoriteQuestion(id string) error {
|
||||
return s.repo.DeleteFavoriteQuestion(id)
|
||||
}
|
||||
|
||||
func (s *quizService) ToggleFavorite(id string) error {
|
||||
return s.repo.ToggleFavorite(id)
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
package resume
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"resume-platform/internal/ai"
|
||||
"resume-platform/internal/model"
|
||||
"resume-platform/internal/prompt"
|
||||
"resume-platform/pkg/logger"
|
||||
"resume-platform/pkg/utils"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type ResumeGeneratorService struct {
|
||||
aiProvider ai.Provider
|
||||
}
|
||||
|
||||
func NewResumeGeneratorService(provider ai.Provider) *ResumeGeneratorService {
|
||||
return &ResumeGeneratorService{aiProvider: provider}
|
||||
}
|
||||
|
||||
func (s *ResumeGeneratorService) GenerateFromDocument(ctx context.Context, documentContent string) (*model.Resume, error) {
|
||||
if s.aiProvider == nil {
|
||||
return nil, fmt.Errorf("AI provider not configured")
|
||||
}
|
||||
|
||||
cleanedContent := cleanDocumentContent(documentContent)
|
||||
if cleanedContent == "" {
|
||||
return nil, fmt.Errorf("document content is empty after cleaning")
|
||||
}
|
||||
|
||||
maxContentLength := 15000
|
||||
if len(cleanedContent) > maxContentLength {
|
||||
cleanedContent = truncateContent(cleanedContent, maxContentLength)
|
||||
}
|
||||
|
||||
return s.extractAllSections(ctx, cleanedContent)
|
||||
}
|
||||
|
||||
func (s *ResumeGeneratorService) extractAllSections(ctx context.Context, content string) (*model.Resume, error) {
|
||||
promptText := prompt.BuildResumeExtractorPrompt(content)
|
||||
|
||||
result, err := s.aiProvider.Generate(ctx, promptText)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cleanedResult := utils.CleanAIResponse(result)
|
||||
|
||||
var rawData struct {
|
||||
BasicInfo struct {
|
||||
Name string `json:"name"`
|
||||
Title string `json:"title"`
|
||||
Email string `json:"email"`
|
||||
Phone string `json:"phone"`
|
||||
Location string `json:"location"`
|
||||
Website string `json:"website"`
|
||||
Summary string `json:"summary"`
|
||||
ExperienceYears interface{} `json:"experience_years"`
|
||||
JobTarget string `json:"job_target"`
|
||||
Industry string `json:"industry"`
|
||||
CoreAdvantages []interface{} `json:"core_advantages"`
|
||||
} `json:"basic_info"`
|
||||
Experience []struct {
|
||||
Company string `json:"company"`
|
||||
Position string `json:"position"`
|
||||
StartDate string `json:"start_date"`
|
||||
EndDate string `json:"end_date"`
|
||||
Description string `json:"description"`
|
||||
Highlights []interface{} `json:"highlights"`
|
||||
Platforms []interface{} `json:"platforms"`
|
||||
} `json:"experience"`
|
||||
Education []struct {
|
||||
School string `json:"school"`
|
||||
Degree string `json:"degree"`
|
||||
Major string `json:"major"`
|
||||
StartDate string `json:"start_date"`
|
||||
EndDate string `json:"end_date"`
|
||||
GPA string `json:"gpa"`
|
||||
} `json:"education"`
|
||||
Skills []struct {
|
||||
Name string `json:"name"`
|
||||
Level string `json:"level"`
|
||||
Category string `json:"category"`
|
||||
} `json:"skills"`
|
||||
Projects []struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
TechStack []interface{} `json:"tech_stack"`
|
||||
URL string `json:"url"`
|
||||
Highlights []interface{} `json:"highlights"`
|
||||
Achievements []interface{} `json:"achievements"`
|
||||
StartDate string `json:"start_date"`
|
||||
EndDate string `json:"end_date"`
|
||||
} `json:"projects"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal([]byte(cleanedResult), &rawData); err != nil {
|
||||
logger.Errorf("Failed to parse AI result: %v, raw result: %s", err, cleanedResult)
|
||||
return nil, fmt.Errorf("failed to parse AI result: %w", err)
|
||||
}
|
||||
|
||||
coreAdvantages := make([]string, 0, len(rawData.BasicInfo.CoreAdvantages))
|
||||
for _, item := range rawData.BasicInfo.CoreAdvantages {
|
||||
coreAdvantages = append(coreAdvantages, utils.ConvertToString(item))
|
||||
}
|
||||
|
||||
experience := make([]model.Experience, 0, len(rawData.Experience))
|
||||
for _, exp := range rawData.Experience {
|
||||
if exp.Company == "" && exp.Position == "" {
|
||||
continue
|
||||
}
|
||||
highlights := make([]string, 0, len(exp.Highlights))
|
||||
for _, item := range exp.Highlights {
|
||||
highlights = append(highlights, utils.ConvertToString(item))
|
||||
}
|
||||
platforms := make([]string, 0, len(exp.Platforms))
|
||||
for _, item := range exp.Platforms {
|
||||
platforms = append(platforms, utils.ConvertToString(item))
|
||||
}
|
||||
experience = append(experience, model.Experience{
|
||||
Company: exp.Company,
|
||||
Position: exp.Position,
|
||||
StartDate: exp.StartDate,
|
||||
EndDate: exp.EndDate,
|
||||
Description: exp.Description,
|
||||
Highlights: highlights,
|
||||
Platforms: platforms,
|
||||
})
|
||||
}
|
||||
|
||||
education := make([]model.Education, 0, len(rawData.Education))
|
||||
for _, edu := range rawData.Education {
|
||||
if edu.School == "" {
|
||||
continue
|
||||
}
|
||||
education = append(education, model.Education{
|
||||
School: edu.School,
|
||||
Degree: edu.Degree,
|
||||
Major: edu.Major,
|
||||
StartDate: edu.StartDate,
|
||||
EndDate: edu.EndDate,
|
||||
GPA: edu.GPA,
|
||||
})
|
||||
}
|
||||
|
||||
skills := make([]model.Skill, 0, len(rawData.Skills))
|
||||
for _, skill := range rawData.Skills {
|
||||
if skill.Name == "" {
|
||||
continue
|
||||
}
|
||||
skills = append(skills, model.Skill{
|
||||
Name: skill.Name,
|
||||
Level: skill.Level,
|
||||
Category: skill.Category,
|
||||
})
|
||||
}
|
||||
|
||||
projects := make([]model.Project, 0, len(rawData.Projects))
|
||||
for _, proj := range rawData.Projects {
|
||||
if proj.Name == "" && proj.Description == "" {
|
||||
continue
|
||||
}
|
||||
techStack := make([]string, 0, len(proj.TechStack))
|
||||
for _, item := range proj.TechStack {
|
||||
techStack = append(techStack, utils.ConvertToString(item))
|
||||
}
|
||||
highlights := make([]string, 0, len(proj.Highlights))
|
||||
for _, item := range proj.Highlights {
|
||||
highlights = append(highlights, utils.ConvertToString(item))
|
||||
}
|
||||
achievements := make([]string, 0, len(proj.Achievements))
|
||||
for _, item := range proj.Achievements {
|
||||
achievements = append(achievements, utils.ConvertToString(item))
|
||||
}
|
||||
projects = append(projects, model.Project{
|
||||
Name: proj.Name,
|
||||
Description: proj.Description,
|
||||
TechStack: techStack,
|
||||
URL: proj.URL,
|
||||
Highlights: highlights,
|
||||
Achievements: achievements,
|
||||
StartDate: proj.StartDate,
|
||||
EndDate: proj.EndDate,
|
||||
ShowInResume: false,
|
||||
})
|
||||
}
|
||||
|
||||
resume := &model.Resume{
|
||||
ID: uuid.New().String(),
|
||||
Route: generateRouteFromName(rawData.BasicInfo.Name),
|
||||
BasicInfo: model.BasicInfo{
|
||||
Name: rawData.BasicInfo.Name,
|
||||
Title: rawData.BasicInfo.Title,
|
||||
Email: rawData.BasicInfo.Email,
|
||||
Phone: rawData.BasicInfo.Phone,
|
||||
Location: rawData.BasicInfo.Location,
|
||||
Website: rawData.BasicInfo.Website,
|
||||
Summary: rawData.BasicInfo.Summary,
|
||||
ExperienceYears: utils.ConvertToString(rawData.BasicInfo.ExperienceYears),
|
||||
JobTarget: rawData.BasicInfo.JobTarget,
|
||||
Industry: rawData.BasicInfo.Industry,
|
||||
CoreAdvantages: coreAdvantages,
|
||||
},
|
||||
Experience: experience,
|
||||
Education: education,
|
||||
Skills: skills,
|
||||
Projects: projects,
|
||||
Template: "modern",
|
||||
ShowInHome: false,
|
||||
}
|
||||
|
||||
return resume, nil
|
||||
}
|
||||
|
||||
func cleanDocumentContent(content string) string {
|
||||
return utils.CleanText(content)
|
||||
}
|
||||
|
||||
func generateRouteFromName(name string) string {
|
||||
if name == "" {
|
||||
return uuid.New().String()[:8]
|
||||
}
|
||||
route := ""
|
||||
for _, c := range name {
|
||||
if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') {
|
||||
route += string(c)
|
||||
}
|
||||
}
|
||||
if route == "" {
|
||||
return uuid.New().String()[:8]
|
||||
}
|
||||
return strings.ToLower(route)
|
||||
}
|
||||
|
||||
func truncateContent(content string, maxLength int) string {
|
||||
if len(content) <= maxLength {
|
||||
return content
|
||||
}
|
||||
|
||||
truncated := content[:maxLength]
|
||||
|
||||
for i := len(truncated) - 1; i >= 0; i-- {
|
||||
c := rune(truncated[i])
|
||||
if c == '。' || c == '!' || c == '?' || c == ';' ||
|
||||
c == '.' || c == '!' || c == '?' || c == ';' ||
|
||||
c == '\n' || c == '\r' {
|
||||
truncated = truncated[:i+1]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return truncated + "\n\n[内容已截断,仅保留关键信息]"
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
package resume
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"regexp"
|
||||
"resume-platform/internal/model"
|
||||
"resume-platform/internal/repository"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type ResumeService interface {
|
||||
GetResume(id string) (*model.Resume, error)
|
||||
GetResumeByRoute(route string) (*model.Resume, error)
|
||||
RouteExists(route string) bool
|
||||
GetAllResumes() ([]*model.Resume, error)
|
||||
GetResumesByUserID(userID string) ([]*model.Resume, error)
|
||||
GetResumesWithPagination(page, pageSize int) ([]*model.Resume, int, error)
|
||||
GetResumeWithMask(id string) (*model.Resume, error)
|
||||
CreateResume(resume *model.Resume) (*model.Resume, error)
|
||||
CreateResumeWithRoute(route string, resume *model.Resume) (*model.Resume, error)
|
||||
UpdateResume(id string, update *model.ResumeUpdate) (*model.Resume, error)
|
||||
DeleteResume(id string) error
|
||||
UpdateResumeUserID(resumeID, userID string) error
|
||||
InitDefaultResume() (*model.Resume, error)
|
||||
}
|
||||
|
||||
type resumeService struct {
|
||||
repo repository.ResumeRepository
|
||||
}
|
||||
|
||||
func NewResumeService(repo repository.ResumeRepository) ResumeService {
|
||||
return &resumeService{repo: repo}
|
||||
}
|
||||
|
||||
func (s *resumeService) GetResume(id string) (*model.Resume, error) {
|
||||
return s.repo.GetResume(id)
|
||||
}
|
||||
|
||||
func (s *resumeService) GetResumeByRoute(route string) (*model.Resume, error) {
|
||||
return s.repo.GetResumeByRoute(route)
|
||||
}
|
||||
|
||||
func (s *resumeService) RouteExists(route string) bool {
|
||||
return s.repo.RouteExists(route)
|
||||
}
|
||||
|
||||
func (s *resumeService) GetAllResumes() ([]*model.Resume, error) {
|
||||
return s.repo.GetAllResumes()
|
||||
}
|
||||
|
||||
func (s *resumeService) GetResumesWithPagination(page, pageSize int) ([]*model.Resume, int, error) {
|
||||
return s.repo.GetResumesWithPagination(page, pageSize)
|
||||
}
|
||||
|
||||
func (s *resumeService) CreateResume(resume *model.Resume) (*model.Resume, error) {
|
||||
if resume.ID == "" {
|
||||
resume.ID = uuid.New().String()
|
||||
}
|
||||
if resume.Template == "" {
|
||||
resume.Template = "modern"
|
||||
}
|
||||
err := s.repo.CreateResume(resume)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.repo.GetResume(resume.ID)
|
||||
}
|
||||
|
||||
func (s *resumeService) CreateResumeWithRoute(route string, resume *model.Resume) (*model.Resume, error) {
|
||||
if s.repo.RouteExists(route) {
|
||||
return nil, errors.New("route already exists")
|
||||
}
|
||||
|
||||
if resume.ID == "" {
|
||||
resume.ID = uuid.New().String()
|
||||
}
|
||||
if resume.Template == "" {
|
||||
resume.Template = "modern"
|
||||
}
|
||||
resume.Route = route
|
||||
|
||||
err := s.repo.CreateResume(resume)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.repo.GetResume(resume.ID)
|
||||
}
|
||||
|
||||
func (s *resumeService) GetResumesByUserID(userID string) ([]*model.Resume, error) {
|
||||
return s.repo.GetResumesByUserID(userID)
|
||||
}
|
||||
|
||||
func (s *resumeService) UpdateResume(id string, update *model.ResumeUpdate) (*model.Resume, error) {
|
||||
if !s.repo.Exists(id) {
|
||||
return nil, errors.New("resume not found")
|
||||
}
|
||||
err := s.repo.UpdateResume(id, update)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.repo.GetResume(id)
|
||||
}
|
||||
|
||||
func (s *resumeService) DeleteResume(id string) error {
|
||||
if !s.repo.Exists(id) {
|
||||
return errors.New("resume not found")
|
||||
}
|
||||
return s.repo.DeleteResume(id)
|
||||
}
|
||||
|
||||
func (s *resumeService) UpdateResumeUserID(resumeID, userID string) error {
|
||||
if !s.repo.Exists(resumeID) {
|
||||
return errors.New("resume not found")
|
||||
}
|
||||
return s.repo.UpdateResumeUserID(resumeID, userID)
|
||||
}
|
||||
|
||||
func (s *resumeService) InitDefaultResume() (*model.Resume, error) {
|
||||
defaultID := "default"
|
||||
if s.repo.Exists(defaultID) {
|
||||
return s.repo.GetResume(defaultID)
|
||||
}
|
||||
|
||||
defaultResume := &model.Resume{
|
||||
ID: defaultID,
|
||||
Route: defaultID,
|
||||
Template: "modern",
|
||||
BasicInfo: model.BasicInfo{
|
||||
Name: "张三",
|
||||
Title: "全栈开发工程师",
|
||||
Email: "zhangsan@example.com",
|
||||
Phone: "13800138000",
|
||||
Location: "北京市",
|
||||
Website: "https://zhangsan.dev",
|
||||
Summary: "5年以上软件开发经验,专注于Web全栈开发,熟悉Go、React、Vue等技术栈。热爱技术,善于解决复杂问题。",
|
||||
Avatar: "",
|
||||
},
|
||||
Experience: []model.Experience{
|
||||
{
|
||||
Company: "某知名互联网公司",
|
||||
Position: "高级开发工程师",
|
||||
StartDate: "2022-01",
|
||||
EndDate: "至今",
|
||||
Description: "负责核心业务系统的架构设计与开发",
|
||||
Highlights: []string{"主导微服务架构改造", "优化系统性能提升50%", "带领5人团队完成多个重要项目"},
|
||||
},
|
||||
{
|
||||
Company: "创业公司",
|
||||
Position: "技术负责人",
|
||||
StartDate: "2019-06",
|
||||
EndDate: "2021-12",
|
||||
Description: "从0到1搭建技术团队和产品",
|
||||
Highlights: []string{"建立完整技术体系", "打造百万级用户产品", "获得天使轮融资"},
|
||||
},
|
||||
},
|
||||
Education: []model.Education{
|
||||
{
|
||||
School: "清华大学",
|
||||
Degree: "本科",
|
||||
Major: "计算机科学与技术",
|
||||
StartDate: "2015-09",
|
||||
EndDate: "2019-06",
|
||||
GPA: "3.8/4.0",
|
||||
},
|
||||
},
|
||||
Skills: []model.Skill{
|
||||
{Name: "Go", Level: "expert", Category: "后端"},
|
||||
{Name: "Python", Level: "advanced", Category: "后端"},
|
||||
{Name: "React", Level: "advanced", Category: "前端"},
|
||||
{Name: "Vue", Level: "advanced", Category: "前端"},
|
||||
{Name: "MySQL", Level: "advanced", Category: "数据库"},
|
||||
{Name: "Redis", Level: "intermediate", Category: "数据库"},
|
||||
{Name: "Docker", Level: "advanced", Category: "运维"},
|
||||
{Name: "Kubernetes", Level: "intermediate", Category: "运维"},
|
||||
},
|
||||
Projects: []model.Project{
|
||||
{
|
||||
Name: "电商平台",
|
||||
Description: "基于微服务架构的B2C电商平台",
|
||||
TechStack: []string{"Go", "React", "MySQL", "Redis", "Kubernetes"},
|
||||
URL: "https://github.com/example/ecommerce",
|
||||
Highlights: []string{"日订单量突破10万", "系统可用性99.9%", "支持百万级并发"},
|
||||
},
|
||||
{
|
||||
Name: "任务管理系统",
|
||||
Description: "团队协作任务管理工具",
|
||||
TechStack: []string{"Vue", "Node.js", "MongoDB"},
|
||||
URL: "https://github.com/example/taskmanager",
|
||||
Highlights: []string{"500+企业客户", "月活跃用户10万"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
err := s.repo.CreateResume(defaultResume)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return defaultResume, nil
|
||||
}
|
||||
|
||||
func maskPhone(phone string) string {
|
||||
re := regexp.MustCompile(`(\d{3})\d{4}(\d{4})`)
|
||||
return re.ReplaceAllString(phone, "$1****$2")
|
||||
}
|
||||
|
||||
func maskEmail(email string) string {
|
||||
re := regexp.MustCompile(`(\w{1,3})[\w.]*@`)
|
||||
return re.ReplaceAllString(email, "$1***@")
|
||||
}
|
||||
|
||||
func (s *resumeService) GetResumeWithMask(id string) (*model.Resume, error) {
|
||||
resume, err := s.repo.GetResume(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resume.BasicInfo.Phone = maskPhone(resume.BasicInfo.Phone)
|
||||
resume.BasicInfo.Email = maskEmail(resume.BasicInfo.Email)
|
||||
|
||||
return resume, nil
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package system
|
||||
|
||||
import (
|
||||
"resume-platform/internal/model"
|
||||
"resume-platform/internal/repository"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type SystemService interface {
|
||||
GetAllMenus() ([]*model.Menu, error)
|
||||
CreateMenu(menu *model.Menu) error
|
||||
UpdateMenu(id string, menu *model.Menu) error
|
||||
DeleteMenu(id string) error
|
||||
GetSystemConfig() (*model.SystemConfig, error)
|
||||
CreateSystemConfig(config *model.SystemConfig) error
|
||||
UpdateSystemConfig(id string, config *model.SystemConfig) error
|
||||
InitDefaultMenus() error
|
||||
}
|
||||
|
||||
type systemService struct {
|
||||
repo repository.ResumeRepository
|
||||
}
|
||||
|
||||
func NewSystemService(repo repository.ResumeRepository) SystemService {
|
||||
return &systemService{repo: repo}
|
||||
}
|
||||
|
||||
func (s *systemService) GetAllMenus() ([]*model.Menu, error) {
|
||||
return s.repo.GetAllMenus()
|
||||
}
|
||||
|
||||
func (s *systemService) CreateMenu(menu *model.Menu) error {
|
||||
if menu.ID == "" {
|
||||
menu.ID = uuid.New().String()
|
||||
}
|
||||
return s.repo.CreateMenu(menu)
|
||||
}
|
||||
|
||||
func (s *systemService) UpdateMenu(id string, menu *model.Menu) error {
|
||||
return s.repo.UpdateMenu(id, menu)
|
||||
}
|
||||
|
||||
func (s *systemService) DeleteMenu(id string) error {
|
||||
return s.repo.DeleteMenu(id)
|
||||
}
|
||||
|
||||
func (s *systemService) GetSystemConfig() (*model.SystemConfig, error) {
|
||||
return s.repo.GetSystemConfig()
|
||||
}
|
||||
|
||||
func (s *systemService) CreateSystemConfig(config *model.SystemConfig) error {
|
||||
if config.ID == "" {
|
||||
config.ID = "default"
|
||||
}
|
||||
return s.repo.CreateSystemConfig(config)
|
||||
}
|
||||
|
||||
func (s *systemService) UpdateSystemConfig(id string, config *model.SystemConfig) error {
|
||||
return s.repo.UpdateSystemConfig(id, config)
|
||||
}
|
||||
|
||||
func (s *systemService) InitDefaultMenus() error {
|
||||
menus, err := s.repo.GetAllMenus()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(menus) > 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
defaultMenus := []*model.Menu{
|
||||
{ID: "menu-user", Name: "人员管理", Icon: "fas fa-users", Path: "user", ParentID: "", SortOrder: 1, IsFixed: true, IsEnabled: true},
|
||||
{ID: "menu-menu", Name: "菜单管理", Icon: "fas fa-list", Path: "menu", ParentID: "", SortOrder: 2, IsFixed: true, IsEnabled: true},
|
||||
{ID: "menu-settings", Name: "系统设置", Icon: "fas fa-cog", Path: "settings", ParentID: "", SortOrder: 3, IsFixed: true, IsEnabled: true},
|
||||
}
|
||||
|
||||
for _, menu := range defaultMenus {
|
||||
if err := s.repo.CreateMenu(menu); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"resume-platform/internal/model"
|
||||
"resume-platform/internal/repository"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type UserService interface {
|
||||
CreateUser(user *model.User) error
|
||||
GetUserByUsername(username string) (*model.User, error)
|
||||
GetUserByID(id string) (*model.User, error)
|
||||
GetAllUsers() ([]*model.User, error)
|
||||
GetUsersWithPagination(page, pageSize int) ([]*model.User, int, error)
|
||||
UpdateUser(id string, user *model.User) error
|
||||
DeleteUser(id string) error
|
||||
}
|
||||
|
||||
type userService struct {
|
||||
repo repository.ResumeRepository
|
||||
}
|
||||
|
||||
func NewUserService(repo repository.ResumeRepository) UserService {
|
||||
return &userService{repo: repo}
|
||||
}
|
||||
|
||||
func (s *userService) CreateUser(user *model.User) error {
|
||||
if user.ID == "" {
|
||||
user.ID = uuid.New().String()
|
||||
}
|
||||
return s.repo.CreateUser(user)
|
||||
}
|
||||
|
||||
func (s *userService) GetUserByUsername(username string) (*model.User, error) {
|
||||
return s.repo.GetUserByUsername(username)
|
||||
}
|
||||
|
||||
func (s *userService) GetUserByID(id string) (*model.User, error) {
|
||||
return s.repo.GetUserByID(id)
|
||||
}
|
||||
|
||||
func (s *userService) GetAllUsers() ([]*model.User, error) {
|
||||
return s.repo.GetAllUsers()
|
||||
}
|
||||
|
||||
func (s *userService) GetUsersWithPagination(page, pageSize int) ([]*model.User, int, error) {
|
||||
return s.repo.GetUsersWithPagination(page, pageSize)
|
||||
}
|
||||
|
||||
func (s *userService) UpdateUser(id string, user *model.User) error {
|
||||
return s.repo.UpdateUser(id, user)
|
||||
}
|
||||
|
||||
func (s *userService) DeleteUser(id string) error {
|
||||
return s.repo.DeleteUser(id)
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
// Package config 配置加载模块,基于 Viper 读取 YAML 配置并提供默认值
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
const (
|
||||
DefaultAdminPath = "/admin" // 默认后台管理路径
|
||||
DefaultMaxLoginAttempts = 5 // 默认最大登录尝试次数
|
||||
DefaultLockoutDuration = 15 // 默认锁定时长(分钟)
|
||||
DefaultServerHost = "0.0.0.0" // 默认服务监听地址
|
||||
DefaultServerPort = 8090 // 默认服务端口
|
||||
DefaultServerMode = "release" // 默认运行模式
|
||||
DefaultDatabaseType = "sqlite" // 默认数据库类型
|
||||
DefaultDatabasePath = "./data/resume.db" // 默认数据库文件路径
|
||||
DefaultLogLevel = "info" // 默认日志级别
|
||||
DefaultTemplateDefault = "modern" // 默认简历模板
|
||||
)
|
||||
|
||||
// Config 全局配置结构体
|
||||
type Config struct {
|
||||
Server ServerConfig `mapstructure:"server"` // 服务配置
|
||||
Database DatabaseConfig `mapstructure:"database"` // 数据库配置
|
||||
Auth AuthConfig `mapstructure:"auth"` // 认证配置
|
||||
Template TemplateConfig `mapstructure:"template"` // 模板配置
|
||||
Log LogConfig `mapstructure:"log"` // 日志配置
|
||||
AI AIConfig `mapstructure:"ai"` // AI配置
|
||||
}
|
||||
|
||||
// ServerConfig 服务配置
|
||||
type ServerConfig struct {
|
||||
Host string `mapstructure:"host"` // 监听地址
|
||||
Port int `mapstructure:"port"` // 监听端口
|
||||
Mode string `mapstructure:"mode"` // 运行模式(debug/release)
|
||||
Debug bool `mapstructure:"debug"` // 是否开启调试
|
||||
}
|
||||
|
||||
// DatabaseConfig 数据库配置
|
||||
type DatabaseConfig struct {
|
||||
Type string `mapstructure:"type"` // 数据库类型
|
||||
Path string `mapstructure:"path"` // 数据库文件路径
|
||||
Password string `mapstructure:"password"` // 数据库加密密码
|
||||
}
|
||||
|
||||
// AuthConfig 认证配置
|
||||
type AuthConfig struct {
|
||||
Enabled bool `mapstructure:"enabled"` // 是否启用认证
|
||||
Username string `mapstructure:"username"` // 管理员用户名
|
||||
Password string `mapstructure:"password"` // 管理员密码
|
||||
AdminPath string `mapstructure:"admin_path"` // 后台路径
|
||||
MaxLoginAttempts int `mapstructure:"max_login_attempts"` // 最大登录尝试
|
||||
LockoutDuration int `mapstructure:"lockout_duration"` // 锁定时长(分钟)
|
||||
}
|
||||
|
||||
// TemplateConfig 模板配置
|
||||
type TemplateConfig struct {
|
||||
Default string `mapstructure:"default"` // 默认模板名称
|
||||
}
|
||||
|
||||
// LogConfig 日志配置
|
||||
type LogConfig struct {
|
||||
Level string `mapstructure:"level"` // 日志级别
|
||||
Output string `mapstructure:"output"` // 输出文件路径
|
||||
}
|
||||
|
||||
// AIConfig AI 大模型配置
|
||||
type AIConfig struct {
|
||||
Provider string `mapstructure:"provider"` // 默认供应商
|
||||
Enabled bool `mapstructure:"enabled"` // 是否启用
|
||||
Spark SparkConfig `mapstructure:"spark"` // 讯飞星火配置
|
||||
Tongyi TongyiConfig `mapstructure:"tongyi"` // 通义千问配置
|
||||
Doubao DoubaoConfig `mapstructure:"doubao"` // 豆包配置
|
||||
DeepSeek DeepSeekConfig `mapstructure:"deepseek"` // DeepSeek配置
|
||||
}
|
||||
|
||||
// SparkConfig 讯飞星火配置
|
||||
type SparkConfig struct {
|
||||
APPID string `mapstructure:"app_id"` // APP ID
|
||||
APIKey string `mapstructure:"api_key"` // API Key
|
||||
APISecret string `mapstructure:"api_secret"` // API Secret
|
||||
APIPassword string `mapstructure:"api_password"` // API 密码(HTTP接口使用)
|
||||
BaseURL string `mapstructure:"base_url"` // 接口地址
|
||||
Model string `mapstructure:"model"` // 模型版本
|
||||
MaxTokens int `mapstructure:"max_tokens"` // 最大token数
|
||||
Timeout int `mapstructure:"timeout"` // 超时时间(秒)
|
||||
WebSearch bool `mapstructure:"web_search"` // 是否启用联网搜索
|
||||
ShowRefLabel bool `mapstructure:"show_ref_label"` // 是否显示引用标签
|
||||
}
|
||||
|
||||
// TongyiConfig 通义千问配置
|
||||
type TongyiConfig struct {
|
||||
APIKey string `mapstructure:"api_key"` // API Key
|
||||
BaseURL string `mapstructure:"base_url"` // 接口地址
|
||||
Model string `mapstructure:"model"` // 模型名称
|
||||
MaxTokens int `mapstructure:"max_tokens"` // 最大token数
|
||||
Timeout int `mapstructure:"timeout"` // 超时时间(秒)
|
||||
}
|
||||
|
||||
// DoubaoConfig 豆包配置
|
||||
type DoubaoConfig struct {
|
||||
APIKey string `mapstructure:"api_key"` // API Key
|
||||
BaseURL string `mapstructure:"base_url"` // 接口地址
|
||||
Model string `mapstructure:"model"` // 模型名称
|
||||
MaxTokens int `mapstructure:"max_tokens"` // 最大token数
|
||||
Timeout int `mapstructure:"timeout"` // 超时时间(秒)
|
||||
}
|
||||
|
||||
// DeepSeekConfig DeepSeek 配置
|
||||
type DeepSeekConfig struct {
|
||||
APIKey string `mapstructure:"api_key"` // API Key
|
||||
BaseURL string `mapstructure:"base_url"` // 接口地址
|
||||
Model string `mapstructure:"model"` // 模型名称
|
||||
MaxTokens int `mapstructure:"max_tokens"` // 最大token数
|
||||
Timeout int `mapstructure:"timeout"` // 超时时间(秒)
|
||||
}
|
||||
|
||||
// setDefaults 设置 Viper 默认值
|
||||
// @author sunct
|
||||
func setDefaults() {
|
||||
viper.SetDefault("server.host", DefaultServerHost)
|
||||
viper.SetDefault("server.port", DefaultServerPort)
|
||||
viper.SetDefault("server.mode", DefaultServerMode)
|
||||
viper.SetDefault("server.debug", false)
|
||||
|
||||
viper.SetDefault("database.type", DefaultDatabaseType)
|
||||
viper.SetDefault("database.path", DefaultDatabasePath)
|
||||
|
||||
viper.SetDefault("auth.enabled", true)
|
||||
viper.SetDefault("auth.admin_path", DefaultAdminPath)
|
||||
viper.SetDefault("auth.max_login_attempts", DefaultMaxLoginAttempts)
|
||||
viper.SetDefault("auth.lockout_duration", DefaultLockoutDuration)
|
||||
|
||||
viper.SetDefault("template.default", DefaultTemplateDefault)
|
||||
|
||||
viper.SetDefault("log.level", DefaultLogLevel)
|
||||
}
|
||||
|
||||
// LoadConfig 加载并解析配置文件,支持多路径查找
|
||||
// @return *Config 配置实例
|
||||
// @return error 加载错误
|
||||
// @author sunct
|
||||
func LoadConfig() (*Config, error) {
|
||||
setDefaults()
|
||||
|
||||
viper.SetConfigName("config")
|
||||
viper.SetConfigType("yaml")
|
||||
|
||||
viper.AddConfigPath("./config")
|
||||
viper.AddConfigPath(".")
|
||||
viper.AddConfigPath("../config")
|
||||
viper.AddConfigPath("../")
|
||||
|
||||
if err := viper.ReadInConfig(); err != nil {
|
||||
var configFileNotFoundError viper.ConfigFileNotFoundError
|
||||
if !errors.As(err, &configFileNotFoundError) {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
var cfg Config
|
||||
if err := viper.Unmarshal(&cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := validateAndNormalize(&cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
// validateAndNormalize 校验并规范化配置,补全默认值
|
||||
// @param cfg 配置实例
|
||||
// @return error 校验错误
|
||||
// @author sunct
|
||||
func validateAndNormalize(cfg *Config) error {
|
||||
if cfg.Server.Port <= 0 || cfg.Server.Port > 65535 {
|
||||
cfg.Server.Port = DefaultServerPort
|
||||
}
|
||||
if cfg.Server.Host == "" {
|
||||
cfg.Server.Host = DefaultServerHost
|
||||
}
|
||||
if cfg.Server.Mode == "" {
|
||||
cfg.Server.Mode = DefaultServerMode
|
||||
}
|
||||
|
||||
if cfg.Database.Type == "" {
|
||||
cfg.Database.Type = DefaultDatabaseType
|
||||
}
|
||||
if cfg.Database.Path == "" {
|
||||
cfg.Database.Path = DefaultDatabasePath
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(cfg.Auth.AdminPath, "/") {
|
||||
cfg.Auth.AdminPath = "/" + cfg.Auth.AdminPath
|
||||
}
|
||||
if cfg.Auth.AdminPath == "/" {
|
||||
cfg.Auth.AdminPath = DefaultAdminPath
|
||||
}
|
||||
if cfg.Auth.MaxLoginAttempts <= 0 {
|
||||
cfg.Auth.MaxLoginAttempts = DefaultMaxLoginAttempts
|
||||
}
|
||||
if cfg.Auth.LockoutDuration <= 0 {
|
||||
cfg.Auth.LockoutDuration = DefaultLockoutDuration
|
||||
}
|
||||
|
||||
if cfg.Template.Default == "" {
|
||||
cfg.Template.Default = DefaultTemplateDefault
|
||||
}
|
||||
|
||||
if cfg.Log.Level == "" {
|
||||
cfg.Log.Level = DefaultLogLevel
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package constant
|
||||
|
||||
const (
|
||||
RequestIdKey = "request-id"
|
||||
AppNameKey = "app-name"
|
||||
RequestRouteKey = "request-route"
|
||||
ClientIP = "client-ip"
|
||||
)
|
||||
@@ -0,0 +1,76 @@
|
||||
package idgen
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
generator *FlakeIDGenerator
|
||||
once sync.Once
|
||||
)
|
||||
|
||||
type FlakeIDGenerator struct {
|
||||
mu sync.Mutex
|
||||
lastTs int64
|
||||
seq int64
|
||||
nodeID int64
|
||||
}
|
||||
|
||||
func NewFlakeIDGenerator(nodeID int64) *FlakeIDGenerator {
|
||||
return &FlakeIDGenerator{
|
||||
nodeID: nodeID,
|
||||
}
|
||||
}
|
||||
|
||||
func (g *FlakeIDGenerator) GetID() (string, error) {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
|
||||
now := time.Now().UnixNano() / 1e6
|
||||
|
||||
if now < g.lastTs {
|
||||
return "", fmt.Errorf("clock moved backwards")
|
||||
}
|
||||
|
||||
if now == g.lastTs {
|
||||
g.seq++
|
||||
if g.seq >= 1024 {
|
||||
for now <= g.lastTs {
|
||||
now = time.Now().UnixNano() / 1e6
|
||||
}
|
||||
g.seq = 0
|
||||
}
|
||||
} else {
|
||||
g.seq = 0
|
||||
}
|
||||
|
||||
g.lastTs = now
|
||||
|
||||
id := (now << 22) | (g.nodeID << 10) | g.seq
|
||||
return fmt.Sprintf("%d", id), nil
|
||||
}
|
||||
|
||||
func GetID() (string, error) {
|
||||
once.Do(func() {
|
||||
nodeID := int64(1)
|
||||
b := make([]byte, 2)
|
||||
_, err := rand.Read(b)
|
||||
if err == nil {
|
||||
nodeID = int64(b[0])<<8 | int64(b[1])
|
||||
nodeID &= 0x3FF
|
||||
}
|
||||
generator = NewFlakeIDGenerator(nodeID)
|
||||
})
|
||||
return generator.GetID()
|
||||
}
|
||||
|
||||
func MustGetID() string {
|
||||
id, err := GetID()
|
||||
if err != nil {
|
||||
return fmt.Sprintf("%d", time.Now().UnixNano())
|
||||
}
|
||||
return id
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
// Package logger 日志模块,基于 logrus 封装统一的日志接口
|
||||
package logger
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"resume-platform/pkg/constant"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
var (
|
||||
defaultLogger *logrus.Logger // 默认日志实例
|
||||
)
|
||||
|
||||
// Config 日志配置
|
||||
type Config struct {
|
||||
Level string // 日志级别
|
||||
Output string // 输出文件路径
|
||||
}
|
||||
|
||||
// Init 初始化日志系统
|
||||
// @param cfg 日志配置
|
||||
// @return error 初始化错误
|
||||
// @author sunct
|
||||
func Init(cfg Config) error {
|
||||
l := logrus.New()
|
||||
|
||||
level, err := logrus.ParseLevel(cfg.Level)
|
||||
if err != nil {
|
||||
level = logrus.InfoLevel
|
||||
}
|
||||
l.SetLevel(level)
|
||||
l.SetFormatter(&logrus.TextFormatter{
|
||||
FullTimestamp: true,
|
||||
TimestampFormat: "2006-01-02 15:04:05",
|
||||
})
|
||||
|
||||
if cfg.Output != "" {
|
||||
logDir := filepath.Dir(cfg.Output)
|
||||
if logDir != "." && logDir != "" {
|
||||
if err := os.MkdirAll(logDir, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
file, err := os.OpenFile(cfg.Output, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
mw := io.MultiWriter(os.Stdout, file)
|
||||
l.SetOutput(mw)
|
||||
} else {
|
||||
l.SetOutput(os.Stdout)
|
||||
}
|
||||
|
||||
defaultLogger = l
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get 获取默认日志实例
|
||||
// @return *logrus.Logger 日志实例
|
||||
// @author sunct
|
||||
func Get() *logrus.Logger {
|
||||
if defaultLogger == nil {
|
||||
defaultLogger = logrus.New()
|
||||
defaultLogger.SetFormatter(&logrus.TextFormatter{
|
||||
FullTimestamp: true,
|
||||
TimestampFormat: "2006-01-02 15:04:05",
|
||||
})
|
||||
}
|
||||
return defaultLogger
|
||||
}
|
||||
|
||||
// Debug 输出 Debug 级别日志
|
||||
// @param args 日志内容
|
||||
func Debug(args ...interface{}) { Get().Debug(args...) }
|
||||
|
||||
// Debugf 格式化输出 Debug 级别日志
|
||||
// @param format 格式字符串
|
||||
// @param args 格式化参数
|
||||
func Debugf(format string, args ...interface{}) { Get().Debugf(format, args...) }
|
||||
|
||||
// Info 输出 Info 级别日志
|
||||
// @param args 日志内容
|
||||
func Info(args ...interface{}) { Get().Info(args...) }
|
||||
|
||||
// Infof 格式化输出 Info 级别日志
|
||||
// @param format 格式字符串
|
||||
// @param args 格式化参数
|
||||
func Infof(format string, args ...interface{}) { Get().Infof(format, args...) }
|
||||
|
||||
// Warn 输出 Warn 级别日志
|
||||
// @param args 日志内容
|
||||
func Warn(args ...interface{}) { Get().Warn(args...) }
|
||||
|
||||
// Warnf 格式化输出 Warn 级别日志
|
||||
// @param format 格式字符串
|
||||
// @param args 格式化参数
|
||||
func Warnf(format string, args ...interface{}) { Get().Warnf(format, args...) }
|
||||
|
||||
// Error 输出 Error 级别日志
|
||||
// @param args 日志内容
|
||||
func Error(args ...interface{}) { Get().Error(args...) }
|
||||
|
||||
// Errorf 格式化输出 Error 级别日志
|
||||
// @param format 格式字符串
|
||||
// @param args 格式化参数
|
||||
func Errorf(format string, args ...interface{}) { Get().Errorf(format, args...) }
|
||||
|
||||
// Fatal 输出 Fatal 级别日志并退出
|
||||
// @param args 日志内容
|
||||
func Fatal(args ...interface{}) { Get().Fatal(args...) }
|
||||
|
||||
// Fatalf 格式化输出 Fatal 级别日志并退出
|
||||
// @param format 格式字符串
|
||||
// @param args 格式化参数
|
||||
func Fatalf(format string, args ...interface{}) { Get().Fatalf(format, args...) }
|
||||
|
||||
// WithField 添加单字段上下文
|
||||
// @param key 字段名
|
||||
// @param value 字段值
|
||||
// @return *logrus.Entry 带字段的日志条目
|
||||
// @author sunct
|
||||
func WithField(key string, value interface{}) *logrus.Entry {
|
||||
return Get().WithField(key, value)
|
||||
}
|
||||
|
||||
// WithFields 添加多字段上下文
|
||||
// @param fields 字段映射
|
||||
// @return *logrus.Entry 带字段的日志条目
|
||||
// @author sunct
|
||||
func WithFields(fields logrus.Fields) *logrus.Entry {
|
||||
return Get().WithFields(fields)
|
||||
}
|
||||
|
||||
func FromContext(ctx context.Context) *logrus.Entry {
|
||||
entry := Get().WithField("app", "resume-platform")
|
||||
|
||||
if reqId := ctx.Value(constant.RequestIdKey); reqId != nil {
|
||||
entry = entry.WithField("request-id", reqId)
|
||||
}
|
||||
if route := ctx.Value(constant.RequestRouteKey); route != nil {
|
||||
entry = entry.WithField("route", route)
|
||||
}
|
||||
if clientIP := ctx.Value(constant.ClientIP); clientIP != nil {
|
||||
entry = entry.WithField("client-ip", clientIP)
|
||||
}
|
||||
|
||||
return entry
|
||||
}
|
||||
|
||||
func CtxDebug(ctx context.Context, args ...interface{}) {
|
||||
FromContext(ctx).Debug(args...)
|
||||
}
|
||||
|
||||
func CtxDebugf(ctx context.Context, format string, args ...interface{}) {
|
||||
FromContext(ctx).Debugf(format, args...)
|
||||
}
|
||||
|
||||
func CtxInfo(ctx context.Context, args ...interface{}) {
|
||||
FromContext(ctx).Info(args...)
|
||||
}
|
||||
|
||||
func CtxInfof(ctx context.Context, format string, args ...interface{}) {
|
||||
FromContext(ctx).Infof(format, args...)
|
||||
}
|
||||
|
||||
func CtxWarn(ctx context.Context, args ...interface{}) {
|
||||
FromContext(ctx).Warn(args...)
|
||||
}
|
||||
|
||||
func CtxWarnf(ctx context.Context, format string, args ...interface{}) {
|
||||
FromContext(ctx).Warnf(format, args...)
|
||||
}
|
||||
|
||||
func CtxError(ctx context.Context, args ...interface{}) {
|
||||
FromContext(ctx).Error(args...)
|
||||
}
|
||||
|
||||
func CtxErrorf(ctx context.Context, format string, args ...interface{}) {
|
||||
FromContext(ctx).Errorf(format, args...)
|
||||
}
|
||||
|
||||
func CtxFatal(ctx context.Context, args ...interface{}) {
|
||||
FromContext(ctx).Fatal(args...)
|
||||
}
|
||||
|
||||
func CtxFatalf(ctx context.Context, format string, args ...interface{}) {
|
||||
FromContext(ctx).Fatalf(format, args...)
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func CleanAIResponse(response string) string {
|
||||
result := strings.TrimSpace(response)
|
||||
|
||||
for i := 0; i < 10; i++ {
|
||||
changed := false
|
||||
|
||||
if strings.HasPrefix(result, "```json") {
|
||||
result = strings.TrimSpace(strings.TrimPrefix(result, "```json"))
|
||||
changed = true
|
||||
} else if strings.HasPrefix(result, "```") {
|
||||
result = strings.TrimSpace(strings.TrimPrefix(result, "```"))
|
||||
changed = true
|
||||
}
|
||||
|
||||
if strings.HasSuffix(result, "```") {
|
||||
result = strings.TrimSpace(strings.TrimSuffix(result, "```"))
|
||||
changed = true
|
||||
}
|
||||
|
||||
if !changed {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
firstBrace := strings.Index(result, "{")
|
||||
lastBrace := strings.LastIndex(result, "}")
|
||||
if firstBrace >= 0 && lastBrace >= 0 && lastBrace > firstBrace {
|
||||
result = result[firstBrace : lastBrace+1]
|
||||
}
|
||||
|
||||
result = strings.ReplaceAll(result, "`", "")
|
||||
|
||||
result = strings.ReplaceAll(result, "\\n", "\n")
|
||||
result = strings.ReplaceAll(result, "\\r", "\r")
|
||||
result = strings.ReplaceAll(result, "\\t", "\t")
|
||||
|
||||
return strings.TrimSpace(result)
|
||||
}
|
||||
|
||||
func IsValidJSON(s string) bool {
|
||||
var js json.RawMessage
|
||||
return json.Unmarshal([]byte(s), &js) == nil
|
||||
}
|
||||
|
||||
func ExtractJSONFromText(text string) string {
|
||||
firstBrace := strings.Index(text, "{")
|
||||
lastBrace := strings.LastIndex(text, "}")
|
||||
|
||||
if firstBrace >= 0 && lastBrace >= 0 && lastBrace > firstBrace {
|
||||
return text[firstBrace : lastBrace+1]
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package utils
|
||||
|
||||
func Min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func Max(a, b int) int {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func Clamp(value, min, max int) int {
|
||||
if value < min {
|
||||
return min
|
||||
}
|
||||
if value > max {
|
||||
return max
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func Abs(n int) int {
|
||||
if n < 0 {
|
||||
return -n
|
||||
}
|
||||
return n
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
func ConvertToString(v interface{}) string {
|
||||
if v == nil {
|
||||
return ""
|
||||
}
|
||||
switch val := v.(type) {
|
||||
case string:
|
||||
return val
|
||||
case int, int8, int16, int32, int64:
|
||||
return fmt.Sprintf("%d", val)
|
||||
case float32, float64:
|
||||
return fmt.Sprintf("%.0f", val)
|
||||
default:
|
||||
return fmt.Sprintf("%v", val)
|
||||
}
|
||||
}
|
||||
|
||||
func ConvertToStringSlice(v []interface{}) []interface{} {
|
||||
if v == nil {
|
||||
return []interface{}{}
|
||||
}
|
||||
result := make([]interface{}, 0, len(v))
|
||||
for _, item := range v {
|
||||
result = append(result, ConvertToString(item))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func CleanText(text string) string {
|
||||
var builder strings.Builder
|
||||
for _, r := range text {
|
||||
if unicode.IsPrint(r) || r == '\n' || r == '\r' || r == '\t' {
|
||||
builder.WriteRune(r)
|
||||
} else {
|
||||
builder.WriteRune(' ')
|
||||
}
|
||||
}
|
||||
|
||||
result := builder.String()
|
||||
result = strings.ReplaceAll(result, "\r\n", "\n")
|
||||
result = strings.ReplaceAll(result, "\r", "\n")
|
||||
|
||||
for strings.Contains(result, "\n\n\n") {
|
||||
result = strings.ReplaceAll(result, "\n\n\n", "\n\n")
|
||||
}
|
||||
|
||||
for strings.Contains(result, " ") {
|
||||
result = strings.ReplaceAll(result, " ", " ")
|
||||
}
|
||||
|
||||
return strings.TrimSpace(result)
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 380 KiB |
@@ -0,0 +1,97 @@
|
||||
// admin-menus.js - 菜单管理页面功能
|
||||
|
||||
function openAddMenuModal() {
|
||||
document.getElementById('menuId').value = '';
|
||||
document.getElementById('menuName').value = '';
|
||||
document.getElementById('menuIcon').value = '';
|
||||
document.getElementById('menuPath').value = '';
|
||||
document.getElementById('menuSortOrder').value = '0';
|
||||
document.getElementById('menuParentID').value = '';
|
||||
document.getElementById('menuIsEnabled').checked = true;
|
||||
document.getElementById('menuIsFixed').checked = false;
|
||||
document.getElementById('menuModalTitle').textContent = '添加菜单';
|
||||
document.getElementById('menuModal').style.display = 'flex';
|
||||
}
|
||||
|
||||
function closeMenuModal() {
|
||||
document.getElementById('menuModal').style.display = 'none';
|
||||
}
|
||||
|
||||
function editMenu(id) {
|
||||
fetch(`/api/admin/menus/${id}`)
|
||||
.then(response => response.json())
|
||||
.then(menu => {
|
||||
document.getElementById('menuId').value = menu.id;
|
||||
document.getElementById('menuName').value = menu.name;
|
||||
document.getElementById('menuIcon').value = menu.icon || '';
|
||||
document.getElementById('menuPath').value = menu.path;
|
||||
document.getElementById('menuSortOrder').value = menu.sort_order || '0';
|
||||
document.getElementById('menuParentID').value = menu.parent_id || '';
|
||||
document.getElementById('menuIsEnabled').checked = menu.is_enabled;
|
||||
document.getElementById('menuIsFixed').checked = menu.is_fixed;
|
||||
document.getElementById('menuModalTitle').textContent = '编辑菜单';
|
||||
document.getElementById('menuModal').style.display = 'flex';
|
||||
})
|
||||
.catch(error => {
|
||||
showCustomAlert('error', '获取失败', '获取菜单信息失败');
|
||||
});
|
||||
}
|
||||
|
||||
function saveMenu(event) {
|
||||
event.preventDefault();
|
||||
|
||||
const menuId = document.getElementById('menuId').value;
|
||||
const data = {
|
||||
name: document.getElementById('menuName').value,
|
||||
icon: document.getElementById('menuIcon').value,
|
||||
path: document.getElementById('menuPath').value,
|
||||
sort_order: parseInt(document.getElementById('menuSortOrder').value) || 0,
|
||||
parent_id: document.getElementById('menuParentID').value || null,
|
||||
is_enabled: document.getElementById('menuIsEnabled').checked,
|
||||
is_fixed: document.getElementById('menuIsFixed').checked
|
||||
};
|
||||
|
||||
const url = menuId ? `/api/admin/menus/${menuId}` : '/api/admin/menus';
|
||||
const method = menuId ? 'PUT' : 'POST';
|
||||
|
||||
fetch(url, {
|
||||
method: method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(data)
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(result => {
|
||||
if (result.success) {
|
||||
showCustomAlert('success', '保存成功', menuId ? '菜单更新成功' : '菜单添加成功');
|
||||
closeMenuModal();
|
||||
window.location.reload();
|
||||
} else {
|
||||
showCustomAlert('error', '保存失败', result.message || '操作失败');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
showCustomAlert('error', '保存失败', '网络请求失败');
|
||||
});
|
||||
}
|
||||
|
||||
function deleteMenu(id) {
|
||||
if (!confirm('确定要删除该菜单吗?')) return;
|
||||
|
||||
fetch(`/api/admin/menus/${id}`, {
|
||||
method: 'DELETE'
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(result => {
|
||||
if (result.success) {
|
||||
showCustomAlert('success', '删除成功', '菜单删除成功');
|
||||
window.location.reload();
|
||||
} else {
|
||||
showCustomAlert('error', '删除失败', result.message || '操作失败');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
showCustomAlert('error', '删除失败', '网络请求失败');
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
// admin-portfolio.js - 作品集管理页面功能
|
||||
|
||||
function openAddPortfolioModal() {
|
||||
document.getElementById('portfolioId').value = '';
|
||||
document.getElementById('portfolioName').value = '';
|
||||
document.getElementById('portfolioDescription').value = '';
|
||||
document.getElementById('portfolioTechStack').value = '';
|
||||
document.getElementById('portfolioURL').value = '';
|
||||
document.getElementById('portfolioStartDate').value = '';
|
||||
document.getElementById('portfolioEndDate').value = '';
|
||||
document.getElementById('portfolioModalTitle').textContent = '添加项目';
|
||||
document.getElementById('portfolioModal').style.display = 'flex';
|
||||
}
|
||||
|
||||
function closePortfolioModal() {
|
||||
document.getElementById('portfolioModal').style.display = 'none';
|
||||
}
|
||||
|
||||
function editPortfolio(id) {
|
||||
fetch(`/api/admin/portfolio/${id}`)
|
||||
.then(response => response.json())
|
||||
.then(item => {
|
||||
document.getElementById('portfolioId').value = item.id;
|
||||
document.getElementById('portfolioName').value = item.name;
|
||||
document.getElementById('portfolioDescription').value = item.description;
|
||||
document.getElementById('portfolioTechStack').value = item.tech_stack ? item.tech_stack.join(', ') : '';
|
||||
document.getElementById('portfolioURL').value = item.url || '';
|
||||
document.getElementById('portfolioStartDate').value = item.start_date || '';
|
||||
document.getElementById('portfolioEndDate').value = item.end_date || '';
|
||||
document.getElementById('portfolioModalTitle').textContent = '编辑项目';
|
||||
document.getElementById('portfolioModal').style.display = 'flex';
|
||||
})
|
||||
.catch(error => {
|
||||
showCustomAlert('error', '获取失败', '获取项目信息失败');
|
||||
});
|
||||
}
|
||||
|
||||
function savePortfolio(event) {
|
||||
event.preventDefault();
|
||||
|
||||
const portfolioId = document.getElementById('portfolioId').value;
|
||||
const userId = document.getElementById('portfolioUserId').value;
|
||||
const techStack = document.getElementById('portfolioTechStack').value
|
||||
.split(',')
|
||||
.map(s => s.trim())
|
||||
.filter(s => s);
|
||||
|
||||
const data = {
|
||||
user_id: userId,
|
||||
name: document.getElementById('portfolioName').value,
|
||||
description: document.getElementById('portfolioDescription').value,
|
||||
tech_stack: techStack,
|
||||
url: document.getElementById('portfolioURL').value,
|
||||
start_date: document.getElementById('portfolioStartDate').value,
|
||||
end_date: document.getElementById('portfolioEndDate').value
|
||||
};
|
||||
|
||||
const url = portfolioId ? `/api/admin/portfolio/${portfolioId}` : '/api/admin/portfolio';
|
||||
const method = portfolioId ? 'PUT' : 'POST';
|
||||
|
||||
fetch(url, {
|
||||
method: method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(data)
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(result => {
|
||||
if (result.success) {
|
||||
showCustomAlert('success', '保存成功', portfolioId ? '项目更新成功' : '项目添加成功');
|
||||
closePortfolioModal();
|
||||
window.location.reload();
|
||||
} else {
|
||||
showCustomAlert('error', '保存失败', result.message || '操作失败');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
showCustomAlert('error', '保存失败', '网络请求失败');
|
||||
});
|
||||
}
|
||||
|
||||
function deletePortfolio(id) {
|
||||
if (!confirm('确定要删除该项目吗?')) return;
|
||||
|
||||
fetch(`/api/admin/portfolio/${id}`, {
|
||||
method: 'DELETE'
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(result => {
|
||||
if (result.success) {
|
||||
showCustomAlert('success', '删除成功', '项目删除成功');
|
||||
window.location.reload();
|
||||
} else {
|
||||
showCustomAlert('error', '删除失败', result.message || '操作失败');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
showCustomAlert('error', '删除失败', '网络请求失败');
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
// admin-quiz.js - 面试题库页面功能
|
||||
|
||||
function generateQuestions() {
|
||||
showCustomAlert('info', '生成中', '正在生成面试题目,请稍候...');
|
||||
}
|
||||
|
||||
function filterFavorites() {
|
||||
showCustomAlert('info', '筛选收藏', '已筛选收藏的题目');
|
||||
}
|
||||
|
||||
function filterWrong() {
|
||||
showCustomAlert('info', '筛选错题', '已筛选错题');
|
||||
}
|
||||
|
||||
function clearAllRecords() {
|
||||
if (!confirm('确定要清空所有答题记录吗?')) return;
|
||||
showCustomAlert('success', '清空成功', '记录已清空');
|
||||
}
|
||||
|
||||
function deleteFavorite(id) {
|
||||
if (!confirm('确定要删除该收藏吗?')) return;
|
||||
|
||||
fetch(`/api/admin/favorites/${id}`, {
|
||||
method: 'DELETE'
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(result => {
|
||||
if (result.success) {
|
||||
showCustomAlert('success', '删除成功', '收藏已删除');
|
||||
window.location.reload();
|
||||
} else {
|
||||
showCustomAlert('error', '删除失败', result.message || '操作失败');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
showCustomAlert('error', '删除失败', '网络请求失败');
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
// admin-resume.js - 个人简历页面功能
|
||||
|
||||
function createResume(userId) {
|
||||
fetch(`/api/admin/users/${userId}/resume`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
basic_info: {
|
||||
name: '',
|
||||
title: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
location: '',
|
||||
website: '',
|
||||
summary: ''
|
||||
}
|
||||
})
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(result => {
|
||||
if (result.success) {
|
||||
showCustomAlert('success', '创建成功', '简历创建成功');
|
||||
window.location.reload();
|
||||
} else {
|
||||
showCustomAlert('error', '创建失败', result.message || '操作失败');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
showCustomAlert('error', '创建失败', '网络请求失败');
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
// admin-settings.js - 系统设置页面功能
|
||||
|
||||
function saveWebsiteConfig(event) {
|
||||
event.preventDefault();
|
||||
|
||||
const data = {
|
||||
website_domain: document.getElementById('websiteDomain').value,
|
||||
website_title: document.getElementById('websiteTitle').value,
|
||||
website_description: document.getElementById('websiteDesc').value,
|
||||
website_keywords: document.getElementById('websiteKeywords').value,
|
||||
website_logo: document.getElementById('websiteLogo').value
|
||||
};
|
||||
|
||||
fetch('/api/admin/settings/website', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(data)
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(result => {
|
||||
if (result.success) {
|
||||
showCustomAlert('success', '保存成功', '网站配置已保存');
|
||||
} else {
|
||||
showCustomAlert('error', '保存失败', result.message || '操作失败');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
showCustomAlert('error', '保存失败', '网络请求失败');
|
||||
});
|
||||
}
|
||||
|
||||
function saveAIConfig(event) {
|
||||
event.preventDefault();
|
||||
|
||||
const data = {
|
||||
ai_provider: document.getElementById('aiProvider').value,
|
||||
ai_api_key: document.getElementById('aiAPIKey').value,
|
||||
ai_api_secret: document.getElementById('aiAPISecret').value
|
||||
};
|
||||
|
||||
fetch('/api/admin/settings/ai', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(data)
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(result => {
|
||||
if (result.success) {
|
||||
showCustomAlert('success', '保存成功', 'AI配置已保存');
|
||||
} else {
|
||||
showCustomAlert('error', '保存失败', result.message || '操作失败');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
showCustomAlert('error', '保存失败', '网络请求失败');
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
// admin-users.js - 人员管理页面功能
|
||||
|
||||
function openAddUserModal() {
|
||||
document.getElementById('userId').value = '';
|
||||
document.getElementById('userName').value = '';
|
||||
document.getElementById('userUsername').value = '';
|
||||
document.getElementById('userEmail').value = '';
|
||||
document.getElementById('userPhone').value = '';
|
||||
document.getElementById('userRole').value = 'user';
|
||||
document.getElementById('userModalTitle').textContent = '添加用户';
|
||||
document.getElementById('userModal').style.display = 'flex';
|
||||
}
|
||||
|
||||
function closeUserModal() {
|
||||
document.getElementById('userModal').style.display = 'none';
|
||||
}
|
||||
|
||||
function editUser(id) {
|
||||
fetch(`/api/admin/users/${id}`)
|
||||
.then(response => response.json())
|
||||
.then(user => {
|
||||
document.getElementById('userId').value = user.id;
|
||||
document.getElementById('userName').value = user.name;
|
||||
document.getElementById('userUsername').value = user.username;
|
||||
document.getElementById('userEmail').value = user.email;
|
||||
document.getElementById('userPhone').value = user.phone || '';
|
||||
document.getElementById('userRole').value = user.role;
|
||||
document.getElementById('userModalTitle').textContent = '编辑用户';
|
||||
document.getElementById('userModal').style.display = 'flex';
|
||||
})
|
||||
.catch(error => {
|
||||
showCustomAlert('error', '获取失败', '获取用户信息失败');
|
||||
});
|
||||
}
|
||||
|
||||
function saveUser(event) {
|
||||
event.preventDefault();
|
||||
|
||||
const userId = document.getElementById('userId').value;
|
||||
const data = {
|
||||
name: document.getElementById('userName').value,
|
||||
username: document.getElementById('userUsername').value,
|
||||
email: document.getElementById('userEmail').value,
|
||||
phone: document.getElementById('userPhone').value,
|
||||
role: document.getElementById('userRole').value
|
||||
};
|
||||
|
||||
const url = userId ? `/api/admin/users/${userId}` : '/api/admin/users';
|
||||
const method = userId ? 'PUT' : 'POST';
|
||||
|
||||
fetch(url, {
|
||||
method: method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(data)
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(result => {
|
||||
if (result.success) {
|
||||
showCustomAlert('success', '保存成功', userId ? '用户更新成功' : '用户添加成功');
|
||||
closeUserModal();
|
||||
window.location.reload();
|
||||
} else {
|
||||
showCustomAlert('error', '保存失败', result.message || '操作失败');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
showCustomAlert('error', '保存失败', '网络请求失败');
|
||||
});
|
||||
}
|
||||
|
||||
function deleteUser(id) {
|
||||
if (!confirm('确定要删除该用户吗?')) return;
|
||||
|
||||
fetch(`/api/admin/users/${id}`, {
|
||||
method: 'DELETE'
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(result => {
|
||||
if (result.success) {
|
||||
showCustomAlert('success', '删除成功', '用户删除成功');
|
||||
window.location.reload();
|
||||
} else {
|
||||
showCustomAlert('error', '删除失败', result.message || '操作失败');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
showCustomAlert('error', '删除失败', '网络请求失败');
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
// admin.js - 后台管理公共功能模块
|
||||
|
||||
/**
|
||||
* 侧边栏切换
|
||||
*/
|
||||
function toggleSidebar() {
|
||||
const sidebar = document.getElementById('sidebar');
|
||||
const mainArea = document.querySelector('.main-area');
|
||||
const toggleIcon = document.querySelector('.sidebar-toggle i');
|
||||
|
||||
if (sidebar && mainArea && toggleIcon) {
|
||||
if (sidebar.classList.contains('collapsed')) {
|
||||
sidebar.classList.remove('collapsed');
|
||||
mainArea.style.marginLeft = '256px';
|
||||
toggleIcon.className = 'fas fa-chevron-left';
|
||||
} else {
|
||||
sidebar.classList.add('collapsed');
|
||||
mainArea.style.marginLeft = '0';
|
||||
toggleIcon.className = 'fas fa-chevron-right';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 自定义弹窗提示
|
||||
* @param {string} type - 类型: success, error, info, warning
|
||||
* @param {string} title - 标题
|
||||
* @param {string} message - 消息内容
|
||||
*/
|
||||
function showCustomAlert(type, title, message) {
|
||||
const modal = document.getElementById('customAlertModal');
|
||||
const icon = document.getElementById('alertIcon');
|
||||
const alertTitle = document.getElementById('alertTitle');
|
||||
const alertMessage = document.getElementById('alertMessage');
|
||||
|
||||
if (!modal || !icon || !alertTitle || !alertMessage) return;
|
||||
|
||||
const icons = {
|
||||
success: '<i class="fas fa-check-circle" style="color: #10b981;"></i>',
|
||||
error: '<i class="fas fa-exclamation-circle" style="color: #ef4444;"></i>',
|
||||
info: '<i class="fas fa-info-circle" style="color: #3b82f6;"></i>',
|
||||
warning: '<i class="fas fa-exclamation-triangle" style="color: #f59e0b;"></i>'
|
||||
};
|
||||
|
||||
icon.innerHTML = icons[type] || icons.info;
|
||||
alertTitle.textContent = title;
|
||||
alertMessage.textContent = message;
|
||||
modal.style.display = 'flex';
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭自定义弹窗
|
||||
*/
|
||||
function closeCustomAlert() {
|
||||
const modal = document.getElementById('customAlertModal');
|
||||
if (modal) modal.style.display = 'none';
|
||||
}
|
||||
|
||||
/**
|
||||
* HTML转义,防止XSS攻击
|
||||
* @param {string} text - 需要转义的文本
|
||||
* @returns {string} 转义后的文本
|
||||
*/
|
||||
function escapeHtml(text) {
|
||||
if (!text) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证邮箱格式
|
||||
* @param {string} email - 邮箱地址
|
||||
* @returns {boolean} 是否有效
|
||||
*/
|
||||
function isValidEmail(email) {
|
||||
const re = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
return re.test(email);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证手机号格式
|
||||
* @param {string} phone - 手机号码
|
||||
* @returns {boolean} 是否有效
|
||||
*/
|
||||
function isValidPhone(phone) {
|
||||
const re = /^1[3-9]\d{9}$/;
|
||||
return re.test(phone);
|
||||
}
|
||||
|
||||
// 点击模态框外部关闭弹窗
|
||||
document.addEventListener('click', function(e) {
|
||||
const alertModal = document.getElementById('customAlertModal');
|
||||
if (alertModal && e.target === alertModal) {
|
||||
closeCustomAlert();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
function revealEmail(element) {
|
||||
const maskedEmail = element.getAttribute('data-email');
|
||||
if (!maskedEmail) return;
|
||||
|
||||
if (element.classList.contains('revealed')) {
|
||||
window.location.href = 'mailto:' + maskedEmail;
|
||||
return;
|
||||
}
|
||||
|
||||
element.classList.add('revealed');
|
||||
|
||||
const emailParts = maskedEmail.split('@');
|
||||
if (emailParts.length === 2) {
|
||||
element.innerHTML = '<i class="fas fa-envelope"></i> <a href="mailto:' + maskedEmail + '" style="color: inherit; text-decoration: none;">' + maskedEmail + '</a>';
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const emailElements = document.querySelectorAll('.email-protected');
|
||||
emailElements.forEach(function(el) {
|
||||
el.style.cursor = 'pointer';
|
||||
el.title = '点击查看完整邮箱';
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>未授权访问 - 简历管理后台</title>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
|
||||
|
||||
body {
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.error-container {
|
||||
text-align: center;
|
||||
padding: 3.75rem 2.50rem;
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
width: 7.50rem;
|
||||
height: 7.50rem;
|
||||
margin: 0 auto 2rem;
|
||||
border-radius: 2rem;
|
||||
background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 1.25rem 3.75rem rgba(251, 191, 36, 0.3);
|
||||
animation: float 6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.error-icon i {
|
||||
font-size: 3.50rem;
|
||||
color: #d97706;
|
||||
}
|
||||
|
||||
.error-number {
|
||||
font-size: 5rem;
|
||||
font-weight: 700;
|
||||
background: linear-gradient(135deg, #d97706 0%, #f59e0b 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
margin-bottom: 1rem;
|
||||
letter-spacing: -0.25rem;
|
||||
}
|
||||
|
||||
.error-title {
|
||||
font-size: 1.50rem;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.error-description {
|
||||
font-size: 0.94rem;
|
||||
color: #64748b;
|
||||
max-width: 26.25rem;
|
||||
margin: 0 auto 2rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.50rem;
|
||||
padding: 0.88rem 2rem;
|
||||
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 0.88rem;
|
||||
font-size: 0.94rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
box-shadow: 0 0.25rem 0.94rem rgba(99, 102, 241, 0.4);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
transform: translateY(-0.12rem);
|
||||
box-shadow: 0 0.38rem 1.25rem rgba(99, 102, 241, 0.5);
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-0.62rem); }
|
||||
}
|
||||
|
||||
.bg-decoration {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.bg-decoration::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -12.50rem;
|
||||
right: -6.25rem;
|
||||
width: 25rem;
|
||||
height: 25rem;
|
||||
background: radial-gradient(circle, rgba(99, 102, 241, 0.1) 0%, transparent 70%);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.bg-decoration::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -9.38rem;
|
||||
left: -6.25rem;
|
||||
width: 18.75rem;
|
||||
height: 18.75rem;
|
||||
background: radial-gradient(circle, rgba(251, 191, 36, 0.1) 0%, transparent 70%);
|
||||
border-radius: 50%;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="bg-decoration"></div>
|
||||
|
||||
<div class="error-container">
|
||||
<div class="error-icon">
|
||||
<i class="fas fa-lock"></i>
|
||||
</div>
|
||||
|
||||
<div class="error-number">401</div>
|
||||
|
||||
<h1 class="error-title">未授权访问</h1>
|
||||
|
||||
<p class="error-description">
|
||||
您没有权限访问此页面。请先登录后台管理系统,或联系管理员获取访问权限。
|
||||
</p>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,180 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>页面未找到 - 简历管理后台</title>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
|
||||
|
||||
body {
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.error-container {
|
||||
text-align: center;
|
||||
padding: 3.75rem 2.50rem;
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
width: 7.50rem;
|
||||
height: 7.50rem;
|
||||
margin: 0 auto 2rem;
|
||||
border-radius: 2rem;
|
||||
background: linear-gradient(135deg, #cffafe 0%, #a5f3fc 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 1.25rem 3.75rem rgba(6, 182, 212, 0.3);
|
||||
animation: float 6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.error-icon i {
|
||||
font-size: 3.50rem;
|
||||
color: #0891b2;
|
||||
}
|
||||
|
||||
.error-number {
|
||||
font-size: 5rem;
|
||||
font-weight: 700;
|
||||
background: linear-gradient(135deg, #0891b2 0%, #06b6d4 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
margin-bottom: 1rem;
|
||||
letter-spacing: -0.25rem;
|
||||
}
|
||||
|
||||
.error-title {
|
||||
font-size: 1.50rem;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.error-description {
|
||||
font-size: 0.94rem;
|
||||
color: #64748b;
|
||||
max-width: 26.25rem;
|
||||
margin: 0 auto 2rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.50rem;
|
||||
padding: 0.88rem 2rem;
|
||||
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 0.88rem;
|
||||
font-size: 0.94rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
box-shadow: 0 0.25rem 0.94rem rgba(99, 102, 241, 0.4);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
transform: translateY(-0.12rem);
|
||||
box-shadow: 0 0.38rem 1.25rem rgba(99, 102, 241, 0.5);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.50rem;
|
||||
padding: 0.88rem 2rem;
|
||||
background: white;
|
||||
color: #64748b;
|
||||
border: 0.06rem solid #e2e8f0;
|
||||
border-radius: 0.88rem;
|
||||
font-size: 0.94rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
margin-left: 0.75rem;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #f8fafc;
|
||||
border-color: #cbd5e1;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-0.62rem); }
|
||||
}
|
||||
|
||||
.bg-decoration {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.bg-decoration::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -12.50rem;
|
||||
right: -6.25rem;
|
||||
width: 25rem;
|
||||
height: 25rem;
|
||||
background: radial-gradient(circle, rgba(99, 102, 241, 0.1) 0%, transparent 70%);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.bg-decoration::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -9.38rem;
|
||||
left: -6.25rem;
|
||||
width: 18.75rem;
|
||||
height: 18.75rem;
|
||||
background: radial-gradient(circle, rgba(6, 182, 212, 0.1) 0%, transparent 70%);
|
||||
border-radius: 50%;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="bg-decoration"></div>
|
||||
|
||||
<div class="error-container">
|
||||
<div class="error-icon">
|
||||
<i class="fas fa-search"></i>
|
||||
</div>
|
||||
|
||||
<div class="error-number">404</div>
|
||||
|
||||
<h1 class="error-title">页面未找到</h1>
|
||||
|
||||
<p class="error-description">
|
||||
您访问的页面不存在或已被移除。请检查网址是否正确,或返回后台首页继续操作。
|
||||
</p>
|
||||
|
||||
<div class="flex items-center justify-center gap-3">
|
||||
<a href="/dashboard/home" class="btn-primary">
|
||||
<i class="fas fa-home"></i> 返回后台首页
|
||||
</a>
|
||||
<button onclick="history.back()" class="btn-secondary">
|
||||
<i class="fas fa-arrow-left"></i> 返回上一页
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,277 @@
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-5">
|
||||
<div class="stat-card bg-gradient-to-br from-blue-500 to-blue-600">
|
||||
<div class="stat-icon">
|
||||
<i class="fas fa-users"></i>
|
||||
</div>
|
||||
<div>
|
||||
<p class="stat-label">总用户数</p>
|
||||
<h3 class="stat-number">{{.Stats.TotalUsers}}</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card bg-gradient-to-br from-emerald-500 to-emerald-600">
|
||||
<div class="stat-icon">
|
||||
<i class="fas fa-file-alt"></i>
|
||||
</div>
|
||||
<div>
|
||||
<p class="stat-label">总简历数</p>
|
||||
<h3 class="stat-number">{{.Stats.TotalResumes}}</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card bg-gradient-to-br from-violet-500 to-violet-600">
|
||||
<div class="stat-icon">
|
||||
<i class="fas fa-folder-open"></i>
|
||||
</div>
|
||||
<div>
|
||||
<p class="stat-label">作品集项目</p>
|
||||
<h3 class="stat-number">{{.Stats.TotalPortfolio}}</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card bg-gradient-to-br from-orange-500 to-orange-600">
|
||||
<div class="stat-icon">
|
||||
<i class="fas fa-clipboard-list"></i>
|
||||
</div>
|
||||
<div>
|
||||
<p class="stat-label">答题记录</p>
|
||||
<h3 class="stat-number">{{.Stats.TotalQuizRecords}}</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-5 mt-5">
|
||||
<div class="bg-white rounded-xl shadow-sm border border-slate-100 p-5 flex items-center gap-4">
|
||||
<div class="w-12 h-12 rounded-lg bg-amber-50 flex items-center justify-center text-amber-500">
|
||||
<i class="fas fa-star text-xl"></i>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-slate-500">收藏题目</p>
|
||||
<h4 class="text-xl font-bold text-slate-800">{{.Stats.TotalFavorites}}</h4>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white rounded-xl shadow-sm border border-slate-100 p-5 flex items-center gap-4">
|
||||
<div class="w-12 h-12 rounded-lg bg-rose-50 flex items-center justify-center text-rose-500">
|
||||
<i class="fas fa-times-circle text-xl"></i>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-slate-500">错题总数</p>
|
||||
<h4 class="text-xl font-bold text-slate-800">{{.Stats.TotalWrongAnswers}}</h4>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white rounded-xl shadow-sm border border-slate-100 p-5 flex items-center gap-4">
|
||||
<div class="w-12 h-12 rounded-lg bg-sky-50 flex items-center justify-center text-sky-500">
|
||||
<i class="fas fa-clock text-xl"></i>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-slate-500">运行时长</p>
|
||||
<h4 class="text-xl font-bold text-slate-800">{{.Uptime}}</h4>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 grid grid-cols-1 lg:grid-cols-3 gap-5">
|
||||
<div class="lg:col-span-2 bg-white rounded-xl shadow-sm border border-slate-100 overflow-hidden">
|
||||
<div class="px-5 py-4 border-b border-slate-100 flex items-center justify-between">
|
||||
<h3 class="font-semibold text-slate-800 flex items-center gap-2">
|
||||
<i class="fas fa-users text-blue-500"></i> 最近用户
|
||||
</h3>
|
||||
<a href="{{.AdminPath}}/users" class="btn btn-secondary btn-sm">
|
||||
<i class="fas fa-list"></i> 查看全部
|
||||
</a>
|
||||
</div>
|
||||
<div class="divide-y divide-slate-50">
|
||||
{{ range .RecentUsers }}
|
||||
<div class="px-5 py-4 flex items-center gap-4 hover:bg-slate-50 transition-colors">
|
||||
<div class="w-10 h-10 rounded-full bg-gradient-to-br from-blue-400 to-blue-600 flex items-center justify-center text-white font-medium text-sm flex-shrink-0">
|
||||
{{if .Name}}{{substr .Name 0 1}}{{else}}U{{end}}
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="font-medium text-slate-800 truncate">{{.Name}}</p>
|
||||
<p class="text-sm text-slate-500 truncate">{{.Email}}</p>
|
||||
</div>
|
||||
<div class="flex gap-2 flex-shrink-0">
|
||||
<a href="{{$.AdminPath}}/user/{{.ID}}/resume" class="btn btn-primary btn-xs">
|
||||
<i class="fas fa-file-alt"></i> 简历
|
||||
</a>
|
||||
<a href="{{$.AdminPath}}/user/{{.ID}}/portfolio" class="btn btn-info btn-xs">
|
||||
<i class="fas fa-folder-open"></i> 作品集
|
||||
</a>
|
||||
<a href="{{$.AdminPath}}/user/{{.ID}}/quiz" class="btn btn-success btn-xs">
|
||||
<i class="fas fa-question-circle"></i> 答题
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{{ else }}
|
||||
<div class="text-center py-12">
|
||||
<div class="w-16 h-16 mx-auto mb-4 rounded-full bg-slate-100 flex items-center justify-center">
|
||||
<i class="fas fa-users text-slate-400 text-2xl"></i>
|
||||
</div>
|
||||
<p class="text-slate-500">暂无用户</p>
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white rounded-xl shadow-sm border border-slate-100 overflow-hidden">
|
||||
<div class="px-5 py-4 border-b border-slate-100 flex items-center justify-between">
|
||||
<h3 class="font-semibold text-slate-800 flex items-center gap-2">
|
||||
<i class="fas fa-file-alt text-emerald-500"></i> 最近简历
|
||||
</h3>
|
||||
<a href="{{.AdminPath}}/resumes" class="btn btn-secondary btn-sm">
|
||||
<i class="fas fa-list"></i> 全部
|
||||
</a>
|
||||
</div>
|
||||
<div class="divide-y divide-slate-50">
|
||||
{{ range .RecentResumes }}
|
||||
<div class="px-5 py-4 flex items-center gap-3 hover:bg-slate-50 transition-colors">
|
||||
<div class="w-10 h-10 rounded-lg bg-gradient-to-br from-emerald-400 to-emerald-600 flex items-center justify-center text-white flex-shrink-0">
|
||||
<i class="fas fa-file-alt"></i>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="font-medium text-slate-800 truncate text-sm">{{.BasicInfo.Name}}</p>
|
||||
<p class="text-xs text-slate-500 truncate">{{.BasicInfo.Title}}</p>
|
||||
</div>
|
||||
<a href="/{{.Route}}" target="_blank" class="text-blue-500 hover:text-blue-600 text-sm flex-shrink-0">
|
||||
<i class="fas fa-external-link-alt"></i>
|
||||
</a>
|
||||
</div>
|
||||
{{ else }}
|
||||
<div class="text-center py-12">
|
||||
<div class="w-16 h-16 mx-auto mb-4 rounded-full bg-slate-100 flex items-center justify-center">
|
||||
<i class="fas fa-file-alt text-slate-400 text-2xl"></i>
|
||||
</div>
|
||||
<p class="text-slate-500">暂无简历</p>
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-5">
|
||||
<a href="{{.AdminPath}}/users" class="quick-action-card">
|
||||
<div class="quick-action-icon bg-blue-50 text-blue-500">
|
||||
<i class="fas fa-user-plus"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-medium text-slate-800">用户管理</h4>
|
||||
<p class="text-sm text-slate-500">添加/编辑用户信息</p>
|
||||
</div>
|
||||
<i class="fas fa-chevron-right text-slate-300"></i>
|
||||
</a>
|
||||
<a href="{{.AdminPath}}/resumes" class="quick-action-card">
|
||||
<div class="quick-action-icon bg-emerald-50 text-emerald-500">
|
||||
<i class="fas fa-file-medical"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-medium text-slate-800">简历管理</h4>
|
||||
<p class="text-sm text-slate-500">查看和编辑简历</p>
|
||||
</div>
|
||||
<i class="fas fa-chevron-right text-slate-300"></i>
|
||||
</a>
|
||||
<a href="{{.AdminPath}}/portfolios" class="quick-action-card">
|
||||
<div class="quick-action-icon bg-violet-50 text-violet-500">
|
||||
<i class="fas fa-folder-plus"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-medium text-slate-800">作品集管理</h4>
|
||||
<p class="text-sm text-slate-500">管理项目作品集</p>
|
||||
</div>
|
||||
<i class="fas fa-chevron-right text-slate-300"></i>
|
||||
</a>
|
||||
<a href="{{.AdminPath}}/settings" class="quick-action-card">
|
||||
<div class="quick-action-icon bg-slate-50 text-slate-500">
|
||||
<i class="fas fa-cog"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-medium text-slate-800">系统设置</h4>
|
||||
<p class="text-sm text-slate-500">配置系统参数</p>
|
||||
</div>
|
||||
<i class="fas fa-chevron-right text-slate-300"></i>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.stat-card {
|
||||
position: relative;
|
||||
border-radius: 0.5rem;
|
||||
padding: 1.25rem;
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 0.25rem 0.38rem -0.06rem rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
.stat-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -50%;
|
||||
right: -20%;
|
||||
width: 7.50rem;
|
||||
height: 7.50rem;
|
||||
background: rgba(255,255,255,0.1);
|
||||
border-radius: 50%;
|
||||
}
|
||||
.stat-card::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -30%;
|
||||
right: 10%;
|
||||
width: 5rem;
|
||||
height: 5rem;
|
||||
background: rgba(255,255,255,0.08);
|
||||
border-radius: 50%;
|
||||
}
|
||||
.stat-icon {
|
||||
width: 3.25rem;
|
||||
height: 3.25rem;
|
||||
background: rgba(255,255,255,0.2);
|
||||
border-radius: 0.75rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.5rem;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
backdrop-filter: blur(0.25rem);
|
||||
}
|
||||
.stat-label {
|
||||
font-size: 0.875rem;
|
||||
opacity: 0.9;
|
||||
margin-bottom: 0.25rem;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
.stat-number {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.quick-action-card {
|
||||
background: white;
|
||||
border: 0.06rem solid #f1f5f9;
|
||||
border-radius: 0.75rem;
|
||||
padding: 1.25rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
text-decoration: none;
|
||||
transition: all 0.2s ease;
|
||||
box-shadow: 0 0.06rem 0.12rem rgba(0,0,0,0.04);
|
||||
}
|
||||
.quick-action-card:hover {
|
||||
border-color: #e2e8f0;
|
||||
transform: translateY(-0.12rem);
|
||||
box-shadow: 0 0.25rem 0.75rem rgba(0,0,0,0.06);
|
||||
}
|
||||
.quick-action-icon {
|
||||
width: 2.75rem;
|
||||
height: 2.75rem;
|
||||
border-radius: 0.625rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.125rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,483 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{ .SystemConfig.WebsiteTitle }} - 后台登录</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||
<style>
|
||||
:root {
|
||||
--primary: #7B8EA2;
|
||||
--primary-hover: #6B7E92;
|
||||
--accent: #987E68;
|
||||
--text-primary: #2F343A;
|
||||
--text-secondary: #6C737C;
|
||||
--text-muted: #9CA3AF;
|
||||
--bg: #F8F7F4;
|
||||
--bg-card: #FFFFFF;
|
||||
--border: #E5E3DF;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Noto Sans SC', sans-serif;
|
||||
background-color: var(--bg);
|
||||
color: var(--text-primary);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.login-container {
|
||||
width: 100%;
|
||||
max-width: 26.25rem;
|
||||
padding: 3rem;
|
||||
background: var(--bg-card);
|
||||
border-radius: 1rem;
|
||||
box-shadow: 0 0.25rem 1.50rem rgba(47, 52, 58, 0.08);
|
||||
border: 0.06rem solid var(--border);
|
||||
}
|
||||
|
||||
.brand-section {
|
||||
text-align: center;
|
||||
margin-bottom: 2.50rem;
|
||||
}
|
||||
|
||||
.brand-logo {
|
||||
width: 3.50rem;
|
||||
height: 3.50rem;
|
||||
margin: 0 auto 1.25rem;
|
||||
background: var(--primary);
|
||||
border-radius: 0.75rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-size: 1.50rem;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.brand-logo img {
|
||||
border-radius: 0.50rem;
|
||||
}
|
||||
|
||||
.brand-logo:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.brand-title {
|
||||
font-size: 1.38rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 0.38rem;
|
||||
letter-spacing: 0.12rem;
|
||||
}
|
||||
|
||||
.brand-subtitle {
|
||||
font-size: 0.81rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.input-group {
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.input-group label {
|
||||
display: block;
|
||||
font-size: 0.81rem;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 0.50rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.input-group input {
|
||||
width: 100%;
|
||||
padding: 0.75rem 0.88rem;
|
||||
border: 0.06rem solid var(--border);
|
||||
border-radius: 0.50rem;
|
||||
font-size: 0.88rem;
|
||||
color: var(--text-primary);
|
||||
background: var(--bg);
|
||||
transition: all 0.25s ease;
|
||||
outline: none;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.input-group input:focus {
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 0.19rem rgba(123, 142, 162, 0.1);
|
||||
background: white;
|
||||
}
|
||||
|
||||
.input-group input::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.input-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.input-icon {
|
||||
position: absolute;
|
||||
left: 0.75rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
font-size: 0.88rem;
|
||||
color: var(--text-muted);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.input-group input {
|
||||
padding-left: 2.50rem;
|
||||
}
|
||||
|
||||
.input-toggle {
|
||||
position: absolute;
|
||||
right: 0.75rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
font-size: 0.88rem;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.input-toggle:hover {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.captcha-row {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.captcha-row .input-group {
|
||||
flex: 1;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.captcha-img {
|
||||
width: 6.25rem;
|
||||
height: 2.75rem;
|
||||
border-radius: 0.50rem;
|
||||
cursor: pointer;
|
||||
border: 0.06rem solid var(--border);
|
||||
background: var(--bg);
|
||||
transition: all 0.25s ease;
|
||||
}
|
||||
|
||||
.captcha-img:hover {
|
||||
border-color: var(--primary);
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.btn-login {
|
||||
width: 100%;
|
||||
padding: 0.81rem;
|
||||
border: none;
|
||||
border-radius: 0.50rem;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 500;
|
||||
color: white;
|
||||
background: var(--primary);
|
||||
cursor: pointer;
|
||||
transition: all 0.25s ease;
|
||||
margin-top: 1.50rem;
|
||||
letter-spacing: 0.06rem;
|
||||
}
|
||||
|
||||
.btn-login:hover {
|
||||
background: var(--primary-hover);
|
||||
transform: translateY(-0.06rem);
|
||||
box-shadow: 0 0.25rem 0.75rem rgba(123, 142, 162, 0.25);
|
||||
}
|
||||
|
||||
.btn-login:active {
|
||||
transform: translateY(0);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.btn-login:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.footer-text {
|
||||
text-align: center;
|
||||
margin-top: 2rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.alert-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(47, 52, 58, 0.4);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.alert-overlay.show {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.alert-box {
|
||||
background: white;
|
||||
border-radius: 0.75rem;
|
||||
padding: 2rem;
|
||||
max-width: 22.50rem;
|
||||
width: 90%;
|
||||
text-align: center;
|
||||
box-shadow: 0 0.75rem 2.50rem rgba(47, 52, 58, 0.15);
|
||||
transform: scale(0.9) translateY(1.25rem);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.alert-overlay.show .alert-box {
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
|
||||
.alert-icon {
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
margin: 0 auto 1rem;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.38rem;
|
||||
}
|
||||
|
||||
.alert-icon.error {
|
||||
background: #FEF2F2;
|
||||
color: #DC2626;
|
||||
}
|
||||
|
||||
.alert-icon.success {
|
||||
background: #ECFDF5;
|
||||
color: #16A34A;
|
||||
}
|
||||
|
||||
.alert-icon.info {
|
||||
background: #EFF6FF;
|
||||
color: #2563EB;
|
||||
}
|
||||
|
||||
.alert-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 0.50rem;
|
||||
}
|
||||
|
||||
.alert-message {
|
||||
font-size: 0.81rem;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 1.25rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.btn-alert {
|
||||
padding: 0.62rem 1.50rem;
|
||||
border: none;
|
||||
border-radius: 0.38rem;
|
||||
font-size: 0.81rem;
|
||||
font-weight: 500;
|
||||
color: white;
|
||||
background: var(--primary);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.btn-alert:hover {
|
||||
background: var(--primary-hover);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="login-container">
|
||||
<div class="brand-section">
|
||||
<div class="brand-logo">
|
||||
{{ if .SystemConfig.LogoPath }}
|
||||
<img src="{{.SystemConfig.LogoPath}}" alt="Logo">
|
||||
{{ else }}
|
||||
<i class="fas fa-file-alt"></i>
|
||||
{{ end }}
|
||||
</div>
|
||||
<div class="brand-title">{{ .SystemConfig.WebsiteTitle }}</div>
|
||||
<div class="brand-subtitle">{{ .SystemConfig.WebsiteDesc }}</div>
|
||||
</div>
|
||||
|
||||
<form id="loginForm" onsubmit="login(event)">
|
||||
<div class="input-group">
|
||||
<label for="username">用户名</label>
|
||||
<div class="input-wrapper">
|
||||
<i class="fas fa-user input-icon"></i>
|
||||
<input type="text" id="username" placeholder="请输入用户名" required>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="input-group">
|
||||
<label for="password">密码</label>
|
||||
<div class="input-wrapper">
|
||||
<i class="fas fa-lock input-icon"></i>
|
||||
<input type="password" id="password" placeholder="请输入密码" required>
|
||||
<i class="fas fa-eye input-toggle" id="passwordToggle" onclick="togglePassword()"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="captcha-row">
|
||||
<div class="input-group">
|
||||
<label for="captcha">验证码</label>
|
||||
<div class="input-wrapper">
|
||||
<i class="fas fa-shield-halved input-icon"></i>
|
||||
<input type="text" id="captcha" placeholder="验证码" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col justify-center">
|
||||
<img id="captchaImg" src="" alt="验证码" class="captcha-img" onclick="refreshCaptcha()">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" id="loginBtn" class="btn-login">登 录</button>
|
||||
</form>
|
||||
|
||||
<div class="footer-text">
|
||||
© 2026 {{ .SystemConfig.WebsiteTitle }}. All rights reserved.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="customAlert" class="alert-overlay">
|
||||
<div class="alert-box" id="alertContent">
|
||||
<div id="alertIcon" class="alert-icon"></div>
|
||||
<h3 id="alertTitle" class="alert-title"></h3>
|
||||
<p id="alertMessage" class="alert-message"></p>
|
||||
<button onclick="closeCustomAlert()" class="btn-alert">确定</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const adminPath = "{{.adminPath}}";
|
||||
let captchaToken = '';
|
||||
|
||||
function togglePassword() {
|
||||
const password = document.getElementById('password');
|
||||
const toggle = document.getElementById('passwordToggle');
|
||||
if (password.type === 'password') {
|
||||
password.type = 'text';
|
||||
toggle.classList.remove('fa-eye');
|
||||
toggle.classList.add('fa-eye-slash');
|
||||
} else {
|
||||
password.type = 'password';
|
||||
toggle.classList.remove('fa-eye-slash');
|
||||
toggle.classList.add('fa-eye');
|
||||
}
|
||||
}
|
||||
|
||||
function refreshCaptcha() {
|
||||
const img = document.getElementById('captchaImg');
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('GET', adminPath + '/captcha?' + Date.now(), true);
|
||||
xhr.responseType = 'blob';
|
||||
xhr.onload = function() {
|
||||
if (this.status === 200) {
|
||||
captchaToken = this.getResponseHeader('X-Captcha-Token') || '';
|
||||
const blob = this.response;
|
||||
img.src = URL.createObjectURL(blob);
|
||||
}
|
||||
};
|
||||
xhr.send();
|
||||
}
|
||||
|
||||
function showCustomAlert(title, message, type) {
|
||||
const alert = document.getElementById('customAlert');
|
||||
const icon = document.getElementById('alertIcon');
|
||||
const titleEl = document.getElementById('alertTitle');
|
||||
const messageEl = document.getElementById('alertMessage');
|
||||
|
||||
titleEl.textContent = title;
|
||||
messageEl.textContent = message;
|
||||
|
||||
icon.className = 'alert-icon ' + type;
|
||||
if (type === 'error') {
|
||||
icon.innerHTML = '<i class="fas fa-times-circle"></i>';
|
||||
} else if (type === 'success') {
|
||||
icon.innerHTML = '<i class="fas fa-check-circle"></i>';
|
||||
} else {
|
||||
icon.innerHTML = '<i class="fas fa-info-circle"></i>';
|
||||
}
|
||||
|
||||
alert.classList.add('show');
|
||||
}
|
||||
|
||||
function closeCustomAlert() {
|
||||
const alert = document.getElementById('customAlert');
|
||||
alert.classList.remove('show');
|
||||
}
|
||||
|
||||
async function login(event) {
|
||||
event.preventDefault();
|
||||
|
||||
const btn = document.getElementById('loginBtn');
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<i class="fas fa-spinner fa-spin mr-2"></i> 登录中...';
|
||||
|
||||
const username = document.getElementById('username').value;
|
||||
const password = document.getElementById('password').value;
|
||||
const captcha = document.getElementById('captcha').value;
|
||||
|
||||
try {
|
||||
const response = await fetch(adminPath + '/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
username,
|
||||
password,
|
||||
captcha,
|
||||
captcha_token: captchaToken
|
||||
})
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
showCustomAlert('登录成功', '正在跳转...', 'success');
|
||||
setTimeout(() => {
|
||||
window.location.href = result.redirect || '/dashboard/home';
|
||||
}, 1500);
|
||||
} else {
|
||||
showCustomAlert('登录失败', result.message || '请检查用户名、密码和验证码', 'error');
|
||||
refreshCaptcha();
|
||||
}
|
||||
} catch (error) {
|
||||
showCustomAlert('网络错误', '无法连接服务器,请稍后重试', 'error');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '登 录';
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('load', function() {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('GET', adminPath + '/captcha', true);
|
||||
xhr.responseType = 'blob';
|
||||
xhr.onload = function() {
|
||||
if (this.status === 200) {
|
||||
captchaToken = this.getResponseHeader('X-Captcha-Token') || '';
|
||||
const blob = this.response;
|
||||
document.getElementById('captchaImg').src = URL.createObjectURL(blob);
|
||||
}
|
||||
};
|
||||
xhr.send();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,290 @@
|
||||
<div class="bg-white rounded-xl shadow-sm border border-slate-200 overflow-hidden">
|
||||
<div class="border-b border-slate-200 px-6 py-4 bg-slate-50">
|
||||
<div>
|
||||
<h3 class="font-semibold text-slate-800 flex items-center gap-2">
|
||||
<i class="fas fa-folder-open text-purple-500"></i> {{ if .Item.ID }}编辑项目{{ else }}新增项目{{ end }}
|
||||
</h3>
|
||||
<p class="text-sm text-slate-500 mt-1">{{ if .Item.ID }}修改作品集项目信息{{ else }}创建新的作品集项目{{ end }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<form id="portfolioForm">
|
||||
<input type="hidden" id="portfolioId" value="{{.Item.ID}}">
|
||||
<div class="space-y-6">
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700 mb-1">选择人员 <span class="text-red-500">*</span></label>
|
||||
<select id="portfolioUserId" class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500" required>
|
||||
<option value="">请选择人员</option>
|
||||
{{ range .Users }}
|
||||
<option value="{{.ID}}" {{ if eq .ID $.Item.UserID }}selected{{ end }}>{{.Name}} - {{.Email}}</option>
|
||||
{{ end }}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700 mb-1">项目名称 <span class="text-red-500">*</span></label>
|
||||
<input type="text" id="portfolioName" class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500" value="{{.Item.Name}}" required>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700 mb-1">项目描述</label>
|
||||
<textarea id="portfolioDescription" rows="3" class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500">{{.Item.Description}}</textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700 mb-1">技术栈</label>
|
||||
<input type="text" id="portfolioTechStack" class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500" value="{{ join .Item.TechStack "," }}" placeholder="多个技术栈用逗号分隔">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700 mb-1">项目链接</label>
|
||||
<input type="url" id="portfolioUrl" class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500" value="{{.Item.URL}}" placeholder="https://">
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700 mb-1">开始日期</label>
|
||||
<input type="date" id="portfolioStartDate" class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500" value="{{.Item.StartDate}}">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700 mb-1">结束日期</label>
|
||||
<input type="date" id="portfolioEndDate" class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500" value="{{.Item.EndDate}}">
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700 mb-1">访问密码</label>
|
||||
<input type="password" id="portfolioPassword" class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500" value="{{.Item.Password}}" placeholder="留空则无需密码">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700 mb-1">关联简历项目ID</label>
|
||||
<div class="space-y-2">
|
||||
<select id="portfolioProjectResumeId" class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500">
|
||||
<option value="">请选择简历</option>
|
||||
{{ range .Resumes }}
|
||||
<option value="{{.ID}}" {{ if eq .ID $.Item.ResumeID }}selected{{ end }}>{{.BasicInfo.Name}} - {{.BasicInfo.Title}}</option>
|
||||
{{ end }}
|
||||
</select>
|
||||
<select id="portfolioProjectId" class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500">
|
||||
<option value="">请选择项目</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" id="portfolioShowInResume" {{ if .Item.ShowInResume }}checked{{ end }} class="w-4 h-4 text-primary-600 rounded focus:ring-primary-500">
|
||||
<span class="text-sm font-medium text-slate-700">在简历中显示</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" id="portfolioHidden" {{ if .Item.Hidden }}checked{{ end }} class="w-4 h-4 text-primary-600 rounded focus:ring-2 focus:ring-primary-500">
|
||||
<span class="text-sm font-medium text-slate-700">隐藏项目</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6" style="height: calc(100vh - 26.25rem); min-height: 31.25rem;">
|
||||
<div class="flex flex-col">
|
||||
<label class="block text-sm font-medium text-slate-700 mb-1">项目文档 <span class="text-slate-400 text-xs">(Markdown格式,支持图片、表格、流程图)</span></label>
|
||||
<div class="flex-1 relative overflow-hidden rounded-lg border border-slate-300">
|
||||
<textarea id="portfolioDoc" class="w-full h-full px-4 py-2 font-mono text-sm resize-none">{{.Item.Doc}}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<label class="block text-sm font-medium text-slate-700 mb-1">预览</label>
|
||||
<div id="docPreview" class="flex-1 p-4 border border-slate-200 rounded-lg bg-slate-50 markdown-body overflow-y-auto"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="fixed bottom-0 left-0 right-0 bg-white border-t border-slate-200 px-6 py-4 shadow-lg z-50">
|
||||
<div class="flex items-center justify-center gap-3">
|
||||
<button onclick="goBack()" class="btn btn-secondary">
|
||||
<i class="fas fa-arrow-left"></i> 返回
|
||||
</button>
|
||||
<button onclick="savePortfolio()" class="btn btn-primary">
|
||||
<i class="fas fa-save"></i> 保存
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="height: 5rem;"></div>
|
||||
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/simplemde/latest/simplemde.min.css">
|
||||
<script src="https://cdn.jsdelivr.net/simplemde/latest/simplemde.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/github-markdown-css/5.5.0/github-markdown.min.css">
|
||||
<script>
|
||||
let simplemde;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
simplemde = new SimpleMDE({
|
||||
element: document.getElementById('portfolioDoc'),
|
||||
toolbar: [
|
||||
'bold', 'italic', 'strikethrough', 'heading',
|
||||
'heading-smaller', 'heading-bigger', 'heading-1', 'heading-2', 'heading-3',
|
||||
'|',
|
||||
'code', 'quote', 'unordered-list', 'ordered-list',
|
||||
'|',
|
||||
'link', 'image', 'table', 'horizontal-rule',
|
||||
'|',
|
||||
'preview', 'side-by-side', 'fullscreen',
|
||||
'|',
|
||||
'guide'
|
||||
],
|
||||
spellChecker: false,
|
||||
autoDownloadFontAwesome: true,
|
||||
previewRender: function(plainText) {
|
||||
return marked.parse(plainText) || '';
|
||||
}
|
||||
});
|
||||
|
||||
simplemde.codemirror.on('change', function() {
|
||||
const text = simplemde.value();
|
||||
const preview = document.getElementById('docPreview');
|
||||
try {
|
||||
preview.innerHTML = marked.parse(text) || '<p class="text-slate-400">暂无内容</p>';
|
||||
} catch (err) {
|
||||
preview.innerHTML = '<p class="text-red-500">预览解析错误</p>';
|
||||
}
|
||||
});
|
||||
|
||||
const initialText = simplemde.value();
|
||||
if (initialText) {
|
||||
try {
|
||||
document.getElementById('docPreview').innerHTML = marked.parse(initialText) || '<p class="text-slate-400">暂无内容</p>';
|
||||
} catch (err) {
|
||||
document.getElementById('docPreview').innerHTML = '<p class="text-red-500">预览解析错误</p>';
|
||||
}
|
||||
} else {
|
||||
document.getElementById('docPreview').innerHTML = '<p class="text-slate-400">暂无内容,开始编写项目文档...</p>';
|
||||
}
|
||||
|
||||
document.getElementById('portfolioUserId').addEventListener('change', function() {
|
||||
loadResumesByUser(this.value, '');
|
||||
});
|
||||
|
||||
document.getElementById('portfolioProjectResumeId').addEventListener('change', function() {
|
||||
loadProjectsByResume(this.value, 0);
|
||||
});
|
||||
|
||||
const userId = document.getElementById('portfolioUserId').value;
|
||||
const savedResumeId = "{{.Item.ResumeID}}";
|
||||
const savedProjectId = {{ if .Item.ProjectID }}{{.Item.ProjectID}}{{ else }}0{{ end }};
|
||||
if (userId) {
|
||||
loadResumesByUser(userId, savedResumeId, savedProjectId);
|
||||
}
|
||||
});
|
||||
|
||||
function loadResumesByUser(userId, selectedResumeId, autoLoadProjectId) {
|
||||
const resumeSelect = document.getElementById('portfolioProjectResumeId');
|
||||
resumeSelect.innerHTML = '<option value="">请选择简历</option>';
|
||||
|
||||
if (!userId) return;
|
||||
|
||||
fetch('/api/user/resumes?user_id=' + userId)
|
||||
.then(response => response.json())
|
||||
.then(result => {
|
||||
if (!result.success) return;
|
||||
const resumes = result.data || [];
|
||||
resumes.forEach(resume => {
|
||||
const option = document.createElement('option');
|
||||
option.value = resume.id;
|
||||
option.textContent = (resume.basic_info.name || '未命名') + ' - ' + (resume.basic_info.title || '');
|
||||
if (selectedResumeId && resume.id === selectedResumeId) {
|
||||
option.selected = true;
|
||||
}
|
||||
resumeSelect.appendChild(option);
|
||||
});
|
||||
// 恢复选中后,按需加载该简历下的项目
|
||||
if (selectedResumeId && autoLoadProjectId) {
|
||||
loadProjectsByResume(selectedResumeId, autoLoadProjectId);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
function loadProjectsByResume(resumeId, selectedProjectId) {
|
||||
const projectSelect = document.getElementById('portfolioProjectId');
|
||||
projectSelect.innerHTML = '<option value="">请选择项目</option>';
|
||||
|
||||
if (!resumeId) return;
|
||||
|
||||
fetch('/dashboard/api/resume/' + resumeId)
|
||||
.then(response => response.json())
|
||||
.then(result => {
|
||||
if (!result.success) return;
|
||||
const resume = result.data || {};
|
||||
const projects = resume.projects || [];
|
||||
projects.forEach(project => {
|
||||
const option = document.createElement('option');
|
||||
option.value = project.id;
|
||||
option.textContent = project.name;
|
||||
if (selectedProjectId && project.id === selectedProjectId) {
|
||||
option.selected = true;
|
||||
}
|
||||
projectSelect.appendChild(option);
|
||||
});
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
window.history.back();
|
||||
}
|
||||
|
||||
function savePortfolio() {
|
||||
const techStackStr = document.getElementById('portfolioTechStack').value;
|
||||
const techStack = techStackStr ? techStackStr.split(',').map(s => s.trim()).filter(s => s) : [];
|
||||
|
||||
const portfolioId = document.getElementById('portfolioId').value;
|
||||
const projectIdVal = document.getElementById('portfolioProjectId').value;
|
||||
|
||||
const data = {
|
||||
id: portfolioId ? parseInt(portfolioId) : 0,
|
||||
user_id: document.getElementById('portfolioUserId').value,
|
||||
name: document.getElementById('portfolioName').value,
|
||||
description: document.getElementById('portfolioDescription').value,
|
||||
tech_stack: techStack,
|
||||
url: document.getElementById('portfolioUrl').value,
|
||||
start_date: document.getElementById('portfolioStartDate').value,
|
||||
end_date: document.getElementById('portfolioEndDate').value,
|
||||
doc: simplemde.value(),
|
||||
show_in_resume: document.getElementById('portfolioShowInResume').checked,
|
||||
resume_id: document.getElementById('portfolioProjectResumeId').value,
|
||||
project_id: projectIdVal ? parseInt(projectIdVal) : 0,
|
||||
password: document.getElementById('portfolioPassword').value,
|
||||
hidden: document.getElementById('portfolioHidden').checked
|
||||
};
|
||||
|
||||
if (!data.user_id) {
|
||||
showCustomAlert('error', '错误', '请选择人员');
|
||||
return;
|
||||
}
|
||||
if (!data.name) {
|
||||
showCustomAlert('error', '错误', '请输入项目名称');
|
||||
return;
|
||||
}
|
||||
|
||||
const isEdit = parseInt(portfolioId) > 0;
|
||||
const url = isEdit ? '/dashboard/api/portfolio/' + portfolioId : '/dashboard/api/portfolio';
|
||||
const method = isEdit ? 'PUT' : 'POST';
|
||||
|
||||
fetch(url, {
|
||||
method: method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(result => {
|
||||
if (result.success) {
|
||||
showCustomAlert('success', '成功', isEdit ? '项目更新成功' : '项目创建成功');
|
||||
setTimeout(() => goBack(), 1500);
|
||||
} else {
|
||||
showCustomAlert('error', '错误', '保存失败:' + (result.message || '未知错误'));
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
showCustomAlert('error', '错误', '保存失败');
|
||||
});
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,321 @@
|
||||
<div class="bg-white rounded-xl shadow-sm border border-slate-200 overflow-hidden">
|
||||
<div class="border-b border-slate-200 px-6 py-4 bg-slate-50">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="font-semibold text-slate-800 flex items-center gap-2">
|
||||
<i class="fas fa-folder-open text-purple-500"></i> 作品集管理
|
||||
</h3>
|
||||
<p class="text-sm text-slate-500 mt-1">管理所有用户的作品集项目</p>
|
||||
</div>
|
||||
<a href="{{$.AdminPath}}/portfolio/add" class="btn btn-primary">
|
||||
<i class="fas fa-plus"></i> 新增项目
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr class="bg-slate-50 border-b border-slate-200">
|
||||
<th class="text-left px-6 py-4 text-sm font-semibold text-slate-600">项目名称</th>
|
||||
<th class="text-left px-6 py-4 text-sm font-semibold text-slate-600">用户</th>
|
||||
<th class="text-left px-6 py-4 text-sm font-semibold text-slate-600">链接</th>
|
||||
<th class="text-left px-6 py-4 text-sm font-semibold text-slate-600">隐藏状态</th>
|
||||
<th class="text-left px-6 py-4 text-sm font-semibold text-slate-600">首页展示</th>
|
||||
<th class="text-left px-6 py-4 text-sm font-semibold text-slate-600 actions-col">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{ range .Items }}
|
||||
<tr class="border-b border-slate-100 hover:bg-slate-50 transition-colors">
|
||||
<td class="px-6 py-4">
|
||||
<span class="font-medium text-slate-800">{{.Name}}</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-slate-600">
|
||||
{{ if .UserID }}
|
||||
{{ $user := index $.UserMap .UserID }}
|
||||
{{ if $user }}
|
||||
{{$user.Name}}({{$user.Username}})
|
||||
{{ else }}
|
||||
<span class="text-slate-400">已失效</span>
|
||||
{{ end }}
|
||||
{{ else }}
|
||||
<span class="text-slate-400">未分配</span>
|
||||
{{ end }}
|
||||
</td>
|
||||
<td class="px-6 py-4 text-slate-600">
|
||||
{{ if .URL }}
|
||||
<a href="{{.URL}}" target="_blank" class="text-blue-600 hover:underline">点击访问</a>
|
||||
{{ else }}
|
||||
-
|
||||
{{ end }}
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
{{ if .Hidden }}
|
||||
<span class="badge badge-danger">已隐藏</span>
|
||||
{{ else }}
|
||||
<span class="badge badge-success">显示</span>
|
||||
{{ end }}
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
{{ if .ShowInResume }}
|
||||
<span class="badge badge-success">是</span>
|
||||
{{ else }}
|
||||
<span class="badge badge-slate">否</span>
|
||||
{{ end }}
|
||||
</td>
|
||||
<td class="px-6 py-4 actions-col">
|
||||
<div class="btn-group">
|
||||
<a href="{{$.AdminPath}}/portfolio/{{.ID}}/edit" class="btn btn-warning btn-xs" title="编辑">
|
||||
<i class="fas fa-edit"></i> 编辑
|
||||
</a>
|
||||
<button onclick="deletePortfolio('{{.ID}}')" class="btn btn-danger btn-xs" title="删除">
|
||||
<i class="fas fa-trash"></i> 删除
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{{ else }}
|
||||
<tr>
|
||||
<td colspan="6" class="px-6 py-12 text-center text-slate-500">
|
||||
<div class="w-16 h-16 mx-auto mb-4 rounded-full bg-slate-100 flex items-center justify-center">
|
||||
<i class="fas fa-folder-open text-slate-400 text-2xl"></i>
|
||||
</div>
|
||||
<p>暂无作品集项目</p>
|
||||
</td>
|
||||
</tr>
|
||||
{{ end }}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{ if gt .TotalPages 1 }}
|
||||
<div class="border-t border-slate-200 px-6 py-4 bg-slate-50 flex items-center justify-between">
|
||||
<div class="text-sm text-slate-500">共 {{ .Total }} 条记录,第 {{ .Page }} / {{ .TotalPages }} 页</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<a href="?page=1" class="btn btn-sm btn-slate {{ if eq .Page 1 }}opacity-50 cursor-not-allowed{{ end }}" {{ if eq .Page 1 }}onclick="return false"{{ end }}>
|
||||
<i class="fas fa-chevron-left"></i>
|
||||
<i class="fas fa-chevron-left"></i>
|
||||
</a>
|
||||
<a href="?page={{ if eq .Page 1 }}1{{ else }}{{ subtract .Page 1 }}{{ end }}" class="btn btn-sm btn-slate {{ if eq .Page 1 }}opacity-50 cursor-not-allowed{{ end }}" {{ if eq .Page 1 }}onclick="return false"{{ end }}>
|
||||
<i class="fas fa-chevron-left"></i>
|
||||
</a>
|
||||
{{ range $i := to 1 $.TotalPages }}
|
||||
{{ if or (eq $i 1) (eq $i $.TotalPages) (and (ge $i (subtract $.Page 2)) (le $i (add $.Page 2))) }}
|
||||
<a href="?page={{ $i }}" class="btn btn-sm {{ if eq $i $.Page }}btn-primary{{ else }}btn-slate{{ end }}">{{ $i }}</a>
|
||||
{{ else if and (eq $i 2) (gt (subtract $.Page 3) 1) }}
|
||||
<span class="text-slate-400 px-2">...</span>
|
||||
{{ else if and (eq $i (subtract $.TotalPages 1)) (lt (add $.Page 3) $.TotalPages) }}
|
||||
<span class="text-slate-400 px-2">...</span>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
<a href="?page={{ if eq .Page .TotalPages }}{{ .TotalPages }}{{ else }}{{ add .Page 1 }}{{ end }}" class="btn btn-sm btn-slate {{ if eq .Page .TotalPages }}opacity-50 cursor-not-allowed{{ end }}" {{ if eq .Page .TotalPages }}onclick="return false"{{ end }}>
|
||||
<i class="fas fa-chevron-right"></i>
|
||||
</a>
|
||||
<a href="?page={{ .TotalPages }}" class="btn btn-sm btn-slate {{ if eq .Page .TotalPages }}opacity-50 cursor-not-allowed{{ end }}" {{ if eq .Page .TotalPages }}onclick="return false"{{ end }}>
|
||||
<i class="fas fa-chevron-right"></i>
|
||||
<i class="fas fa-chevron-right"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
<div class="modal hidden fixed inset-0 bg-black/50 flex items-center justify-center z-50" id="confirmModal">
|
||||
<div class="modal-content bg-white rounded-xl shadow-xl w-full max-w-sm mx-4">
|
||||
<div class="flex items-center justify-between p-4 border-b border-slate-200">
|
||||
<h3 class="font-semibold text-slate-800">确认删除</h3>
|
||||
<button onclick="closeConfirmModal()" class="btn btn-secondary btn-sm">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<div class="w-10 h-10 rounded-full bg-red-100 flex items-center justify-center">
|
||||
<i class="fas fa-exclamation-triangle text-red-500"></i>
|
||||
</div>
|
||||
<p class="text-slate-700">确定要删除这个项目吗?此操作不可撤销。</p>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<button onclick="closeConfirmModal()" class="flex-1 btn btn-secondary">取消</button>
|
||||
<button onclick="confirmDeleteAction()" class="flex-1 btn btn-danger">确认删除</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal hidden fixed inset-0 bg-black/50 flex items-center justify-center z-50" id="portfolioModal">
|
||||
<div class="modal-content bg-white rounded-xl shadow-xl w-full max-w-lg mx-4">
|
||||
<div class="flex items-center justify-between p-4 border-b border-slate-200">
|
||||
<h3 class="font-semibold text-slate-800" id="portfolioModalTitle">新增项目</h3>
|
||||
<button onclick="closePortfolioModal()" class="btn btn-secondary btn-sm">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<form id="portfolioForm" onsubmit="savePortfolio(event)">
|
||||
<input type="hidden" id="portfolioId">
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700 mb-1">选择人员</label>
|
||||
<select id="portfolioUserId" class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500" required>
|
||||
<option value="">请选择人员</option>
|
||||
{{ range .Users }}
|
||||
<option value="{{.ID}}">{{.Name}} - {{.Email}}</option>
|
||||
{{ end }}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700 mb-1">项目名称</label>
|
||||
<input type="text" id="portfolioName" class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500" required>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700 mb-1">项目描述</label>
|
||||
<textarea id="portfolioDescription" rows="3" class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700 mb-1">技术栈 (每行一个)</label>
|
||||
<textarea id="portfolioTechStack" rows="3" class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500" placeholder="Go React MySQL"></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700 mb-1">项目链接</label>
|
||||
<input type="url" id="portfolioURL" class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500">
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700 mb-1">开始日期</label>
|
||||
<input type="date" id="portfolioStartDate" class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700 mb-1">结束日期</label>
|
||||
<input type="date" id="portfolioEndDate" class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500">
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<label class="flex items-center gap-2">
|
||||
<input type="checkbox" id="portfolioHidden" class="rounded border-slate-300 text-primary-600 focus:ring-primary-500">
|
||||
<span class="text-sm text-slate-700">隐藏项目</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2">
|
||||
<input type="checkbox" id="portfolioShowInResume" class="rounded border-slate-300 text-primary-600 focus:ring-primary-500">
|
||||
<span class="text-sm text-slate-700">首页展示</span>
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700 mb-1">访问密码 (可选)</label>
|
||||
<input type="text" id="portfolioPassword" class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500" placeholder="留空则无需密码">
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-6 flex gap-3">
|
||||
<button type="button" onclick="closePortfolioModal()" class="flex-1 btn btn-secondary">取消</button>
|
||||
<button type="submit" class="flex-1 btn btn-primary">保存</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
function openAddPortfolioModal() {
|
||||
document.getElementById('portfolioModalTitle').textContent = '新增项目';
|
||||
document.getElementById('portfolioForm').reset();
|
||||
document.getElementById('portfolioId').value = '';
|
||||
const modal = document.getElementById('portfolioModal');
|
||||
modal.classList.remove('hidden');
|
||||
modal.classList.add('active');
|
||||
}
|
||||
|
||||
function closePortfolioModal() {
|
||||
const modal = document.getElementById('portfolioModal');
|
||||
modal.classList.add('hidden');
|
||||
modal.classList.remove('active');
|
||||
}
|
||||
|
||||
function editPortfolio(id) {
|
||||
fetch('/api/portfolio/' + id)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
const item = data.data;
|
||||
document.getElementById('portfolioModalTitle').textContent = '编辑项目';
|
||||
document.getElementById('portfolioId').value = item.id;
|
||||
document.getElementById('portfolioUserId').value = item.user_id;
|
||||
document.getElementById('portfolioName').value = item.name;
|
||||
document.getElementById('portfolioDescription').value = item.description || '';
|
||||
document.getElementById('portfolioTechStack').value = (item.tech_stack || []).join('\n');
|
||||
document.getElementById('portfolioURL').value = item.url || '';
|
||||
document.getElementById('portfolioStartDate').value = item.start_date || '';
|
||||
document.getElementById('portfolioEndDate').value = item.end_date || '';
|
||||
document.getElementById('portfolioHidden').checked = item.hidden || false;
|
||||
document.getElementById('portfolioShowInResume').checked = item.show_in_resume || false;
|
||||
document.getElementById('portfolioPassword').value = item.password || '';
|
||||
const modal = document.getElementById('portfolioModal');
|
||||
modal.classList.remove('hidden');
|
||||
modal.classList.add('active');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function savePortfolio(event) {
|
||||
event.preventDefault();
|
||||
const id = document.getElementById('portfolioId').value;
|
||||
const url = id ? '/api/portfolio/' + id : '/api/portfolio';
|
||||
const method = id ? 'PUT' : 'POST';
|
||||
|
||||
fetch(url, {
|
||||
method: method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
user_id: document.getElementById('portfolioUserId').value,
|
||||
name: document.getElementById('portfolioName').value,
|
||||
description: document.getElementById('portfolioDescription').value,
|
||||
tech_stack: document.getElementById('portfolioTechStack').value.split('\n').filter(s => s.trim()),
|
||||
url: document.getElementById('portfolioURL').value,
|
||||
start_date: document.getElementById('portfolioStartDate').value,
|
||||
end_date: document.getElementById('portfolioEndDate').value,
|
||||
hidden: document.getElementById('portfolioHidden').checked,
|
||||
show_in_resume: document.getElementById('portfolioShowInResume').checked,
|
||||
password: document.getElementById('portfolioPassword').value,
|
||||
})
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
closePortfolioModal();
|
||||
location.reload();
|
||||
} else {
|
||||
showToast(data.message || '保存失败', 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
var deleteTargetId = null;
|
||||
|
||||
function deletePortfolio(id) {
|
||||
deleteTargetId = id;
|
||||
const modal = document.getElementById('confirmModal');
|
||||
modal.classList.remove('hidden');
|
||||
modal.classList.add('active');
|
||||
}
|
||||
|
||||
function closeConfirmModal() {
|
||||
deleteTargetId = null;
|
||||
const modal = document.getElementById('confirmModal');
|
||||
modal.classList.add('hidden');
|
||||
modal.classList.remove('active');
|
||||
}
|
||||
|
||||
function confirmDeleteAction() {
|
||||
if (!deleteTargetId) return;
|
||||
|
||||
fetch('/api/portfolio/' + deleteTargetId, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
closeConfirmModal();
|
||||
if (data.success) {
|
||||
location.reload();
|
||||
} else {
|
||||
showToast(data.message || '删除失败', 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,482 @@
|
||||
<div class="bg-white rounded-xl shadow-sm border border-slate-200 overflow-hidden">
|
||||
<div class="border-b border-slate-200 px-6 py-4 bg-slate-50">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="font-semibold text-slate-800 flex items-center gap-2">
|
||||
<i class="fas fa-robot text-orange-500"></i> AI生成题目
|
||||
</h3>
|
||||
<p class="text-sm text-slate-500 mt-1">基于简历或关键词智能生成面试题目</p>
|
||||
</div>
|
||||
<div class="btn-group">
|
||||
<button onclick="goBack()" class="btn btn-secondary btn-sm">
|
||||
<i class="fas fa-arrow-left"></i> 返回
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<div class="grid grid-cols-1 lg:grid-cols-4 gap-6">
|
||||
<div class="lg:col-span-1">
|
||||
<div class="bg-slate-50 rounded-lg p-4 sticky top-6">
|
||||
<h4 class="font-medium text-slate-700 mb-4 flex items-center gap-2">
|
||||
<i class="fas fa-sliders-h text-primary-500"></i> 生成设置
|
||||
</h4>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700 mb-1">选择简历(可选)</label>
|
||||
<select id="quizResumeId" class="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500 text-sm">
|
||||
<option value="">不选择简历(使用自定义关键词)</option>
|
||||
{{ range .Resumes }}
|
||||
<option value="{{.ID}}">{{.BasicInfo.Name}} - {{.BasicInfo.Title}}</option>
|
||||
{{ end }}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700 mb-1">自定义关键词(可选)</label>
|
||||
<textarea id="quizKeywords" rows="3" class="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500 text-sm resize-none" placeholder="如:Go,MySQL,微服务,Redis 不选简历时必填"></textarea>
|
||||
</div>
|
||||
<button onclick="doGenerateQuestions()" id="generateBtn" class="w-full btn btn-primary">
|
||||
<i class="fas fa-robot"></i> 生成题目
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="lg:col-span-3">
|
||||
<div id="questionsContainer" class="hidden">
|
||||
<div class="bg-slate-50 rounded-lg p-6">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h4 class="font-medium text-slate-700 flex items-center gap-2">
|
||||
<i class="fas fa-question-circle text-primary-500"></i> 请回答以下问题
|
||||
</h4>
|
||||
<span class="text-sm text-slate-500">共 <span id="questionCount">0</span> 题</span>
|
||||
</div>
|
||||
<div id="questionsList" class="space-y-6"></div>
|
||||
<div class="mt-6 flex gap-3">
|
||||
<button onclick="goBack()" class="flex-1 btn btn-secondary">取消</button>
|
||||
<button onclick="submitAnswers()" class="flex-1 btn btn-primary">提交答案</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="emptyState" class="bg-slate-50 rounded-lg p-12 text-center">
|
||||
<div class="w-20 h-20 mx-auto mb-4 rounded-full bg-orange-100 flex items-center justify-center">
|
||||
<i class="fas fa-robot text-orange-500 text-4xl"></i>
|
||||
</div>
|
||||
<h4 class="font-medium text-slate-700 mb-2">开始生成题目</h4>
|
||||
<p class="text-sm text-slate-500">选择简历或输入关键词,点击生成按钮开始</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<input type="hidden" id="user-id" value="{{ with .User }}{{.ID}}{{ end }}">
|
||||
<script>
|
||||
var currentQuestions = [];
|
||||
|
||||
function goBack() {
|
||||
window.history.back();
|
||||
}
|
||||
|
||||
function doGenerateQuestions() {
|
||||
const resumeId = document.getElementById('quizResumeId').value;
|
||||
const keywords = document.getElementById('quizKeywords').value.trim();
|
||||
|
||||
if (!resumeId && !keywords) {
|
||||
showToast('请选择简历或输入关键词', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const btn = document.getElementById('generateBtn');
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> 生成中...';
|
||||
|
||||
fetch('/api/ai/agent/generate-questions', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
resume_id: resumeId,
|
||||
keywords: keywords,
|
||||
})
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<i class="fas fa-robot"></i> 生成题目';
|
||||
|
||||
const resultData = data.data || data;
|
||||
if (data.success && resultData.questions && resultData.questions.length > 0) {
|
||||
currentQuestions = resultData.questions;
|
||||
renderQuestions(resultData.questions);
|
||||
document.getElementById('emptyState').classList.add('hidden');
|
||||
document.getElementById('questionsContainer').classList.remove('hidden');
|
||||
} else {
|
||||
showToast(data.message || '生成题目失败', 'error');
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<i class="fas fa-robot"></i> 生成题目';
|
||||
showToast('生成题目失败', 'error');
|
||||
});
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function renderQuestions(questions) {
|
||||
const container = document.getElementById('questionsList');
|
||||
const countSpan = document.getElementById('questionCount');
|
||||
container.innerHTML = '';
|
||||
countSpan.textContent = questions.length;
|
||||
|
||||
questions.forEach((q, index) => {
|
||||
const qDiv = document.createElement('div');
|
||||
qDiv.className = 'bg-white p-5 rounded-lg border border-slate-200 shadow-sm';
|
||||
|
||||
let typeLabel = '';
|
||||
let typeColor = '';
|
||||
switch(q.type) {
|
||||
case 'mcq': typeLabel = '选择题'; typeColor = 'bg-blue-100 text-blue-700'; break;
|
||||
case 'fill': typeLabel = '填空题'; typeColor = 'bg-purple-100 text-purple-700'; break;
|
||||
case 'sa': typeLabel = '简答题'; typeColor = 'bg-green-100 text-green-700'; break;
|
||||
case 'algo': typeLabel = '算法题'; typeColor = 'bg-orange-100 text-orange-700'; break;
|
||||
default: typeLabel = '问答题'; typeColor = 'bg-gray-100 text-gray-700';
|
||||
}
|
||||
|
||||
const category = q.category || '综合能力';
|
||||
const difficulty = q.difficulty || '初级';
|
||||
const hints = q.hints || [];
|
||||
|
||||
let hintsHtml = '';
|
||||
if (hints.length > 0) {
|
||||
hintsHtml = '<div class="mt-3 p-3 bg-yellow-50 rounded-lg border border-yellow-200">' +
|
||||
'<div class="flex items-center gap-2 text-sm font-medium text-yellow-700 mb-2">' +
|
||||
'<i class="fas fa-lightbulb"></i> 回答要点提示</div>' +
|
||||
'<ul class="text-xs text-yellow-600 space-y-1">';
|
||||
hints.forEach(h => {
|
||||
hintsHtml += '<li class="flex items-start gap-2"><span class="text-yellow-500">•</span>' + escapeHtml(h) + '</li>';
|
||||
});
|
||||
hintsHtml += '</ul></div>';
|
||||
}
|
||||
|
||||
let optionsHtml = '';
|
||||
if (q.options && q.options.length > 0) {
|
||||
optionsHtml = '<div class="space-y-2 mt-3">';
|
||||
q.options.forEach((opt, optIndex) => {
|
||||
const letter = String.fromCharCode(65 + optIndex);
|
||||
optionsHtml +=
|
||||
'<label class="flex items-center gap-3 p-3 rounded-lg hover:bg-slate-50 cursor-pointer border border-slate-100 transition-colors">' +
|
||||
'<input type="radio" name="q' + index + '" value="' + letter + '" class="w-4 h-4 text-primary-600">' +
|
||||
'<span class="text-sm text-slate-700">' + letter + '. ' + escapeHtml(opt) + '</span>' +
|
||||
'</label>';
|
||||
});
|
||||
optionsHtml += '</div>';
|
||||
} else if (q.type === 'fill') {
|
||||
optionsHtml =
|
||||
'<div class="mt-3">' +
|
||||
'<input type="text" id="ans' + index + '" class="w-full px-4 py-2.5 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500 text-sm" placeholder="请输入答案" autocomplete="off" >' +
|
||||
'</div>';
|
||||
} else {
|
||||
optionsHtml =
|
||||
'<div class="mt-3">' +
|
||||
'<textarea id="ans' + index + '" rows="4" class="w-full px-4 py-2.5 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500 text-sm resize-none" placeholder="请输入您的回答"></textarea>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
qDiv.innerHTML =
|
||||
'<div class="flex flex-wrap items-center justify-between mb-3">' +
|
||||
'<div class="flex flex-wrap items-center gap-2">' +
|
||||
'<span class="px-2.5 py-1 ' + typeColor + ' rounded-full text-xs font-medium">' + typeLabel + '</span>' +
|
||||
'<span class="px-2.5 py-1 ' + getDifficultyColor(difficulty) + ' rounded-full text-xs font-medium">' + difficulty + '</span>' +
|
||||
'<span class="px-2.5 py-1 bg-indigo-100 text-indigo-700 rounded-full text-xs font-medium">' + category + '</span>' +
|
||||
'<span class="text-xs text-slate-400">第 ' + (index + 1) + ' 题 · ' + q.score + ' 分</span>' +
|
||||
'</div>' +
|
||||
'<button onclick="toggleQuestionFavorite(' + index + ')" id="favBtn' + index + '" class="btn btn-xs btn-yellow flex items-center gap-1 hover:bg-yellow-100 transition-colors" title="收藏本题">' +
|
||||
'<i class="fas fa-star"></i> 收藏' +
|
||||
'</button>' +
|
||||
'</div>' +
|
||||
'<p class="text-slate-800 font-medium leading-relaxed">' + escapeHtml(q.text) + '</p>' +
|
||||
optionsHtml +
|
||||
hintsHtml;
|
||||
container.appendChild(qDiv);
|
||||
});
|
||||
}
|
||||
|
||||
var favoriteQuestions = [];
|
||||
|
||||
function toggleQuestionFavorite(index) {
|
||||
const q = currentQuestions[index];
|
||||
const btn = document.getElementById(`favBtn${index}`);
|
||||
|
||||
const idx = favoriteQuestions.indexOf(index);
|
||||
if (idx > -1) {
|
||||
favoriteQuestions.splice(idx, 1);
|
||||
btn.classList.remove('bg-yellow-100', 'text-yellow-700');
|
||||
btn.innerHTML = '<i class="fas fa-star"></i> 收藏';
|
||||
} else {
|
||||
favoriteQuestions.push(index);
|
||||
btn.classList.add('bg-yellow-100', 'text-yellow-700');
|
||||
btn.innerHTML = '<i class="fas fa-star"></i> 已收藏';
|
||||
showToast('题目已收藏', 'success');
|
||||
}
|
||||
}
|
||||
|
||||
function submitAnswers() {
|
||||
const answers = {};
|
||||
const unanswered = [];
|
||||
|
||||
currentQuestions.forEach((q, index) => {
|
||||
let answer = '';
|
||||
if (q.type === 'mcq') {
|
||||
const radios = document.getElementsByName('q' + index);
|
||||
for (let radio of radios) {
|
||||
if (radio.checked) {
|
||||
answer = radio.value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const input = document.getElementById('ans' + index);
|
||||
answer = input ? input.value.trim() : '';
|
||||
}
|
||||
|
||||
if (!answer) {
|
||||
unanswered.push(index + 1);
|
||||
}
|
||||
answers['q' + index] = answer;
|
||||
});
|
||||
|
||||
if (unanswered.length > 0) {
|
||||
const unansweredList = unanswered.join('、');
|
||||
showCustomConfirm(
|
||||
'确认提交',
|
||||
`您还有 ${unanswered.length} 道题未作答:第 ${unansweredList} 题。\n\n确定要继续提交吗?未作答的题目将不计分。`,
|
||||
function(confirmed) {
|
||||
if (confirmed) {
|
||||
doSubmit(answers);
|
||||
}
|
||||
}
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
doSubmit(answers);
|
||||
}
|
||||
|
||||
function doSubmit(answers) {
|
||||
let score = 0;
|
||||
let correctCount = 0;
|
||||
let unansweredCount = 0;
|
||||
|
||||
currentQuestions.forEach((q, index) => {
|
||||
const userAnswer = answers['q' + index];
|
||||
if (!userAnswer) {
|
||||
unansweredCount++;
|
||||
return;
|
||||
}
|
||||
|
||||
if (isAnswerCorrect(q, userAnswer)) {
|
||||
score += q.score;
|
||||
correctCount++;
|
||||
}
|
||||
});
|
||||
|
||||
const resultData = {
|
||||
questions: currentQuestions,
|
||||
user_answers: answers,
|
||||
score: score,
|
||||
correct_count: correctCount,
|
||||
total_count: currentQuestions.length,
|
||||
unanswered_count: unansweredCount
|
||||
};
|
||||
|
||||
fetch('/api/ai/agent/submit-answers', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(resultData)
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
saveQuizRecord(answers, score, correctCount, currentQuestions.length, unansweredCount);
|
||||
} else {
|
||||
showToast(data.error || '提交失败', 'error');
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
showToast('提交失败', 'error');
|
||||
});
|
||||
}
|
||||
|
||||
function isAnswerCorrect(question, userAnswer) {
|
||||
const userAns = userAnswer.trim().toLowerCase();
|
||||
const correctAns = String(question.answer).trim().toLowerCase();
|
||||
|
||||
if (question.type === 'mcq') {
|
||||
return userAns === correctAns;
|
||||
}
|
||||
|
||||
if (question.type === 'fill') {
|
||||
return userAns === correctAns;
|
||||
}
|
||||
|
||||
if (question.type === 'sa' || question.type === 'algo') {
|
||||
return userAns.length > 0;
|
||||
}
|
||||
|
||||
return userAns === correctAns;
|
||||
}
|
||||
|
||||
function saveQuizRecord(answers, score, correctCount, totalCount, unansweredCount) {
|
||||
const resumeId = document.getElementById('quizResumeId').value;
|
||||
const keywords = document.getElementById('quizKeywords').value.trim();
|
||||
|
||||
const recordId = 'quiz-' + Date.now() + '-' + Math.random().toString(36).substr(2, 9);
|
||||
|
||||
const userAnswers = {};
|
||||
currentQuestions.forEach((q, index) => {
|
||||
userAnswers[index] = answers['q' + index] || '';
|
||||
});
|
||||
|
||||
const now = new Date();
|
||||
const title = now.toLocaleString('zh-CN', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
|
||||
const userId = document.getElementById('user-id').value;
|
||||
const recordData = {
|
||||
id: recordId,
|
||||
user_id: userId,
|
||||
resume_id: resumeId,
|
||||
resume_route: resumeId ? '' : 'custom-quiz',
|
||||
title: '答题记录 - ' + title,
|
||||
questions: currentQuestions.map(q => ({
|
||||
type: q.type,
|
||||
text: q.text,
|
||||
options: q.options || [],
|
||||
answer: q.answer,
|
||||
analysis: q.analysis || '',
|
||||
score: q.score,
|
||||
keywords: q.keywords || []
|
||||
})),
|
||||
user_answers: userAnswers,
|
||||
score: score,
|
||||
correct_count: correctCount,
|
||||
total_count: totalCount
|
||||
};
|
||||
|
||||
fetch('/api/quiz/record', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(recordData)
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
try {
|
||||
saveFavoriteAndWrongQuestions(resumeId, answers);
|
||||
} catch (e) {
|
||||
console.error('Failed to save favorite/wrong questions:', e);
|
||||
}
|
||||
|
||||
let message = `答题完成!`;
|
||||
if (unansweredCount > 0) {
|
||||
message += `\n得分:${score}分(${correctCount}/${totalCount - unansweredCount}正确,${unansweredCount}题未作答)`;
|
||||
} else {
|
||||
message += `\n得分:${score}分(${correctCount}/${totalCount}正确)`;
|
||||
}
|
||||
showToast(message, 'success');
|
||||
|
||||
setTimeout(() => {
|
||||
const url = new URL(window.location.href);
|
||||
url.pathname = url.pathname.replace('/quiz/generate', '/quiz');
|
||||
url.searchParams.set('record', recordId);
|
||||
window.location.href = url.toString();
|
||||
}, 2000);
|
||||
})
|
||||
.catch(() => {
|
||||
let message = `答题完成!`;
|
||||
if (unansweredCount > 0) {
|
||||
message += `\n得分:${score}分(${correctCount}/${totalCount - unansweredCount}正确,${unansweredCount}题未作答)`;
|
||||
} else {
|
||||
message += `\n得分:${score}分(${correctCount}/${totalCount}正确)`;
|
||||
}
|
||||
showToast(message, 'success');
|
||||
|
||||
setTimeout(() => {
|
||||
const url = new URL(window.location.href);
|
||||
url.pathname = url.pathname.replace('/quiz/generate', '/quiz');
|
||||
url.searchParams.set('record', recordId);
|
||||
window.location.href = url.toString();
|
||||
}, 2000);
|
||||
});
|
||||
}
|
||||
|
||||
function saveFavoriteAndWrongQuestions(resumeId, answers) {
|
||||
const userId = document.getElementById('user-id').value;
|
||||
currentQuestions.forEach((q, index) => {
|
||||
const userAnswer = answers['q' + index];
|
||||
const isCorrect = userAnswer && isAnswerCorrect(q, userAnswer);
|
||||
const isFavorite = favoriteQuestions.includes(index);
|
||||
|
||||
if (!isCorrect || isFavorite) {
|
||||
const favData = {
|
||||
id: generateUUID(),
|
||||
user_id: userId,
|
||||
resume_id: resumeId,
|
||||
resume_route: '',
|
||||
question: {
|
||||
type: q.type,
|
||||
text: q.text,
|
||||
options: q.options || [],
|
||||
answer: q.answer,
|
||||
analysis: q.analysis || '',
|
||||
score: q.score,
|
||||
keywords: q.keywords || [],
|
||||
difficulty: q.difficulty || '初级',
|
||||
category: q.category || '综合能力'
|
||||
},
|
||||
user_answer: userAnswer || '',
|
||||
is_correct: isCorrect,
|
||||
is_favorite: isFavorite
|
||||
};
|
||||
|
||||
fetch('/api/favorite', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(favData)
|
||||
}).catch(() => {});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function generateUUID() {
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
|
||||
var r = Math.random() * 16 | 0, v = c == 'x' ? r : (r & 0x3 | 0x8);
|
||||
return v.toString(16);
|
||||
});
|
||||
}
|
||||
|
||||
function getDifficultyColor(difficulty) {
|
||||
switch(difficulty) {
|
||||
case '入门': return 'bg-green-100 text-green-700';
|
||||
case '初级': return 'bg-blue-100 text-blue-700';
|
||||
case '中级': return 'bg-yellow-100 text-yellow-700';
|
||||
case '进阶': return 'bg-orange-100 text-orange-700';
|
||||
case '高级': return 'bg-red-100 text-red-700';
|
||||
default: return 'bg-gray-100 text-gray-700';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,270 @@
|
||||
<div class="bg-white rounded-xl shadow-sm border border-slate-200 overflow-hidden">
|
||||
<div class="border-b border-slate-200 px-6 py-4 bg-slate-50">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<a href="{{.AdminPath}}/user/{{.User.ID}}/quiz?tab=records" class="text-slate-400 hover:text-slate-600 transition-colors">
|
||||
<i class="fas fa-arrow-left"></i>
|
||||
</a>
|
||||
<div>
|
||||
<h3 class="font-semibold text-slate-800 flex items-center gap-2">
|
||||
<i class="fas fa-file-alt text-blue-500"></i> {{ if .Record.Title }}{{.Record.Title}}{{ else }}答题详情{{ end }}
|
||||
</h3>
|
||||
<p class="text-sm text-slate-500 mt-1">{{ if .User.Name }}{{.User.Name}}{{ else }}{{.User.Username}}{{ end }} 的答题记录</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-6">
|
||||
<div class="text-center">
|
||||
<p class="text-xs text-slate-500 mb-1">答题时间</p>
|
||||
<p class="text-sm font-medium text-slate-700">{{.Record.CreatedAt}}</p>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<p class="text-xs text-slate-500 mb-1">得分</p>
|
||||
<p class="text-3xl font-bold {{ if ge .Record.Score 60.0 }}text-green-600{{ else }}text-red-600{{ end }}">{{.Record.Score}}<span class="text-sm font-normal text-slate-400 ml-1">分</span></p>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<p class="text-xs text-slate-500 mb-1">正确率</p>
|
||||
<p class="text-sm font-medium text-slate-700">{{.Record.CorrectCount}}/{{.Record.TotalCount}} 题</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-6">
|
||||
<div class="space-y-4">
|
||||
{{ range $index, $q := .Questions }}
|
||||
<div class="border border-slate-200 rounded-xl overflow-hidden" data-question-index="{{$index}}">
|
||||
<div class="flex items-center justify-between px-4 py-3 bg-slate-50 border-b border-slate-200">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="w-8 h-8 rounded-full bg-slate-200 flex items-center justify-center text-sm font-medium text-slate-600">{{ add $index 1 }}</span>
|
||||
<div class="flex items-center gap-2">
|
||||
{{ if eq $q.Type "mcq" }}<span class="px-2 py-0.5 bg-blue-100 text-blue-600 rounded text-xs">选择题</span>{{ end }}
|
||||
{{ if eq $q.Type "fill" }}<span class="px-2 py-0.5 bg-green-100 text-green-600 rounded text-xs">填空题</span>{{ end }}
|
||||
{{ if eq $q.Type "sa" }}<span class="px-2 py-0.5 bg-purple-100 text-purple-600 rounded text-xs">简答题</span>{{ end }}
|
||||
{{ if eq $q.Type "algo" }}<span class="px-2 py-0.5 bg-orange-100 text-orange-600 rounded text-xs">算法题</span>{{ end }}
|
||||
{{ if $q.Difficulty }}
|
||||
<span class="px-2 py-0.5 rounded text-xs"
|
||||
{{ if eq $q.Difficulty "入门" }}style="background-color: #dcfce7; color: #166534;"{{ end }}
|
||||
{{ if eq $q.Difficulty "初级" }}style="background-color: #dbeafe; color: #1e40af;"{{ end }}
|
||||
{{ if eq $q.Difficulty "中级" }}style="background-color: #fef3c7; color: #92400e;"{{ end }}
|
||||
{{ if eq $q.Difficulty "进阶" }}style="background-color: #ffedd5; color: #c2410c;"{{ end }}
|
||||
{{ if eq $q.Difficulty "高级" }}style="background-color: #fee2e2; color: #991b1b;"{{ end }}>
|
||||
{{ $q.Difficulty }}
|
||||
</span>
|
||||
{{ end }}
|
||||
{{ if $q.Category }}<span class="px-2 py-0.5 bg-indigo-100 text-indigo-600 rounded text-xs">{{$q.Category}}</span>{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<button class="toggle-favorite-btn flex items-center gap-1 px-3 py-1.5 rounded-lg text-sm transition-all hover:bg-slate-100"
|
||||
data-index="{{$index}}" data-user-id="{{$.User.ID}}" data-resume-id="{{$.Record.ResumeID}}" data-resume-route="{{$.Record.ResumeRoute}}">
|
||||
<i class="fas fa-star text-slate-400"></i>
|
||||
<span class="text-slate-500">收藏</span>
|
||||
</button>
|
||||
<span class="px-2 py-1 rounded-full text-xs font-medium {{ if index $.Correct $index }}bg-green-100 text-green-700{{ else }}bg-red-100 text-red-700{{ end }}">
|
||||
{{ if index $.Correct $index }}回答正确{{ else }}回答错误{{ end }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-5">
|
||||
<p class="text-slate-800 font-medium mb-4">{{ $q.Text }}</p>
|
||||
|
||||
{{ if $q.Options }}
|
||||
<div class="space-y-2 mb-4">
|
||||
{{ range $optIndex, $opt := $q.Options }}
|
||||
{{ $letter := printf "%c" (index "ABCDE" $optIndex) }}
|
||||
{{ $isCorrect := eq $letter $q.Answer }}
|
||||
{{ $userAnswer := index $.UserAnswers $index }}
|
||||
{{ $isUserAns := eq $letter $userAnswer }}
|
||||
<div class="flex items-center gap-3 p-3 rounded-lg {{ if $isCorrect }}bg-green-50 border border-green-200{{ else if and $isUserAns (not $isCorrect) }}bg-red-50 border border-red-200{{ else }}bg-slate-50{{ end }}">
|
||||
<span class="w-6 h-6 rounded-full flex items-center justify-center text-xs font-medium {{ if $isCorrect }}bg-green-500 text-white{{ else if and $isUserAns (not $isCorrect) }}bg-red-500 text-white{{ else }}bg-slate-200 text-slate-600{{ end }}">{{ $letter }}</span>
|
||||
<span class="text-sm {{ if $isCorrect }}text-green-700 font-medium{{ else if and $isUserAns (not $isCorrect) }}text-red-700{{ else }}text-slate-600{{ end }}">{{ $opt }}</span>
|
||||
{{ if $isCorrect }}<i class="fas fa-check text-green-500 ml-auto text-sm"></i>{{ end }}
|
||||
{{ if and $isUserAns (not $isCorrect) }}<i class="fas fa-times text-red-500 ml-auto text-sm"></i>{{ end }}
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
{{ end }}
|
||||
|
||||
<div class="flex items-center gap-6 mb-4 text-sm">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-slate-500">正确答案:</span>
|
||||
<span class="font-medium text-green-600 markdown-answer">{{ $q.Answer }}</span>
|
||||
</div>
|
||||
{{ with index $.UserAnswers $index }}
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-slate-500">你的答案:</span>
|
||||
<span class="font-medium {{ if index $.Correct $index }}text-green-600{{ else }}text-red-600{{ end }}">{{ . }}</span>
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
|
||||
{{ if $q.Analysis }}
|
||||
<div class="p-4 bg-blue-50 rounded-lg border border-blue-100">
|
||||
<div class="font-medium text-blue-700 text-sm mb-2 flex items-center gap-2">
|
||||
<i class="fas fa-lightbulb text-xs"></i> 答案解析
|
||||
</div>
|
||||
<div class="text-sm text-slate-600 markdown-body markdown-analysis">{{ $q.Analysis }}</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/github-markdown-css/5.5.0/github-markdown.min.css">
|
||||
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/dompurify@3/dist/purify.min.js"></script>
|
||||
<style>
|
||||
.markdown-body { background-color: transparent !important; font-size: 0.88rem; }
|
||||
.markdown-body p { margin: 0.5em 0; }
|
||||
.markdown-body pre { background: #1e293b; padding: 0.75rem; border-radius: 0.38rem; overflow-x: auto; }
|
||||
.markdown-body code { background: rgba(0,0,0,0.06); padding: 0.12rem 0.38rem; border-radius: 0.25rem; font-size: 0.81rem; }
|
||||
.markdown-body pre code { background: transparent; padding: 0; color: #e2e8f0; }
|
||||
.toggle-favorite-btn.favorited i { color: #fbbf24; }
|
||||
.toggle-favorite-btn.favorited span { color: #d97706; }
|
||||
.toggle-favorite-btn.favorited { background-color: #fef3c7; }
|
||||
</style>
|
||||
<script>
|
||||
function decodeUnicode(str) {
|
||||
return str.replace(/\\u([0-9a-fA-F]{4})/g, function (_, hex) {
|
||||
return String.fromCharCode(parseInt(hex, 16));
|
||||
});
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
return str.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
marked.setOptions({ breaks: true, gfm: true });
|
||||
|
||||
document.querySelectorAll('.markdown-analysis').forEach(function (el) {
|
||||
let md = decodeUnicode(el.textContent || '');
|
||||
if (!md.trim()) return;
|
||||
md = escapeHtml(md);
|
||||
el.innerHTML = DOMPurify.sanitize(marked.parse(md));
|
||||
el.classList.add('markdown-body');
|
||||
});
|
||||
|
||||
document.querySelectorAll('.markdown-answer').forEach(function (el) {
|
||||
let md = decodeUnicode(el.textContent || '');
|
||||
if (!md.trim()) return;
|
||||
md = escapeHtml(md);
|
||||
el.innerHTML = DOMPurify.sanitize(marked.parseInline(md));
|
||||
});
|
||||
|
||||
document.querySelectorAll('.toggle-favorite-btn').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
const index = parseInt(this.dataset.index);
|
||||
const userId = this.dataset.userId;
|
||||
const resumeId = this.dataset.resumeId;
|
||||
const resumeRoute = this.dataset.resumeRoute;
|
||||
|
||||
const questionEl = this.closest('[data-question-index]');
|
||||
const questionType = questionEl.querySelector('[class*="bg-blue-100"], [class*="bg-green-100"], [class*="bg-purple-100"], [class*="bg-orange-100"]')?.textContent || '综合';
|
||||
const questionText = questionEl.querySelector('.text-slate-800.font-medium')?.textContent || '';
|
||||
const questionOptions = [];
|
||||
questionEl.querySelectorAll('.space-y-2 .text-sm').forEach(function(opt) {
|
||||
questionOptions.push(opt.textContent.trim());
|
||||
});
|
||||
const questionAnswer = questionEl.querySelector('.text-green-600.markdown-answer')?.textContent || '';
|
||||
const questionAnalysis = questionEl.querySelector('.markdown-analysis')?.textContent || '';
|
||||
const questionDifficulty = questionEl.querySelector('[style*="background-color"]')?.textContent || '初级';
|
||||
const questionCategory = questionEl.querySelector('.bg-indigo-100')?.textContent || '综合';
|
||||
|
||||
const isCorrectEl = this.nextElementSibling;
|
||||
const isCorrect = isCorrectEl.textContent.includes('正确');
|
||||
const userAnswerEl = questionEl.querySelector('.text-red-600') || questionEl.querySelector('.text-green-600');
|
||||
const userAnswer = userAnswerEl ? userAnswerEl.textContent.trim().replace('你的答案:', '') : '';
|
||||
|
||||
const questionData = {
|
||||
type: questionType.includes('选择') ? 'mcq' :
|
||||
questionType.includes('填空') ? 'fill' :
|
||||
questionType.includes('简答') ? 'sa' : 'algo',
|
||||
text: questionText,
|
||||
options: questionOptions.length > 0 ? questionOptions : null,
|
||||
answer: questionAnswer,
|
||||
analysis: questionAnalysis,
|
||||
score: 10,
|
||||
keywords: [],
|
||||
difficulty: questionDifficulty,
|
||||
category: questionCategory
|
||||
};
|
||||
|
||||
const payload = {
|
||||
user_id: userId,
|
||||
resume_id: resumeId,
|
||||
resume_route: resumeRoute,
|
||||
question: questionData,
|
||||
user_answer: userAnswer,
|
||||
is_correct: isCorrect,
|
||||
is_favorite: true
|
||||
};
|
||||
|
||||
fetch('/api/quiz/favorite', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success || data.code === 201) {
|
||||
this.classList.add('favorited');
|
||||
this.querySelector('i').classList.remove('text-slate-400');
|
||||
this.querySelector('i').classList.add('text-yellow-500');
|
||||
this.querySelector('span').textContent = '已收藏';
|
||||
showToast('收藏成功');
|
||||
} else {
|
||||
showToast('收藏失败: ' + (data.message || '未知错误'));
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
showToast('收藏失败: ' + error.message);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function showToast(message) {
|
||||
const toast = document.createElement('div');
|
||||
toast.style.cssText = `
|
||||
position: fixed;
|
||||
top: 1.25rem;
|
||||
right: 1.25rem;
|
||||
padding: 0.75rem 1.50rem;
|
||||
background: rgba(0,0,0,0.8);
|
||||
color: white;
|
||||
border-radius: 0.50rem;
|
||||
z-index: 1000;
|
||||
font-size: 0.88rem;
|
||||
animation: slideIn 0.3s ease;
|
||||
`;
|
||||
toast.textContent = message;
|
||||
document.body.appendChild(toast);
|
||||
|
||||
setTimeout(() => {
|
||||
toast.style.animation = 'slideOut 0.3s ease';
|
||||
setTimeout(() => toast.remove(), 300);
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
const style = document.createElement('style');
|
||||
style.textContent = `
|
||||
@keyframes slideIn {
|
||||
from { transform: translateX(100%); opacity: 0; }
|
||||
to { transform: translateX(0); opacity: 1; }
|
||||
}
|
||||
@keyframes slideOut {
|
||||
from { transform: translateX(0); opacity: 1; }
|
||||
to { transform: translateX(100%); opacity: 0; }
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
</script>
|
||||
@@ -0,0 +1,220 @@
|
||||
<div class="bg-white rounded-xl shadow-sm border border-slate-200 overflow-hidden">
|
||||
<div class="border-b border-slate-200 px-6 py-4 bg-slate-50">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="font-semibold text-slate-800 flex items-center gap-2">
|
||||
<i class="fas fa-question-circle text-purple-500"></i> 面试答题管理
|
||||
</h3>
|
||||
<p class="text-sm text-slate-500 mt-1">管理用户的面试题库、答题记录和学习进度</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||
<div class="bg-gradient-to-br from-blue-50 to-blue-100 rounded-xl p-5 border border-blue-200">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-blue-600 font-medium">总用户数</p>
|
||||
<p class="text-2xl font-bold text-blue-800 mt-1">{{ len .UsersWithStats }}</p>
|
||||
</div>
|
||||
<div class="w-12 h-12 rounded-full bg-blue-200 flex items-center justify-center">
|
||||
<i class="fas fa-users text-blue-600"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-gradient-to-br from-green-50 to-green-100 rounded-xl p-5 border border-green-200">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-green-600 font-medium">总答题次数</p>
|
||||
<p class="text-2xl font-bold text-green-800 mt-1">{{ .TotalStats.TotalAttempts }}</p>
|
||||
</div>
|
||||
<div class="w-12 h-12 rounded-full bg-green-200 flex items-center justify-center">
|
||||
<i class="fas fa-check-circle text-green-600"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-gradient-to-br from-yellow-50 to-yellow-100 rounded-xl p-5 border border-yellow-200">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-yellow-600 font-medium">收藏题目</p>
|
||||
<p class="text-2xl font-bold text-yellow-800 mt-1">{{ .TotalStats.TotalFavorites }}</p>
|
||||
</div>
|
||||
<div class="w-12 h-12 rounded-full bg-yellow-200 flex items-center justify-center">
|
||||
<i class="fas fa-star text-yellow-600"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-gradient-to-br from-red-50 to-red-100 rounded-xl p-5 border border-red-200">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-red-600 font-medium">错题数量</p>
|
||||
<p class="text-2xl font-bold text-red-800 mt-1">{{ .TotalStats.TotalWrong }}</p>
|
||||
</div>
|
||||
<div class="w-12 h-12 rounded-full bg-red-200 flex items-center justify-center">
|
||||
<i class="fas fa-times-circle text-red-600"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr class="bg-slate-50 border-b border-slate-200">
|
||||
<th class="text-left px-6 py-4 text-sm font-semibold text-slate-600">用户信息</th>
|
||||
<th class="text-left px-6 py-4 text-sm font-semibold text-slate-600">答题统计</th>
|
||||
<th class="text-left px-6 py-4 text-sm font-semibold text-slate-600">正确率</th>
|
||||
<th class="text-left px-6 py-4 text-sm font-semibold text-slate-600">收藏/错题</th>
|
||||
<th class="text-left px-6 py-4 text-sm font-semibold text-slate-600">最近答题</th>
|
||||
<th class="text-left px-6 py-4 text-sm font-semibold text-slate-600">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{ range .UsersWithStats }}
|
||||
<tr class="border-b border-slate-100 hover:bg-slate-50 transition-colors">
|
||||
<td class="px-6 py-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-full bg-gradient-to-br from-purple-500 to-purple-600 flex items-center justify-center text-white font-bold shadow-md">
|
||||
{{ if .User.Name }}{{ substr .User.Name 0 1 }}{{ else }}{{ substr .User.Username 0 1 }}{{ end }}
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-medium text-slate-800">{{ if .User.Name }}{{ .User.Name }}{{ else }}{{ .User.Username }}{{ end }}</p>
|
||||
<p class="text-sm text-slate-500">{{ .User.Email }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<div class="flex items-center gap-4">
|
||||
<div>
|
||||
<p class="text-xs text-slate-500">答题次数</p>
|
||||
<p class="font-semibold text-slate-800">{{ .TotalAttempts }}</p>
|
||||
</div>
|
||||
<div class="w-px h-10 bg-slate-200"></div>
|
||||
<div>
|
||||
<p class="text-xs text-slate-500">平均得分</p>
|
||||
<p class="font-semibold text-slate-800">{{ printf "%.1f" .AvgScore }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-20 h-3 bg-slate-100 rounded-full overflow-hidden">
|
||||
<div class="h-full rounded-full transition-all"
|
||||
style="width: {{ .CorrectRate }}%; background-color: {{ if ge .CorrectRate 80 }}#22c55e{{ else if ge .CorrectRate 60 }}#eab308{{ else }}#ef4444{{ end }}"></div>
|
||||
</div>
|
||||
<span class="text-sm font-medium text-slate-700">{{ .CorrectRate }}%</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="flex items-center gap-1 text-yellow-600">
|
||||
<i class="fas fa-star"></i>
|
||||
<span class="text-sm font-medium">{{ .FavoriteCount }}</span>
|
||||
</span>
|
||||
<span class="flex items-center gap-1 text-red-500">
|
||||
<i class="fas fa-times-circle"></i>
|
||||
<span class="text-sm font-medium">{{ .WrongCount }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<span class="text-sm text-slate-500">{{ if .LastAttemptDate }}{{ .LastAttemptDate }}{{ else }}暂无记录{{ end }}</span>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<a href="{{$.AdminPath}}/user/{{.User.ID}}/quiz/generate" class="btn btn-primary btn-xs" title="AI生成题目">
|
||||
<i class="fas fa-robot"></i> 生成题目
|
||||
</a>
|
||||
<a href="{{$.AdminPath}}/user/{{.User.ID}}/quiz" class="btn btn-info btn-xs" title="查看题库">
|
||||
<i class="fas fa-book-open"></i> 题库
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{{ else }}
|
||||
<tr>
|
||||
<td colspan="6" class="px-6 py-12 text-center text-slate-500">
|
||||
<div class="w-16 h-16 mx-auto mb-4 rounded-full bg-slate-100 flex items-center justify-center">
|
||||
<i class="fas fa-users text-slate-400 text-2xl"></i>
|
||||
</div>
|
||||
<p>暂无用户,请先添加用户</p>
|
||||
</td>
|
||||
</tr>
|
||||
{{ end }}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{ if gt .TotalPages 1 }}
|
||||
<div class="border-t border-slate-200 px-6 py-4 bg-slate-50 flex items-center justify-between">
|
||||
<div class="text-sm text-slate-500">共 {{ .TotalRecords }} 条记录,第 {{ .CurrentPage }} / {{ .TotalPages }} 页</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<a href="?page=1" class="btn btn-sm btn-slate {{ if eq .CurrentPage 1 }}opacity-50 cursor-not-allowed{{ end }}" {{ if eq .CurrentPage 1 }}onclick="return false"{{ end }}>
|
||||
<i class="fas fa-chevron-left"></i>
|
||||
<i class="fas fa-chevron-left"></i>
|
||||
</a>
|
||||
<a href="?page={{ if eq .CurrentPage 1 }}1{{ else }}{{ subtract .CurrentPage 1 }}{{ end }}" class="btn btn-sm btn-slate {{ if eq .CurrentPage 1 }}opacity-50 cursor-not-allowed{{ end }}" {{ if eq .CurrentPage 1 }}onclick="return false"{{ end }}>
|
||||
<i class="fas fa-chevron-left"></i>
|
||||
</a>
|
||||
{{ range $i := to 1 $.TotalPages }}
|
||||
{{ if or (eq $i 1) (eq $i $.TotalPages) (and (ge $i (subtract $.CurrentPage 2)) (le $i (add $.CurrentPage 2))) }}
|
||||
<a href="?page={{ $i }}" class="btn btn-sm {{ if eq $i $.CurrentPage }}btn-primary{{ else }}btn-slate{{ end }}">{{ $i }}</a>
|
||||
{{ else if and (eq $i 2) (gt (subtract $.CurrentPage 3) 1) }}
|
||||
<span class="text-slate-400 px-2">...</span>
|
||||
{{ else if and (eq $i (subtract $.TotalPages 1)) (lt (add $.CurrentPage 3) $.TotalPages) }}
|
||||
<span class="text-slate-400 px-2">...</span>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
<a href="?page={{ if eq .CurrentPage .TotalPages }}{{ .TotalPages }}{{ else }}{{ add .CurrentPage 1 }}{{ end }}" class="btn btn-sm btn-slate {{ if eq .CurrentPage .TotalPages }}opacity-50 cursor-not-allowed{{ end }}" {{ if eq .CurrentPage .TotalPages }}onclick="return false"{{ end }}>
|
||||
<i class="fas fa-chevron-right"></i>
|
||||
</a>
|
||||
<a href="?page={{ .TotalPages }}" class="btn btn-sm btn-slate {{ if eq .CurrentPage .TotalPages }}opacity-50 cursor-not-allowed{{ end }}" {{ if eq .CurrentPage .TotalPages }}onclick="return false"{{ end }}>
|
||||
<i class="fas fa-chevron-right"></i>
|
||||
<i class="fas fa-chevron-right"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
<div class="mt-6 bg-slate-50 rounded-xl p-5">
|
||||
<h4 class="font-medium text-slate-700 mb-4 flex items-center gap-2">
|
||||
<i class="fas fa-chart-line text-purple-500"></i> 功能说明
|
||||
</h4>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div class="bg-white p-4 rounded-lg border border-slate-200">
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<div class="w-8 h-8 rounded-full bg-blue-100 flex items-center justify-center">
|
||||
<i class="fas fa-robot text-blue-600"></i>
|
||||
</div>
|
||||
<span class="font-medium text-slate-700">AI生成题目</span>
|
||||
</div>
|
||||
<p class="text-sm text-slate-500">基于简历内容或关键词智能生成面试题目,支持多种难度级别</p>
|
||||
</div>
|
||||
<div class="bg-white p-4 rounded-lg border border-slate-200">
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<div class="w-8 h-8 rounded-full bg-yellow-100 flex items-center justify-center">
|
||||
<i class="fas fa-star text-yellow-600"></i>
|
||||
</div>
|
||||
<span class="font-medium text-slate-700">收藏题目</span>
|
||||
</div>
|
||||
<p class="text-sm text-slate-500">收藏重要题目,方便复习回顾,支持快速筛选查看</p>
|
||||
</div>
|
||||
<div class="bg-white p-4 rounded-lg border border-slate-200">
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<div class="w-8 h-8 rounded-full bg-red-100 flex items-center justify-center">
|
||||
<i class="fas fa-times-circle text-red-600"></i>
|
||||
</div>
|
||||
<span class="font-medium text-slate-700">错题本</span>
|
||||
</div>
|
||||
<p class="text-sm text-slate-500">自动记录答错的题目,支持针对性练习和错题回顾</p>
|
||||
</div>
|
||||
<div class="bg-white p-4 rounded-lg border border-slate-200">
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<div class="w-8 h-8 rounded-full bg-green-100 flex items-center justify-center">
|
||||
<i class="fas fa-chart-bar text-green-600"></i>
|
||||
</div>
|
||||
<span class="font-medium text-slate-700">数据统计</span>
|
||||
</div>
|
||||
<p class="text-sm text-slate-500">查看答题记录、正确率、得分趋势等学习数据</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,661 @@
|
||||
<div class="bg-white rounded-xl shadow-sm border border-slate-200 overflow-hidden">
|
||||
<div class="border-b border-slate-200 px-6 py-4 bg-slate-50">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="font-semibold text-slate-800 flex items-center gap-2">
|
||||
<i class="fas fa-file-alt text-purple-500"></i> 简历管理
|
||||
</h3>
|
||||
<p class="text-sm text-slate-500 mt-1">管理所有用户的简历</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button onclick="openUploadDocumentModal()" class="btn btn-success">
|
||||
<i class="fas fa-upload"></i> 上传文档生成简历
|
||||
</button>
|
||||
<a href="{{$.AdminPath}}/resume/add" class="btn btn-primary">
|
||||
<i class="fas fa-plus"></i> 添加简历
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-6 py-4 border-b border-slate-100 bg-white">
|
||||
<form id="filterForm" method="GET" action="{{$.AdminPath}}/resumes" class="flex flex-wrap items-center gap-3">
|
||||
<div class="flex-1 min-w-[11.25rem]">
|
||||
<input type="text" name="keyword" value="{{.FilterKeyword}}" placeholder="搜索姓名/路由..." class="w-full px-3 py-2 text-sm border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
<select name="user_id" class="px-3 py-2 text-sm border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
<option value="">全部人员</option>
|
||||
{{ range .Users }}
|
||||
<option value="{{.ID}}" {{ if eq $.FilterUserID .ID }}selected{{ end }}>{{.Name}}({{.Username}})</option>
|
||||
{{ end }}
|
||||
</select>
|
||||
<select name="template" class="px-3 py-2 text-sm border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
<option value="">全部模板</option>
|
||||
<option value="modern" {{ if eq .FilterTemplate "modern" }}selected{{ end }}>现代商务</option>
|
||||
<option value="classic" {{ if eq .FilterTemplate "classic" }}selected{{ end }}>传统经典</option>
|
||||
<option value="tech" {{ if eq .FilterTemplate "tech" }}selected{{ end }}>科技极客</option>
|
||||
<option value="sidebar" {{ if eq .FilterTemplate "sidebar" }}selected{{ end }}>经典侧边栏</option>
|
||||
<option value="elegant" {{ if eq .FilterTemplate "elegant" }}selected{{ end }}>优雅轻奢</option>
|
||||
<option value="fresh" {{ if eq .FilterTemplate "fresh" }}selected{{ end }}>清新自然</option>
|
||||
<option value="minimalist" {{ if eq .FilterTemplate "minimalist" }}selected{{ end }}>极简风格</option>
|
||||
<option value="professional" {{ if eq .FilterTemplate "professional" }}selected{{ end }}>专业商务</option>
|
||||
</select>
|
||||
<select name="show_in_home" class="px-3 py-2 text-sm border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
<option value="">首页展示</option>
|
||||
<option value="1" {{ if eq .FilterShowInHome "1" }}selected{{ end }}>已展示</option>
|
||||
<option value="0" {{ if eq .FilterShowInHome "0" }}selected{{ end }}>未展示</option>
|
||||
</select>
|
||||
<button type="submit" class="btn btn-primary btn-sm">
|
||||
<i class="fas fa-search"></i> 筛选
|
||||
</button>
|
||||
<a href="{{$.AdminPath}}/resumes" class="btn btn-secondary btn-sm">
|
||||
<i class="fas fa-redo"></i> 重置
|
||||
</a>
|
||||
</form>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr class="bg-slate-50 border-b border-slate-200">
|
||||
<th class="text-left px-6 py-4 text-sm font-semibold text-slate-600">姓名</th>
|
||||
<th class="text-left px-6 py-4 text-sm font-semibold text-slate-600">职位</th>
|
||||
<th class="text-left px-6 py-4 text-sm font-semibold text-slate-600">所属人员</th>
|
||||
<th class="text-left px-6 py-4 text-sm font-semibold text-slate-600">访问路由</th>
|
||||
<th class="text-left px-6 py-4 text-sm font-semibold text-slate-600">首页展示</th>
|
||||
<th class="text-left px-6 py-4 text-sm font-semibold text-slate-600">更新时间</th>
|
||||
<th class="text-left px-6 py-4 text-sm font-semibold text-slate-600 actions-col">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{ range .Resumes }}
|
||||
<tr class="border-b border-slate-100 hover:bg-slate-50 transition-colors">
|
||||
<td class="px-6 py-4">
|
||||
<span class="font-medium text-slate-800">{{.BasicInfo.Name}}</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-slate-600">{{.BasicInfo.Title}}</td>
|
||||
<td class="px-6 py-4">
|
||||
{{ if .UserID }}
|
||||
{{ $user := index $.UserMap .UserID }}
|
||||
{{ if $user }}
|
||||
<span class="badge badge-success">{{$user.Name}}({{$user.Username}})</span>
|
||||
{{ else }}
|
||||
<span class="badge badge-warning">已失效</span>
|
||||
{{ end }}
|
||||
{{ else }}
|
||||
<span class="badge badge-danger">未绑定</span>
|
||||
{{ end }}
|
||||
</td>
|
||||
<td class="px-6 py-4 text-slate-600">/{{.Route}}</td>
|
||||
<td class="px-6 py-4">
|
||||
{{ if .ShowInHome }}
|
||||
<span class="badge badge-success">是</span>
|
||||
{{ else }}
|
||||
<span class="badge badge-slate">否</span>
|
||||
{{ end }}
|
||||
</td>
|
||||
<td class="px-6 py-4 text-slate-500 text-sm">{{.UpdatedAt}}</td>
|
||||
<td class="px-6 py-4 actions-col">
|
||||
<div class="btn-group flex-wrap gap-1">
|
||||
<a href="/{{.Route}}" target="_blank" class="btn btn-info btn-xs" title="预览">
|
||||
<i class="fas fa-eye"></i> 预览
|
||||
</a>
|
||||
{{ if .UserID }}
|
||||
<a href="{{$.AdminPath}}/user/{{.UserID}}/resume/{{.ID}}/edit" class="btn btn-primary btn-xs" title="编辑">
|
||||
<i class="fas fa-edit"></i> 编辑
|
||||
</a>
|
||||
{{ else }}
|
||||
<a href="{{$.AdminPath}}/resume/{{.ID}}/edit" class="btn btn-primary btn-xs" title="编辑">
|
||||
<i class="fas fa-edit"></i> 编辑
|
||||
</a>
|
||||
{{ end }}
|
||||
<button onclick="toggleShowInHome('{{.ID}}', {{.ShowInHome}})" class="btn btn-warning btn-xs" title="{{if .ShowInHome}}取消展示{{else}}展示{{end}}">
|
||||
<i class="fas fa-home"></i> {{if .ShowInHome}}取消{{else}}展示{{end}}
|
||||
</button>
|
||||
<button onclick="updatePassword('{{.ID}}')" class="btn btn-secondary btn-xs" title="密码">
|
||||
<i class="fas fa-lock"></i> 密码
|
||||
</button>
|
||||
{{ if .UserID }}
|
||||
<button onclick="unbindUser('{{.ID}}')" class="btn btn-orange btn-xs" title="解绑人员">
|
||||
<i class="fas fa-unlink"></i> 解绑
|
||||
</button>
|
||||
{{ else }}
|
||||
<button onclick="bindUser('{{.ID}}')" class="btn btn-success btn-xs" title="绑定人员">
|
||||
<i class="fas fa-link"></i> 绑定
|
||||
</button>
|
||||
{{ end }}
|
||||
<button onclick="deleteResume('{{.ID}}')" class="btn btn-danger btn-xs" title="删除">
|
||||
<i class="fas fa-trash"></i> 删除
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{{ else }}
|
||||
<tr>
|
||||
<td colspan="7" class="px-6 py-12 text-center text-slate-500">
|
||||
<div class="w-16 h-16 mx-auto mb-4 rounded-full bg-slate-100 flex items-center justify-center">
|
||||
<i class="fas fa-file-alt text-slate-400 text-2xl"></i>
|
||||
</div>
|
||||
<p>暂无简历</p>
|
||||
</td>
|
||||
</tr>
|
||||
{{ end }}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{ if gt .TotalPages 1 }}
|
||||
<div class="border-t border-slate-200 px-6 py-4 bg-slate-50 flex items-center justify-between">
|
||||
<div class="text-sm text-slate-500">共 {{ .Total }} 条记录,第 {{ .Page }} / {{ .TotalPages }} 页</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<a href="?page=1{{ .FilterQuery }}" class="btn btn-sm btn-slate {{ if eq .Page 1 }}opacity-50 cursor-not-allowed{{ end }}" {{ if eq .Page 1 }}onclick="return false"{{ end }}>
|
||||
<i class="fas fa-chevron-left"></i>
|
||||
<i class="fas fa-chevron-left"></i>
|
||||
</a>
|
||||
<a href="?page={{ if eq .Page 1 }}1{{ else }}{{ subtract .Page 1 }}{{ end }}{{ .FilterQuery }}" class="btn btn-sm btn-slate {{ if eq .Page 1 }}opacity-50 cursor-not-allowed{{ end }}" {{ if eq .Page 1 }}onclick="return false"{{ end }}>
|
||||
<i class="fas fa-chevron-left"></i>
|
||||
</a>
|
||||
{{ if gt .TotalPages 7 }}
|
||||
{{ if gt .Page 3 }}
|
||||
<a href="?page=1{{ .FilterQuery }}" class="btn btn-sm btn-slate">1</a>
|
||||
{{ if gt .Page 4 }}
|
||||
<span class="text-slate-400 text-sm">...</span>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
{{ range $i := to 1 .TotalPages }}
|
||||
{{ if and (ge $i (subtract $.Page 2)) (le $i (add $.Page 2)) }}
|
||||
<a href="?page={{ $i }}{{ $.FilterQuery }}" class="btn btn-sm {{ if eq $i $.Page }}btn-primary{{ else }}btn-slate{{ end }}">{{ $i }}</a>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
{{ if gt .TotalPages 7 }}
|
||||
{{ if lt .Page (subtract .TotalPages 2) }}
|
||||
{{ if lt .Page (subtract .TotalPages 3) }}
|
||||
<span class="text-slate-400 text-sm">...</span>
|
||||
{{ end }}
|
||||
<a href="?page={{ .TotalPages }}{{ .FilterQuery }}" class="btn btn-sm btn-slate">{{ .TotalPages }}</a>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
<a href="?page={{ if eq .Page .TotalPages }}{{ .TotalPages }}{{ else }}{{ add .Page 1 }}{{ end }}{{ .FilterQuery }}" class="btn btn-sm btn-slate {{ if eq .Page .TotalPages }}opacity-50 cursor-not-allowed{{ end }}" {{ if eq .Page .TotalPages }}onclick="return false"{{ end }}>
|
||||
<i class="fas fa-chevron-right"></i>
|
||||
</a>
|
||||
<a href="?page={{ .TotalPages }}{{ .FilterQuery }}" class="btn btn-sm btn-slate {{ if eq .Page .TotalPages }}opacity-50 cursor-not-allowed{{ end }}" {{ if eq .Page .TotalPages }}onclick="return false"{{ end }}>
|
||||
<i class="fas fa-chevron-right"></i>
|
||||
<i class="fas fa-chevron-right"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
<div class="modal" id="addResumeModal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3 class="modal-title" id="addResumeModalTitle">
|
||||
<i class="fas fa-file-plus text-blue-500"></i> 添加简历
|
||||
</h3>
|
||||
<div class="modal-close" onclick="closeAddResumeModal()">
|
||||
<i class="fas fa-times"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="addResumeForm" onsubmit="saveResume(event)">
|
||||
<input type="hidden" id="resumeId">
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="form-label">姓名</label>
|
||||
<input type="text" id="resumeName" class="form-input" required>
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label">职位</label>
|
||||
<input type="text" id="resumeTitle" class="form-input" required>
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label">访问路由(如 zhangsan)</label>
|
||||
<input type="text" id="resumeRoute" class="form-input" required>
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label">绑定人员(可选,可后续绑定)</label>
|
||||
<select id="resumeUserId" class="form-input">
|
||||
<option value="">不绑定人员</option>
|
||||
{{ range .Users }}
|
||||
<option value="{{.ID}}">{{.Name}} - {{.Username}}</option>
|
||||
{{ end }}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button onclick="closeAddResumeModal()" class="btn btn-secondary">
|
||||
<i class="fas fa-times"></i> 取消
|
||||
</button>
|
||||
<button type="submit" form="addResumeForm" class="btn btn-primary">
|
||||
<i class="fas fa-save"></i> 保存
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal" id="bindUserModal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3 class="modal-title">
|
||||
<i class="fas fa-link text-green-500"></i> 绑定人员
|
||||
</h3>
|
||||
<div class="modal-close" onclick="closeBindUserModal()">
|
||||
<i class="fas fa-times"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-4">
|
||||
<label class="form-label">选择要绑定的人员</label>
|
||||
<select id="bindUserId" class="form-input">
|
||||
{{ range .Users }}
|
||||
<option value="{{.ID}}">{{.Name}} - {{.Username}}</option>
|
||||
{{ end }}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button onclick="closeBindUserModal()" class="btn btn-secondary">
|
||||
<i class="fas fa-times"></i> 取消
|
||||
</button>
|
||||
<button onclick="confirmBindUser()" class="btn btn-primary">
|
||||
<i class="fas fa-check"></i> 确认绑定
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal" id="passwordModal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3 class="modal-title">
|
||||
<i class="fas fa-lock text-yellow-500"></i> 设置访问密码
|
||||
</h3>
|
||||
<div class="modal-close" onclick="closePasswordModal()">
|
||||
<i class="fas fa-times"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="form-label">访问密码(留空则取消密码保护)</label>
|
||||
<input type="password" id="resumePassword" class="form-input" placeholder="请输入密码">
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label">确认密码</label>
|
||||
<input type="password" id="resumePasswordConfirm" class="form-input" placeholder="请再次输入密码">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button onclick="closePasswordModal()" class="btn btn-secondary">
|
||||
<i class="fas fa-times"></i> 取消
|
||||
</button>
|
||||
<button onclick="confirmUpdatePassword()" class="btn btn-primary">
|
||||
<i class="fas fa-save"></i> 保存
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal" id="uploadDocumentModal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3 class="modal-title">
|
||||
<i class="fas fa-upload text-green-500"></i> 上传文档生成简历
|
||||
</h3>
|
||||
<div class="modal-close" onclick="closeUploadDocumentModal()">
|
||||
<i class="fas fa-times"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="uploadDocumentForm" enctype="multipart/form-data" onsubmit="uploadDocument(event)">
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="form-label">选择文档</label>
|
||||
<div class="border-2 border-dashed border-slate-300 rounded-lg p-8 text-center hover:border-green-500 transition-colors" id="fileDropArea">
|
||||
<i class="fas fa-file-upload text-4xl text-slate-400 mb-4"></i>
|
||||
<p class="text-slate-600 mb-2">点击或拖拽文件到此处</p>
|
||||
<p class="text-sm text-slate-400">支持格式:PDF、DOCX、XLSX、MD、TXT</p>
|
||||
<input type="file" id="documentFile" name="file" accept=".pdf,.docx,.xlsx,.md,.txt" class="hidden" onchange="handleFileSelect(this)">
|
||||
</div>
|
||||
<div id="selectedFileInfo" class="mt-2 hidden">
|
||||
<div class="flex items-center gap-2 text-sm text-slate-600">
|
||||
<i class="fas fa-file"></i>
|
||||
<span id="selectedFileName"></span>
|
||||
<button type="button" onclick="clearFileSelection()" class="text-red-500 hover:text-red-700">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label">绑定人员(可选)</label>
|
||||
<select id="documentUserId" class="form-input">
|
||||
<option value="">不绑定人员</option>
|
||||
{{ range .Users }}
|
||||
<option value="{{.ID}}">{{.Name}} - {{.Username}}</option>
|
||||
{{ end }}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button onclick="closeUploadDocumentModal()" class="btn btn-secondary">
|
||||
<i class="fas fa-times"></i> 取消
|
||||
</button>
|
||||
<button type="submit" form="uploadDocumentForm" class="btn btn-success" id="uploadSubmitBtn">
|
||||
<i class="fas fa-upload"></i> 上传生成
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
var currentBindResumeId = '';
|
||||
var currentPasswordResumeId = '';
|
||||
|
||||
function openUploadDocumentModal() {
|
||||
document.getElementById('uploadDocumentModal').classList.add('active');
|
||||
document.getElementById('documentFile').value = '';
|
||||
document.getElementById('selectedFileInfo').classList.add('hidden');
|
||||
document.getElementById('selectedFileName').textContent = '';
|
||||
document.getElementById('uploadSubmitBtn').disabled = false;
|
||||
document.getElementById('uploadSubmitBtn').innerHTML = '<i class="fas fa-upload"></i> 上传生成';
|
||||
}
|
||||
|
||||
function closeUploadDocumentModal() {
|
||||
document.getElementById('uploadDocumentModal').classList.remove('active');
|
||||
}
|
||||
|
||||
document.getElementById('fileDropArea').addEventListener('click', function() {
|
||||
document.getElementById('documentFile').click();
|
||||
});
|
||||
|
||||
document.getElementById('fileDropArea').addEventListener('dragover', function(e) {
|
||||
e.preventDefault();
|
||||
this.classList.add('border-green-500');
|
||||
});
|
||||
|
||||
document.getElementById('fileDropArea').addEventListener('dragleave', function(e) {
|
||||
e.preventDefault();
|
||||
this.classList.remove('border-green-500');
|
||||
});
|
||||
|
||||
document.getElementById('fileDropArea').addEventListener('drop', function(e) {
|
||||
e.preventDefault();
|
||||
this.classList.remove('border-green-500');
|
||||
var files = e.dataTransfer.files;
|
||||
if (files.length > 0) {
|
||||
document.getElementById('documentFile').files = files;
|
||||
handleFileSelect(document.getElementById('documentFile'));
|
||||
}
|
||||
});
|
||||
|
||||
function handleFileSelect(input) {
|
||||
if (input.files.length > 0) {
|
||||
var file = input.files[0];
|
||||
document.getElementById('selectedFileName').textContent = file.name;
|
||||
document.getElementById('selectedFileInfo').classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
function clearFileSelection() {
|
||||
document.getElementById('documentFile').value = '';
|
||||
document.getElementById('selectedFileInfo').classList.add('hidden');
|
||||
document.getElementById('selectedFileName').textContent = '';
|
||||
}
|
||||
|
||||
function uploadDocument(event) {
|
||||
event.preventDefault();
|
||||
var fileInput = document.getElementById('documentFile');
|
||||
var userId = document.getElementById('documentUserId').value;
|
||||
|
||||
if (fileInput.files.length === 0) {
|
||||
showCustomAlert('error', '错误', '请选择要上传的文件');
|
||||
return;
|
||||
}
|
||||
|
||||
var file = fileInput.files[0];
|
||||
var allowedExts = ['.pdf', '.docx', '.xlsx', '.md', '.txt'];
|
||||
var fileExt = file.name.toLowerCase().substring(file.name.lastIndexOf('.'));
|
||||
|
||||
if (!allowedExts.includes(fileExt)) {
|
||||
showCustomAlert('error', '错误', '不支持的文件格式,支持:PDF、DOCX、XLSX、MD、TXT');
|
||||
return;
|
||||
}
|
||||
|
||||
var formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
if (userId) {
|
||||
formData.append('user_id', userId);
|
||||
}
|
||||
|
||||
var submitBtn = document.getElementById('uploadSubmitBtn');
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> 处理中...';
|
||||
|
||||
fetch('/dashboard/api/resume/generate-from-document', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
credentials: 'include'
|
||||
})
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error('HTTP ' + response.status);
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then(result => {
|
||||
console.log('API Response:', result);
|
||||
if (result.success) {
|
||||
showCustomAlert('success', '成功', result.message || '简历生成成功!');
|
||||
closeUploadDocumentModal();
|
||||
setTimeout(() => location.reload(), 2000);
|
||||
} else {
|
||||
showCustomAlert('error', '错误', result.message || '生成失败');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Upload error:', error);
|
||||
showCustomAlert('error', '错误', '上传失败: ' + error.message);
|
||||
})
|
||||
.finally(() => {
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.innerHTML = '<i class="fas fa-upload"></i> 上传生成';
|
||||
});
|
||||
}
|
||||
|
||||
function openAddResumeModal() {
|
||||
document.getElementById('resumeId').value = '';
|
||||
document.getElementById('resumeName').value = '';
|
||||
document.getElementById('resumeTitle').value = '';
|
||||
document.getElementById('resumeRoute').value = '';
|
||||
document.getElementById('resumeUserId').value = '';
|
||||
document.getElementById('addResumeModal').classList.add('active');
|
||||
}
|
||||
|
||||
function closeAddResumeModal() {
|
||||
document.getElementById('addResumeModal').classList.remove('active');
|
||||
}
|
||||
|
||||
function saveResume(event) {
|
||||
event.preventDefault();
|
||||
const id = document.getElementById('resumeId').value;
|
||||
const name = document.getElementById('resumeName').value;
|
||||
const title = document.getElementById('resumeTitle').value;
|
||||
const route = document.getElementById('resumeRoute').value;
|
||||
const userId = document.getElementById('resumeUserId').value;
|
||||
|
||||
if (!name || !title || !route) {
|
||||
showCustomAlert('error', '错误', '请填写完整信息');
|
||||
return;
|
||||
}
|
||||
|
||||
fetch('/dashboard/api/resume/create', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
user_id: userId,
|
||||
route: route,
|
||||
basic_info: {
|
||||
name: name,
|
||||
title: title
|
||||
}
|
||||
})
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(result => {
|
||||
if (result.success) {
|
||||
showCustomAlert('success', '成功', '添加成功');
|
||||
closeAddResumeModal();
|
||||
setTimeout(() => location.reload(), 1500);
|
||||
} else {
|
||||
showCustomAlert('error', '错误', '添加失败:' + (result.message || '未知错误'));
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
showCustomAlert('error', '错误', '添加失败');
|
||||
});
|
||||
}
|
||||
|
||||
function bindUser(resumeId) {
|
||||
currentBindResumeId = resumeId;
|
||||
document.getElementById('bindUserModal').classList.add('active');
|
||||
}
|
||||
|
||||
function closeBindUserModal() {
|
||||
document.getElementById('bindUserModal').classList.remove('active');
|
||||
currentBindResumeId = '';
|
||||
}
|
||||
|
||||
function confirmBindUser() {
|
||||
const userId = document.getElementById('bindUserId').value;
|
||||
if (!userId) {
|
||||
showCustomAlert('error', '错误', '请选择要绑定的人员');
|
||||
return;
|
||||
}
|
||||
|
||||
fetch('/dashboard/api/resume/' + currentBindResumeId + '/user', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ user_id: userId })
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(result => {
|
||||
if (result.success) {
|
||||
showCustomAlert('success', '成功', '绑定成功');
|
||||
closeBindUserModal();
|
||||
setTimeout(() => location.reload(), 1500);
|
||||
} else {
|
||||
showCustomAlert('error', '错误', '绑定失败:' + (result.message || '未知错误'));
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
showCustomAlert('error', '错误', '绑定失败');
|
||||
});
|
||||
}
|
||||
|
||||
function unbindUser(resumeId) {
|
||||
showCustomConfirm('确认解绑', '确定要解绑该简历与人员的关联吗?', function(confirmed) {
|
||||
if (!confirmed) return;
|
||||
|
||||
fetch('/dashboard/api/resume/' + resumeId + '/user', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ user_id: '' })
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(result => {
|
||||
if (result.success) {
|
||||
showCustomAlert('success', '成功', '解绑成功');
|
||||
setTimeout(() => location.reload(), 1500);
|
||||
} else {
|
||||
showCustomAlert('error', '错误', '解绑失败:' + (result.message || '未知错误'));
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
showCustomAlert('error', '错误', '解绑失败');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function toggleShowInHome(resumeId, currentState) {
|
||||
fetch('/dashboard/api/resume/' + resumeId + '/show_in_home', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ show_in_home: !currentState })
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(result => {
|
||||
if (result.success !== undefined) {
|
||||
showCustomAlert('success', '成功', currentState ? '已取消首页展示' : '已设置首页展示');
|
||||
setTimeout(() => location.reload(), 1500);
|
||||
} else {
|
||||
showCustomAlert('error', '错误', '操作失败');
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
showCustomAlert('error', '错误', '操作失败');
|
||||
});
|
||||
}
|
||||
|
||||
function updatePassword(resumeId) {
|
||||
currentPasswordResumeId = resumeId;
|
||||
document.getElementById('resumePassword').value = '';
|
||||
document.getElementById('resumePasswordConfirm').value = '';
|
||||
document.getElementById('passwordModal').classList.add('active');
|
||||
}
|
||||
|
||||
function closePasswordModal() {
|
||||
document.getElementById('passwordModal').classList.remove('active');
|
||||
currentPasswordResumeId = '';
|
||||
}
|
||||
|
||||
function confirmUpdatePassword() {
|
||||
const password = document.getElementById('resumePassword').value;
|
||||
const confirmPassword = document.getElementById('resumePasswordConfirm').value;
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
showCustomAlert('error', '错误', '两次输入的密码不一致');
|
||||
return;
|
||||
}
|
||||
|
||||
fetch('/dashboard/api/resume/' + currentPasswordResumeId + '/password', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ password: password })
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(result => {
|
||||
if (result.success) {
|
||||
showCustomAlert('success', '成功', password ? '密码设置成功' : '密码已取消');
|
||||
closePasswordModal();
|
||||
} else {
|
||||
showCustomAlert('error', '错误', '设置失败:' + (result.message || '未知错误'));
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
showCustomAlert('error', '错误', '设置失败');
|
||||
});
|
||||
}
|
||||
|
||||
function deleteResume(resumeId) {
|
||||
showCustomConfirm('确认删除', '确定要删除该简历吗?此操作不可恢复。', function(confirmed) {
|
||||
if (!confirmed) return;
|
||||
|
||||
fetch('/dashboard/api/resume/' + resumeId, {
|
||||
method: 'DELETE'
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(result => {
|
||||
if (result.success) {
|
||||
showCustomAlert('success', '成功', '删除成功');
|
||||
setTimeout(() => location.reload(), 1500);
|
||||
} else {
|
||||
showCustomAlert('error', '错误', '删除失败:' + (result.message || '未知错误'));
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
showCustomAlert('error', '错误', '删除失败');
|
||||
});
|
||||
});
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,347 @@
|
||||
<div class="bg-white rounded-xl shadow-sm border border-slate-200 p-6">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h3 class="font-semibold text-slate-800 flex items-center gap-2">
|
||||
<i class="fas fa-cog text-purple-500"></i> 系统设置
|
||||
</h3>
|
||||
<p class="text-sm text-slate-500 mt-1">配置系统参数</p>
|
||||
</div>
|
||||
<button onclick="saveSettings()" class="btn btn-primary">
|
||||
<i class="fas fa-save"></i> 保存设置
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<div class="lg:col-span-2 space-y-6">
|
||||
<div class="p-6 bg-slate-50 rounded-lg">
|
||||
<h4 class="font-medium text-slate-700 mb-4 flex items-center gap-2">
|
||||
<i class="fas fa-globe text-blue-500"></i> 网站基本设置
|
||||
</h4>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700 mb-1">网站域名</label>
|
||||
<input type="text" id="settingDomain" class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500" value="{{.Config.WebsiteDomain}}">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700 mb-1">网站标题</label>
|
||||
<input type="text" id="settingTitle" class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500" value="{{.Config.WebsiteTitle}}">
|
||||
</div>
|
||||
<div class="md:col-span-2">
|
||||
<label class="block text-sm font-medium text-slate-700 mb-1">网站描述</label>
|
||||
<textarea id="settingDescription" rows="3" class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">{{.Config.WebsiteDescription}}</textarea>
|
||||
</div>
|
||||
<div class="md:col-span-2">
|
||||
<label class="block text-sm font-medium text-slate-700 mb-1">网站关键词</label>
|
||||
<input type="text" id="settingKeywords" class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500" value="{{.Config.WebsiteKeywords}}" placeholder="多个关键词用逗号分隔">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700 mb-1">管理员邮箱</label>
|
||||
<input type="email" id="settingAdminEmail" class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500" value="{{.Config.AdminEmail}}">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700 mb-1">管理员姓名</label>
|
||||
<input type="text" id="settingAdminName" class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500" value="{{.Config.AdminName}}">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-6 bg-slate-50 rounded-lg">
|
||||
<h4 class="font-medium text-slate-700 mb-4 flex items-center gap-2">
|
||||
<i class="fas fa-image text-blue-500"></i> Logo设置
|
||||
</h4>
|
||||
<div class="flex items-center gap-6">
|
||||
<div class="flex-shrink-0">
|
||||
{{ if .Config.LogoPath }}
|
||||
<div class="relative group">
|
||||
<img id="logoPreview" src="{{.Config.LogoPath}}" data-path="{{.Config.LogoPath}}" class="w-24 h-24 rounded-xl object-cover border-2 border-blue-200 shadow-md">
|
||||
<button onclick="clearLogo()" class="absolute -top-2 -right-2 w-6 h-6 bg-red-500 text-white rounded-full opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center shadow-lg hover:bg-red-600">
|
||||
<i class="fas fa-times text-xs"></i>
|
||||
</button>
|
||||
</div>
|
||||
{{ else }}
|
||||
<div id="logoPreview" class="w-24 h-24 rounded-xl bg-white border-2 border-dashed border-slate-300 flex flex-col items-center justify-center text-slate-400 hover:border-blue-400 hover:text-blue-500 transition-all cursor-pointer" onclick="document.getElementById('logoFile').click()">
|
||||
<i class="fas fa-image text-2xl mb-1"></i>
|
||||
<span class="text-xs">点击上传</span>
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<div id="logoDropZone" class="w-full border-2 border-dashed border-slate-300 rounded-lg p-6 text-center hover:border-blue-400 hover:bg-blue-50 transition-all cursor-pointer" onclick="document.getElementById('logoFile').click()">
|
||||
<i class="fas fa-cloud-upload-alt text-blue-500 text-3xl mb-3"></i>
|
||||
<p class="text-sm text-slate-600">点击或拖动文件到此处自动上传</p>
|
||||
</div>
|
||||
<input type="file" id="logoFile" accept="image/*" class="hidden" onchange="handleLogoSelect(this)">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-6 bg-slate-50 rounded-lg">
|
||||
<h4 class="font-medium text-slate-700 mb-4 flex items-center gap-2">
|
||||
<i class="fas fa-list text-blue-500"></i> 后台设置
|
||||
</h4>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700 mb-1">后台列表每页数据条数</label>
|
||||
<input type="number" id="settingAdminPageSize" class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500" value="{{.Config.AdminPageSize}}" min="1" max="100">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700 mb-1">SEO 关键词</label>
|
||||
<input type="text" id="settingSEOKeywords" class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500" value="{{.Config.SEOKeywords}}" placeholder="多个关键词用逗号分隔">
|
||||
</div>
|
||||
<div class="md:col-span-2">
|
||||
<label class="block text-sm font-medium text-slate-700 mb-1">SEO 描述</label>
|
||||
<textarea id="settingSEODescription" rows="2" class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">{{.Config.SEODescription}}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="p-6 bg-gradient-to-br from-blue-50 to-indigo-50 rounded-lg border border-blue-200">
|
||||
<h4 class="font-medium text-blue-800 mb-4 flex items-center gap-2">
|
||||
<i class="fas fa-server text-blue-600"></i> 系统信息
|
||||
</h4>
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="text-slate-500">系统版本</span>
|
||||
<span class="text-slate-800 font-medium">1.0.0</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="text-slate-500">Go 版本</span>
|
||||
<span class="text-slate-800 font-medium" id="sysGoVersion">-</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="text-slate-500">数据库</span>
|
||||
<span class="text-slate-800 font-medium">SQLite</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="text-slate-500">操作系统</span>
|
||||
<span class="text-slate-800 font-medium" id="sysOS">-</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="text-slate-500">架构</span>
|
||||
<span class="text-slate-800 font-medium" id="sysArch">-</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="text-slate-500">启动时间</span>
|
||||
<span class="text-slate-800 font-medium" id="sysStartTime">-</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="text-slate-500">运行时长</span>
|
||||
<span class="text-slate-800 font-medium" id="sysUptime">-</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-6 bg-slate-50 rounded-lg">
|
||||
<h4 class="font-medium text-slate-700 mb-4 flex items-center gap-2">
|
||||
<i class="fas fa-history text-blue-500"></i> 最近登录记录
|
||||
</h4>
|
||||
<div id="loginHistoryList" class="space-y-3 max-h-60 overflow-y-auto">
|
||||
<div class="text-center text-slate-400 text-sm py-4">加载中...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function handleLogoSelect(input) {
|
||||
if (input.files && input.files[0]) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = function(e) {
|
||||
const preview = document.getElementById('logoPreview');
|
||||
if (preview.tagName === 'IMG') {
|
||||
preview.src = e.target.result;
|
||||
// 上传未完成前先清空 data-path,等待 uploadLogo 完成后再回填服务端路径
|
||||
preview.removeAttribute('data-path');
|
||||
} else {
|
||||
preview.outerHTML = '<div id="logoPreview" class="relative group"><img src="' + e.target.result + '" class="w-24 h-24 rounded-xl object-cover border-2 border-blue-200 shadow-md"><button onclick="clearLogo()" class="absolute -top-2 -right-2 w-6 h-6 bg-red-500 text-white rounded-full opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center shadow-lg hover:bg-red-600"><i class="fas fa-times text-xs"></i></button></div>';
|
||||
}
|
||||
}
|
||||
reader.readAsDataURL(input.files[0]);
|
||||
|
||||
uploadLogo(input.files[0]);
|
||||
}
|
||||
}
|
||||
|
||||
function clearLogo() {
|
||||
const preview = document.getElementById('logoPreview');
|
||||
preview.outerHTML = '<div id="logoPreview" class="w-24 h-24 rounded-xl bg-white border-2 border-dashed border-slate-300 flex flex-col items-center justify-center text-slate-400 hover:border-blue-400 hover:text-blue-500 transition-all cursor-pointer" onclick="document.getElementById(\'logoFile\').click()"><i class="fas fa-image text-2xl mb-1"></i><span class="text-xs">点击上传</span></div>';
|
||||
showCustomAlert('success', '成功', '已清除预览,请点击保存设置生效');
|
||||
}
|
||||
|
||||
function setupDropZone() {
|
||||
const dropZone = document.getElementById('logoDropZone');
|
||||
const fileInput = document.getElementById('logoFile');
|
||||
|
||||
dropZone.addEventListener('dragover', function(e) {
|
||||
e.preventDefault();
|
||||
dropZone.classList.add('border-blue-500', 'bg-blue-50');
|
||||
});
|
||||
|
||||
dropZone.addEventListener('dragleave', function(e) {
|
||||
e.preventDefault();
|
||||
dropZone.classList.remove('border-blue-500', 'bg-blue-50');
|
||||
});
|
||||
|
||||
dropZone.addEventListener('drop', function(e) {
|
||||
e.preventDefault();
|
||||
dropZone.classList.remove('border-blue-500', 'bg-blue-50');
|
||||
|
||||
const files = e.dataTransfer.files;
|
||||
if (files.length > 0 && files[0].type.startsWith('image/')) {
|
||||
fileInput.files = files;
|
||||
handleLogoSelect(fileInput);
|
||||
} else {
|
||||
showCustomAlert('error', '错误', '请上传图片文件');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function uploadLogo(file) {
|
||||
const formData = new FormData();
|
||||
formData.append('logo', file);
|
||||
|
||||
fetch('/dashboard/api/settings/logo', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(result => {
|
||||
const logoPath = result.data && result.data.logo_path;
|
||||
if (result.success && logoPath) {
|
||||
const preview = document.getElementById('logoPreview');
|
||||
if (preview.tagName === 'IMG') {
|
||||
preview.src = logoPath;
|
||||
preview.setAttribute('data-path', logoPath);
|
||||
} else {
|
||||
const img = preview.querySelector('img');
|
||||
if (img) {
|
||||
img.src = logoPath;
|
||||
img.setAttribute('data-path', logoPath);
|
||||
}
|
||||
}
|
||||
showCustomAlert('success', '成功', 'Logo上传成功');
|
||||
} else {
|
||||
showCustomAlert('error', '错误', '上传失败:' + (result.message || '未知错误'));
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
showCustomAlert('error', '错误', '上传失败');
|
||||
});
|
||||
}
|
||||
|
||||
function saveSettings() {
|
||||
const logoPreview = document.getElementById('logoPreview');
|
||||
let logoPath = '';
|
||||
// 优先读取自定义 data-path 属性(保存的是相对路径),避免浏览器把 src 转为绝对 URL
|
||||
if (logoPreview.tagName === 'IMG') {
|
||||
logoPath = logoPreview.getAttribute('data-path') || '';
|
||||
} else {
|
||||
const img = logoPreview.querySelector('img');
|
||||
if (img) logoPath = img.getAttribute('data-path') || '';
|
||||
}
|
||||
|
||||
const adminPageSize = parseInt(document.getElementById('settingAdminPageSize').value, 10);
|
||||
if (isNaN(adminPageSize) || adminPageSize < 1 || adminPageSize > 100) {
|
||||
showCustomAlert('error', '错误', '后台列表每页数据条数必须在 1-100 之间');
|
||||
return;
|
||||
}
|
||||
|
||||
const data = {
|
||||
website_domain: document.getElementById('settingDomain').value,
|
||||
website_title: document.getElementById('settingTitle').value,
|
||||
website_description: document.getElementById('settingDescription').value,
|
||||
website_keywords: document.getElementById('settingKeywords').value,
|
||||
admin_email: document.getElementById('settingAdminEmail').value,
|
||||
admin_name: document.getElementById('settingAdminName').value,
|
||||
logo_path: logoPath,
|
||||
seo_keywords: document.getElementById('settingSEOKeywords').value,
|
||||
seo_description: document.getElementById('settingSEODescription').value,
|
||||
admin_page_size: adminPageSize || 20
|
||||
};
|
||||
|
||||
fetch('/dashboard/api/settings', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(result => {
|
||||
if (result.success) {
|
||||
showCustomAlert('success', '成功', '设置保存成功');
|
||||
setTimeout(() => location.reload(), 1500);
|
||||
} else {
|
||||
showCustomAlert('error', '错误', '保存失败:' + (result.message || '未知错误'));
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
showCustomAlert('error', '错误', '保存失败');
|
||||
});
|
||||
}
|
||||
|
||||
function loadSystemInfo() {
|
||||
fetch('/dashboard/api/settings/system-info')
|
||||
.then(response => response.json())
|
||||
.then(result => {
|
||||
if (result.success) {
|
||||
const data = result.data;
|
||||
document.getElementById('sysGoVersion').textContent = data.go_version || '-';
|
||||
document.getElementById('sysOS').textContent = data.os || '-';
|
||||
document.getElementById('sysArch').textContent = data.arch || '-';
|
||||
document.getElementById('sysStartTime').textContent = data.start_time || '-';
|
||||
document.getElementById('sysUptime').textContent = data.uptime || '-';
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error loading system info:', error);
|
||||
});
|
||||
}
|
||||
|
||||
function loadLoginHistory() {
|
||||
fetch('/dashboard/api/settings/login-history?page=1&pageSize=5')
|
||||
.then(response => response.json())
|
||||
.then(result => {
|
||||
if (result.success) {
|
||||
const historyList = document.getElementById('loginHistoryList');
|
||||
if (result.data.list.length === 0) {
|
||||
historyList.innerHTML = '<div class="text-center text-slate-400 text-sm py-4">暂无登录记录</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
historyList.innerHTML = '';
|
||||
result.data.list.forEach(item => {
|
||||
const statusIcon = item.success
|
||||
? '<i class="fas fa-check-circle text-green-500"></i>'
|
||||
: '<i class="fas fa-times-circle text-red-500"></i>';
|
||||
const statusText = item.success ? '成功' : '失败';
|
||||
|
||||
const itemDiv = document.createElement('div');
|
||||
itemDiv.className = 'flex items-center justify-between p-3 bg-white rounded-lg border border-slate-100';
|
||||
itemDiv.innerHTML = `
|
||||
<div class="flex items-center gap-2">
|
||||
${statusIcon}
|
||||
<span class="text-sm text-slate-700">${item.user_name || '未知用户'}</span>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="text-xs text-slate-500">${item.ip}</div>
|
||||
<div class="text-xs text-slate-400">${item.created_at}</div>
|
||||
</div>
|
||||
`;
|
||||
historyList.appendChild(itemDiv);
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error loading login history:', error);
|
||||
document.getElementById('loginHistoryList').innerHTML = '<div class="text-center text-slate-400 text-sm py-4">加载失败</div>';
|
||||
});
|
||||
}
|
||||
|
||||
setupDropZone();
|
||||
loadSystemInfo();
|
||||
loadLoginHistory();
|
||||
</script>
|
||||
@@ -0,0 +1,278 @@
|
||||
<div class="bg-white rounded-xl shadow-sm border border-slate-200 p-6">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h3 class="font-semibold text-slate-800 flex items-center gap-2">
|
||||
<i class="fas fa-folder-open text-purple-500"></i> {{ if .User }}{{.User.Name}}{{ else }}未知用户{{ end }} 的作品集管理
|
||||
</h3>
|
||||
<p class="text-sm text-slate-500 mt-1">管理用户的作品集项目</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<a href="{{.AdminPath}}/portfolio/add" class="btn btn-primary">
|
||||
<i class="fas fa-plus"></i> 添加项目
|
||||
</a>
|
||||
<a href="{{.AdminPath}}/users" class="btn btn-secondary">
|
||||
<i class="fas fa-arrow-left"></i> 返回列表
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{{ range .PortfolioItems }}
|
||||
<div class="p-4 bg-slate-50 rounded-lg border border-slate-200 hover:border-primary-300 transition-all {{ if .Hidden }}opacity-50{{ end }}">
|
||||
<div class="flex items-start justify-between mb-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<h4 class="font-medium text-slate-800">{{.Name}}</h4>
|
||||
{{ if .Hidden }}
|
||||
<span class="px-2 py-0.5 bg-gray-200 text-gray-600 rounded text-xs">已隐藏</span>
|
||||
{{ end }}
|
||||
{{ if .Password }}
|
||||
<span class="px-2 py-0.5 bg-amber-100 text-amber-700 rounded text-xs"><i class="fas fa-lock"></i></span>
|
||||
{{ end }}
|
||||
</div>
|
||||
<div class="btn-group">
|
||||
<a href="{{$.AdminPath}}/portfolio/{{.ID}}/edit" class="btn btn-warning">
|
||||
<i class="fas fa-edit"></i> 编辑
|
||||
</a>
|
||||
<button onclick="togglePortfolioHidden('{{.ID}}', {{.Hidden}})" class="btn btn-{{ if .Hidden }}success{{ else }}secondary{{ end }}">
|
||||
<i class="fas {{ if .Hidden }}fa-eye{{ else }}fa-eye-slash{{ end }}"></i> {{ if .Hidden }}显示{{ else }}隐藏{{ end }}
|
||||
</button>
|
||||
<button onclick="deletePortfolio('{{.ID}}')" class="btn btn-danger">
|
||||
<i class="fas fa-trash"></i> 删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-sm text-slate-600 mb-3">{{.Description}}</p>
|
||||
<div class="flex flex-wrap gap-1 mb-3">
|
||||
{{ range .TechStack }}
|
||||
<span class="px-2 py-0.5 bg-slate-200 text-slate-600 rounded text-xs">{{.}}</span>
|
||||
{{ end }}
|
||||
</div>
|
||||
<div class="flex items-center justify-between text-xs text-slate-500">
|
||||
<span>{{.StartDate}} ~ {{.EndDate}}</span>
|
||||
{{ if .URL }}
|
||||
<a href="{{.URL}}" target="_blank" class="text-primary-600 hover:text-primary-700">
|
||||
<i class="fas fa-external-link-alt"></i> 查看
|
||||
</a>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
{{ else }}
|
||||
<div class="col-span-full text-center py-12">
|
||||
<div class="w-16 h-16 mx-auto mb-4 rounded-full bg-slate-100 flex items-center justify-center">
|
||||
<i class="fas fa-folder-open text-slate-400 text-2xl"></i>
|
||||
</div>
|
||||
<h4 class="text-lg font-semibold text-slate-700 mb-2">暂无作品集项目</h4>
|
||||
<p class="text-slate-500">该用户还没有添加作品集项目</p>
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal hidden fixed inset-0 bg-black/50 flex items-center justify-center z-50" id="portfolioModal">
|
||||
<div class="modal-content bg-white rounded-xl shadow-xl w-full max-w-lg mx-4">
|
||||
<div class="flex items-center justify-between p-4 border-b border-slate-200">
|
||||
<h3 class="font-semibold text-slate-800" id="portfolioModalTitle">添加项目</h3>
|
||||
<button onclick="closePortfolioModal()" class="btn btn-secondary btn-sm">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<form id="portfolioForm" onsubmit="savePortfolio(event)">
|
||||
<input type="hidden" id="portfolioId">
|
||||
<input type="hidden" id="portfolioUserId" value="{{ with .User }}{{.ID}}{{ end }}">
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700 mb-1">项目名称</label>
|
||||
<input type="text" id="portfolioName" class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500" required>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700 mb-1">项目描述</label>
|
||||
<textarea id="portfolioDescription" rows="3" class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700 mb-1">技术栈 (每行一个)</label>
|
||||
<textarea id="portfolioTechStack" rows="3" class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500" placeholder="Go React MySQL"></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700 mb-1">项目链接</label>
|
||||
<input type="url" id="portfolioURL" class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500">
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700 mb-1">开始日期</label>
|
||||
<input type="text" id="portfolioStartDate" class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500" placeholder="2024-01">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700 mb-1">结束日期</label>
|
||||
<input type="text" id="portfolioEndDate" class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500" placeholder="2024-12">
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700 mb-1">关联简历项目</label>
|
||||
<select id="portfolioProjectID" class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500">
|
||||
<option value="0">不关联简历项目</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" id="portfolioShowInResume" class="w-4 h-4 text-primary-600 rounded focus:ring-primary-500">
|
||||
<span class="text-sm font-medium text-slate-700">在简历中显示</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" id="portfolioHidden" class="w-4 h-4 text-primary-600 rounded focus:ring-primary-500">
|
||||
<span class="text-sm font-medium text-slate-700">隐藏项目</span>
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700 mb-1">访问密码(留空则不设密码)</label>
|
||||
<input type="password" id="portfolioPassword" class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500" placeholder="输入访问密码">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700 mb-1">项目文档 (Markdown格式)</label>
|
||||
<textarea id="portfolioDoc" rows="8" class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500 font-mono text-sm" placeholder="# 项目概述 这里编写项目详细说明..."></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-center gap-3 mt-6">
|
||||
<button type="button" onclick="closePortfolioModal()" class="btn btn-secondary">
|
||||
<i class="fas fa-times"></i> 取消
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fas fa-save"></i> 保存
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
function openAddPortfolioModal() {
|
||||
document.getElementById('portfolioModalTitle').textContent = '添加项目';
|
||||
document.getElementById('portfolioId').value = '';
|
||||
document.getElementById('portfolioName').value = '';
|
||||
document.getElementById('portfolioDescription').value = '';
|
||||
document.getElementById('portfolioTechStack').value = '';
|
||||
document.getElementById('portfolioURL').value = '';
|
||||
document.getElementById('portfolioStartDate').value = '';
|
||||
document.getElementById('portfolioEndDate').value = '';
|
||||
document.getElementById('portfolioProjectID').value = '0';
|
||||
document.getElementById('portfolioShowInResume').checked = false;
|
||||
document.getElementById('portfolioHidden').checked = false;
|
||||
document.getElementById('portfolioPassword').value = '';
|
||||
document.getElementById('portfolioDoc').value = '';
|
||||
document.getElementById('portfolioModal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
function closePortfolioModal() {
|
||||
document.getElementById('portfolioModal').classList.add('hidden');
|
||||
}
|
||||
|
||||
function editPortfolio(id) {
|
||||
fetch('/dashboard/api/portfolio/' + id)
|
||||
.then(response => response.json())
|
||||
.then(result => {
|
||||
if (!result.success || !result.data) {
|
||||
showCustomAlert('error', '错误', '获取项目信息失败');
|
||||
return;
|
||||
}
|
||||
const item = result.data;
|
||||
document.getElementById('portfolioModalTitle').textContent = '编辑项目';
|
||||
document.getElementById('portfolioId').value = item.id;
|
||||
document.getElementById('portfolioName').value = item.name;
|
||||
document.getElementById('portfolioDescription').value = item.description || '';
|
||||
document.getElementById('portfolioTechStack').value = (item.tech_stack || []).join('\n');
|
||||
document.getElementById('portfolioURL').value = item.url || '';
|
||||
document.getElementById('portfolioStartDate').value = item.start_date || '';
|
||||
document.getElementById('portfolioEndDate').value = item.end_date || '';
|
||||
document.getElementById('portfolioProjectID').value = item.project_id || '0';
|
||||
document.getElementById('portfolioShowInResume').checked = item.show_in_resume || false;
|
||||
document.getElementById('portfolioHidden').checked = item.hidden || false;
|
||||
document.getElementById('portfolioPassword').value = item.password || '';
|
||||
document.getElementById('portfolioDoc').value = item.doc || '';
|
||||
document.getElementById('portfolioModal').classList.remove('hidden');
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
showCustomAlert('error', '错误', '获取项目信息失败');
|
||||
});
|
||||
}
|
||||
|
||||
function deletePortfolio(id) {
|
||||
showCustomConfirm('确认删除', '确定要删除该项目吗?', function(confirmed) {
|
||||
if (!confirmed) return;
|
||||
fetch('/dashboard/api/portfolio/' + id, { method: 'DELETE' })
|
||||
.then(response => response.json())
|
||||
.then(result => {
|
||||
if (result.success) {
|
||||
location.reload();
|
||||
} else {
|
||||
showCustomAlert('error', '错误', '删除失败:' + (result.message || '未知错误'));
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
showCustomAlert('error', '错误', '删除失败');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function savePortfolio(event) {
|
||||
event.preventDefault();
|
||||
const id = document.getElementById('portfolioId').value;
|
||||
const userId = document.getElementById('portfolioUserId').value;
|
||||
const data = {
|
||||
user_id: userId,
|
||||
name: document.getElementById('portfolioName').value,
|
||||
description: document.getElementById('portfolioDescription').value,
|
||||
tech_stack: document.getElementById('portfolioTechStack').value.split('\n').map(s => s.trim()).filter(s => s),
|
||||
url: document.getElementById('portfolioURL').value,
|
||||
start_date: document.getElementById('portfolioStartDate').value,
|
||||
end_date: document.getElementById('portfolioEndDate').value,
|
||||
project_id: parseInt(document.getElementById('portfolioProjectID').value) || 0,
|
||||
show_in_resume: document.getElementById('portfolioShowInResume').checked,
|
||||
hidden: document.getElementById('portfolioHidden').checked,
|
||||
password: document.getElementById('portfolioPassword').value,
|
||||
doc: document.getElementById('portfolioDoc').value
|
||||
};
|
||||
|
||||
const url = id ? '/dashboard/api/portfolio/' + id : '/dashboard/api/portfolio';
|
||||
const method = id ? 'PUT' : 'POST';
|
||||
|
||||
fetch(url, {
|
||||
method: method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(result => {
|
||||
if (result.success) {
|
||||
showCustomAlert('success', '成功', '保存成功');
|
||||
closePortfolioModal();
|
||||
setTimeout(() => location.reload(), 1500);
|
||||
} else {
|
||||
showCustomAlert('error', '错误', '保存失败:' + (result.message || '未知错误'));
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
showCustomAlert('error', '错误', '保存失败');
|
||||
});
|
||||
}
|
||||
|
||||
function togglePortfolioHidden(id, isHidden) {
|
||||
fetch('/dashboard/api/portfolio/' + id, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ hidden: !isHidden })
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(result => {
|
||||
if (result.success) {
|
||||
location.reload();
|
||||
} else {
|
||||
showCustomAlert('error', '错误', '操作失败:' + (result.message || '未知错误'));
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
showCustomAlert('error', '错误', '操作失败');
|
||||
});
|
||||
}
|
||||
</script>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user