feat: init cpdaily-xiaozhushou with blue-green deploy
- backed/: Go Fiber backend with cpdaily integration - frondend/: Vue 3 + Vite frontend - Dockerfile: multi-stage build (Node → Go → Alpine) - deploy/docker-compose.yml: production compose file - .woodpecker/build.yml: CI/CD with blue-green zero-downtime deploy - /health endpoint for deployment health checks - Port 8884/8885 blue-green swap via 1Panel OpenResty
This commit is contained in:
+23
@@ -0,0 +1,23 @@
|
|||||||
|
# Go
|
||||||
|
backed/server
|
||||||
|
backed/cpdaily-new
|
||||||
|
backed/cpdaily-server
|
||||||
|
backed/*.log
|
||||||
|
backed/*.har
|
||||||
|
|
||||||
|
# Node
|
||||||
|
frondend/node_modules/
|
||||||
|
frondend/dist/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Docker
|
||||||
|
*.tar
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
when:
|
||||||
|
- event: push
|
||||||
|
branch: [main, master]
|
||||||
|
- event: tag
|
||||||
|
branch: [v*]
|
||||||
|
|
||||||
|
clone:
|
||||||
|
git:
|
||||||
|
image: woodpeckerci/plugin-git
|
||||||
|
settings:
|
||||||
|
remote: http://10.10.10.128:3000/becomer/cpdaily_xiaozhushou.git
|
||||||
|
|
||||||
|
steps:
|
||||||
|
build-image:
|
||||||
|
name: 构建 Docker 镜像
|
||||||
|
image: docker
|
||||||
|
commands: |
|
||||||
|
docker build -t cpdaily-xiaozhushou:latest .
|
||||||
|
docker tag cpdaily-xiaozhushou:latest cpdaily-xiaozhushou:${CI_COMMIT_TAG:-${CI_COMMIT_SHA:0:8}}
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
name: 蓝绿部署
|
||||||
|
image: docker
|
||||||
|
commands: |
|
||||||
|
set -e
|
||||||
|
|
||||||
|
IMAGE_TAG="${CI_COMMIT_TAG:-latest}"
|
||||||
|
|
||||||
|
# 确保网络存在
|
||||||
|
docker network create likedream-net 2>/dev/null || true
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 第一步:初始化 1Panel 站点配置(首次部署时才需要)
|
||||||
|
# ============================================================
|
||||||
|
if ! docker exec 1Panel-openresty-ESmn ls /www/sites/xiaozhushou/proxy/api.conf 2>/dev/null; then
|
||||||
|
echo "=== 首次部署:创建 1Panel 站点配置 ==="
|
||||||
|
docker exec 1Panel-openresty-ESmn mkdir -p /www/sites/xiaozhushou/proxy /www/sites/xiaozhushou/log
|
||||||
|
cat <<'NGINX_EOF' | docker exec -i 1Panel-openresty-ESmn tee /usr/local/openresty/nginx/conf/conf.d/xiaozhushou.conf
|
||||||
|
server {
|
||||||
|
listen 80 ;
|
||||||
|
server_name xiaozhushou.eu.cc;
|
||||||
|
index index.php index.html index.htm default.php default.htm default.html;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Host $server_name;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection $http_connection;
|
||||||
|
access_log /www/sites/xiaozhushou/log/access.log main;
|
||||||
|
error_log /www/sites/xiaozhushou/log/error.log;
|
||||||
|
location ^~ /.well-known/acme-challenge {
|
||||||
|
allow all;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
}
|
||||||
|
include /www/sites/xiaozhushou/proxy/*.conf;
|
||||||
|
}
|
||||||
|
NGINX_EOF
|
||||||
|
cat <<'PROXY_EOF' | docker exec -i 1Panel-openresty-ESmn tee /www/sites/xiaozhushou/proxy/api.conf
|
||||||
|
location ^~ / {
|
||||||
|
proxy_pass http://10.10.10.128:8884/;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header REMOTE-HOST $remote_addr;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection $http_connection;
|
||||||
|
proxy_set_header X-Forwarded-Proto https;
|
||||||
|
proxy_set_header X-Forwarded-Port $server_port;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
add_header X-Cache $upstream_cache_status;
|
||||||
|
add_header Cache-Control no-cache;
|
||||||
|
proxy_ssl_server_name off;
|
||||||
|
proxy_ssl_name $proxy_host;
|
||||||
|
}
|
||||||
|
PROXY_EOF
|
||||||
|
docker exec 1Panel-openresty-ESmn nginx -s reload
|
||||||
|
echo " 1Panel 站点配置完成 ✓"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 第二步:读取当前代理端口,确定蓝绿状态
|
||||||
|
# ============================================================
|
||||||
|
CURRENT_PORT=$(docker exec 1Panel-openresty-ESmn \
|
||||||
|
grep -o 'proxy_pass http://10.10.10.128:[0-9]\+' \
|
||||||
|
/www/sites/xiaozhushou/proxy/api.conf | grep -o '[0-9]\+$')
|
||||||
|
echo "=== 当前 1Panel 反代端口: $CURRENT_PORT ==="
|
||||||
|
|
||||||
|
if [ "$CURRENT_PORT" = "8884" ]; then
|
||||||
|
PORT_ACTIVE=8884
|
||||||
|
PORT_STANDBY=8885
|
||||||
|
elif [ "$CURRENT_PORT" = "8885" ]; then
|
||||||
|
PORT_ACTIVE=8885
|
||||||
|
PORT_STANDBY=8884
|
||||||
|
else
|
||||||
|
echo "未知当前端口 $CURRENT_PORT,默认使用 8884/8885"
|
||||||
|
PORT_ACTIVE=8884
|
||||||
|
PORT_STANDBY=8885
|
||||||
|
fi
|
||||||
|
echo " 活跃: $PORT_ACTIVE | 备用: $PORT_STANDBY"
|
||||||
|
|
||||||
|
# 清理之前可能残留的临时容器
|
||||||
|
docker rm -f cpdaily-xiaozhushou-new 2>/dev/null || true
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 第三步:在备用端口启动新容器
|
||||||
|
# ============================================================
|
||||||
|
echo "=== 蓝绿部署: 启动新容器 on :$PORT_STANDBY (image: $IMAGE_TAG) ==="
|
||||||
|
|
||||||
|
docker run -d --name cpdaily-xiaozhushou-new \
|
||||||
|
--restart unless-stopped \
|
||||||
|
--network likedream-net \
|
||||||
|
-p $PORT_STANDBY:8093 \
|
||||||
|
-v /opt/cpdaily-xiaozhushou/config.toml:/app/config.toml:ro \
|
||||||
|
cpdaily-xiaozhushou:$IMAGE_TAG
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 第四步:健康检查
|
||||||
|
# ============================================================
|
||||||
|
HEALTHY=false
|
||||||
|
for i in $(seq 1 15); do
|
||||||
|
if wget -q --timeout=3 -O /dev/null http://10.10.10.128:$PORT_STANDBY/health 2>/dev/null; then
|
||||||
|
HEALTHY=true
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
echo " 等待新容器就绪... ($i/15)"
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ "$HEALTHY" = false ]; then
|
||||||
|
echo "ERROR: 新容器未通过健康检查,回滚"
|
||||||
|
docker stop cpdaily-xiaozhushou-new
|
||||||
|
docker rm cpdaily-xiaozhushou-new
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo " 新容器已就绪 ✓"
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 第五步:切换 1Panel 代理到备用端口
|
||||||
|
# ============================================================
|
||||||
|
echo "=== 切换 1Panel 反代 :$PORT_ACTIVE → :$PORT_STANDBY ==="
|
||||||
|
docker exec 1Panel-openresty-ESmn \
|
||||||
|
sed -i "s|:$PORT_ACTIVE|:$PORT_STANDBY|g" \
|
||||||
|
/www/sites/xiaozhushou/proxy/api.conf
|
||||||
|
docker exec 1Panel-openresty-ESmn nginx -s reload
|
||||||
|
echo " Nginx 热加载完成(零中断)✓"
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 第六步:停止旧容器
|
||||||
|
# ============================================================
|
||||||
|
echo "=== 清理旧容器 ==="
|
||||||
|
docker stop cpdaily-xiaozhushou 2>/dev/null || true
|
||||||
|
docker rm cpdaily-xiaozhushou 2>/dev/null || true
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 第七步:在活跃端口启动正式容器
|
||||||
|
# ============================================================
|
||||||
|
echo "=== 启动正式容器 on :$PORT_ACTIVE ==="
|
||||||
|
docker run -d --name cpdaily-xiaozhushou \
|
||||||
|
--restart unless-stopped \
|
||||||
|
--network likedream-net \
|
||||||
|
-p $PORT_ACTIVE:8093 \
|
||||||
|
-v /opt/cpdaily-xiaozhushou/config.toml:/app/config.toml:ro \
|
||||||
|
cpdaily-xiaozhushou:$IMAGE_TAG
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 第八步:切回 1Panel 代理到活跃端口
|
||||||
|
# ============================================================
|
||||||
|
echo "=== 切回 1Panel 反代 :$PORT_STANDBY → :$PORT_ACTIVE ==="
|
||||||
|
docker exec 1Panel-openresty-ESmn \
|
||||||
|
sed -i "s|:$PORT_STANDBY|:$PORT_ACTIVE|g" \
|
||||||
|
/www/sites/xiaozhushou/proxy/api.conf
|
||||||
|
docker exec 1Panel-openresty-ESmn nginx -s reload
|
||||||
|
echo " Nginx 热加载完成(零中断)✓"
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 第九步:清理
|
||||||
|
# ============================================================
|
||||||
|
echo "=== 清理 ==="
|
||||||
|
docker rm -f cpdaily-xiaozhushou-new 2>/dev/null || true
|
||||||
|
docker images cpdaily-xiaozhushou --format '{{.Tag}}' | grep -v latest | head -n -3 | while read tag; do
|
||||||
|
docker rmi cpdaily-xiaozhushou:$tag 2>/dev/null || true
|
||||||
|
done
|
||||||
|
echo "=== 部署完成 ✓ ==="
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
+31
@@ -0,0 +1,31 @@
|
|||||||
|
# ============================================================
|
||||||
|
# Stage 1: 构建前端 (Vue 3 + Vite)
|
||||||
|
# ============================================================
|
||||||
|
FROM node:23-alpine AS frontend-builder
|
||||||
|
WORKDIR /build
|
||||||
|
COPY frondend/package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
COPY frondend/ ./
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Stage 2: 构建后端 (Go + Fiber)
|
||||||
|
# ============================================================
|
||||||
|
FROM golang:1.26-alpine AS backend-builder
|
||||||
|
WORKDIR /build
|
||||||
|
ENV GOPROXY=https://goproxy.cn,direct
|
||||||
|
COPY backed/go.mod backed/go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
COPY backed/ ./
|
||||||
|
RUN go build -ldflags="-s -w" -o server ./cmd/server/
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Stage 3: 运行时镜像(仅含二进制 + 前端静态文件)
|
||||||
|
# ============================================================
|
||||||
|
FROM alpine:latest
|
||||||
|
RUN apk add --no-cache ca-certificates tzdata
|
||||||
|
COPY --from=backend-builder /build/server /app/server
|
||||||
|
COPY --from=frontend-builder /build/dist /app/dist
|
||||||
|
WORKDIR /app
|
||||||
|
EXPOSE 8093
|
||||||
|
CMD ["./server"]
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"cpdaily-new/internal/config"
|
||||||
|
"cpdaily-new/internal/database"
|
||||||
|
"cpdaily-new/internal/model"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if err := config.Load(""); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
database.Init()
|
||||||
|
var agents []model.AgentUser
|
||||||
|
database.DB.Find(&agents)
|
||||||
|
if len(agents) == 0 {
|
||||||
|
fmt.Println("数据库中没有任何管理员/代理账号")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, a := range agents {
|
||||||
|
fmt.Printf("ID=%d Username=%s Role=%s Status=%s PasswordHash=%s\n", a.ID, a.Username, a.Role, a.Status, a.Password)
|
||||||
|
testPasswords := []string{"baca122...", "baca122", "admin123", "123456"}
|
||||||
|
for _, p := range testPasswords {
|
||||||
|
err := bcrypt.CompareHashAndPassword([]byte(a.Password), []byte(p))
|
||||||
|
if err == nil {
|
||||||
|
fmt.Printf(" >>> 密码匹配成功: '%s'\n", p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"cpdaily-new/internal/config"
|
||||||
|
"cpdaily-new/internal/database"
|
||||||
|
"cpdaily-new/internal/model"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if err := config.Load(""); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
database.Init()
|
||||||
|
|
||||||
|
hashed, err := bcrypt.GenerateFromPassword([]byte("baca122..."), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("加密失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := database.DB.Model(&model.AgentUser{}).
|
||||||
|
Where("username = ?", "becomer").
|
||||||
|
Update("password", string(hashed))
|
||||||
|
|
||||||
|
if result.Error != nil {
|
||||||
|
log.Fatalf("更新失败: %v", result.Error)
|
||||||
|
}
|
||||||
|
fmt.Printf("密码已重置,影响行数: %d\n", result.RowsAffected)
|
||||||
|
}
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"cpdaily-new/internal/config"
|
||||||
|
"cpdaily-new/internal/database"
|
||||||
|
"cpdaily-new/internal/handler"
|
||||||
|
"cpdaily-new/internal/middleware"
|
||||||
|
"cpdaily-new/internal/ratelimit"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
"github.com/gofiber/fiber/v2/middleware/filesystem"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// 加载配置
|
||||||
|
if err := config.Load(""); err != nil {
|
||||||
|
log.Fatalf("加载配置失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 连接数据库
|
||||||
|
database.Init()
|
||||||
|
|
||||||
|
// 启动限流记录定时清理(每5分钟清理一次)
|
||||||
|
ratelimit.Cleanup(5 * time.Minute)
|
||||||
|
|
||||||
|
// 创建 Fiber 应用
|
||||||
|
app := fiber.New(fiber.Config{
|
||||||
|
AppName: "Cpdaily New",
|
||||||
|
})
|
||||||
|
|
||||||
|
// 中间件
|
||||||
|
app.Use(middleware.CORS())
|
||||||
|
|
||||||
|
// 路由
|
||||||
|
api := app.Group("/api")
|
||||||
|
|
||||||
|
// 认证相关
|
||||||
|
auth := api.Group("/auth")
|
||||||
|
auth.Get("/captcha", handler.GetCaptcha)
|
||||||
|
auth.Get("/schools", handler.SearchSchools)
|
||||||
|
auth.Post("/login", handler.Login)
|
||||||
|
auth.Post("/refresh", handler.RefreshToken)
|
||||||
|
auth.Get("/userinfo", middleware.AuthRequired(), handler.GetUserInfo)
|
||||||
|
auth.Post("/generate-invite", middleware.AuthRequired(), handler.GenerateMyInviteCode)
|
||||||
|
auth.Get("/my-invite", middleware.AuthRequired(), handler.GetMyInviteInfo)
|
||||||
|
auth.Post("/redeem-invite", middleware.AuthRequired(), handler.RedeemInviteReward)
|
||||||
|
|
||||||
|
// 签到任务相关
|
||||||
|
task := api.Group("/task", middleware.AuthRequired())
|
||||||
|
task.Get("/titles", handler.GetTaskTitles)
|
||||||
|
task.Post("/create", handler.CreateTask)
|
||||||
|
task.Get("/list", handler.ListTasks)
|
||||||
|
task.Get("/form/:id", handler.GetTaskForm)
|
||||||
|
task.Post("/form/:id", handler.SaveTaskForm)
|
||||||
|
task.Put("/update/:id", handler.UpdateTask)
|
||||||
|
task.Delete("/delete/:id", handler.DeleteTask)
|
||||||
|
task.Get("/execute/:id", handler.ExecuteTask)
|
||||||
|
task.Get("/logs", handler.ListTaskLogs)
|
||||||
|
task.Delete("/logs/:id", handler.DeleteTaskLog)
|
||||||
|
task.Post("/upload-policy", handler.GetUploadPolicy)
|
||||||
|
task.Post("/preview-url", handler.GetPreviewUrl)
|
||||||
|
|
||||||
|
// CDK 兑换
|
||||||
|
cdk := api.Group("/cdk", middleware.AuthRequired())
|
||||||
|
cdk.Post("/redeem", handler.UserRedeemCDK)
|
||||||
|
|
||||||
|
// 设备绑定
|
||||||
|
device := api.Group("/device")
|
||||||
|
device.Post("/generate", middleware.AuthRequired(), handler.GenerateDeviceQr)
|
||||||
|
device.Post("/submit", handler.SubmitDeviceCode)
|
||||||
|
device.Get("/status", middleware.AuthRequired(), handler.GetDeviceStatus)
|
||||||
|
|
||||||
|
// 管理后台 API
|
||||||
|
admin := api.Group("/admin")
|
||||||
|
admin.Get("/setup/status", handler.AdminSetupStatus)
|
||||||
|
admin.Post("/setup/init", handler.AdminSetupInit)
|
||||||
|
admin.Post("/login", handler.AdminLogin)
|
||||||
|
admin.Get("/me", middleware.AdminAuthRequired(), handler.AdminMe)
|
||||||
|
|
||||||
|
// 管理后台 - 代理/管理员
|
||||||
|
adminAuth := api.Group("/admin", middleware.AdminAuthRequired())
|
||||||
|
adminAuth.Get("/cdks", handler.ListCDKs)
|
||||||
|
adminAuth.Post("/cdks", handler.GenerateCDK)
|
||||||
|
adminAuth.Get("/all-cdks", handler.ListAllCDKs)
|
||||||
|
adminAuth.Get("/agents", handler.ListSubAgents)
|
||||||
|
adminAuth.Get("/agent/list", handler.ListAllAgents)
|
||||||
|
adminAuth.Post("/agent/create", handler.CreateSubAgent)
|
||||||
|
adminAuth.Put("/agent/update/:id", handler.UpdateSubAgent)
|
||||||
|
adminAuth.Put("/agent/password/:id", handler.UpdateAgentPassword)
|
||||||
|
adminAuth.Put("/agent/status/:id", handler.UpdateAgentStatus)
|
||||||
|
adminAuth.Get("/agent/detail/:id", handler.GetAgentDetail)
|
||||||
|
|
||||||
|
// 管理后台 - 公告
|
||||||
|
adminAuth.Post("/announcement/create", handler.CreateAnnouncement)
|
||||||
|
adminAuth.Get("/announcements", handler.ListAnnouncements)
|
||||||
|
adminAuth.Get("/announcement/:id", handler.GetAnnouncement)
|
||||||
|
adminAuth.Delete("/announcement/:id", handler.DeleteAnnouncement)
|
||||||
|
|
||||||
|
// 管理后台 - 系统设置
|
||||||
|
adminAuth.Get("/settings", handler.GetSettings)
|
||||||
|
adminAuth.Post("/settings", handler.UpdateSetting)
|
||||||
|
|
||||||
|
// 管理后台 - 我的公告(代理/管理员视角,共用普通用户公告处理器,但 target_type=agent)
|
||||||
|
adminAuth.Get("/my-announcements", handler.ListUserAnnouncements)
|
||||||
|
adminAuth.Get("/my-announcements/unread-count", handler.GetUnreadCount)
|
||||||
|
adminAuth.Get("/my-announcements/my-info", handler.GetMyAnnouncementInfo)
|
||||||
|
adminAuth.Post("/my-announcements/:id/read", handler.MarkAnnouncementRead)
|
||||||
|
|
||||||
|
// 用户公告
|
||||||
|
api.Get("/announcements", middleware.AuthRequired(), handler.ListUserAnnouncements)
|
||||||
|
api.Get("/announcements/unread-count", middleware.AuthRequired(), handler.GetUnreadCount)
|
||||||
|
api.Get("/announcements/my-info", middleware.AuthRequired(), handler.GetMyAnnouncementInfo)
|
||||||
|
api.Post("/announcements/:id/read", middleware.AuthRequired(), handler.MarkAnnouncementRead)
|
||||||
|
|
||||||
|
// 设备授权页面(纯 HTML)
|
||||||
|
api.Get("/device/authorize/:token", handler.AuthorizePage)
|
||||||
|
api.Get("/device/close", handler.ClosePage)
|
||||||
|
|
||||||
|
// 工单系统
|
||||||
|
ticket := api.Group("/ticket", middleware.AuthRequired())
|
||||||
|
ticket.Post("/create", handler.CreateTicket)
|
||||||
|
ticket.Get("/list", handler.ListMyTickets)
|
||||||
|
ticket.Get("/:id", handler.GetTicketDetail)
|
||||||
|
ticket.Post("/:id/message", handler.AddTicketMessage)
|
||||||
|
ticket.Put("/:id/close", handler.CloseTicket)
|
||||||
|
ticket.Delete("/:id", handler.DeleteTicket)
|
||||||
|
ticket.Get("/notifications/list", handler.ListTicketNotifications)
|
||||||
|
ticket.Get("/notifications/unread-count", handler.GetTicketUnreadCount)
|
||||||
|
ticket.Post("/notifications/:id/read", handler.MarkTicketNotificationRead)
|
||||||
|
|
||||||
|
// 用户报告登录失败(无需认证,有独立速率限制 + 验证码校验)
|
||||||
|
// 使用独立路径避免与 /ticket 组的 AuthRequired 中间件冲突
|
||||||
|
api.Post("/report-failed-login", handler.ReportFailedLogin)
|
||||||
|
|
||||||
|
// 管理后台 - 工单管理
|
||||||
|
adminAuth.Post("/ticket/reply/:id", handler.AdminReplyTicket)
|
||||||
|
adminAuth.Put("/ticket/status/:id", handler.AdminUpdateTicketStatus)
|
||||||
|
adminAuth.Get("/ticket/list", handler.AdminListTickets)
|
||||||
|
adminAuth.Get("/ticket/:id", handler.AdminGetTicket)
|
||||||
|
|
||||||
|
// 管理后台 - 待适配学校
|
||||||
|
adminAuth.Get("/pending-schools", handler.AdminListPendingSchools)
|
||||||
|
adminAuth.Post("/pending-schools/:id/mark-done", handler.AdminMarkSchoolDone)
|
||||||
|
|
||||||
|
// 健康检查(用于蓝绿部署)
|
||||||
|
app.Get("/health", func(c *fiber.Ctx) error {
|
||||||
|
return c.SendString("ok")
|
||||||
|
})
|
||||||
|
|
||||||
|
// 静态文件服务(前端 SPA)
|
||||||
|
app.Static("/assets", "./dist/assets", fiber.Static{
|
||||||
|
MaxAge: 86400,
|
||||||
|
})
|
||||||
|
app.Use(filesystem.New(filesystem.Config{
|
||||||
|
Root: http.Dir("./dist"),
|
||||||
|
Index: "index.html",
|
||||||
|
NotFoundFile: "index.html",
|
||||||
|
MaxAge: 3600,
|
||||||
|
}))
|
||||||
|
|
||||||
|
// 启动
|
||||||
|
addr := fmt.Sprintf(":%d", config.Global.Server.Port)
|
||||||
|
log.Printf("服务器启动在 %s", addr)
|
||||||
|
if err := app.Listen(addr); err != nil {
|
||||||
|
log.Fatalf("服务器启动失败: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"cpdaily-new/internal/config"
|
||||||
|
"cpdaily-new/internal/database"
|
||||||
|
"cpdaily-new/internal/model"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if err := config.Load(""); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
database.Init()
|
||||||
|
var agent model.AgentUser
|
||||||
|
database.DB.Where("username = ?", "becomer").First(&agent)
|
||||||
|
err := bcrypt.CompareHashAndPassword([]byte(agent.Password), []byte("baca122..."))
|
||||||
|
if err == nil {
|
||||||
|
fmt.Println("✅ 密码验证通过: baca122...")
|
||||||
|
} else {
|
||||||
|
fmt.Println("❌ 密码验证失败:", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
[server]
|
||||||
|
port = 8093
|
||||||
|
mode = "dev"
|
||||||
|
|
||||||
|
[database]
|
||||||
|
host = "127.0.0.1"
|
||||||
|
port = 3306
|
||||||
|
user = "likedream-go"
|
||||||
|
password = "bh7S4JzPVyY0VLJh44i"
|
||||||
|
dbname = "likedream-go"
|
||||||
|
|
||||||
|
[jwt]
|
||||||
|
secret = "cpdaily-new-jwt-secret-key-2026"
|
||||||
|
accessExpire = 3600
|
||||||
|
refreshExpire = 864000
|
||||||
|
|
||||||
|
[captcha]
|
||||||
|
width = 150
|
||||||
|
height = 46
|
||||||
|
length = 4
|
||||||
|
|
||||||
|
[cpdaily]
|
||||||
|
appVersion = "9.8.8"
|
||||||
|
systemVersion = "iOS 17"
|
||||||
|
calVersion = "first_v4"
|
||||||
|
sliderIds = ["1018764144934965", "sdhyxy", "hqdx"]
|
||||||
|
captchaBaseUrl = "http://10.10.10.128:8890"
|
||||||
|
captchaApiKey = "baca122..."
|
||||||
|
|
||||||
|
[agent]
|
||||||
|
systemCdkPrice = 10.0
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
module cpdaily-new
|
||||||
|
|
||||||
|
go 1.26.4
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/BurntSushi/toml v1.6.0
|
||||||
|
github.com/PuerkitoBio/goquery v1.12.0
|
||||||
|
github.com/go-resty/resty/v2 v2.16.5
|
||||||
|
github.com/go-sql-driver/mysql v1.8.1
|
||||||
|
github.com/gofiber/fiber/v2 v2.52.13
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||||
|
golang.org/x/crypto v0.50.0
|
||||||
|
golang.org/x/time v0.6.0
|
||||||
|
gorm.io/driver/mysql v1.6.0
|
||||||
|
gorm.io/gorm v1.31.2
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
filippo.io/edwards25519 v1.1.0 // indirect
|
||||||
|
github.com/andybalholm/brotli v1.1.0 // indirect
|
||||||
|
github.com/andybalholm/cascadia v1.3.3 // indirect
|
||||||
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
|
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||||
|
github.com/jinzhu/now v1.1.5 // indirect
|
||||||
|
github.com/klauspost/compress v1.17.9 // indirect
|
||||||
|
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||||
|
github.com/rivo/uniseg v0.2.0 // indirect
|
||||||
|
github.com/tidwall/match v1.1.1 // indirect
|
||||||
|
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||||
|
github.com/valyala/fasthttp v1.51.0 // indirect
|
||||||
|
github.com/valyala/tcplisten v1.0.0 // indirect
|
||||||
|
golang.org/x/net v0.52.0 // indirect
|
||||||
|
golang.org/x/sys v0.43.0 // indirect
|
||||||
|
golang.org/x/text v0.36.0 // indirect
|
||||||
|
)
|
||||||
+123
@@ -0,0 +1,123 @@
|
|||||||
|
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||||
|
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||||
|
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
|
||||||
|
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||||
|
github.com/PuerkitoBio/goquery v1.12.0 h1:pAcL4g3WRXekcB9AU/y1mbKez2dbY2AajVhtkO8RIBo=
|
||||||
|
github.com/PuerkitoBio/goquery v1.12.0/go.mod h1:802ej+gV2y7bbIhOIoPY5sT183ZW0YFofScC4q/hIpQ=
|
||||||
|
github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
|
||||||
|
github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
|
||||||
|
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
|
||||||
|
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
|
||||||
|
github.com/go-resty/resty/v2 v2.16.5 h1:hBKqmWrr7uRc3euHVqmh1HTHcKn99Smr7o5spptdhTM=
|
||||||
|
github.com/go-resty/resty/v2 v2.16.5/go.mod h1:hkJtXbA2iKHzJheXYvQ8snQES5ZLGKMwQ07xAwp/fiA=
|
||||||
|
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
|
||||||
|
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
|
||||||
|
github.com/gofiber/fiber/v2 v2.52.13 h1:TOKP64iqC9b5P49VrBW5tHhUOvDyrtJ0xePEfzJbCbk=
|
||||||
|
github.com/gofiber/fiber/v2 v2.52.13/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||||
|
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
|
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/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||||
|
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||||
|
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||||
|
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||||
|
github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
|
||||||
|
github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
|
||||||
|
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.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||||
|
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-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||||
|
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||||
|
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
|
||||||
|
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||||
|
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
|
||||||
|
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
|
||||||
|
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||||
|
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||||
|
github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1SqA=
|
||||||
|
github.com/valyala/fasthttp v1.51.0/go.mod h1:oI2XroL+lI7vdXyYoQk03bXBThfFl2cVdIA3Xl7cH8g=
|
||||||
|
github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8=
|
||||||
|
github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc=
|
||||||
|
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
|
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
|
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||||
|
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||||
|
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||||
|
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||||
|
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
||||||
|
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
||||||
|
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||||
|
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||||
|
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||||
|
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||||
|
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||||
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
|
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||||
|
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||||
|
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||||
|
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||||
|
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||||
|
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||||
|
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||||
|
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
||||||
|
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
||||||
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||||
|
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
|
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
|
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
|
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
|
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
|
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||||
|
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
|
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||||
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
|
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||||
|
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||||
|
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
||||||
|
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||||
|
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||||
|
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
||||||
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
|
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
|
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||||
|
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||||
|
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||||
|
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
|
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
|
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||||
|
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||||
|
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||||
|
golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||||
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
|
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||||
|
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||||
|
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||||
|
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||||
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg=
|
||||||
|
gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo=
|
||||||
|
gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo=
|
||||||
|
gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/BurntSushi/toml"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
Server ServerConfig `toml:"server"`
|
||||||
|
Database DatabaseConfig `toml:"database"`
|
||||||
|
Jwt JwtConfig `toml:"jwt"`
|
||||||
|
Captcha CaptchaConfig `toml:"captcha"`
|
||||||
|
Cpdaily CpdailyConfig `toml:"cpdaily"`
|
||||||
|
Agent AgentConfig `toml:"agent"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AgentConfig 代理系统配置
|
||||||
|
type AgentConfig struct {
|
||||||
|
SystemCdkPrice float64 `toml:"systemCdkPrice"` // CDK系统底价(元)
|
||||||
|
}
|
||||||
|
|
||||||
|
type ServerConfig struct {
|
||||||
|
Port int `toml:"port"`
|
||||||
|
Mode string `toml:"mode"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DatabaseConfig struct {
|
||||||
|
Host string `toml:"host"`
|
||||||
|
Port int `toml:"port"`
|
||||||
|
User string `toml:"user"`
|
||||||
|
Password string `toml:"password"`
|
||||||
|
Dbname string `toml:"dbname"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type JwtConfig struct {
|
||||||
|
Secret string `toml:"secret"`
|
||||||
|
AccessExpire int `toml:"accessExpire"`
|
||||||
|
RefreshExpire int `toml:"refreshExpire"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CaptchaConfig struct {
|
||||||
|
Width int `toml:"width"`
|
||||||
|
Height int `toml:"height"`
|
||||||
|
Length int `toml:"length"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CpdailyConfig struct {
|
||||||
|
AppVersion string `toml:"appVersion"`
|
||||||
|
SystemVersion string `toml:"systemVersion"`
|
||||||
|
CalVersion string `toml:"calVersion"`
|
||||||
|
SliderIds []string `toml:"sliderIds"`
|
||||||
|
CaptchaBaseUrl string `toml:"captchaBaseUrl"`
|
||||||
|
CaptchaApiKey string `toml:"captchaApiKey"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var Global Config
|
||||||
|
|
||||||
|
func Load(path string) error {
|
||||||
|
if path == "" {
|
||||||
|
path = "config.toml"
|
||||||
|
if _, err := os.Stat(path); os.IsNotExist(err) {
|
||||||
|
path = "../config.toml"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_, err := toml.DecodeFile(path, &Global)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// 环境变量覆盖
|
||||||
|
if v := os.Getenv("SERVER_PORT"); v != "" {
|
||||||
|
Global.Server.Port, _ = strconv.Atoi(v)
|
||||||
|
}
|
||||||
|
if v := os.Getenv("DB_PASSWORD"); v != "" {
|
||||||
|
Global.Database.Password = v
|
||||||
|
}
|
||||||
|
if v := os.Getenv("JWT_SECRET"); v != "" {
|
||||||
|
Global.Jwt.Secret = v
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func DSN() string {
|
||||||
|
cfg := Global.Database
|
||||||
|
return cfg.User + ":" + cfg.Password + "@tcp(" + cfg.Host + ":" + strconv.Itoa(cfg.Port) + ")/" + cfg.Dbname + "?charset=utf8mb4&parseTime=True&loc=Local"
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"cpdaily-new/internal/config"
|
||||||
|
"cpdaily-new/internal/model"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"gorm.io/driver/mysql"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
var DB *gorm.DB
|
||||||
|
|
||||||
|
func Init() {
|
||||||
|
dsn := config.DSN()
|
||||||
|
var err error
|
||||||
|
DB, err = gorm.Open(mysql.Open(dsn), &gorm.Config{
|
||||||
|
Logger: logger.Default.LogMode(logger.Warn),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("连接数据库失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 自动迁移
|
||||||
|
if err := DB.AutoMigrate(&model.SysUser{}, &model.DeviceBindToken{}, &model.CpTask{}, &model.CpTaskLog{}, &model.AgentUser{}, &model.CDKCode{}, &model.SystemConfig{}, &model.Announcement{}, &model.AnnouncementRead{}, &model.Ticket{}, &model.TicketMessage{}, &model.TicketNotification{}, &model.PendingSchool{}); err != nil {
|
||||||
|
log.Fatalf("自动迁移失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sqlDB, _ := DB.DB()
|
||||||
|
sqlDB.SetMaxIdleConns(10)
|
||||||
|
sqlDB.SetMaxOpenConns(100)
|
||||||
|
|
||||||
|
log.Println("数据库连接成功")
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"cpdaily-new/internal/database"
|
||||||
|
"cpdaily-new/internal/model"
|
||||||
|
"cpdaily-new/internal/utils"
|
||||||
|
"errors"
|
||||||
|
"log/slog"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CreateAnnouncementRequest 创建公告请求
|
||||||
|
type CreateAnnouncementRequest struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
TargetType string `json:"targetType"` // agent / user
|
||||||
|
ForcePopup bool `json:"forcePopup"`
|
||||||
|
LoopPopup bool `json:"loopPopup"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateAnnouncement 创建公告
|
||||||
|
func CreateAnnouncement(c *fiber.Ctx) error {
|
||||||
|
adminID := c.Locals("adminId").(uint)
|
||||||
|
|
||||||
|
var req CreateAnnouncementRequest
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return utils.FailParam(c, "请求参数错误")
|
||||||
|
}
|
||||||
|
if req.Title == "" {
|
||||||
|
return utils.FailParam(c, "公告标题不能为空")
|
||||||
|
}
|
||||||
|
if req.Content == "" {
|
||||||
|
return utils.FailParam(c, "公告内容不能为空")
|
||||||
|
}
|
||||||
|
if req.TargetType != "agent" && req.TargetType != "user" {
|
||||||
|
return utils.Fail(c, 400, "目标类型必须为 agent 或 user")
|
||||||
|
}
|
||||||
|
|
||||||
|
announcement := model.Announcement{
|
||||||
|
Title: req.Title,
|
||||||
|
Content: req.Content,
|
||||||
|
TargetType: req.TargetType,
|
||||||
|
ForcePopup: req.ForcePopup,
|
||||||
|
LoopPopup: req.LoopPopup,
|
||||||
|
CreatedBy: adminID,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := database.DB.Create(&announcement).Error; err != nil {
|
||||||
|
slog.Error("创建公告失败", "error", err)
|
||||||
|
return utils.FailServer(c, "创建公告失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("创建公告", "id", announcement.ID, "title", req.Title, "targetType", req.TargetType)
|
||||||
|
return utils.Success(c, announcement)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AnnouncementListItem 公告列表项(含统计)
|
||||||
|
type AnnouncementListItem struct {
|
||||||
|
model.Announcement
|
||||||
|
ReadCount int `json:"readCount"`
|
||||||
|
UnreadCount int `json:"unreadCount"`
|
||||||
|
TotalTargets int64 `json:"totalTargets"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListAnnouncements 公告列表(含已读/未读统计)
|
||||||
|
func ListAnnouncements(c *fiber.Ctx) error {
|
||||||
|
var announcements []model.Announcement
|
||||||
|
database.DB.Order("created_at DESC").Find(&announcements)
|
||||||
|
|
||||||
|
// 获取目标总数
|
||||||
|
var totalAgents int64
|
||||||
|
var totalUsers int64
|
||||||
|
database.DB.Model(&model.AgentUser{}).Count(&totalAgents)
|
||||||
|
database.DB.Model(&model.SysUser{}).Count(&totalUsers)
|
||||||
|
|
||||||
|
items := make([]AnnouncementListItem, 0, len(announcements))
|
||||||
|
for _, a := range announcements {
|
||||||
|
var readCount int64
|
||||||
|
database.DB.Model(&model.AnnouncementRead{}).
|
||||||
|
Where("announcement_id = ?", a.ID).
|
||||||
|
Count(&readCount)
|
||||||
|
|
||||||
|
var totalTargets int64
|
||||||
|
if a.TargetType == "agent" {
|
||||||
|
totalTargets = totalAgents
|
||||||
|
} else {
|
||||||
|
totalTargets = totalUsers
|
||||||
|
}
|
||||||
|
|
||||||
|
items = append(items, AnnouncementListItem{
|
||||||
|
Announcement: a,
|
||||||
|
ReadCount: int(readCount),
|
||||||
|
UnreadCount: int(max(0, totalTargets-readCount)),
|
||||||
|
TotalTargets: totalTargets,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return utils.Success(c, items)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAnnouncement 公告详情(含已读用户列表)
|
||||||
|
func GetAnnouncement(c *fiber.Ctx) error {
|
||||||
|
id := c.Params("id")
|
||||||
|
|
||||||
|
var announcement model.Announcement
|
||||||
|
if err := database.DB.First(&announcement, id).Error; err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return utils.Fail(c, 404, "公告不存在")
|
||||||
|
}
|
||||||
|
return utils.FailServer(c, "查询失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查询已读记录(带用户信息)
|
||||||
|
type ReadUser struct {
|
||||||
|
UserID uint `json:"userId"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
ReadAt time.Time `json:"readAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var reads []model.AnnouncementRead
|
||||||
|
database.DB.Where("announcement_id = ?", announcement.ID).Find(&reads)
|
||||||
|
|
||||||
|
readUsers := make([]ReadUser, 0, len(reads))
|
||||||
|
for _, r := range reads {
|
||||||
|
username := ""
|
||||||
|
if r.UserType == "agent" {
|
||||||
|
var agent model.AgentUser
|
||||||
|
if err := database.DB.First(&agent, r.UserID).Error; err == nil {
|
||||||
|
username = agent.Username
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
var user model.SysUser
|
||||||
|
if err := database.DB.First(&user, r.UserID).Error; err == nil {
|
||||||
|
username = user.Username
|
||||||
|
}
|
||||||
|
}
|
||||||
|
readUsers = append(readUsers, ReadUser{
|
||||||
|
UserID: r.UserID,
|
||||||
|
Username: username,
|
||||||
|
ReadAt: r.ReadAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return utils.Success(c, fiber.Map{
|
||||||
|
"announcement": announcement,
|
||||||
|
"readUsers": readUsers,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteAnnouncement 删除公告
|
||||||
|
func DeleteAnnouncement(c *fiber.Ctx) error {
|
||||||
|
id := c.Params("id")
|
||||||
|
|
||||||
|
result := database.DB.Delete(&model.Announcement{}, id)
|
||||||
|
if result.Error != nil {
|
||||||
|
return utils.FailServer(c, "删除失败")
|
||||||
|
}
|
||||||
|
if result.RowsAffected == 0 {
|
||||||
|
return utils.Fail(c, 404, "公告不存在")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 同时删除已读记录
|
||||||
|
database.DB.Where("announcement_id = ?", id).Delete(&model.AnnouncementRead{})
|
||||||
|
|
||||||
|
slog.Info("删除公告", "id", id)
|
||||||
|
return utils.Success(c, nil)
|
||||||
|
}
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"cpdaily-new/internal/database"
|
||||||
|
"cpdaily-new/internal/model"
|
||||||
|
"cpdaily-new/internal/service"
|
||||||
|
"cpdaily-new/internal/utils"
|
||||||
|
"log/slog"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AdminSetupStatus 检查是否已安装(是否有admin账号)
|
||||||
|
func AdminSetupStatus(c *fiber.Ctx) error {
|
||||||
|
var count int64
|
||||||
|
database.DB.Model(&model.AgentUser{}).Where("role = ?", "admin").Count(&count)
|
||||||
|
return utils.Success(c, fiber.Map{
|
||||||
|
"installed": count > 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminSetupInitRequest 初始化安装请求
|
||||||
|
type AdminSetupInitRequest struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminSetupInit 初始化创建管理员账号
|
||||||
|
func AdminSetupInit(c *fiber.Ctx) error {
|
||||||
|
var req AdminSetupInitRequest
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return utils.FailParam(c, "请求参数错误")
|
||||||
|
}
|
||||||
|
if req.Username == "" || req.Password == "" {
|
||||||
|
return utils.FailParam(c, "用户名和密码不能为空")
|
||||||
|
}
|
||||||
|
if len(req.Password) < 6 {
|
||||||
|
return utils.FailParam(c, "密码长度不能少于6位")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否已存在admin
|
||||||
|
var count int64
|
||||||
|
database.DB.Model(&model.AgentUser{}).Where("role = ?", "admin").Count(&count)
|
||||||
|
if count > 0 {
|
||||||
|
return utils.Fail(c, 400, "管理员账号已存在,不能重复初始化")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加密密码
|
||||||
|
hashedPwd, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("密码加密失败", "error", err)
|
||||||
|
return utils.FailServer(c, "初始化失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建管理员
|
||||||
|
admin := model.AgentUser{
|
||||||
|
Username: req.Username,
|
||||||
|
Password: string(hashedPwd),
|
||||||
|
Role: "admin",
|
||||||
|
Rate: 1.00,
|
||||||
|
Balance: 0,
|
||||||
|
Status: "active",
|
||||||
|
}
|
||||||
|
if err := database.DB.Create(&admin).Error; err != nil {
|
||||||
|
slog.Error("创建管理员失败", "error", err)
|
||||||
|
return utils.FailServer(c, "初始化失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成 token 直接登录
|
||||||
|
token, expiresIn, err := service.GenerateAdminToken(admin.ID, admin.Username, admin.Role)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("生成token失败", "error", err)
|
||||||
|
return utils.FailServer(c, "初始化成功但生成token失败,请前往登录")
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("管理员初始化成功", "username", req.Username)
|
||||||
|
|
||||||
|
return utils.Success(c, fiber.Map{
|
||||||
|
"token": fiber.Map{
|
||||||
|
"accessToken": token,
|
||||||
|
"expiresIn": expiresIn,
|
||||||
|
"tokenType": "Bearer",
|
||||||
|
},
|
||||||
|
"agent": fiber.Map{
|
||||||
|
"id": admin.ID,
|
||||||
|
"username": admin.Username,
|
||||||
|
"role": admin.Role,
|
||||||
|
"rate": admin.Rate,
|
||||||
|
"balance": admin.Balance,
|
||||||
|
"status": admin.Status,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminLoginRequest 管理员登录请求
|
||||||
|
type AdminLoginRequest struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminLogin 管理员/代理登录
|
||||||
|
func AdminLogin(c *fiber.Ctx) error {
|
||||||
|
var req AdminLoginRequest
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return utils.FailParam(c, "请求参数错误")
|
||||||
|
}
|
||||||
|
if req.Username == "" || req.Password == "" {
|
||||||
|
return utils.FailParam(c, "用户名或密码不能为空")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查找管理员/代理
|
||||||
|
var agent model.AgentUser
|
||||||
|
if err := database.DB.Where("username = ?", req.Username).First(&agent).Error; err != nil {
|
||||||
|
return utils.Fail(c, 401, "用户名或密码错误")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证密码
|
||||||
|
if err := bcrypt.CompareHashAndPassword([]byte(agent.Password), []byte(req.Password)); err != nil {
|
||||||
|
return utils.Fail(c, 401, "用户名或密码错误")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查状态
|
||||||
|
if agent.Status == "disabled" {
|
||||||
|
return utils.Fail(c, 403, "账号已被禁用")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成 token
|
||||||
|
token, expiresIn, err := service.GenerateAdminToken(agent.ID, agent.Username, agent.Role)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("生成管理员token失败", "error", err)
|
||||||
|
return utils.FailServer(c, "登录失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("管理员登录成功", "agentId", agent.ID, "username", agent.Username, "role", agent.Role)
|
||||||
|
|
||||||
|
return utils.Success(c, fiber.Map{
|
||||||
|
"token": fiber.Map{
|
||||||
|
"accessToken": token,
|
||||||
|
"expiresIn": expiresIn,
|
||||||
|
"tokenType": "Bearer",
|
||||||
|
},
|
||||||
|
"agent": fiber.Map{
|
||||||
|
"id": agent.ID,
|
||||||
|
"username": agent.Username,
|
||||||
|
"role": agent.Role,
|
||||||
|
"rate": agent.Rate,
|
||||||
|
"balance": agent.Balance,
|
||||||
|
"status": agent.Status,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminMe 获取当前管理员/代理信息
|
||||||
|
func AdminMe(c *fiber.Ctx) error {
|
||||||
|
agentID := c.Locals("adminId").(uint)
|
||||||
|
|
||||||
|
var agent model.AgentUser
|
||||||
|
if err := database.DB.First(&agent, agentID).Error; err != nil {
|
||||||
|
return utils.FailAuth(c, "账号不存在")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查询下级代理数量
|
||||||
|
var subCount int64
|
||||||
|
database.DB.Model(&model.AgentUser{}).Where("parent_id = ?", agentID).Count(&subCount)
|
||||||
|
|
||||||
|
// 查询CDK数量
|
||||||
|
var cdkCount int64
|
||||||
|
database.DB.Model(&model.CDKCode{}).Where("agent_id = ?", agentID).Count(&cdkCount)
|
||||||
|
|
||||||
|
return utils.Success(c, fiber.Map{
|
||||||
|
"id": agent.ID,
|
||||||
|
"username": agent.Username,
|
||||||
|
"role": agent.Role,
|
||||||
|
"rate": agent.Rate,
|
||||||
|
"balance": agent.Balance,
|
||||||
|
"status": agent.Status,
|
||||||
|
"parentId": agent.ParentID,
|
||||||
|
"subCount": subCount,
|
||||||
|
"cdkCount": cdkCount,
|
||||||
|
"createdAt": agent.CreatedAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,381 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"cpdaily-new/internal/config"
|
||||||
|
"cpdaily-new/internal/database"
|
||||||
|
"cpdaily-new/internal/model"
|
||||||
|
"cpdaily-new/internal/utils"
|
||||||
|
"errors"
|
||||||
|
"log/slog"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CreateAgentRequest 创建代理请求
|
||||||
|
type CreateAgentRequest struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
Rate float64 `json:"rate"`
|
||||||
|
Balance float64 `json:"balance"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateSubAgent 创建下级代理
|
||||||
|
func CreateSubAgent(c *fiber.Ctx) error {
|
||||||
|
parentID := c.Locals("adminId").(uint)
|
||||||
|
parentRole := c.Locals("adminRole").(string)
|
||||||
|
|
||||||
|
var req CreateAgentRequest
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return utils.FailParam(c, "请求参数错误")
|
||||||
|
}
|
||||||
|
if req.Username == "" || req.Password == "" {
|
||||||
|
return utils.FailParam(c, "用户名和密码不能为空")
|
||||||
|
}
|
||||||
|
if req.Rate <= 0 {
|
||||||
|
return utils.FailParam(c, "费率必须大于0")
|
||||||
|
}
|
||||||
|
if req.Balance < 0 {
|
||||||
|
return utils.FailParam(c, "充值金额不能为负数")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取当前代理信息
|
||||||
|
var parentAgent model.AgentUser
|
||||||
|
if err := database.DB.First(&parentAgent, parentID).Error; err != nil {
|
||||||
|
return utils.Fail(c, 404, "当前账号不存在")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 校验费率:下级费率不能低于自身费率
|
||||||
|
if req.Rate < parentAgent.Rate {
|
||||||
|
return utils.Fail(c, 400, "下级代理费率("+strconv.FormatFloat(req.Rate, 'f', 2, 64)+
|
||||||
|
")不得低于自身费率("+strconv.FormatFloat(parentAgent.Rate, 'f', 2, 64)+")")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 校验密码长度
|
||||||
|
if len(req.Password) < 6 {
|
||||||
|
return utils.FailParam(c, "密码长度不能少于6位")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查用户名是否已存在
|
||||||
|
var count int64
|
||||||
|
database.DB.Model(&model.AgentUser{}).Where("username = ?", req.Username).Count(&count)
|
||||||
|
if count > 0 {
|
||||||
|
return utils.Fail(c, 400, "用户名已存在")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加密密码
|
||||||
|
hashedPwd, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("密码加密失败", "error", err)
|
||||||
|
return utils.FailServer(c, "创建失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果上级是admin,设置费率为系统默认
|
||||||
|
rate := req.Rate
|
||||||
|
if parentRole == "admin" && rate == 0 {
|
||||||
|
rate = config.Global.Agent.SystemCdkPrice
|
||||||
|
}
|
||||||
|
|
||||||
|
// 在事务中创建代理并扣减上级余额
|
||||||
|
err = database.DB.Transaction(func(tx *gorm.DB) error {
|
||||||
|
// 如果上级不是admin(即上级也是代理),扣减其余额
|
||||||
|
if parentRole != "admin" && req.Balance > 0 {
|
||||||
|
result := tx.Model(&model.AgentUser{}).
|
||||||
|
Where("id = ? AND balance >= ?", parentID, req.Balance).
|
||||||
|
Update("balance", gorm.Expr("balance - ?", req.Balance))
|
||||||
|
if result.RowsAffected == 0 {
|
||||||
|
return errors.New("余额不足")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建下级代理
|
||||||
|
subAgent := model.AgentUser{
|
||||||
|
Username: req.Username,
|
||||||
|
Password: string(hashedPwd),
|
||||||
|
Role: "agent",
|
||||||
|
ParentID: &parentID,
|
||||||
|
Rate: rate,
|
||||||
|
Balance: req.Balance,
|
||||||
|
Status: "active",
|
||||||
|
}
|
||||||
|
if err := tx.Create(&subAgent).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
if err.Error() == "余额不足" {
|
||||||
|
return utils.Fail(c, 400, "余额不足,无法充值")
|
||||||
|
}
|
||||||
|
slog.Error("创建代理失败", "error", err)
|
||||||
|
return utils.FailServer(c, "创建失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("创建下级代理成功", "parentId", parentID, "username", req.Username, "rate", rate, "balance", req.Balance)
|
||||||
|
|
||||||
|
return utils.Success(c, fiber.Map{
|
||||||
|
"username": req.Username,
|
||||||
|
"role": "agent",
|
||||||
|
"rate": rate,
|
||||||
|
"balance": req.Balance,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateAgentRequest 更新代理请求(充值/修改费率)
|
||||||
|
type UpdateAgentRequest struct {
|
||||||
|
Balance *float64 `json:"balance,omitempty"` // 充值金额(追加)
|
||||||
|
Rate *float64 `json:"rate,omitempty"` // 修改费率
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateSubAgent 更新下级代理(充值/修改费率)
|
||||||
|
func UpdateSubAgent(c *fiber.Ctx) error {
|
||||||
|
agentID := c.Locals("adminId").(uint)
|
||||||
|
adminRole := c.Locals("adminRole").(string)
|
||||||
|
targetID := c.Params("id")
|
||||||
|
|
||||||
|
var req UpdateAgentRequest
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return utils.FailParam(c, "请求参数错误")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查找目标代理
|
||||||
|
var target model.AgentUser
|
||||||
|
if err := database.DB.First(&target, targetID).Error; err != nil {
|
||||||
|
return utils.Fail(c, 404, "代理不存在")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 权限校验:admin可操作所有,agent只能操作下级
|
||||||
|
if adminRole != "admin" {
|
||||||
|
if target.ParentID == nil || *target.ParentID != agentID {
|
||||||
|
return utils.Fail(c, 403, "只能操作自己的下级代理")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取当前代理信息(用于费率校验)
|
||||||
|
var currentAgent model.AgentUser
|
||||||
|
if err := database.DB.First(¤tAgent, agentID).Error; err != nil {
|
||||||
|
return utils.Fail(c, 404, "当前账号不存在")
|
||||||
|
}
|
||||||
|
|
||||||
|
updates := map[string]interface{}{}
|
||||||
|
|
||||||
|
// 修改费率
|
||||||
|
if req.Rate != nil {
|
||||||
|
if *req.Rate <= 0 {
|
||||||
|
return utils.Fail(c, 400, "费率必须大于0")
|
||||||
|
}
|
||||||
|
// 校验费率:不能低于自身费率
|
||||||
|
if *req.Rate < currentAgent.Rate {
|
||||||
|
return utils.Fail(c, 400, "下级代理费率不得低于自身费率("+
|
||||||
|
strconv.FormatFloat(currentAgent.Rate, 'f', 2, 64)+")")
|
||||||
|
}
|
||||||
|
updates["rate"] = *req.Rate
|
||||||
|
}
|
||||||
|
|
||||||
|
// 充值(原子操作)
|
||||||
|
if req.Balance != nil {
|
||||||
|
if *req.Balance < 0 {
|
||||||
|
return utils.Fail(c, 400, "充值金额不能为负数")
|
||||||
|
}
|
||||||
|
if *req.Balance > 0 {
|
||||||
|
// 如果不是admin(即代理给下级充值),扣减自己的余额
|
||||||
|
if adminRole != "admin" {
|
||||||
|
result := database.DB.Model(&model.AgentUser{}).
|
||||||
|
Where("id = ? AND balance >= ?", agentID, *req.Balance).
|
||||||
|
Update("balance", gorm.Expr("balance - ?", *req.Balance))
|
||||||
|
if result.RowsAffected == 0 {
|
||||||
|
return utils.Fail(c, 400, "余额不足")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 给目标代理增加余额
|
||||||
|
updates["balance"] = gorm.Expr("balance + ?", *req.Balance)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(updates) > 0 {
|
||||||
|
if err := database.DB.Model(&target).Updates(updates).Error; err != nil {
|
||||||
|
slog.Error("更新代理失败", "error", err)
|
||||||
|
return utils.FailServer(c, "更新失败")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重新查询返回最新数据
|
||||||
|
database.DB.First(&target, target.ID)
|
||||||
|
|
||||||
|
slog.Info("更新代理成功", "agentId", target.ID, "updates", updates)
|
||||||
|
|
||||||
|
return utils.Success(c, target)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateAgentPassword 修改代理密码(仅admin)
|
||||||
|
func UpdateAgentPassword(c *fiber.Ctx) error {
|
||||||
|
targetID := c.Params("id")
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return utils.FailParam(c, "请求参数错误")
|
||||||
|
}
|
||||||
|
if req.Password == "" || len(req.Password) < 6 {
|
||||||
|
return utils.FailParam(c, "密码长度不能少于6位")
|
||||||
|
}
|
||||||
|
|
||||||
|
var target model.AgentUser
|
||||||
|
if err := database.DB.First(&target, targetID).Error; err != nil {
|
||||||
|
return utils.Fail(c, 404, "代理不存在")
|
||||||
|
}
|
||||||
|
|
||||||
|
hashedPwd, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
return utils.FailServer(c, "修改失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
database.DB.Model(&target).Update("password", string(hashedPwd))
|
||||||
|
|
||||||
|
slog.Info("修改代理密码成功", "agentId", target.ID)
|
||||||
|
return utils.Success(c, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateAgentStatus 启用/禁用代理
|
||||||
|
func UpdateAgentStatus(c *fiber.Ctx) error {
|
||||||
|
agentID := c.Locals("adminId").(uint)
|
||||||
|
adminRole := c.Locals("adminRole").(string)
|
||||||
|
targetID := c.Params("id")
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
}
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return utils.FailParam(c, "请求参数错误")
|
||||||
|
}
|
||||||
|
if req.Status != "active" && req.Status != "disabled" {
|
||||||
|
return utils.FailParam(c, "状态值无效,必须为 active 或 disabled")
|
||||||
|
}
|
||||||
|
|
||||||
|
var target model.AgentUser
|
||||||
|
if err := database.DB.First(&target, targetID).Error; err != nil {
|
||||||
|
return utils.Fail(c, 404, "代理不存在")
|
||||||
|
}
|
||||||
|
|
||||||
|
if target.Role == "admin" {
|
||||||
|
return utils.Fail(c, 400, "不能禁用管理员账号")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 非管理员只能操作自己的下级
|
||||||
|
if adminRole != "admin" {
|
||||||
|
if target.ParentID == nil || *target.ParentID != agentID {
|
||||||
|
return utils.Fail(c, 403, "只能操作自己的下级代理")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
database.DB.Model(&target).Update("status", req.Status)
|
||||||
|
|
||||||
|
slog.Info("修改代理状态成功", "agentId", target.ID, "status", req.Status)
|
||||||
|
return utils.Success(c, fiber.Map{
|
||||||
|
"id": target.ID,
|
||||||
|
"status": req.Status,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListSubAgents 获取下级代理列表(包含上级用户名)
|
||||||
|
func ListSubAgents(c *fiber.Ctx) error {
|
||||||
|
agentID := c.Locals("adminId").(uint)
|
||||||
|
adminRole := c.Locals("adminRole").(string)
|
||||||
|
|
||||||
|
var agents []model.AgentUser
|
||||||
|
|
||||||
|
if adminRole == "admin" {
|
||||||
|
// admin 查看所有下级代理(parent_id 不为 null 的都是子代理)
|
||||||
|
database.DB.Where("parent_id IS NOT NULL").Order("created_at DESC").Find(&agents)
|
||||||
|
} else {
|
||||||
|
// agent 只查看自己的下级
|
||||||
|
database.DB.Where("parent_id = ?", agentID).Order("created_at DESC").Find(&agents)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 为每个代理填充上级用户名
|
||||||
|
type AgentWithParent struct {
|
||||||
|
model.AgentUser
|
||||||
|
ParentUsername string `json:"parentUsername"`
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make([]AgentWithParent, 0, len(agents))
|
||||||
|
for _, a := range agents {
|
||||||
|
parentUsername := ""
|
||||||
|
if a.ParentID != nil {
|
||||||
|
var parent model.AgentUser
|
||||||
|
if err := database.DB.Select("username").First(&parent, *a.ParentID).Error; err == nil {
|
||||||
|
parentUsername = parent.Username
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result = append(result, AgentWithParent{
|
||||||
|
AgentUser: a,
|
||||||
|
ParentUsername: parentUsername,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return utils.Success(c, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListAllAgents 获取所有代理列表(仅admin)
|
||||||
|
func ListAllAgents(c *fiber.Ctx) error {
|
||||||
|
var agents []model.AgentUser
|
||||||
|
database.DB.Order("role ASC, created_at DESC").Find(&agents)
|
||||||
|
|
||||||
|
// 为每个代理填充上级用户名
|
||||||
|
type AgentWithParent struct {
|
||||||
|
model.AgentUser
|
||||||
|
ParentUsername string `json:"parentUsername"`
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make([]AgentWithParent, 0, len(agents))
|
||||||
|
for _, a := range agents {
|
||||||
|
parentUsername := ""
|
||||||
|
if a.ParentID != nil {
|
||||||
|
var parent model.AgentUser
|
||||||
|
if err := database.DB.Select("username").First(&parent, *a.ParentID).Error; err == nil {
|
||||||
|
parentUsername = parent.Username
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result = append(result, AgentWithParent{
|
||||||
|
AgentUser: a,
|
||||||
|
ParentUsername: parentUsername,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return utils.Success(c, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAgentDetail 获取代理详情
|
||||||
|
func GetAgentDetail(c *fiber.Ctx) error {
|
||||||
|
targetID := c.Params("id")
|
||||||
|
|
||||||
|
var agent model.AgentUser
|
||||||
|
if err := database.DB.First(&agent, targetID).Error; err != nil {
|
||||||
|
return utils.Fail(c, 404, "代理不存在")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 统计下级代理数量
|
||||||
|
var subCount int64
|
||||||
|
database.DB.Model(&model.AgentUser{}).Where("parent_id = ?", agent.ID).Count(&subCount)
|
||||||
|
|
||||||
|
// 统计CDK数量
|
||||||
|
var cdkTotal, cdkUsed, cdkUnused int64
|
||||||
|
database.DB.Model(&model.CDKCode{}).Where("agent_id = ?", agent.ID).Count(&cdkTotal)
|
||||||
|
database.DB.Model(&model.CDKCode{}).Where("agent_id = ? AND status = 'used'", agent.ID).Count(&cdkUsed)
|
||||||
|
database.DB.Model(&model.CDKCode{}).Where("agent_id = ? AND status = 'unused'", agent.ID).Count(&cdkUnused)
|
||||||
|
|
||||||
|
return utils.Success(c, fiber.Map{
|
||||||
|
"agent": agent,
|
||||||
|
"subCount": subCount,
|
||||||
|
"cdkStats": fiber.Map{
|
||||||
|
"total": cdkTotal,
|
||||||
|
"used": cdkUsed,
|
||||||
|
"unused": cdkUnused,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,381 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"cpdaily-new/internal/database"
|
||||||
|
"cpdaily-new/internal/model"
|
||||||
|
"cpdaily-new/internal/service"
|
||||||
|
cpd "cpdaily-new/internal/service/cpdaily"
|
||||||
|
"cpdaily-new/internal/utils"
|
||||||
|
"errors"
|
||||||
|
"log/slog"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetCaptcha 获取图片验证码
|
||||||
|
func GetCaptcha(c *fiber.Ctx) error {
|
||||||
|
result, err := service.GenerateCaptcha()
|
||||||
|
if err != nil {
|
||||||
|
return utils.FailServer(c, "生成验证码失败")
|
||||||
|
}
|
||||||
|
// 调试:记录验证码
|
||||||
|
if code, ok := service.GetCaptchaDebug(result.CaptchaID); ok {
|
||||||
|
slog.Info("验证码", "captchaId", result.CaptchaID[:8], "code", code)
|
||||||
|
}
|
||||||
|
return utils.Success(c, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchSchools 搜索学校 (透传 cpdaily API)
|
||||||
|
func SearchSchools(c *fiber.Ctx) error {
|
||||||
|
name := c.Query("keyword")
|
||||||
|
if name == "" {
|
||||||
|
name = c.Query("name")
|
||||||
|
}
|
||||||
|
if name == "" {
|
||||||
|
return utils.Success(c, []interface{}{})
|
||||||
|
}
|
||||||
|
schools, err := cpd.SearchSchools(name)
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("搜索学校失败", "error", err)
|
||||||
|
return utils.FailServer(c, "搜索学校失败")
|
||||||
|
}
|
||||||
|
return utils.Success(c, schools)
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoginRequest 登录请求
|
||||||
|
type LoginRequest struct {
|
||||||
|
SchoolID string `json:"schoolId"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
CaptchaID string `json:"captchaId"`
|
||||||
|
CaptchaCode string `json:"captchaCode"`
|
||||||
|
InviteCode string `json:"inviteCode,omitempty"` // 可选邀请码
|
||||||
|
}
|
||||||
|
|
||||||
|
// Login 登录
|
||||||
|
func Login(c *fiber.Ctx) error {
|
||||||
|
var req LoginRequest
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return utils.FailParam(c, "请求参数错误")
|
||||||
|
}
|
||||||
|
if req.SchoolID == "" || req.Username == "" || req.Password == "" {
|
||||||
|
return utils.FailParam(c, "参数不完整")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 校验图片验证码
|
||||||
|
if !service.VerifyCaptcha(req.CaptchaID, req.CaptchaCode) {
|
||||||
|
return utils.FailParam(c, "验证码错误或已过期")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取学校详情
|
||||||
|
school, err := cpd.GetSchoolDetail(req.SchoolID)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("获取学校详情失败", "schoolId", req.SchoolID, "error", err)
|
||||||
|
return utils.FailServer(c, "获取学校信息失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 执行 cpdaily 登录
|
||||||
|
cookies, err := cpd.CpdailyLogin(c.Context(), school, req.Username, req.Password)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("cpdaily登录失败", "error", err)
|
||||||
|
return c.Status(401).JSON(utils.Response{
|
||||||
|
Code: 401,
|
||||||
|
Message: "登录失败: " + err.Error(),
|
||||||
|
Data: fiber.Map{
|
||||||
|
"schoolUrl": school.LoginUrl(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查 MOD_ cookie
|
||||||
|
hasMOD := false
|
||||||
|
for _, cookie := range cookies {
|
||||||
|
if strings.HasPrefix(cookie.Name, "MOD_") {
|
||||||
|
hasMOD = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !hasMOD {
|
||||||
|
return c.Status(401).JSON(utils.Response{
|
||||||
|
Code: 401,
|
||||||
|
Message: "登录失败,未获取到有效的认证信息",
|
||||||
|
Data: fiber.Map{
|
||||||
|
"schoolUrl": school.LoginUrl(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cookie 序列化
|
||||||
|
cookieStr := cpd.SerializeCookies(cookies)
|
||||||
|
|
||||||
|
// 构建用户名: 学校简称 + 学号
|
||||||
|
userName := buildUsername(school, req.Username)
|
||||||
|
|
||||||
|
// 查找或创建用户
|
||||||
|
user, err := findOrCreateUser(userName, req.Password, req.SchoolID, school.Name, req.Username, cookieStr, req.InviteCode)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("用户处理失败", "error", err)
|
||||||
|
return utils.FailServer(c, "用户处理失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新 cookie 和最后登录时间
|
||||||
|
now := time.Now()
|
||||||
|
database.DB.Model(&user).Updates(map[string]interface{}{
|
||||||
|
"cookie": cookieStr,
|
||||||
|
"last_login_at": now,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 保存明文密码(用于 cookie 过期时自动重新登录)
|
||||||
|
if err := saveRawPassword(user.ID, req.Password); err != nil {
|
||||||
|
slog.Error("保存密码失败", "userId", user.ID, "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 尝试从 cpdaily 获取用户主页信息并更新本地数据
|
||||||
|
go syncUserProfile(user, cookieStr, school.WecloudUrl)
|
||||||
|
|
||||||
|
// 生成 JWT
|
||||||
|
tokenPair, err := service.GenerateTokens(user.ID, user.Username, user.SchoolID)
|
||||||
|
if err != nil {
|
||||||
|
return utils.FailServer(c, "生成token失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
return utils.Success(c, fiber.Map{
|
||||||
|
"user": user,
|
||||||
|
"token": tokenPair,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// RefreshToken 刷新 token
|
||||||
|
func RefreshToken(c *fiber.Ctx) error {
|
||||||
|
var req struct {
|
||||||
|
RefreshToken string `json:"refreshToken"`
|
||||||
|
}
|
||||||
|
if err := c.BodyParser(&req); err != nil || req.RefreshToken == "" {
|
||||||
|
return utils.FailParam(c, "参数不完整")
|
||||||
|
}
|
||||||
|
|
||||||
|
tokenPair, err := service.RefreshTokens(req.RefreshToken)
|
||||||
|
if err != nil {
|
||||||
|
return utils.FailAuth(c, "refresh token无效或已过期")
|
||||||
|
}
|
||||||
|
|
||||||
|
return utils.Success(c, tokenPair)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUserInfo 获取当前用户信息(含 cpdaily 扩展信息)
|
||||||
|
func GetUserInfo(c *fiber.Ctx) error {
|
||||||
|
userID := c.Locals("userId").(uint)
|
||||||
|
var user model.SysUser
|
||||||
|
if err := database.DB.First(&user, userID).Error; err != nil {
|
||||||
|
return utils.FailAuth(c, "用户不存在")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果还没有同步过 cpdaily 信息,或缺少头像/手机号,尝试同步
|
||||||
|
if (user.RealName == "" || user.Avatar == "" || user.MobilePhone == "") && user.Cookie != "" {
|
||||||
|
cookieMap := cpd.ParseCookieJSON(user.Cookie)
|
||||||
|
// 从学校ID获取 wecloudUrl
|
||||||
|
school, err := cpd.GetSchoolDetail(user.SchoolID)
|
||||||
|
wecloudUrl := ""
|
||||||
|
if err == nil {
|
||||||
|
wecloudUrl = school.WecloudUrl
|
||||||
|
}
|
||||||
|
if profile, err := cpd.FetchUserProfile(cookieMap, wecloudUrl); err == nil {
|
||||||
|
updates := syncProfileToUser(profile)
|
||||||
|
database.DB.Model(&user).Updates(updates)
|
||||||
|
user.RealName = profile.Name
|
||||||
|
user.Gender = profile.Gender
|
||||||
|
user.Avatar = profile.Img
|
||||||
|
user.Academy = profile.Academy
|
||||||
|
user.Major = profile.Major
|
||||||
|
user.ClassName = profile.ClassName
|
||||||
|
user.Grade = profile.Grade
|
||||||
|
user.MobilePhone = profile.MobilePhone
|
||||||
|
user.OrgFullName = profile.OrgFullName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return utils.Success(c, user)
|
||||||
|
}
|
||||||
|
|
||||||
|
// syncUserProfile 同步 cpdaily 用户信息到数据库
|
||||||
|
func syncUserProfile(user *model.SysUser, cookieStr string, wecloudUrl string) {
|
||||||
|
cookieMap := cpd.ParseCookieJSON(cookieStr)
|
||||||
|
profile, err := cpd.FetchUserProfile(cookieMap, wecloudUrl)
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("同步用户信息失败", "userId", user.ID, "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
updates := syncProfileToUser(profile)
|
||||||
|
updates["cookie"] = cookieStr // 确保 cookie 最新
|
||||||
|
database.DB.Model(&user).Updates(updates)
|
||||||
|
slog.Info("用户信息同步成功", "userId", user.ID, "name", profile.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func syncProfileToUser(profile *cpd.CpdailyUserProfile) map[string]interface{} {
|
||||||
|
return map[string]interface{}{
|
||||||
|
"real_name": profile.Name,
|
||||||
|
"gender": profile.Gender,
|
||||||
|
"avatar": profile.Img,
|
||||||
|
"academy": profile.Academy,
|
||||||
|
"major": profile.Major,
|
||||||
|
"class_name": profile.ClassName,
|
||||||
|
"grade": profile.Grade,
|
||||||
|
"mobile_phone": profile.MobilePhone,
|
||||||
|
"org_full_name": profile.OrgFullName,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构建用户名: 学校简称 + 学工号
|
||||||
|
func buildUsername(school *cpd.SchoolDetailInfo, studentID string) string {
|
||||||
|
abbr := school.NameAbbr
|
||||||
|
if abbr == "" {
|
||||||
|
abbr = school.NamePinYin
|
||||||
|
if len(abbr) > 6 {
|
||||||
|
abbr = abbr[:6]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return abbr + studentID
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查找或创建用户
|
||||||
|
func findOrCreateUser(username, password, schoolID, schoolName, studentID, cookie, inviteCode string) (*model.SysUser, error) {
|
||||||
|
var user model.SysUser
|
||||||
|
err := database.DB.Where("username = ?", username).First(&user).Error
|
||||||
|
if err == nil {
|
||||||
|
return &user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 自动注册
|
||||||
|
hashedPwd, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
user = model.SysUser{
|
||||||
|
Username: username,
|
||||||
|
Password: string(hashedPwd),
|
||||||
|
SchoolID: schoolID,
|
||||||
|
SchoolName: schoolName,
|
||||||
|
StudentID: studentID,
|
||||||
|
Cookie: cookie,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := database.DB.Create(&user).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理邀请码奖励
|
||||||
|
if inviteCode != "" {
|
||||||
|
var inviter model.SysUser
|
||||||
|
if err := database.DB.Where("invite_code = ?", inviteCode).First(&inviter).Error; err == nil {
|
||||||
|
// 给邀请人 +0.5 奖励 + 增加邀请计数
|
||||||
|
database.DB.Model(&inviter).Updates(map[string]interface{}{
|
||||||
|
"invite_reward": gorm.Expr("invite_reward + 0.5"),
|
||||||
|
"invited_count": gorm.Expr("invited_count + 1"),
|
||||||
|
})
|
||||||
|
slog.Info("邀请奖励已发放", "inviterId", inviter.ID, "newUserId", user.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("新用户自动注册", "username", username, "school", schoolName, "inviteCode", inviteCode)
|
||||||
|
return &user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateMyInviteCode 生成/获取当前用户的邀请码
|
||||||
|
func GenerateMyInviteCode(c *fiber.Ctx) error {
|
||||||
|
userID := c.Locals("userId").(uint)
|
||||||
|
|
||||||
|
var user model.SysUser
|
||||||
|
if err := database.DB.First(&user, userID).Error; err != nil {
|
||||||
|
return utils.FailAuth(c, "用户不存在")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 已有邀请码直接返回
|
||||||
|
if user.InviteCode != "" {
|
||||||
|
return utils.Success(c, fiber.Map{
|
||||||
|
"inviteCode": user.InviteCode,
|
||||||
|
"inviteReward": user.InviteReward,
|
||||||
|
"invitedCount": user.InvitedCount,
|
||||||
|
"canRedeem": user.InviteReward >= 1.0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成新邀请码(6 位随机大写字母数字)
|
||||||
|
code := generateRandomCode(6)
|
||||||
|
user.InviteCode = code
|
||||||
|
if err := database.DB.Model(&user).Update("invite_code", code).Error; err != nil {
|
||||||
|
return utils.FailServer(c, "生成邀请码失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("邀请码已生成", "userId", userID, "inviteCode", code)
|
||||||
|
|
||||||
|
return utils.Success(c, fiber.Map{
|
||||||
|
"inviteCode": code,
|
||||||
|
"inviteReward": 0,
|
||||||
|
"invitedCount": 0,
|
||||||
|
"canRedeem": false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMyInviteInfo 获取当前用户的邀请信息
|
||||||
|
func GetMyInviteInfo(c *fiber.Ctx) error {
|
||||||
|
userID := c.Locals("userId").(uint)
|
||||||
|
var user model.SysUser
|
||||||
|
if err := database.DB.First(&user, userID).Error; err != nil {
|
||||||
|
return utils.FailAuth(c, "用户不存在")
|
||||||
|
}
|
||||||
|
|
||||||
|
return utils.Success(c, fiber.Map{
|
||||||
|
"inviteCode": user.InviteCode,
|
||||||
|
"inviteReward": user.InviteReward,
|
||||||
|
"invitedCount": user.InvitedCount,
|
||||||
|
"canRedeem": user.InviteReward >= 1.0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// RedeemInviteReward 兑换邀请奖励积分(满 1 兑换 1 次签到)
|
||||||
|
func RedeemInviteReward(c *fiber.Ctx) error {
|
||||||
|
userID := c.Locals("userId").(uint)
|
||||||
|
var user model.SysUser
|
||||||
|
if err := database.DB.First(&user, userID).Error; err != nil {
|
||||||
|
return utils.FailAuth(c, "用户不存在")
|
||||||
|
}
|
||||||
|
|
||||||
|
if user.InviteReward < 1.0 {
|
||||||
|
return utils.Fail(c, 400, "邀请奖励不足,满 1 才可兑换")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 扣 1 积分,加 1 签到次数
|
||||||
|
if err := database.DB.Model(&user).Updates(map[string]interface{}{
|
||||||
|
"invite_reward": gorm.Expr("invite_reward - 1"),
|
||||||
|
"sign_count": gorm.Expr("sign_count + 1"),
|
||||||
|
}).Error; err != nil {
|
||||||
|
return utils.FailServer(c, "兑换失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("邀请奖励兑换成功", "userId", userID, "reward", user.InviteReward)
|
||||||
|
|
||||||
|
return utils.Success(c, fiber.Map{
|
||||||
|
"quota": 1,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateRandomCode 生成 N 位随机大写字母数字码
|
||||||
|
func generateRandomCode(n int) string {
|
||||||
|
const chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
||||||
|
code := make([]byte, n)
|
||||||
|
for i := range code {
|
||||||
|
code[i] = chars[time.Now().UnixNano()%int64(len(chars))]
|
||||||
|
time.Sleep(1)
|
||||||
|
}
|
||||||
|
return string(code)
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"errors"
|
||||||
|
"log/slog"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"cpdaily-new/internal/database"
|
||||||
|
"cpdaily-new/internal/model"
|
||||||
|
cpd "cpdaily-new/internal/service/cpdaily"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrPasswordChanged 当 cpdaily 密码错误时返回此错误
|
||||||
|
var ErrPasswordChanged = errors.New("cpdaily_password_error")
|
||||||
|
|
||||||
|
// encryptRawPassword AES 加密明文密码并 base64 编码
|
||||||
|
func encryptRawPassword(rawPassword string) (string, error) {
|
||||||
|
encrypted := cpd.AesEncrypt([]byte(rawPassword))
|
||||||
|
return base64.StdEncoding.EncodeToString(encrypted), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// decryptRawPassword base64 解码后 AES 解密
|
||||||
|
func decryptRawPassword(encrypted string) (string, error) {
|
||||||
|
data, err := base64.StdEncoding.DecodeString(encrypted)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
decrypted := cpd.AesDecrypt(data)
|
||||||
|
return string(decrypted), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// saveRawPassword 将明文密码 AES 加密后存入数据库
|
||||||
|
func saveRawPassword(userID uint, rawPassword string) error {
|
||||||
|
encrypted, err := encryptRawPassword(rawPassword)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return database.DB.Model(&model.SysUser{}).Where("id = ?", userID).
|
||||||
|
Update("raw_password", encrypted).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateRawPassword 更新用户的 raw_password(密码可能已修改)
|
||||||
|
func updateRawPassword(userID uint, rawPassword string) error {
|
||||||
|
return saveRawPassword(userID, rawPassword)
|
||||||
|
}
|
||||||
|
|
||||||
|
// isCookieExpired 判断错误是否由 cpdaily cookie 过期引起
|
||||||
|
func isCookieExpired(err error) bool {
|
||||||
|
if err == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
msg := err.Error()
|
||||||
|
return strings.Contains(msg, "未登录") ||
|
||||||
|
strings.Contains(msg, "登录已过期")
|
||||||
|
}
|
||||||
|
|
||||||
|
// refreshCpdailyCookie 使用数据库中的 raw_password 重新登录 cpdaily 并更新 cookie
|
||||||
|
// 密码错误时返回 ErrPasswordChanged
|
||||||
|
func refreshCpdailyCookie(user *model.SysUser) error {
|
||||||
|
// 重新从数据库加载用户(获取最新 raw_password)
|
||||||
|
var fresh model.SysUser
|
||||||
|
if err := database.DB.First(&fresh, user.ID).Error; err != nil {
|
||||||
|
slog.Error("刷新cookie: 查询用户失败", "userId", user.ID, "error", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if fresh.RawPassword == "" {
|
||||||
|
slog.Warn("刷新cookie: 未保存密码", "userId", user.ID)
|
||||||
|
return errors.New("未保存登录密码,无法自动重新登录")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解密密码
|
||||||
|
rawPassword, err := decryptRawPassword(fresh.RawPassword)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("刷新cookie: 解密密码失败", "userId", user.ID, "error", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取学校详情
|
||||||
|
school, err := cpd.GetSchoolDetail(fresh.SchoolID)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("刷新cookie: 获取学校详情失败", "userId", user.ID, "schoolId", fresh.SchoolID, "error", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("刷新cookie: 尝试重新登录cpdaily", "userId", user.ID, "school", school.Name)
|
||||||
|
|
||||||
|
// 执行 cpdaily 登录
|
||||||
|
cookies, err := cpd.CpdailyLogin(context.Background(), school, fresh.StudentID, rawPassword)
|
||||||
|
if err != nil {
|
||||||
|
errMsg := err.Error()
|
||||||
|
slog.Warn("刷新cookie: cpdaily登录失败", "userId", user.ID, "error", errMsg)
|
||||||
|
|
||||||
|
// 检测密码错误
|
||||||
|
if strings.Contains(errMsg, "账号或密码错误") ||
|
||||||
|
strings.Contains(errMsg, "密码错误") ||
|
||||||
|
strings.Contains(errMsg, "账号密码") {
|
||||||
|
return ErrPasswordChanged
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查 MOD_ cookie 确保登录成功
|
||||||
|
hasMOD := false
|
||||||
|
for _, cookie := range cookies {
|
||||||
|
if strings.HasPrefix(cookie.Name, "MOD_") {
|
||||||
|
hasMOD = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !hasMOD {
|
||||||
|
slog.Warn("刷新cookie: 未获取到MOD_cookie", "userId", user.ID)
|
||||||
|
return ErrPasswordChanged
|
||||||
|
}
|
||||||
|
|
||||||
|
// 序列化并更新 cookie
|
||||||
|
cookieStr := cpd.SerializeCookies(cookies)
|
||||||
|
if err := database.DB.Model(&fresh).Update("cookie", cookieStr).Error; err != nil {
|
||||||
|
slog.Error("刷新cookie: 更新数据库失败", "userId", user.ID, "error", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 同步用户信息(异步)
|
||||||
|
go syncUserProfile(user, cookieStr, school.WecloudUrl)
|
||||||
|
|
||||||
|
// 更新传入用户的 cookie(引用),上层重试时使用
|
||||||
|
user.Cookie = cookieStr
|
||||||
|
|
||||||
|
slog.Info("刷新cookie: 成功", "userId", user.ID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"cpdaily-new/internal/config"
|
||||||
|
"cpdaily-new/internal/database"
|
||||||
|
"cpdaily-new/internal/model"
|
||||||
|
"cpdaily-new/internal/service"
|
||||||
|
"cpdaily-new/internal/utils"
|
||||||
|
"errors"
|
||||||
|
"log/slog"
|
||||||
|
"math/rand"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GenerateCDKRequest 生成CDK请求
|
||||||
|
type GenerateCDKRequest struct {
|
||||||
|
Count int `json:"count"` // 生成数量,默认1
|
||||||
|
MaxRuns int `json:"maxRuns"` // 每张CDK可运行次数,默认1
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取基础扣费(从系统设置或配置)
|
||||||
|
func getBaseDeduction() float64 {
|
||||||
|
var cfg model.SystemConfig
|
||||||
|
result := database.DB.Where("`key` = ?", "base_deduction_per_run").First(&cfg)
|
||||||
|
if result.Error == nil {
|
||||||
|
if v, err := strconv.ParseFloat(cfg.Value, 64); err == nil && v > 0 {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 兜底:使用 config.toml 中的值
|
||||||
|
return config.Global.Agent.SystemCdkPrice
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateCDK 生成CDK
|
||||||
|
func GenerateCDK(c *fiber.Ctx) error {
|
||||||
|
agentID := c.Locals("adminId").(uint)
|
||||||
|
|
||||||
|
var req GenerateCDKRequest
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return utils.FailParam(c, "请求参数错误")
|
||||||
|
}
|
||||||
|
if req.Count <= 0 {
|
||||||
|
req.Count = 1
|
||||||
|
}
|
||||||
|
if req.Count > 100 {
|
||||||
|
return utils.Fail(c, 400, "单次最多生成100个CDK")
|
||||||
|
}
|
||||||
|
if req.MaxRuns <= 0 {
|
||||||
|
req.MaxRuns = 1
|
||||||
|
}
|
||||||
|
if req.MaxRuns > 999 {
|
||||||
|
return utils.Fail(c, 400, "每张CDK最多999次")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取代理信息
|
||||||
|
var agent model.AgentUser
|
||||||
|
if err := database.DB.First(&agent, agentID).Error; err != nil {
|
||||||
|
return utils.Fail(c, 404, "账号不存在")
|
||||||
|
}
|
||||||
|
|
||||||
|
if agent.Status == "disabled" {
|
||||||
|
return utils.Fail(c, 403, "账号已被禁用")
|
||||||
|
}
|
||||||
|
|
||||||
|
baseDeduction := getBaseDeduction()
|
||||||
|
cdkPrice := baseDeduction * float64(req.MaxRuns) * agent.Rate
|
||||||
|
costTotal := baseDeduction * float64(req.MaxRuns) * agent.Rate * float64(req.Count)
|
||||||
|
|
||||||
|
// 事务:扣余额 + 生成CDK
|
||||||
|
cdkCodes := make([]model.CDKCode, 0, req.Count)
|
||||||
|
|
||||||
|
err := database.DB.Transaction(func(tx *gorm.DB) error {
|
||||||
|
// 扣减代理余额(原子操作)
|
||||||
|
result := tx.Model(&model.AgentUser{}).
|
||||||
|
Where("id = ? AND balance >= ?", agentID, costTotal).
|
||||||
|
Update("balance", gorm.Expr("balance - ?", costTotal))
|
||||||
|
if result.RowsAffected == 0 {
|
||||||
|
return errors.New("余额不足")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 批量生成CDK
|
||||||
|
now := time.Now()
|
||||||
|
for i := 0; i < req.Count; i++ {
|
||||||
|
code := generateCDKCode()
|
||||||
|
cdk := model.CDKCode{
|
||||||
|
Code: code,
|
||||||
|
Price: cdkPrice,
|
||||||
|
CostPrice: baseDeduction * float64(req.MaxRuns),
|
||||||
|
AgentID: agentID,
|
||||||
|
MaxRuns: req.MaxRuns,
|
||||||
|
Status: "unused",
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}
|
||||||
|
if err := tx.Create(&cdk).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
cdkCodes = append(cdkCodes, cdk)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
if err.Error() == "余额不足" {
|
||||||
|
return utils.Fail(c, 400, "账户余额不足,需要 "+formatFloat(costTotal)+" 元")
|
||||||
|
}
|
||||||
|
slog.Error("生成CDK失败", "error", err)
|
||||||
|
return utils.FailServer(c, "生成失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("生成CDK成功", "agentId", agentID, "count", req.Count, "maxRuns", req.MaxRuns, "price", cdkPrice)
|
||||||
|
|
||||||
|
return utils.Success(c, fiber.Map{
|
||||||
|
"count": len(cdkCodes),
|
||||||
|
"price": cdkPrice,
|
||||||
|
"maxRuns": req.MaxRuns,
|
||||||
|
"costTotal": costTotal,
|
||||||
|
"cdkCodes": cdkCodes,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListCDKs 获取自己生成的CDK列表
|
||||||
|
func ListCDKs(c *fiber.Ctx) error {
|
||||||
|
agentID := c.Locals("adminId").(uint)
|
||||||
|
|
||||||
|
var cdks []model.CDKCode
|
||||||
|
database.DB.Where("agent_id = ?", agentID).Order("created_at DESC").Limit(100).Find(&cdks)
|
||||||
|
|
||||||
|
return utils.Success(c, cdks)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListAllCDKs 获取所有CDK列表(仅admin)
|
||||||
|
func ListAllCDKs(c *fiber.Ctx) error {
|
||||||
|
var cdks []model.CDKCode
|
||||||
|
database.DB.Preload("Agent").Order("created_at DESC").Limit(200).Find(&cdks)
|
||||||
|
|
||||||
|
return utils.Success(c, cdks)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserRedeemCDKRequest 用户兑换CDK请求
|
||||||
|
type UserRedeemCDKRequest struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
CaptchaID string `json:"captchaId"`
|
||||||
|
CaptchaCode string `json:"captchaCode"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func UserRedeemCDK(c *fiber.Ctx) error {
|
||||||
|
userID := c.Locals("userId").(uint)
|
||||||
|
|
||||||
|
var req UserRedeemCDKRequest
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return utils.FailParam(c, "请求参数错误")
|
||||||
|
}
|
||||||
|
if req.Code == "" {
|
||||||
|
return utils.FailParam(c, "CDK码不能为空")
|
||||||
|
}
|
||||||
|
if req.CaptchaID == "" || req.CaptchaCode == "" {
|
||||||
|
return utils.FailParam(c, "验证码不能为空")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 校验图片验证码
|
||||||
|
if !service.VerifyCaptcha(req.CaptchaID, req.CaptchaCode) {
|
||||||
|
return utils.FailParam(c, "验证码错误或已过期")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查找CDK
|
||||||
|
var cdk model.CDKCode
|
||||||
|
err := database.DB.Where("code = ? AND status = 'unused'", req.Code).First(&cdk).Error
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return utils.Fail(c, 404, "CDK码无效或已被使用")
|
||||||
|
}
|
||||||
|
return utils.FailServer(c, "查询失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用行锁(悲观锁)确保并发安全
|
||||||
|
err = database.DB.Transaction(func(tx *gorm.DB) error {
|
||||||
|
// 重新查询并锁定该行
|
||||||
|
var lockedCDK model.CDKCode
|
||||||
|
if err := tx.Set("gorm:query_option", "FOR UPDATE").First(&lockedCDK, cdk.ID).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if lockedCDK.Status != "unused" {
|
||||||
|
return errors.New("CDK码已被使用")
|
||||||
|
}
|
||||||
|
if lockedCDK.MaxRuns <= 0 {
|
||||||
|
return errors.New("CDK码已无可运行次数")
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
// 一次性消耗全部次数
|
||||||
|
totalRuns := lockedCDK.MaxRuns
|
||||||
|
updates := map[string]interface{}{
|
||||||
|
"status": "used",
|
||||||
|
"user_id": userID,
|
||||||
|
"used_at": now,
|
||||||
|
}
|
||||||
|
if err := tx.Model(&lockedCDK).Updates(updates).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 增加用户签到次数(一次性加上所有剩余次数)
|
||||||
|
if err := tx.Model(&model.SysUser{}).Where("id = ?", userID).
|
||||||
|
Update("sign_count", gorm.Expr("sign_count + ?", totalRuns)).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
if err.Error() == "CDK码已被使用" {
|
||||||
|
return utils.Fail(c, 400, "CDK码已被使用")
|
||||||
|
}
|
||||||
|
if err.Error() == "CDK码已无可运行次数" {
|
||||||
|
return utils.Fail(c, 400, "CDK码已无可运行次数")
|
||||||
|
}
|
||||||
|
slog.Error("兑换CDK失败", "error", err)
|
||||||
|
return utils.FailServer(c, "兑换失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("CDK兑换成功", "userId", userID, "code", req.Code)
|
||||||
|
|
||||||
|
return utils.Success(c, fiber.Map{
|
||||||
|
"message": "兑换成功",
|
||||||
|
"totalRuns": cdk.MaxRuns,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成CDK码(12位大写字母数字)
|
||||||
|
func generateCDKCode() string {
|
||||||
|
const chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
||||||
|
rng := rand.New(rand.NewSource(time.Now().UnixNano()))
|
||||||
|
code := make([]byte, 12)
|
||||||
|
for i := range code {
|
||||||
|
code[i] = chars[rng.Intn(len(chars))]
|
||||||
|
}
|
||||||
|
return string(code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatFloat 格式化浮点数为字符串保留2位小数
|
||||||
|
func formatFloat(f float64) string {
|
||||||
|
return utils.FormatFloat(f)
|
||||||
|
}
|
||||||
@@ -0,0 +1,390 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"cpdaily-new/internal/database"
|
||||||
|
"cpdaily-new/internal/model"
|
||||||
|
"cpdaily-new/internal/utils"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"html/template"
|
||||||
|
"log/slog"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 生成随机 token
|
||||||
|
func generateToken() string {
|
||||||
|
b := make([]byte, 32)
|
||||||
|
rand.Read(b)
|
||||||
|
return hex.EncodeToString(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateDeviceQr 生成设备绑定二维码
|
||||||
|
func GenerateDeviceQr(c *fiber.Ctx) error {
|
||||||
|
userID := c.Locals("userId").(uint)
|
||||||
|
|
||||||
|
// 生成 token
|
||||||
|
token := generateToken()
|
||||||
|
|
||||||
|
// 创建绑定记录,5 分钟过期
|
||||||
|
record := model.DeviceBindToken{
|
||||||
|
UserID: userID,
|
||||||
|
Token: token,
|
||||||
|
Status: "pending",
|
||||||
|
ExpiresAt: time.Now().Add(5 * time.Minute),
|
||||||
|
}
|
||||||
|
if err := database.DB.Create(&record).Error; err != nil {
|
||||||
|
return utils.FailServer(c, "生成二维码失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
return utils.Success(c, fiber.Map{
|
||||||
|
"token": token,
|
||||||
|
"expiresIn": 300,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDeviceStatus 查询设备绑定状态(长轮询用)
|
||||||
|
func GetDeviceStatus(c *fiber.Ctx) error {
|
||||||
|
userID := c.Locals("userId").(uint)
|
||||||
|
token := c.Query("token")
|
||||||
|
if token == "" {
|
||||||
|
return utils.FailParam(c, "缺少 token")
|
||||||
|
}
|
||||||
|
|
||||||
|
var record model.DeviceBindToken
|
||||||
|
err := database.DB.Where("token = ? AND user_id = ?", token, userID).First(&record).Error
|
||||||
|
if err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
return utils.Fail(c, 404, "二维码已过期或无效")
|
||||||
|
}
|
||||||
|
return utils.FailServer(c, "查询失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
if record.Status == "expired" {
|
||||||
|
return utils.Fail(c, 410, "二维码已过期")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否过期
|
||||||
|
if time.Now().After(record.ExpiresAt) {
|
||||||
|
database.DB.Model(&record).Update("status", "expired")
|
||||||
|
return utils.Fail(c, 410, "二维码已过期")
|
||||||
|
}
|
||||||
|
|
||||||
|
if record.Status == "bound" {
|
||||||
|
// 更新用户的 mobile_code
|
||||||
|
database.DB.Model(&model.SysUser{}).Where("id = ?", userID).Updates(map[string]interface{}{
|
||||||
|
"mobile_code": record.DeviceCode,
|
||||||
|
"mobile_type": "authorize",
|
||||||
|
})
|
||||||
|
return utils.Success(c, fiber.Map{
|
||||||
|
"status": "bound",
|
||||||
|
"deviceCode": record.DeviceCode,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return utils.Success(c, fiber.Map{
|
||||||
|
"status": "pending",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// SubmitDeviceCode 授权页提交设备码
|
||||||
|
type SubmitDeviceCodeRequest struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
DeviceCode string `json:"deviceCode"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func SubmitDeviceCode(c *fiber.Ctx) error {
|
||||||
|
var req SubmitDeviceCodeRequest
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return utils.FailParam(c, "请求参数错误")
|
||||||
|
}
|
||||||
|
if req.Token == "" || req.DeviceCode == "" {
|
||||||
|
return utils.FailParam(c, "参数不完整")
|
||||||
|
}
|
||||||
|
|
||||||
|
var record model.DeviceBindToken
|
||||||
|
err := database.DB.Where("token = ? AND status = ?", req.Token, "pending").First(&record).Error
|
||||||
|
if err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
return utils.Fail(c, 404, "二维码无效或已过期")
|
||||||
|
}
|
||||||
|
return utils.FailServer(c, "查询失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查过期
|
||||||
|
if time.Now().After(record.ExpiresAt) {
|
||||||
|
database.DB.Model(&record).Update("status", "expired")
|
||||||
|
return utils.Fail(c, 410, "二维码已过期")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新绑定状态
|
||||||
|
database.DB.Model(&record).Updates(map[string]interface{}{
|
||||||
|
"device_code": req.DeviceCode,
|
||||||
|
"status": "bound",
|
||||||
|
})
|
||||||
|
|
||||||
|
slog.Info("设备绑定成功", "userId", record.UserID, "deviceCode", req.DeviceCode)
|
||||||
|
|
||||||
|
// 也直接更新用户
|
||||||
|
database.DB.Model(&model.SysUser{}).Where("id = ?", record.UserID).Updates(map[string]interface{}{
|
||||||
|
"mobile_code": req.DeviceCode,
|
||||||
|
"mobile_type": "authorize",
|
||||||
|
})
|
||||||
|
|
||||||
|
return utils.Success(c, fiber.Map{"status": "bound"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClosePage 授权完成页(当 WebView 无法关闭时跳转至此)
|
||||||
|
func ClosePage(c *fiber.Ctx) error {
|
||||||
|
c.Type("html")
|
||||||
|
return c.SendString(`<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1.0,maximum-scale=1.0,user-scalable=no">
|
||||||
|
<title>授权完成</title>
|
||||||
|
<style>
|
||||||
|
*{margin:0;padding:0;box-sizing:border-box}
|
||||||
|
body{font-family:-apple-system,BlinkMacSystemFont,"SF Pro Display","PingFang SC",sans-serif;background:#F5F5F7;min-height:100vh;display:flex;align-items:center;justify-content:center}
|
||||||
|
.card{text-align:center;padding:40px 28px;max-width:380px;width:100%}
|
||||||
|
.icon{width:72px;height:72px;border-radius:20px;background:linear-gradient(135deg,#34c759,#30d158);display:flex;align-items:center;justify-content:center;margin:0 auto 20px;font-size:36px;color:#fff}
|
||||||
|
h1{font-size:22px;font-weight:700;color:#1d1d1f;margin-bottom:10px}
|
||||||
|
p{font-size:15px;color:#86868b;line-height:1.6}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="card">
|
||||||
|
<div class="icon">✓</div>
|
||||||
|
<h1>授权已完成</h1>
|
||||||
|
<p>设备绑定成功,请继续操作</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuthorizePage 扫码后打开的授权页面(HTML)
|
||||||
|
func AuthorizePage(c *fiber.Ctx) error {
|
||||||
|
token := c.Params("token")
|
||||||
|
if token == "" {
|
||||||
|
return c.Status(400).SendString("无效的二维码")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证 token 有效性
|
||||||
|
var record model.DeviceBindToken
|
||||||
|
err := database.DB.Where("token = ?", token).First(&record).Error
|
||||||
|
if err != nil || record.Status != "pending" || time.Now().After(record.ExpiresAt) {
|
||||||
|
if err == nil && time.Now().After(record.ExpiresAt) {
|
||||||
|
database.DB.Model(&record).Update("status", "expired")
|
||||||
|
}
|
||||||
|
return c.Status(400).Type("html").SendString(`
|
||||||
|
<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0,maximum-scale=1.0,user-scalable=no">
|
||||||
|
<title>设备授权</title><style>*{margin:0;padding:0;box-sizing:border-box}
|
||||||
|
body{font-family:-apple-system,BlinkMacSystemFont,"SF Pro Display","PingFang SC",sans-serif;background:#F5F5F7;min-height:100vh;display:flex;align-items:center;justify-content:center}
|
||||||
|
.card{text-align:center;padding:40px 28px;max-width:380px;width:100%}
|
||||||
|
.icon{width:72px;height:72px;border-radius:20px;background:linear-gradient(135deg,#ff3b30,#ff6961);display:flex;align-items:center;justify-content:center;margin:0 auto 20px;font-size:36px;color:#fff}
|
||||||
|
h1{font-size:22px;font-weight:700;color:#1d1d1f;margin-bottom:10px}
|
||||||
|
p{font-size:15px;color:#86868b;line-height:1.6}
|
||||||
|
</style></head><body>
|
||||||
|
<div class="card"><div class="icon">✕</div><h1>二维码无效</h1><p>二维码无效或已过期,请重新获取</p></div>
|
||||||
|
</body></html>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构建提交 URL
|
||||||
|
submitURL := fmt.Sprintf("/api/device/submit")
|
||||||
|
|
||||||
|
tmpl := template.Must(template.New("authorize").Parse(`<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1.0,maximum-scale=1.0,user-scalable=no">
|
||||||
|
<title>设备授权</title>
|
||||||
|
<style>
|
||||||
|
*{margin:0;padding:0;box-sizing:border-box}
|
||||||
|
body{font-family:-apple-system,BlinkMacSystemFont,"SF Pro Display","PingFang SC",sans-serif;background:#F5F5F7;min-height:100vh;display:flex;align-items:center;justify-content:center;overflow-x:hidden}
|
||||||
|
.aurora-orb{position:fixed;border-radius:50%;filter:blur(100px);opacity:0.6;pointer-events:none;z-index:0;animation:float 20s ease-in-out infinite}
|
||||||
|
.aurora-orb-1{width:60vw;height:60vw;background:radial-gradient(circle,rgba(162,210,255,0.4),transparent);top:-20%;left:-10%}
|
||||||
|
.aurora-orb-2{width:55vw;height:55vw;background:radial-gradient(circle,rgba(200,180,255,0.3),transparent);bottom:-20%;right:-10%;animation-delay:-10s}
|
||||||
|
@keyframes float{0%,100%{transform:translate(0,0) scale(1)}50%{transform:translate(30px,-30px) scale(1.1)}}
|
||||||
|
.container{position:relative;z-index:1;width:100%;max-width:380px;padding:20px}
|
||||||
|
.card{background:rgba(255,255,255,0.82);backdrop-filter:blur(20px) saturate(180%);-webkit-backdrop-filter:blur(20px) saturate(180%);border:1px solid rgba(255,255,255,0.6);border-radius:24px;box-shadow:0 8px 32px rgba(0,0,0,0.08);padding:36px 28px 32px;text-align:center}
|
||||||
|
.icon-wrap{width:72px;height:72px;border-radius:20px;display:flex;align-items:center;justify-content:center;margin:0 auto 20px;font-size:36px}
|
||||||
|
.icon-loading{background:linear-gradient(135deg,#0071e3,#40a9ff)}
|
||||||
|
.icon-success{background:linear-gradient(135deg,#34c759,#30d158)}
|
||||||
|
.icon-error{background:linear-gradient(135deg,#ff3b30,#ff6961)}
|
||||||
|
.icon-warn{background:linear-gradient(135deg,#ff9f0a,#ffcc02)}
|
||||||
|
h1{font-size:22px;font-weight:700;letter-spacing:-0.02em;color:#1d1d1f;margin-bottom:10px}
|
||||||
|
.desc{font-size:15px;color:#86868b;line-height:1.6;margin-bottom:16px}
|
||||||
|
.spinner{width:36px;height:36px;border:3px solid rgba(0,113,227,0.15);border-top-color:#0071e3;border-radius:50%;animation:spin 0.8s linear infinite;margin:20px auto 0}
|
||||||
|
@keyframes spin{to{transform:rotate(360deg)}}
|
||||||
|
.code-box{background:#F2F2F7;border-radius:12px;padding:14px 16px;font-family:"SF Mono","Fira Code",monospace;font-size:13px;color:#1d1d1f;word-break:break-all;line-height:1.7;border:1px solid rgba(0,0,0,0.06);user-select:all;text-align:left;margin:12px 0}
|
||||||
|
.btn-primary{background:#0071e3;color:white;padding:13px 24px;border-radius:980px;border:none;font-size:15px;font-weight:500;cursor:pointer;transition:all 0.3s;width:100%}
|
||||||
|
.btn-primary:hover{filter:brightness(1.08)}
|
||||||
|
.btn-primary:disabled{opacity:0.5;cursor:not-allowed}
|
||||||
|
.steps{margin-top:16px;display:flex;flex-direction:column;gap:10px;text-align:left}
|
||||||
|
.step{display:flex;align-items:flex-start;gap:12px;background:rgba(0,0,0,0.03);border-radius:12px;padding:12px 14px;border:1px solid rgba(0,0,0,0.05)}
|
||||||
|
.step-num{width:22px;height:22px;border-radius:50%;background:#0071e3;color:white;font-size:12px;font-weight:700;display:flex;align-items:center;justify-content:center;flex-shrink:0;margin-top:1px}
|
||||||
|
.step-text{font-size:14px;color:#1d1d1f;line-height:1.5}
|
||||||
|
.step-text span{color:#86868b;font-size:13px;display:block;margin-top:2px}
|
||||||
|
.hidden{display:none}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="aurora-orb aurora-orb-1"></div>
|
||||||
|
<div class="aurora-orb aurora-orb-2"></div>
|
||||||
|
<div class="container">
|
||||||
|
<div class="card" id="app">
|
||||||
|
<!-- 加载中 -->
|
||||||
|
<div id="state-loading">
|
||||||
|
<div class="icon-wrap icon-loading" style="color:#fff">📱</div>
|
||||||
|
<h1>正在验证...</h1>
|
||||||
|
<p class="desc">请稍候</p>
|
||||||
|
<div class="spinner"></div>
|
||||||
|
</div>
|
||||||
|
<!-- 非今日校园 -->
|
||||||
|
<div id="state-notcpdaily" class="hidden">
|
||||||
|
<div class="icon-wrap icon-warn" style="color:#fff">📵</div>
|
||||||
|
<h1>请用今日校园扫码</h1>
|
||||||
|
<p class="desc">此页面需要在<strong>今日校园 App</strong>内打开才能获取设备码</p>
|
||||||
|
<div class="steps">
|
||||||
|
<div class="step"><div class="step-num">1</div><div class="step-text">打开今日校园 App<span>在手机上启动今日校园</span></div></div>
|
||||||
|
<div class="step"><div class="step-num">2</div><div class="step-text">点击右上角扫一扫<span>使用 App 内置扫码功能</span></div></div>
|
||||||
|
<div class="step"><div class="step-num">3</div><div class="step-text">扫描电脑上的二维码<span>扫码后设备码将自动发送</span></div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- 获取中 -->
|
||||||
|
<div id="state-fetching" class="hidden">
|
||||||
|
<div class="icon-wrap icon-loading" style="color:#fff">📱</div>
|
||||||
|
<h1>正在获取设备码</h1>
|
||||||
|
<p class="desc">请稍候,正在读取设备信息...</p>
|
||||||
|
<div class="spinner"></div>
|
||||||
|
</div>
|
||||||
|
<!-- 成功 -->
|
||||||
|
<div id="state-success" class="hidden">
|
||||||
|
<div class="icon-wrap icon-success" style="color:#fff">✓</div>
|
||||||
|
<h1>授权成功</h1>
|
||||||
|
<p class="desc">正在跳转...</p>
|
||||||
|
</div>
|
||||||
|
<!-- 提交中 -->
|
||||||
|
<div id="state-submitting" class="hidden">
|
||||||
|
<div class="icon-wrap icon-loading" style="color:#fff">📤</div>
|
||||||
|
<h1>正在提交</h1>
|
||||||
|
<p class="desc">设备码正在提交至服务器...</p>
|
||||||
|
<div class="spinner"></div>
|
||||||
|
</div>
|
||||||
|
<!-- 失败 -->
|
||||||
|
<div id="state-error" class="hidden">
|
||||||
|
<div class="icon-wrap icon-error" style="color:#fff">✕</div>
|
||||||
|
<h1>获取失败</h1>
|
||||||
|
<p class="desc" id="error-msg" style="color:#d70015"></p>
|
||||||
|
<button class="btn-primary" onclick="location.reload()" style="margin-top:16px">重试</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
const TOKEN = '{{.Token}}';
|
||||||
|
const SUBMIT_URL = '{{.SubmitURL}}';
|
||||||
|
|
||||||
|
function isCpdaily() {
|
||||||
|
var ua = navigator.userAgent.toLowerCase();
|
||||||
|
return ua.indexOf('cpdaily') !== -1 || ua.indexOf('wisedu') !== -1 || ua.indexOf('campushoy') !== -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showState(id) {
|
||||||
|
['state-loading','state-notcpdaily','state-fetching','state-success','state-submitting','state-error'].forEach(function(s) {
|
||||||
|
document.getElementById(s).classList.add('hidden');
|
||||||
|
});
|
||||||
|
document.getElementById(id).classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadCordova() {
|
||||||
|
return new Promise(function(resolve, reject) {
|
||||||
|
var isAndroid = /Android|Linux/.test(navigator.userAgent);
|
||||||
|
var src = 'https://static.campushoy.com/innerjs/' + (isAndroid ? 'cordova_android.js' : 'cordova_ios.js');
|
||||||
|
var s = document.createElement('script');
|
||||||
|
s.src = src;
|
||||||
|
s.onload = resolve;
|
||||||
|
s.onerror = function() { reject(new Error('Cordova 脚本加载失败')); };
|
||||||
|
document.head.appendChild(s);
|
||||||
|
if (!window.mamp) window.mamp = {};
|
||||||
|
window.mamp.initEnv = function(cb) { document.addEventListener('deviceready', cb, false); };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function waitCordova() {
|
||||||
|
return new Promise(function(resolve, reject) {
|
||||||
|
var n = 0;
|
||||||
|
var t = setInterval(function() {
|
||||||
|
if (window.cordova && window.cordova.exec) {
|
||||||
|
clearInterval(t);
|
||||||
|
resolve();
|
||||||
|
} else if (++n > 600) {
|
||||||
|
clearInterval(t);
|
||||||
|
reject(new Error('Cordova 加载超时'));
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDeviceUuid() {
|
||||||
|
return new Promise(function(resolve, reject) {
|
||||||
|
window.cordova.exec(
|
||||||
|
function(res) { resolve(res.uuid); },
|
||||||
|
function(err) { reject(new Error(err ? (err.message || String(err)) : '未知错误')); },
|
||||||
|
'MAMPDevice', 'getDeviceInfo', []
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitDeviceCode(deviceCode) {
|
||||||
|
var resp = await fetch(SUBMIT_URL, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({token: TOKEN, deviceCode: deviceCode})
|
||||||
|
});
|
||||||
|
var data = await resp.json();
|
||||||
|
if (data.code !== 0) throw new Error(data.message || '提交失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
(async function() {
|
||||||
|
// 检查是否在今日校园 App 内
|
||||||
|
if (!isCpdaily()) {
|
||||||
|
showState('state-notcpdaily');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
showState('state-fetching');
|
||||||
|
try {
|
||||||
|
await loadCordova();
|
||||||
|
await waitCordova();
|
||||||
|
var deviceCode = await getDeviceUuid();
|
||||||
|
|
||||||
|
showState('state-submitting');
|
||||||
|
await submitDeviceCode(deviceCode);
|
||||||
|
|
||||||
|
showState('state-success');
|
||||||
|
// 立即关闭页面,同时跳转到完成页兜底
|
||||||
|
try { window.close(); } catch(e) {}
|
||||||
|
try { cordova.exec(null, null, 'MAMPWebview', 'close', []); } catch(e) {}
|
||||||
|
try { window.webkit.messageHandlers.close.postMessage(null); } catch(e) {}
|
||||||
|
try { window.JSInterface.close(); } catch(e) {}
|
||||||
|
try { window.history.back(); } catch(e) {}
|
||||||
|
try { window.history.go(-1); } catch(e) {}
|
||||||
|
window.location.replace('/api/device/close');
|
||||||
|
} catch (e) {
|
||||||
|
document.getElementById('error-msg').textContent = e.message || '未知错误';
|
||||||
|
showState('state-error');
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>`))
|
||||||
|
|
||||||
|
c.Type("html")
|
||||||
|
return tmpl.Execute(c.Response().BodyWriter(), map[string]string{
|
||||||
|
"Token": token,
|
||||||
|
"SubmitURL": submitURL,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"cpdaily-new/internal/database"
|
||||||
|
"cpdaily-new/internal/model"
|
||||||
|
"cpdaily-new/internal/service"
|
||||||
|
"cpdaily-new/internal/utils"
|
||||||
|
"log/slog"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---------- 速率限制 ----------
|
||||||
|
|
||||||
|
type rateEntry struct {
|
||||||
|
count int
|
||||||
|
resetTime time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
rateMu sync.Mutex
|
||||||
|
rateMap = make(map[string]*rateEntry)
|
||||||
|
rateLimit = 5 // 每个 IP 最多提交次数
|
||||||
|
rateWindow = 24 * time.Hour // 时间窗口
|
||||||
|
)
|
||||||
|
|
||||||
|
func checkRateLimit(ip string) bool {
|
||||||
|
rateMu.Lock()
|
||||||
|
defer rateMu.Unlock()
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
entry, ok := rateMap[ip]
|
||||||
|
if !ok || now.After(entry.resetTime) {
|
||||||
|
rateMap[ip] = &rateEntry{count: 1, resetTime: now.Add(rateWindow)}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if entry.count >= rateLimit {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
entry.count++
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 请求结构体 ----------
|
||||||
|
|
||||||
|
type ReportFailedLoginRequest struct {
|
||||||
|
SchoolID string `json:"schoolId"`
|
||||||
|
SchoolName string `json:"schoolName"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
CaptchaID string `json:"captchaId"`
|
||||||
|
CaptchaCode string `json:"captchaCode"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PendingSchoolRes struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
SchoolID string `json:"schoolId"`
|
||||||
|
SchoolName string `json:"schoolName"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
IP string `json:"ip"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
CreatedAt string `json:"createdAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 用户端 API ----------
|
||||||
|
|
||||||
|
// ReportFailedLogin 用户报告登录失败(提交测试账号信息)
|
||||||
|
func ReportFailedLogin(c *fiber.Ctx) error {
|
||||||
|
// userId 可能不存在(未登录用户提交)
|
||||||
|
var userID uint
|
||||||
|
if uid, ok := c.Locals("userId").(uint); ok {
|
||||||
|
userID = uid
|
||||||
|
}
|
||||||
|
|
||||||
|
var req ReportFailedLoginRequest
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return utils.FailParam(c, "请求参数错误")
|
||||||
|
}
|
||||||
|
if req.SchoolID == "" || req.SchoolName == "" || req.Username == "" || req.Password == "" {
|
||||||
|
return utils.FailParam(c, "参数不完整")
|
||||||
|
}
|
||||||
|
if req.CaptchaID == "" || req.CaptchaCode == "" {
|
||||||
|
return utils.FailParam(c, "请输入验证码")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 校验图片验证码
|
||||||
|
if !service.VerifyCaptcha(req.CaptchaID, req.CaptchaCode) {
|
||||||
|
return utils.FailParam(c, "验证码错误或已过期")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 0. 速率限制:同 IP 每天最多 5 次
|
||||||
|
clientIP := c.IP()
|
||||||
|
slog.Info("[pending-school] 请求到达",
|
||||||
|
"schoolId", req.SchoolID,
|
||||||
|
"schoolName", req.SchoolName,
|
||||||
|
"username", req.Username,
|
||||||
|
"ip", clientIP,
|
||||||
|
"userId", userID,
|
||||||
|
)
|
||||||
|
|
||||||
|
if !checkRateLimit(clientIP) {
|
||||||
|
slog.Warn("[pending-school] 速率限制触发", "ip", clientIP, "schoolId", req.SchoolID, "userId", userID)
|
||||||
|
return utils.Success(c, fiber.Map{"accepted": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. 检查该校是否有今天登录过的账号(说明学校可正常使用,忽略本次报告)
|
||||||
|
today := time.Now().Truncate(24 * time.Hour)
|
||||||
|
var todayLoginCount int64
|
||||||
|
database.DB.Model(&model.SysUser{}).
|
||||||
|
Where("school_id = ? AND last_login_at >= ?", req.SchoolID, today).
|
||||||
|
Count(&todayLoginCount)
|
||||||
|
if todayLoginCount > 0 {
|
||||||
|
slog.Info("[pending-school] 该校有今日登录的用户,提示用户检查账号",
|
||||||
|
"schoolId", req.SchoolID,
|
||||||
|
"schoolName", req.SchoolName,
|
||||||
|
"todayLoginCount", todayLoginCount,
|
||||||
|
)
|
||||||
|
return utils.Fail(c, 400, "当前学校已有用户正常登录,请仔细检查学工号密码信息")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 先查询待处理表中该校已存在多少条未处理记录
|
||||||
|
var pendingCount int64
|
||||||
|
database.DB.Model(&model.PendingSchool{}).
|
||||||
|
Where("school_id = ? AND status = ?", req.SchoolID, "pending").
|
||||||
|
Count(&pendingCount)
|
||||||
|
|
||||||
|
if pendingCount >= 5 {
|
||||||
|
slog.Info("[pending-school] 该校待处理记录已达上限,忽略提交",
|
||||||
|
"schoolId", req.SchoolID,
|
||||||
|
"schoolName", req.SchoolName,
|
||||||
|
"pendingCount", pendingCount,
|
||||||
|
)
|
||||||
|
return utils.Success(c, fiber.Map{"accepted": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 插入记录
|
||||||
|
record := model.PendingSchool{
|
||||||
|
SchoolID: req.SchoolID,
|
||||||
|
SchoolName: req.SchoolName,
|
||||||
|
Username: req.Username,
|
||||||
|
Password: req.Password,
|
||||||
|
IP: clientIP,
|
||||||
|
Status: "pending",
|
||||||
|
}
|
||||||
|
if err := database.DB.Create(&record).Error; err != nil {
|
||||||
|
slog.Error("[pending-school] 保存记录失败", "error", err)
|
||||||
|
return utils.FailServer(c, "提交失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("[pending-school] 记录已保存",
|
||||||
|
"recordId", record.ID,
|
||||||
|
"schoolId", req.SchoolID,
|
||||||
|
"schoolName", req.SchoolName,
|
||||||
|
"username", req.Username,
|
||||||
|
"ip", clientIP,
|
||||||
|
)
|
||||||
|
|
||||||
|
return utils.Success(c, fiber.Map{"accepted": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 管理端 API ----------
|
||||||
|
|
||||||
|
// AdminListPendingSchools 管理端查看待适配学校列表(按学校分组)
|
||||||
|
func AdminListPendingSchools(c *fiber.Ctx) error {
|
||||||
|
status := c.Query("status", "pending")
|
||||||
|
|
||||||
|
var records []model.PendingSchool
|
||||||
|
query := database.DB.Order("created_at DESC")
|
||||||
|
if status == "pending" {
|
||||||
|
query = query.Where("status = ?", "pending")
|
||||||
|
}
|
||||||
|
if err := query.Find(&records).Error; err != nil {
|
||||||
|
return utils.FailServer(c, "查询失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
items := make([]PendingSchoolRes, 0, len(records))
|
||||||
|
for _, r := range records {
|
||||||
|
items = append(items, PendingSchoolRes{
|
||||||
|
ID: r.ID,
|
||||||
|
SchoolID: r.SchoolID,
|
||||||
|
SchoolName: r.SchoolName,
|
||||||
|
Username: r.Username,
|
||||||
|
Password: r.Password,
|
||||||
|
IP: r.IP,
|
||||||
|
Status: r.Status,
|
||||||
|
CreatedAt: r.CreatedAt.Format("2006-01-02 15:04"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return utils.Success(c, items)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminMarkSchoolDone 标记某条记录为已完成
|
||||||
|
func AdminMarkSchoolDone(c *fiber.Ctx) error {
|
||||||
|
recordID, err := c.ParamsInt("id")
|
||||||
|
if err != nil {
|
||||||
|
return utils.FailParam(c, "无效的记录ID")
|
||||||
|
}
|
||||||
|
|
||||||
|
var record model.PendingSchool
|
||||||
|
if err := database.DB.First(&record, recordID).Error; err != nil {
|
||||||
|
return utils.Fail(c, 404, "记录不存在")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 将该学校所有待处理的记录标记为已完成
|
||||||
|
database.DB.Model(&model.PendingSchool{}).
|
||||||
|
Where("school_id = ? AND status = ?", record.SchoolID, "pending").
|
||||||
|
Update("status", "completed")
|
||||||
|
|
||||||
|
slog.Info("学校已标记为适配完成", "schoolId", record.SchoolID, "schoolName", record.SchoolName)
|
||||||
|
|
||||||
|
return utils.Success(c, fiber.Map{"completed": true})
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"cpdaily-new/internal/database"
|
||||||
|
"cpdaily-new/internal/model"
|
||||||
|
"cpdaily-new/internal/utils"
|
||||||
|
"log/slog"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetSettings 获取所有系统设置
|
||||||
|
func GetSettings(c *fiber.Ctx) error {
|
||||||
|
var configs []model.SystemConfig
|
||||||
|
database.DB.Find(&configs)
|
||||||
|
|
||||||
|
// 转为 map
|
||||||
|
settings := make(map[string]string)
|
||||||
|
for _, cfg := range configs {
|
||||||
|
settings[cfg.Key] = cfg.Value
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置默认值
|
||||||
|
if _, ok := settings["base_deduction_per_run"]; !ok {
|
||||||
|
settings["base_deduction_per_run"] = "1.00"
|
||||||
|
}
|
||||||
|
|
||||||
|
return utils.Success(c, settings)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateSettingRequest 更新设置请求
|
||||||
|
type UpdateSettingRequest struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Value string `json:"value"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateSetting 更新单条系统设置
|
||||||
|
func UpdateSetting(c *fiber.Ctx) error {
|
||||||
|
var req UpdateSettingRequest
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return utils.FailParam(c, "请求参数错误")
|
||||||
|
}
|
||||||
|
if req.Key == "" {
|
||||||
|
return utils.FailParam(c, "key 不能为空")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 支持的 key 白名单
|
||||||
|
allowedKeys := map[string]bool{
|
||||||
|
"base_deduction_per_run": true,
|
||||||
|
}
|
||||||
|
if !allowedKeys[req.Key] {
|
||||||
|
return utils.Fail(c, 400, "不支持的设置项: "+req.Key)
|
||||||
|
}
|
||||||
|
|
||||||
|
var cfg model.SystemConfig
|
||||||
|
result := database.DB.Where("`key` = ?", req.Key).First(&cfg)
|
||||||
|
if result.Error != nil {
|
||||||
|
// 不存在则创建
|
||||||
|
cfg = model.SystemConfig{
|
||||||
|
Key: req.Key,
|
||||||
|
Value: req.Value,
|
||||||
|
}
|
||||||
|
if err := database.DB.Create(&cfg).Error; err != nil {
|
||||||
|
slog.Error("创建设置失败", "key", req.Key, "error", err)
|
||||||
|
return utils.FailServer(c, "保存失败")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 存在则更新
|
||||||
|
if err := database.DB.Model(&cfg).Update("value", req.Value).Error; err != nil {
|
||||||
|
slog.Error("更新设置失败", "key", req.Key, "error", err)
|
||||||
|
return utils.FailServer(c, "保存失败")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("更新系统设置", "key", req.Key, "value", req.Value)
|
||||||
|
return utils.Success(c, fiber.Map{
|
||||||
|
"key": req.Key,
|
||||||
|
"value": req.Value,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,639 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"cpdaily-new/internal/database"
|
||||||
|
"cpdaily-new/internal/model"
|
||||||
|
"cpdaily-new/internal/ratelimit"
|
||||||
|
cpd "cpdaily-new/internal/service/cpdaily"
|
||||||
|
"cpdaily-new/internal/utils"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetTaskTitles 并发获取 sign + sleep_check 两种类型的近期任务
|
||||||
|
func GetTaskTitles(c *fiber.Ctx) error {
|
||||||
|
userID := c.Locals("userId").(uint)
|
||||||
|
|
||||||
|
var user model.SysUser
|
||||||
|
if err := database.DB.First(&user, userID).Error; err != nil {
|
||||||
|
return utils.FailAuth(c, "用户不存在")
|
||||||
|
}
|
||||||
|
if user.Cookie == "" {
|
||||||
|
return utils.Fail(c, 400, "请先登录")
|
||||||
|
}
|
||||||
|
|
||||||
|
cookieMap := cpd.ParseCookieJSON(user.Cookie)
|
||||||
|
host := fmt.Sprintf("https://%s.campusphere.net", user.SchoolID)
|
||||||
|
|
||||||
|
tasks := cpd.FetchAllRecentTasks(cookieMap, host)
|
||||||
|
return utils.Success(c, tasks)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateTask 创建任务
|
||||||
|
type CreateTaskRequest struct {
|
||||||
|
TaskType string `json:"taskType"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
RealTitle string `json:"realTitle"`
|
||||||
|
SignWid string `json:"signWid"`
|
||||||
|
SignInstanceWid string `json:"signInstanceWid"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func CreateTask(c *fiber.Ctx) error {
|
||||||
|
userID := c.Locals("userId").(uint)
|
||||||
|
|
||||||
|
var req CreateTaskRequest
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return utils.FailParam(c, "请求参数错误")
|
||||||
|
}
|
||||||
|
if req.TaskType == "" || req.Title == "" {
|
||||||
|
return utils.FailParam(c, "参数不完整")
|
||||||
|
}
|
||||||
|
|
||||||
|
task := model.CpTask{
|
||||||
|
UserID: userID,
|
||||||
|
TaskType: req.TaskType,
|
||||||
|
Title: req.Title,
|
||||||
|
RealTitle: req.RealTitle,
|
||||||
|
SignWid: req.SignWid,
|
||||||
|
SignInstanceWid: req.SignInstanceWid,
|
||||||
|
TaskStatus: "pending",
|
||||||
|
}
|
||||||
|
if err := database.DB.Create(&task).Error; err != nil {
|
||||||
|
slog.Error("创建任务失败", "error", err)
|
||||||
|
return utils.FailServer(c, "创建任务失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
return utils.Success(c, task)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTaskForm 获取任务表单详情(从 cpdaily 拉取并解析)
|
||||||
|
func GetTaskForm(c *fiber.Ctx) error {
|
||||||
|
userID := c.Locals("userId").(uint)
|
||||||
|
id := c.Params("id")
|
||||||
|
|
||||||
|
var task model.CpTask
|
||||||
|
if err := database.DB.Where("id = ? AND user_id = ?", id, userID).First(&task).Error; err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return utils.Fail(c, 404, "任务不存在")
|
||||||
|
}
|
||||||
|
return utils.FailServer(c, "查询失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
var user model.SysUser
|
||||||
|
if err := database.DB.First(&user, userID).Error; err != nil {
|
||||||
|
return utils.FailAuth(c, "用户不存在")
|
||||||
|
}
|
||||||
|
|
||||||
|
cookieMap := cpd.ParseCookieJSON(user.Cookie)
|
||||||
|
host := fmt.Sprintf("https://%s.campusphere.net", user.SchoolID)
|
||||||
|
|
||||||
|
// 从 cpdaily 拉取任务详情
|
||||||
|
detail, err := cpd.FetchTaskDetail(cookieMap, host, cpd.TaskType(task.TaskType), task.SignWid, task.SignInstanceWid, task.Title)
|
||||||
|
if err != nil {
|
||||||
|
// cookie 过期时自动尝试重新登录
|
||||||
|
if isCookieExpired(err) {
|
||||||
|
slog.Info("获取任务表单cookie过期,尝试刷新", "taskId", id)
|
||||||
|
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
|
||||||
|
cookieMap = cpd.ParseCookieJSON(user.Cookie)
|
||||||
|
detail, err = cpd.FetchTaskDetail(cookieMap, host, cpd.TaskType(task.TaskType), task.SignWid, task.SignInstanceWid, task.Title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("获取任务表单失败", "taskId", id, "error", err)
|
||||||
|
if errors.Is(err, ErrPasswordChanged) {
|
||||||
|
return utils.Fail(c, 401, "登录凭证已过期,请重新登录")
|
||||||
|
}
|
||||||
|
return utils.Fail(c, 503, "获取任务表单失败: "+err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取历史数据用于预填充(含月份降级)
|
||||||
|
historyRows := cpd.FetchHistoryFormData(cookieMap, host, cpd.TaskType(task.TaskType), detail.RealTitle, task.SignInstanceWid)
|
||||||
|
|
||||||
|
// 解析为表单字段
|
||||||
|
formItems := cpd.ParseTaskForm(detail, historyRows)
|
||||||
|
|
||||||
|
return utils.Success(c, fiber.Map{
|
||||||
|
"formItems": formItems,
|
||||||
|
"realTitle": detail.RealTitle,
|
||||||
|
"isPhoto": detail.IsPhoto,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveTaskForm 保存用户配置的表单数据
|
||||||
|
type SaveTaskFormRequest struct {
|
||||||
|
FormData []model.CpTaskFormItem `json:"formData"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func SaveTaskForm(c *fiber.Ctx) error {
|
||||||
|
userID := c.Locals("userId").(uint)
|
||||||
|
id := c.Params("id")
|
||||||
|
|
||||||
|
var req SaveTaskFormRequest
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return utils.FailParam(c, "请求参数错误")
|
||||||
|
}
|
||||||
|
|
||||||
|
var task model.CpTask
|
||||||
|
if err := database.DB.Where("id = ? AND user_id = ?", id, userID).First(&task).Error; err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return utils.Fail(c, 404, "任务不存在")
|
||||||
|
}
|
||||||
|
return utils.FailServer(c, "查询失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 序列化为 JSON
|
||||||
|
formData, _ := json.Marshal(req.FormData)
|
||||||
|
database.DB.Model(&task).Update("form_data", string(formData))
|
||||||
|
|
||||||
|
return utils.Success(c, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TaskWithMatch 任务列表附带匹配状态
|
||||||
|
type TaskWithMatch struct {
|
||||||
|
model.CpTask
|
||||||
|
MatchStatus string `json:"matchStatus"` // matched_unsigned, matched_signed, no_match, check_failed
|
||||||
|
}
|
||||||
|
|
||||||
|
// globMatch 简单通配符匹配(仅支持 *)
|
||||||
|
func globMatch(str, pattern string) bool {
|
||||||
|
if pattern == "" {
|
||||||
|
return str == ""
|
||||||
|
}
|
||||||
|
parts := strings.Split(pattern, "*")
|
||||||
|
if len(parts) == 1 {
|
||||||
|
return str == pattern
|
||||||
|
}
|
||||||
|
// 检查前缀
|
||||||
|
if parts[0] != "" && !strings.HasPrefix(str, parts[0]) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
str = str[len(parts[0]):]
|
||||||
|
// 检查中间部分
|
||||||
|
for i := 1; i < len(parts)-1; i++ {
|
||||||
|
part := parts[i]
|
||||||
|
if part == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
idx := strings.Index(str, part)
|
||||||
|
if idx < 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
str = str[idx+len(part):]
|
||||||
|
}
|
||||||
|
// 检查后缀
|
||||||
|
last := parts[len(parts)-1]
|
||||||
|
if last != "" && !strings.HasSuffix(str, last) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListTasks 查询用户任务列表(附带今日任务匹配状态)
|
||||||
|
func ListTasks(c *fiber.Ctx) error {
|
||||||
|
userID := c.Locals("userId").(uint)
|
||||||
|
|
||||||
|
var tasks []model.CpTask
|
||||||
|
database.DB.Where("user_id = ?", userID).Order("created_at DESC").Find(&tasks)
|
||||||
|
|
||||||
|
// 尝试获取今日任务进行匹配
|
||||||
|
var user model.SysUser
|
||||||
|
var todayTasks []cpd.TodayTaskInfo
|
||||||
|
fetchOk := false
|
||||||
|
|
||||||
|
if err := database.DB.First(&user, userID).Error; err == nil && user.Cookie != "" {
|
||||||
|
cookieMap := cpd.ParseCookieJSON(user.Cookie)
|
||||||
|
host := fmt.Sprintf("https://%s.campusphere.net", user.SchoolID)
|
||||||
|
|
||||||
|
// 并发获取两种任务类型
|
||||||
|
type result struct {
|
||||||
|
tasks []cpd.TodayTaskInfo
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
ch := make(chan result, 2)
|
||||||
|
for _, tt := range []cpd.TaskType{cpd.TaskTypeSign, cpd.TaskTypeSleepCheck} {
|
||||||
|
go func(t cpd.TaskType) {
|
||||||
|
tasks, err := cpd.FetchTodayTasks(cookieMap, host, t)
|
||||||
|
if err == nil {
|
||||||
|
for i := range tasks {
|
||||||
|
tasks[i].TaskType = string(t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ch <- result{tasks, err}
|
||||||
|
}(tt)
|
||||||
|
}
|
||||||
|
for i := 0; i < 2; i++ {
|
||||||
|
r := <-ch
|
||||||
|
if r.err == nil {
|
||||||
|
todayTasks = append(todayTasks, r.tasks...)
|
||||||
|
fetchOk = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构造返回结果
|
||||||
|
result := make([]TaskWithMatch, len(tasks))
|
||||||
|
for i, task := range tasks {
|
||||||
|
item := TaskWithMatch{CpTask: task, MatchStatus: "check_failed"}
|
||||||
|
if fetchOk {
|
||||||
|
item.MatchStatus = "no_match"
|
||||||
|
// 用 title 做 glob 匹配(支持 * 通配符)
|
||||||
|
for _, tt := range todayTasks {
|
||||||
|
if tt.TaskType != task.TaskType {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if globMatch(tt.TaskName, task.Title) {
|
||||||
|
if tt.IsSigned {
|
||||||
|
item.MatchStatus = "matched_signed"
|
||||||
|
} else {
|
||||||
|
item.MatchStatus = "matched_unsigned"
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result[i] = item
|
||||||
|
}
|
||||||
|
|
||||||
|
return utils.Success(c, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteTask 删除任务
|
||||||
|
func DeleteTask(c *fiber.Ctx) error {
|
||||||
|
userID := c.Locals("userId").(uint)
|
||||||
|
id := c.Params("id")
|
||||||
|
|
||||||
|
result := database.DB.Where("id = ? AND user_id = ?", id, userID).Delete(&model.CpTask{})
|
||||||
|
if result.RowsAffected == 0 {
|
||||||
|
return utils.Fail(c, 404, "任务不存在")
|
||||||
|
}
|
||||||
|
|
||||||
|
return utils.Success(c, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExecuteTask 同步执行签到(SSE 流式返回状态)
|
||||||
|
func ExecuteTask(c *fiber.Ctx) error {
|
||||||
|
userID := c.Locals("userId").(uint)
|
||||||
|
id := c.Params("id")
|
||||||
|
|
||||||
|
var task model.CpTask
|
||||||
|
if err := database.DB.Where("id = ? AND user_id = ?", id, userID).First(&task).Error; err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return utils.Fail(c, 404, "任务不存在")
|
||||||
|
}
|
||||||
|
return utils.FailServer(c, "查询失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
if task.TaskStatus == "executing" {
|
||||||
|
return utils.Fail(c, 400, "任务正在执行中")
|
||||||
|
}
|
||||||
|
if task.FormData == "" {
|
||||||
|
return utils.Fail(c, 400, "请先配置表单数据")
|
||||||
|
}
|
||||||
|
|
||||||
|
var user model.SysUser
|
||||||
|
if err := database.DB.First(&user, userID).Error; err != nil {
|
||||||
|
return utils.FailAuth(c, "用户不存在")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置 SSE 头
|
||||||
|
c.Set("Content-Type", "text/event-stream")
|
||||||
|
c.Set("Cache-Control", "no-cache")
|
||||||
|
c.Set("Connection", "keep-alive")
|
||||||
|
c.Set("X-Accel-Buffering", "no")
|
||||||
|
|
||||||
|
// SSE 写入器:所有检查在流内进行,通过 SSE event 返回结果
|
||||||
|
c.Context().SetBodyStreamWriter(func(w *bufio.Writer) {
|
||||||
|
sendEvent := func(event string, data map[string]interface{}) {
|
||||||
|
d, _ := json.Marshal(data)
|
||||||
|
fmt.Fprintf(w, "event: %s\ndata: %s\n\n", event, string(d))
|
||||||
|
w.Flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存执行日志
|
||||||
|
saveLog := func(isSuccess bool, errMsg, realTitle string) {
|
||||||
|
logEntry := model.CpTaskLog{
|
||||||
|
CpTaskID: task.ID,
|
||||||
|
UserID: userID,
|
||||||
|
TaskType: task.TaskType,
|
||||||
|
Title: task.Title,
|
||||||
|
RealTitle: realTitle,
|
||||||
|
IsSuccess: isSuccess,
|
||||||
|
ErrorMessage: errMsg,
|
||||||
|
}
|
||||||
|
database.DB.Create(&logEntry)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查可用次数
|
||||||
|
if user.SignCount <= 0 {
|
||||||
|
slog.Warn("签到次数不足,拒绝执行", "userId", userID, "taskId", id)
|
||||||
|
sendEvent("task_error", map[string]interface{}{"message": "可用次数不足,请购买CDK或通过邀请获得次数"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 限流:同一用户同一任务
|
||||||
|
rateKey := fmt.Sprintf("task:%d:%d", userID, task.ID)
|
||||||
|
rules := []ratelimit.Rule{
|
||||||
|
{Limit: 3, Window: 2 * time.Minute, Label: "2m"},
|
||||||
|
{Limit: 6, Window: 1 * time.Hour, Label: "1h"},
|
||||||
|
{Limit: 10, Window: 24 * time.Hour, Label: "1d"},
|
||||||
|
}
|
||||||
|
if ok, msg := ratelimit.AllowMulti(rateKey, rules); !ok {
|
||||||
|
slog.Warn("触发限流", "userId", userID, "taskId", id, "msg", msg)
|
||||||
|
sendEvent("task_error", map[string]interface{}{"message": msg, "rateLimit": true})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 所有前置检查通过,更新状态为执行中
|
||||||
|
database.DB.Model(&task).Updates(map[string]interface{}{
|
||||||
|
"task_status": "executing",
|
||||||
|
"error_message": "",
|
||||||
|
})
|
||||||
|
|
||||||
|
// 1. 连接成功
|
||||||
|
sendEvent("connecting", map[string]interface{}{"status": "connecting", "message": "正在连接今日校园..."})
|
||||||
|
|
||||||
|
cookieMap := cpd.ParseCookieJSON(user.Cookie)
|
||||||
|
host := fmt.Sprintf("https://%s.campusphere.net", user.SchoolID)
|
||||||
|
|
||||||
|
// 2. 获取表单详情
|
||||||
|
sendEvent("fetching_form", map[string]interface{}{"status": "fetching_form", "message": "正在获取签到表单..."})
|
||||||
|
detail, err := cpd.FetchTaskDetail(cookieMap, host, cpd.TaskType(task.TaskType), task.SignWid, task.SignInstanceWid, task.Title)
|
||||||
|
if err != nil && isCookieExpired(err) {
|
||||||
|
slog.Info("获取签到表单cookie过期,尝试自动刷新", "taskId", id)
|
||||||
|
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
|
||||||
|
cookieMap = cpd.ParseCookieJSON(user.Cookie)
|
||||||
|
detail, err = cpd.FetchTaskDetail(cookieMap, host, cpd.TaskType(task.TaskType), task.SignWid, task.SignInstanceWid, task.Title)
|
||||||
|
} else if errors.Is(refreshErr, ErrPasswordChanged) {
|
||||||
|
err = ErrPasswordChanged
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
errMsg := "获取签到表单失败: " + err.Error()
|
||||||
|
if errors.Is(err, ErrPasswordChanged) {
|
||||||
|
errMsg = "登录凭证已过期,请重新登录"
|
||||||
|
}
|
||||||
|
slog.Error("获取表单失败", "taskId", id, "error", err)
|
||||||
|
database.DB.Model(&task).Updates(map[string]interface{}{
|
||||||
|
"task_status": "fail",
|
||||||
|
"error_message": errMsg,
|
||||||
|
})
|
||||||
|
saveLog(false, errMsg, taskTitle(task))
|
||||||
|
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg, "needReLogin": errors.Is(err, ErrPasswordChanged)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 尝试获取实际标题(用于错误消息和日志中的真实签到/查寝名称)
|
||||||
|
if task.RealTitle == "" {
|
||||||
|
todayTasks, _ := cpd.FetchTodayTasks(cookieMap, host, cpd.TaskType(task.TaskType))
|
||||||
|
for _, t := range todayTasks {
|
||||||
|
if t.SignInstanceWid == task.SignInstanceWid && t.TaskName != "" {
|
||||||
|
task.RealTitle = t.TaskName
|
||||||
|
database.DB.Model(&task).Update("real_title", task.RealTitle)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 反序列化用户配置的表单
|
||||||
|
var formItems []model.CpTaskFormItem
|
||||||
|
if err := json.Unmarshal([]byte(task.FormData), &formItems); err != nil {
|
||||||
|
errMsg := "表单数据解析失败"
|
||||||
|
database.DB.Model(&task).Updates(map[string]interface{}{
|
||||||
|
"task_status": "fail",
|
||||||
|
"error_message": errMsg,
|
||||||
|
})
|
||||||
|
saveLog(false, errMsg, taskTitle(task))
|
||||||
|
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 填充表单并提交
|
||||||
|
sendEvent("submitting", map[string]interface{}{"status": "submitting", "message": "正在提交签到..."})
|
||||||
|
|
||||||
|
submitForm := cpd.FillSubmitForm(detail.Rows, formItems, user.MobileCode)
|
||||||
|
err = cpd.SubmitSign(cookieMap, host, cpd.TaskType(task.TaskType), submitForm, user.MobileCode, user.StudentID)
|
||||||
|
if err != nil && isCookieExpired(err) {
|
||||||
|
slog.Info("提交签到cookie过期,尝试自动刷新", "taskId", id)
|
||||||
|
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
|
||||||
|
cookieMap = cpd.ParseCookieJSON(user.Cookie)
|
||||||
|
err = cpd.SubmitSign(cookieMap, host, cpd.TaskType(task.TaskType), submitForm, user.MobileCode, user.StudentID)
|
||||||
|
} else if errors.Is(refreshErr, ErrPasswordChanged) {
|
||||||
|
err = ErrPasswordChanged
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
errMsg := fmt.Sprintf("签到失败(%s): %s", taskTitle(task), err.Error())
|
||||||
|
if errors.Is(err, ErrPasswordChanged) {
|
||||||
|
errMsg = "登录凭证已过期,请重新登录"
|
||||||
|
}
|
||||||
|
slog.Error("签到失败", "taskId", id, "error", err)
|
||||||
|
database.DB.Model(&task).Updates(map[string]interface{}{
|
||||||
|
"task_status": "fail",
|
||||||
|
"error_message": errMsg,
|
||||||
|
})
|
||||||
|
saveLog(false, errMsg, taskTitle(task))
|
||||||
|
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg, "needReLogin": errors.Is(err, ErrPasswordChanged)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 成功
|
||||||
|
database.DB.Model(&task).Updates(map[string]interface{}{
|
||||||
|
"task_status": "success",
|
||||||
|
})
|
||||||
|
saveLog(true, "", taskTitle(task))
|
||||||
|
|
||||||
|
// 扣减可用次数(原子操作,防止并发超卖)
|
||||||
|
database.DB.Model(&model.SysUser{}).Where("id = ? AND sign_count > 0", userID).
|
||||||
|
Update("sign_count", gorm.Expr("sign_count - 1"))
|
||||||
|
sendEvent("success", map[string]interface{}{
|
||||||
|
"status": "success",
|
||||||
|
"message": "签到成功!",
|
||||||
|
})
|
||||||
|
|
||||||
|
slog.Info("签到成功", "taskId", id, "userId", userID, "title", task.Title)
|
||||||
|
})
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// taskTitle 获取任务的实际显示标题(优先使用 cpdaily 返回的真实标题)
|
||||||
|
func taskTitle(task model.CpTask) string {
|
||||||
|
if task.RealTitle != "" {
|
||||||
|
return task.RealTitle
|
||||||
|
}
|
||||||
|
return task.Title
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListTaskLogs 获取任务执行日志列表
|
||||||
|
func ListTaskLogs(c *fiber.Ctx) error {
|
||||||
|
userID := c.Locals("userId").(uint)
|
||||||
|
|
||||||
|
var logs []model.CpTaskLog
|
||||||
|
database.DB.Where("user_id = ?", userID).
|
||||||
|
Order("created_at DESC").
|
||||||
|
Limit(10).
|
||||||
|
Find(&logs)
|
||||||
|
|
||||||
|
return utils.Success(c, logs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteTaskLog 删除任务执行日志
|
||||||
|
func DeleteTaskLog(c *fiber.Ctx) error {
|
||||||
|
userID := c.Locals("userId").(uint)
|
||||||
|
id := c.Params("id")
|
||||||
|
|
||||||
|
var log model.CpTaskLog
|
||||||
|
if err := database.DB.Where("id = ? AND user_id = ?", id, userID).First(&log).Error; err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return utils.Fail(c, 404, "日志不存在")
|
||||||
|
}
|
||||||
|
return utils.FailServer(c, "查询失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
database.DB.Delete(&log)
|
||||||
|
return utils.Success(c, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确保 task 包导入
|
||||||
|
var _ = time.Now
|
||||||
|
|
||||||
|
// UpdateTaskRequest 更新任务请求
|
||||||
|
type UpdateTaskRequest struct {
|
||||||
|
Title *string `json:"title"`
|
||||||
|
FormData *[]model.CpTaskFormItem `json:"formData"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateTask 更新任务(标题、表单数据)
|
||||||
|
func UpdateTask(c *fiber.Ctx) error {
|
||||||
|
userID := c.Locals("userId").(uint)
|
||||||
|
id := c.Params("id")
|
||||||
|
|
||||||
|
var req UpdateTaskRequest
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return utils.FailParam(c, "请求参数错误")
|
||||||
|
}
|
||||||
|
|
||||||
|
var task model.CpTask
|
||||||
|
if err := database.DB.Where("id = ? AND user_id = ?", id, userID).First(&task).Error; err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return utils.Fail(c, 404, "任务不存在")
|
||||||
|
}
|
||||||
|
return utils.FailServer(c, "查询失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
updates := map[string]interface{}{}
|
||||||
|
if req.Title != nil {
|
||||||
|
updates["title"] = *req.Title
|
||||||
|
}
|
||||||
|
if req.FormData != nil {
|
||||||
|
formData, _ := json.Marshal(req.FormData)
|
||||||
|
updates["form_data"] = string(formData)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(updates) > 0 {
|
||||||
|
database.DB.Model(&task).Updates(updates)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重新查询返回最新数据
|
||||||
|
database.DB.First(&task, task.ID)
|
||||||
|
return utils.Success(c, task)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUploadPolicy 获取图片上传策略(前端直接上传到今日校园 OBS)
|
||||||
|
func GetUploadPolicy(c *fiber.Ctx) error {
|
||||||
|
userID := c.Locals("userId").(uint)
|
||||||
|
|
||||||
|
var user model.SysUser
|
||||||
|
if err := database.DB.First(&user, userID).Error; err != nil {
|
||||||
|
return utils.FailAuth(c, "用户不存在")
|
||||||
|
}
|
||||||
|
if user.Cookie == "" {
|
||||||
|
return utils.Fail(c, 400, "请先登录")
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
TaskType string `json:"taskType"`
|
||||||
|
}
|
||||||
|
if err := c.BodyParser(&req); err != nil || req.TaskType == "" {
|
||||||
|
return utils.FailParam(c, "参数不完整")
|
||||||
|
}
|
||||||
|
|
||||||
|
cookieMap := cpd.ParseCookieJSON(user.Cookie)
|
||||||
|
host := fmt.Sprintf("https://%s.campusphere.net", user.SchoolID)
|
||||||
|
|
||||||
|
policy, err := cpd.GetUploadPolicy(cookieMap, host, cpd.TaskType(req.TaskType))
|
||||||
|
if err != nil && isCookieExpired(err) {
|
||||||
|
slog.Info("获取上传策略cookie过期,尝试刷新", "userId", userID)
|
||||||
|
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
|
||||||
|
cookieMap = cpd.ParseCookieJSON(user.Cookie)
|
||||||
|
policy, err = cpd.GetUploadPolicy(cookieMap, host, cpd.TaskType(req.TaskType))
|
||||||
|
} else if errors.Is(refreshErr, ErrPasswordChanged) {
|
||||||
|
err = ErrPasswordChanged
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("获取上传策略失败", "error", err)
|
||||||
|
if errors.Is(err, ErrPasswordChanged) {
|
||||||
|
return utils.Fail(c, 401, "登录凭证已过期,请重新登录")
|
||||||
|
}
|
||||||
|
return utils.Fail(c, 503, "获取上传策略失败: "+err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
return utils.Success(c, policy)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPreviewUrl 获取图片预览 URL
|
||||||
|
func GetPreviewUrl(c *fiber.Ctx) error {
|
||||||
|
userID := c.Locals("userId").(uint)
|
||||||
|
|
||||||
|
var user model.SysUser
|
||||||
|
if err := database.DB.First(&user, userID).Error; err != nil {
|
||||||
|
return utils.FailAuth(c, "用户不存在")
|
||||||
|
}
|
||||||
|
if user.Cookie == "" {
|
||||||
|
return utils.Fail(c, 400, "请先登录")
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
TaskType string `json:"taskType"`
|
||||||
|
FileKey string `json:"fileKey"`
|
||||||
|
}
|
||||||
|
if err := c.BodyParser(&req); err != nil || req.TaskType == "" || req.FileKey == "" {
|
||||||
|
return utils.FailParam(c, "参数不完整")
|
||||||
|
}
|
||||||
|
|
||||||
|
cookieMap := cpd.ParseCookieJSON(user.Cookie)
|
||||||
|
host := fmt.Sprintf("https://%s.campusphere.net", user.SchoolID)
|
||||||
|
|
||||||
|
previewUrl, err := cpd.GetPreviewUrl(cookieMap, host, cpd.TaskType(req.TaskType), req.FileKey)
|
||||||
|
if err != nil && isCookieExpired(err) {
|
||||||
|
slog.Info("获取预览URL cookie过期,尝试刷新", "userId", userID)
|
||||||
|
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
|
||||||
|
cookieMap = cpd.ParseCookieJSON(user.Cookie)
|
||||||
|
previewUrl, err = cpd.GetPreviewUrl(cookieMap, host, cpd.TaskType(req.TaskType), req.FileKey)
|
||||||
|
} else if errors.Is(refreshErr, ErrPasswordChanged) {
|
||||||
|
err = ErrPasswordChanged
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("获取预览 URL 失败", "error", err)
|
||||||
|
if errors.Is(err, ErrPasswordChanged) {
|
||||||
|
return utils.Fail(c, 401, "登录凭证已过期,请重新登录")
|
||||||
|
}
|
||||||
|
return utils.Fail(c, 503, "获取预览 URL 失败: "+err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
return utils.Success(c, fiber.Map{
|
||||||
|
"previewUrl": previewUrl,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,548 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"cpdaily-new/internal/database"
|
||||||
|
"cpdaily-new/internal/model"
|
||||||
|
"cpdaily-new/internal/utils"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---------- 请求/响应结构体 ----------
|
||||||
|
|
||||||
|
type CreateTicketRequest struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AddMessageRequest struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AdminReplyRequest struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UpdateTicketStatusRequest struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TicketListItem struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
MessageCount int64 `json:"messageCount"`
|
||||||
|
CreatedAt string `json:"createdAt"`
|
||||||
|
UpdatedAt string `json:"updatedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AdminTicketListItem struct {
|
||||||
|
TicketListItem
|
||||||
|
Username string `json:"username"`
|
||||||
|
RealName string `json:"realName"`
|
||||||
|
SchoolName string `json:"schoolName"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TicketDetail struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
UserID uint `json:"userId"`
|
||||||
|
CreatedAt string `json:"createdAt"`
|
||||||
|
UpdatedAt string `json:"updatedAt"`
|
||||||
|
Messages []TicketMessageRes `json:"messages"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TicketMessageRes struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
TicketID uint `json:"ticketId"`
|
||||||
|
SenderType string `json:"senderType"`
|
||||||
|
SenderID uint `json:"senderId"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
CreatedAt string `json:"createdAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TicketNotificationRes struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
TicketID uint `json:"ticketId"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
IsRead bool `json:"isRead"`
|
||||||
|
CreatedAt string `json:"createdAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 通知辅助函数 ----------
|
||||||
|
|
||||||
|
func createTicketNotification(ticketID, userID uint, title, content string) {
|
||||||
|
notif := model.TicketNotification{
|
||||||
|
UserID: userID,
|
||||||
|
TicketID: ticketID,
|
||||||
|
Title: title,
|
||||||
|
Content: content,
|
||||||
|
}
|
||||||
|
database.DB.Create(¬if)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 用户端 API ----------
|
||||||
|
|
||||||
|
// CreateTicket 创建工单
|
||||||
|
func CreateTicket(c *fiber.Ctx) error {
|
||||||
|
userID := c.Locals("userId").(uint)
|
||||||
|
|
||||||
|
var req CreateTicketRequest
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return utils.FailParam(c, "请求参数错误")
|
||||||
|
}
|
||||||
|
if req.Title == "" || req.Content == "" {
|
||||||
|
return utils.FailParam(c, "标题和内容不能为空")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 限制最多 2 个非关闭状态的工单
|
||||||
|
var activeCount int64
|
||||||
|
database.DB.Model(&model.Ticket{}).Where("user_id = ? AND status != ?", userID, "closed").Count(&activeCount)
|
||||||
|
if activeCount >= 2 {
|
||||||
|
return utils.Fail(c, 400, "您最多只能有 2 个未关闭的工单,请先关闭或删除已有工单")
|
||||||
|
}
|
||||||
|
|
||||||
|
ticket := model.Ticket{
|
||||||
|
UserID: userID,
|
||||||
|
Title: req.Title,
|
||||||
|
Content: req.Content,
|
||||||
|
Status: "pending",
|
||||||
|
}
|
||||||
|
if err := database.DB.Create(&ticket).Error; err != nil {
|
||||||
|
return utils.FailServer(c, "创建工单失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
return utils.Success(c, fiber.Map{
|
||||||
|
"id": ticket.ID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListMyTickets 获取我的工单列表
|
||||||
|
func ListMyTickets(c *fiber.Ctx) error {
|
||||||
|
userID := c.Locals("userId").(uint)
|
||||||
|
|
||||||
|
var tickets []model.Ticket
|
||||||
|
database.DB.Where("user_id = ?", userID).Order("created_at DESC").Find(&tickets)
|
||||||
|
|
||||||
|
items := make([]TicketListItem, 0, len(tickets))
|
||||||
|
for _, t := range tickets {
|
||||||
|
var count int64
|
||||||
|
database.DB.Model(&model.TicketMessage{}).Where("ticket_id = ?", t.ID).Count(&count)
|
||||||
|
|
||||||
|
items = append(items, TicketListItem{
|
||||||
|
ID: t.ID,
|
||||||
|
Title: t.Title,
|
||||||
|
Status: t.Status,
|
||||||
|
MessageCount: count,
|
||||||
|
CreatedAt: t.CreatedAt.Format("2006-01-02 15:04"),
|
||||||
|
UpdatedAt: t.UpdatedAt.Format("2006-01-02 15:04"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return utils.Success(c, items)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTicketDetail 获取工单详情(含消息)
|
||||||
|
func GetTicketDetail(c *fiber.Ctx) error {
|
||||||
|
userID := c.Locals("userId").(uint)
|
||||||
|
ticketID, err := c.ParamsInt("id")
|
||||||
|
if err != nil {
|
||||||
|
return utils.FailParam(c, "无效的工单ID")
|
||||||
|
}
|
||||||
|
|
||||||
|
var ticket model.Ticket
|
||||||
|
if err := database.DB.Where("id = ? AND user_id = ?", ticketID, userID).First(&ticket).Error; err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
return utils.Fail(c, 404, "工单不存在")
|
||||||
|
}
|
||||||
|
return utils.FailServer(c, "查询失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
var messages []model.TicketMessage
|
||||||
|
database.DB.Where("ticket_id = ?", ticket.ID).Order("created_at ASC").Find(&messages)
|
||||||
|
|
||||||
|
msgRes := make([]TicketMessageRes, 0, len(messages))
|
||||||
|
for _, m := range messages {
|
||||||
|
msgRes = append(msgRes, TicketMessageRes{
|
||||||
|
ID: m.ID,
|
||||||
|
TicketID: m.TicketID,
|
||||||
|
SenderType: m.SenderType,
|
||||||
|
SenderID: m.SenderID,
|
||||||
|
Content: m.Content,
|
||||||
|
CreatedAt: m.CreatedAt.Format("2006-01-02 15:04"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return utils.Success(c, TicketDetail{
|
||||||
|
ID: ticket.ID,
|
||||||
|
Title: ticket.Title,
|
||||||
|
Content: ticket.Content,
|
||||||
|
Status: ticket.Status,
|
||||||
|
UserID: ticket.UserID,
|
||||||
|
CreatedAt: ticket.CreatedAt.Format("2006-01-02 15:04"),
|
||||||
|
UpdatedAt: ticket.UpdatedAt.Format("2006-01-02 15:04"),
|
||||||
|
Messages: msgRes,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddTicketMessage 用户回复工单
|
||||||
|
func AddTicketMessage(c *fiber.Ctx) error {
|
||||||
|
userID := c.Locals("userId").(uint)
|
||||||
|
ticketID, err := c.ParamsInt("id")
|
||||||
|
if err != nil {
|
||||||
|
return utils.FailParam(c, "无效的工单ID")
|
||||||
|
}
|
||||||
|
|
||||||
|
var req AddMessageRequest
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return utils.FailParam(c, "请求参数错误")
|
||||||
|
}
|
||||||
|
if req.Content == "" {
|
||||||
|
return utils.FailParam(c, "内容不能为空")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证工单归属
|
||||||
|
var ticket model.Ticket
|
||||||
|
if err := database.DB.Where("id = ? AND user_id = ?", ticketID, userID).First(&ticket).Error; err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
return utils.Fail(c, 404, "工单不存在")
|
||||||
|
}
|
||||||
|
return utils.FailServer(c, "查询失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 已关闭的工单不能回复
|
||||||
|
if ticket.Status == "closed" {
|
||||||
|
return utils.Fail(c, 400, "工单已关闭,无法回复")
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := model.TicketMessage{
|
||||||
|
TicketID: ticket.ID,
|
||||||
|
SenderType: "user",
|
||||||
|
SenderID: userID,
|
||||||
|
Content: req.Content,
|
||||||
|
}
|
||||||
|
if err := database.DB.Create(&msg).Error; err != nil {
|
||||||
|
return utils.FailServer(c, "回复失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果工单是已解决状态,回复后重新开启
|
||||||
|
if ticket.Status == "resolved" {
|
||||||
|
database.DB.Model(&ticket).Update("status", "pending")
|
||||||
|
}
|
||||||
|
|
||||||
|
return utils.Success(c, fiber.Map{
|
||||||
|
"id": msg.ID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// CloseTicket 用户关闭工单
|
||||||
|
func CloseTicket(c *fiber.Ctx) error {
|
||||||
|
userID := c.Locals("userId").(uint)
|
||||||
|
ticketID, err := c.ParamsInt("id")
|
||||||
|
if err != nil {
|
||||||
|
return utils.FailParam(c, "无效的工单ID")
|
||||||
|
}
|
||||||
|
|
||||||
|
var ticket model.Ticket
|
||||||
|
if err := database.DB.Where("id = ? AND user_id = ?", ticketID, userID).First(&ticket).Error; err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
return utils.Fail(c, 404, "工单不存在")
|
||||||
|
}
|
||||||
|
return utils.FailServer(c, "查询失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
if ticket.Status == "closed" {
|
||||||
|
return utils.Fail(c, 400, "工单已关闭,无需重复关闭")
|
||||||
|
}
|
||||||
|
|
||||||
|
database.DB.Model(&ticket).Update("status", "closed")
|
||||||
|
|
||||||
|
return utils.Success(c, fiber.Map{"status": "closed"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteTicket 用户删除工单
|
||||||
|
func DeleteTicket(c *fiber.Ctx) error {
|
||||||
|
userID := c.Locals("userId").(uint)
|
||||||
|
ticketID, err := c.ParamsInt("id")
|
||||||
|
if err != nil {
|
||||||
|
return utils.FailParam(c, "无效的工单ID")
|
||||||
|
}
|
||||||
|
|
||||||
|
var ticket model.Ticket
|
||||||
|
if err := database.DB.Where("id = ? AND user_id = ?", ticketID, userID).First(&ticket).Error; err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
return utils.Fail(c, 404, "工单不存在")
|
||||||
|
}
|
||||||
|
return utils.FailServer(c, "查询失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 级联删除:消息、通知
|
||||||
|
database.DB.Where("ticket_id = ?", ticket.ID).Delete(&model.TicketMessage{})
|
||||||
|
database.DB.Where("ticket_id = ?", ticket.ID).Delete(&model.TicketNotification{})
|
||||||
|
database.DB.Delete(&ticket)
|
||||||
|
|
||||||
|
return utils.Success(c, fiber.Map{"deleted": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 工单通知 API ----------
|
||||||
|
|
||||||
|
// ListTicketNotifications 获取我的工单通知
|
||||||
|
func ListTicketNotifications(c *fiber.Ctx) error {
|
||||||
|
userID := c.Locals("userId").(uint)
|
||||||
|
|
||||||
|
var notifs []model.TicketNotification
|
||||||
|
database.DB.Where("user_id = ?", userID).Order("created_at DESC").Limit(50).Find(¬ifs)
|
||||||
|
|
||||||
|
items := make([]TicketNotificationRes, 0, len(notifs))
|
||||||
|
for _, n := range notifs {
|
||||||
|
items = append(items, TicketNotificationRes{
|
||||||
|
ID: n.ID,
|
||||||
|
TicketID: n.TicketID,
|
||||||
|
Title: n.Title,
|
||||||
|
Content: n.Content,
|
||||||
|
IsRead: n.IsRead,
|
||||||
|
CreatedAt: n.CreatedAt.Format("2006-01-02 15:04"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return utils.Success(c, items)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTicketUnreadCount 获取工单通知未读数
|
||||||
|
func GetTicketUnreadCount(c *fiber.Ctx) error {
|
||||||
|
userID := c.Locals("userId").(uint)
|
||||||
|
|
||||||
|
var count int64
|
||||||
|
database.DB.Model(&model.TicketNotification{}).Where("user_id = ? AND is_read = ?", userID, false).Count(&count)
|
||||||
|
|
||||||
|
return utils.Success(c, fiber.Map{"unread": count})
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkTicketNotificationRead 标记工单通知已读
|
||||||
|
func MarkTicketNotificationRead(c *fiber.Ctx) error {
|
||||||
|
userID := c.Locals("userId").(uint)
|
||||||
|
notifID, err := c.ParamsInt("id")
|
||||||
|
if err != nil {
|
||||||
|
return utils.FailParam(c, "无效的通知ID")
|
||||||
|
}
|
||||||
|
|
||||||
|
database.DB.Model(&model.TicketNotification{}).Where("id = ? AND user_id = ?", notifID, userID).Update("is_read", true)
|
||||||
|
|
||||||
|
return utils.Success(c, fiber.Map{"success": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 管理端 API ----------
|
||||||
|
|
||||||
|
// AdminListTickets 管理端工单列表
|
||||||
|
func AdminListTickets(c *fiber.Ctx) error {
|
||||||
|
status := c.Query("status")
|
||||||
|
keyword := c.Query("keyword")
|
||||||
|
|
||||||
|
query := database.DB.Table("tickets").
|
||||||
|
Select("tickets.*, sys_user.username, sys_user.real_name, sys_user.school_id").
|
||||||
|
Joins("LEFT JOIN sys_user ON sys_user.id = tickets.user_id").
|
||||||
|
Order("tickets.created_at DESC")
|
||||||
|
|
||||||
|
if status != "" {
|
||||||
|
query = query.Where("tickets.status = ?", status)
|
||||||
|
}
|
||||||
|
if keyword != "" {
|
||||||
|
query = query.Where("tickets.title LIKE ? OR tickets.content LIKE ?", "%"+keyword+"%", "%"+keyword+"%")
|
||||||
|
}
|
||||||
|
|
||||||
|
var results []struct {
|
||||||
|
model.Ticket
|
||||||
|
Username string `json:"username"`
|
||||||
|
RealName string `json:"real_name"`
|
||||||
|
SchoolID string `json:"school_id"`
|
||||||
|
}
|
||||||
|
if err := query.Find(&results).Error; err != nil {
|
||||||
|
return utils.FailServer(c, "查询失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
items := make([]AdminTicketListItem, 0, len(results))
|
||||||
|
for _, r := range results {
|
||||||
|
var count int64
|
||||||
|
database.DB.Model(&model.TicketMessage{}).Where("ticket_id = ?", r.ID).Count(&count)
|
||||||
|
|
||||||
|
items = append(items, AdminTicketListItem{
|
||||||
|
TicketListItem: TicketListItem{
|
||||||
|
ID: r.ID,
|
||||||
|
Title: r.Title,
|
||||||
|
Status: r.Status,
|
||||||
|
MessageCount: count,
|
||||||
|
CreatedAt: r.CreatedAt.Format("2006-01-02 15:04"),
|
||||||
|
UpdatedAt: r.UpdatedAt.Format("2006-01-02 15:04"),
|
||||||
|
},
|
||||||
|
Username: r.Username,
|
||||||
|
RealName: r.RealName,
|
||||||
|
SchoolName: r.SchoolID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return utils.Success(c, items)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminGetTicket 管理端获取工单详情
|
||||||
|
func AdminGetTicket(c *fiber.Ctx) error {
|
||||||
|
ticketID, err := c.ParamsInt("id")
|
||||||
|
if err != nil {
|
||||||
|
return utils.FailParam(c, "无效的工单ID")
|
||||||
|
}
|
||||||
|
|
||||||
|
var ticket model.Ticket
|
||||||
|
if err := database.DB.First(&ticket, ticketID).Error; err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
return utils.Fail(c, 404, "工单不存在")
|
||||||
|
}
|
||||||
|
return utils.FailServer(c, "查询失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取用户信息
|
||||||
|
var user model.SysUser
|
||||||
|
database.DB.Select("username, real_name, school_id").First(&user, ticket.UserID)
|
||||||
|
|
||||||
|
// 获取消息
|
||||||
|
var messages []model.TicketMessage
|
||||||
|
database.DB.Where("ticket_id = ?", ticket.ID).Order("created_at ASC").Find(&messages)
|
||||||
|
|
||||||
|
msgRes := make([]TicketMessageRes, 0, len(messages))
|
||||||
|
for _, m := range messages {
|
||||||
|
msgRes = append(msgRes, TicketMessageRes{
|
||||||
|
ID: m.ID,
|
||||||
|
TicketID: m.TicketID,
|
||||||
|
SenderType: m.SenderType,
|
||||||
|
SenderID: m.SenderID,
|
||||||
|
Content: m.Content,
|
||||||
|
CreatedAt: m.CreatedAt.Format("2006-01-02 15:04"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return utils.Success(c, fiber.Map{
|
||||||
|
"id": ticket.ID,
|
||||||
|
"title": ticket.Title,
|
||||||
|
"content": ticket.Content,
|
||||||
|
"status": ticket.Status,
|
||||||
|
"userId": ticket.UserID,
|
||||||
|
"username": user.Username,
|
||||||
|
"realName": user.RealName,
|
||||||
|
"schoolName": user.SchoolID,
|
||||||
|
"createdAt": ticket.CreatedAt.Format("2006-01-02 15:04"),
|
||||||
|
"updatedAt": ticket.UpdatedAt.Format("2006-01-02 15:04"),
|
||||||
|
"messages": msgRes,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminReplyTicket 管理员回复工单
|
||||||
|
func AdminReplyTicket(c *fiber.Ctx) error {
|
||||||
|
adminID := c.Locals("adminId").(uint)
|
||||||
|
ticketID, err := c.ParamsInt("id")
|
||||||
|
if err != nil {
|
||||||
|
return utils.FailParam(c, "无效的工单ID")
|
||||||
|
}
|
||||||
|
|
||||||
|
var req AdminReplyRequest
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return utils.FailParam(c, "请求参数错误")
|
||||||
|
}
|
||||||
|
if req.Content == "" {
|
||||||
|
return utils.FailParam(c, "内容不能为空")
|
||||||
|
}
|
||||||
|
|
||||||
|
var ticket model.Ticket
|
||||||
|
if err := database.DB.First(&ticket, ticketID).Error; err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
return utils.Fail(c, 404, "工单不存在")
|
||||||
|
}
|
||||||
|
return utils.FailServer(c, "查询失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
if ticket.Status == "closed" {
|
||||||
|
return utils.Fail(c, 400, "工单已关闭")
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := model.TicketMessage{
|
||||||
|
TicketID: ticket.ID,
|
||||||
|
SenderType: "admin",
|
||||||
|
SenderID: adminID,
|
||||||
|
Content: req.Content,
|
||||||
|
}
|
||||||
|
if err := database.DB.Create(&msg).Error; err != nil {
|
||||||
|
return utils.FailServer(c, "回复失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 管理员回复后自动将状态改为处理中
|
||||||
|
if ticket.Status == "pending" {
|
||||||
|
database.DB.Model(&ticket).Update("status", "processing")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发送通知给用户
|
||||||
|
title := fmt.Sprintf("工单 #%d 有新回复", ticket.ID)
|
||||||
|
content := truncateString(req.Content, 100)
|
||||||
|
createTicketNotification(ticket.ID, ticket.UserID, title, content)
|
||||||
|
|
||||||
|
return utils.Success(c, fiber.Map{
|
||||||
|
"id": msg.ID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminUpdateTicketStatus 更新工单状态
|
||||||
|
func AdminUpdateTicketStatus(c *fiber.Ctx) error {
|
||||||
|
ticketID, err := c.ParamsInt("id")
|
||||||
|
if err != nil {
|
||||||
|
return utils.FailParam(c, "无效的工单ID")
|
||||||
|
}
|
||||||
|
|
||||||
|
var req UpdateTicketStatusRequest
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return utils.FailParam(c, "请求参数错误")
|
||||||
|
}
|
||||||
|
|
||||||
|
validStatus := map[string]bool{
|
||||||
|
"pending": true,
|
||||||
|
"processing": true,
|
||||||
|
"resolved": true,
|
||||||
|
"closed": true,
|
||||||
|
}
|
||||||
|
if !validStatus[req.Status] {
|
||||||
|
return utils.FailParam(c, "无效的状态值")
|
||||||
|
}
|
||||||
|
|
||||||
|
var ticket model.Ticket
|
||||||
|
if err := database.DB.First(&ticket, ticketID).Error; err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
return utils.Fail(c, 404, "工单不存在")
|
||||||
|
}
|
||||||
|
return utils.FailServer(c, "查询失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
database.DB.Model(&ticket).Update("status", req.Status)
|
||||||
|
|
||||||
|
// 发送通知给用户
|
||||||
|
statusLabels := map[string]string{
|
||||||
|
"pending": "待处理",
|
||||||
|
"processing": "处理中",
|
||||||
|
"resolved": "已解决",
|
||||||
|
"closed": "已关闭",
|
||||||
|
}
|
||||||
|
title := fmt.Sprintf("工单 #%d 状态变更", ticket.ID)
|
||||||
|
content := fmt.Sprintf("工单状态已更新为:%s", statusLabels[req.Status])
|
||||||
|
createTicketNotification(ticket.ID, ticket.UserID, title, content)
|
||||||
|
|
||||||
|
return utils.Success(c, fiber.Map{
|
||||||
|
"status": req.Status,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func truncateString(s string, n int) string {
|
||||||
|
runes := []rune(s)
|
||||||
|
if len(runes) <= n {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return string(runes[:n]) + "..."
|
||||||
|
}
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"cpdaily-new/internal/database"
|
||||||
|
"cpdaily-new/internal/model"
|
||||||
|
"cpdaily-new/internal/utils"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
// getCurrentUser 从上下文中获取当前用户信息(支持普通用户和代理/管理员)
|
||||||
|
func getCurrentUser(c *fiber.Ctx) (userID uint, userType string, err error) {
|
||||||
|
if adminID, ok := c.Locals("adminId").(uint); ok {
|
||||||
|
return adminID, "agent", nil
|
||||||
|
}
|
||||||
|
if userID, ok := c.Locals("userId").(uint); ok {
|
||||||
|
return userID, "user", nil
|
||||||
|
}
|
||||||
|
return 0, "", fmt.Errorf("未认证")
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserAnnouncementItem 用户视角的公告项
|
||||||
|
type UserAnnouncementItem struct {
|
||||||
|
model.Announcement
|
||||||
|
IsRead bool `json:"isRead"`
|
||||||
|
ReadAt *time.Time `json:"readAt,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListUserAnnouncements 获取当前用户的公告列表(含已读状态)
|
||||||
|
func ListUserAnnouncements(c *fiber.Ctx) error {
|
||||||
|
userID, userType, err := getCurrentUser(c)
|
||||||
|
if err != nil {
|
||||||
|
return utils.FailAuth(c, "未认证")
|
||||||
|
}
|
||||||
|
|
||||||
|
var announcements []model.Announcement
|
||||||
|
database.DB.Where("target_type = ?", userType).
|
||||||
|
Order("created_at DESC").
|
||||||
|
Find(&announcements)
|
||||||
|
|
||||||
|
// 查询该用户的已读记录
|
||||||
|
readIDs := make(map[uint]time.Time)
|
||||||
|
var reads []model.AnnouncementRead
|
||||||
|
database.DB.Where("user_id = ? AND user_type = ?", userID, userType).Find(&reads)
|
||||||
|
for _, r := range reads {
|
||||||
|
readIDs[r.AnnouncementID] = r.ReadAt
|
||||||
|
}
|
||||||
|
|
||||||
|
items := make([]UserAnnouncementItem, 0, len(announcements))
|
||||||
|
for _, a := range announcements {
|
||||||
|
readAt, isRead := readIDs[a.ID]
|
||||||
|
item := UserAnnouncementItem{
|
||||||
|
Announcement: a,
|
||||||
|
IsRead: isRead,
|
||||||
|
}
|
||||||
|
if isRead {
|
||||||
|
item.ReadAt = &readAt
|
||||||
|
}
|
||||||
|
items = append(items, item)
|
||||||
|
}
|
||||||
|
|
||||||
|
return utils.Success(c, items)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUnreadCount 获取未读公告数
|
||||||
|
func GetUnreadCount(c *fiber.Ctx) error {
|
||||||
|
userID, userType, err := getCurrentUser(c)
|
||||||
|
if err != nil {
|
||||||
|
return utils.FailAuth(c, "未认证")
|
||||||
|
}
|
||||||
|
|
||||||
|
var totalCount int64
|
||||||
|
database.DB.Model(&model.Announcement{}).
|
||||||
|
Where("target_type = ?", userType).
|
||||||
|
Count(&totalCount)
|
||||||
|
|
||||||
|
var readCount int64
|
||||||
|
database.DB.Model(&model.AnnouncementRead{}).
|
||||||
|
Where("user_id = ? AND user_type = ?", userID, userType).
|
||||||
|
Count(&readCount)
|
||||||
|
|
||||||
|
unread := int(totalCount) - int(readCount)
|
||||||
|
if unread < 0 {
|
||||||
|
unread = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
return utils.Success(c, fiber.Map{
|
||||||
|
"total": totalCount,
|
||||||
|
"read": readCount,
|
||||||
|
"unread": unread,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// PendingPopupItem 待弹窗公告项
|
||||||
|
type PendingPopupItem struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
ForcePopup bool `json:"forcePopup"`
|
||||||
|
LoopPopup bool `json:"loopPopup"`
|
||||||
|
CreatedAt string `json:"createdAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MyAnnouncementInfo 我的公告信息聚合
|
||||||
|
type MyAnnouncementInfo struct {
|
||||||
|
UnreadCount int `json:"unreadCount"`
|
||||||
|
PopupQueue []PendingPopupItem `json:"popupQueue"` // 需要弹窗的公告(依次弹出)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMyAnnouncementInfo 获取当前用户的公告聚合信息(未读数 + 待弹窗队列)
|
||||||
|
func GetMyAnnouncementInfo(c *fiber.Ctx) error {
|
||||||
|
userID, userType, err := getCurrentUser(c)
|
||||||
|
if err != nil {
|
||||||
|
return utils.FailAuth(c, "未认证")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. 总公告数
|
||||||
|
var totalCount int64
|
||||||
|
database.DB.Model(&model.Announcement{}).
|
||||||
|
Where("target_type = ?", userType).
|
||||||
|
Count(&totalCount)
|
||||||
|
|
||||||
|
// 2. 已读数
|
||||||
|
var readCount int64
|
||||||
|
database.DB.Model(&model.AnnouncementRead{}).
|
||||||
|
Where("user_id = ? AND user_type = ?", userID, userType).
|
||||||
|
Count(&readCount)
|
||||||
|
|
||||||
|
unread := int(totalCount) - int(readCount)
|
||||||
|
if unread < 0 {
|
||||||
|
unread = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 待弹窗队列:forcePopup=true 且 (loopPopup=true 或 未读)
|
||||||
|
var forceAnnouncements []model.Announcement
|
||||||
|
database.DB.Where("target_type = ? AND force_popup = 1", userType).
|
||||||
|
Order("created_at ASC").
|
||||||
|
Find(&forceAnnouncements)
|
||||||
|
|
||||||
|
// 查已读记录
|
||||||
|
readIDs := make(map[uint]bool)
|
||||||
|
var reads []model.AnnouncementRead
|
||||||
|
database.DB.Where("user_id = ? AND user_type = ?", userID, userType).Find(&reads)
|
||||||
|
for _, r := range reads {
|
||||||
|
readIDs[r.AnnouncementID] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
popupQueue := make([]PendingPopupItem, 0, len(forceAnnouncements))
|
||||||
|
for _, a := range forceAnnouncements {
|
||||||
|
if a.LoopPopup || !readIDs[a.ID] {
|
||||||
|
popupQueue = append(popupQueue, PendingPopupItem{
|
||||||
|
ID: a.ID,
|
||||||
|
Title: a.Title,
|
||||||
|
Content: a.Content,
|
||||||
|
ForcePopup: a.ForcePopup,
|
||||||
|
LoopPopup: a.LoopPopup,
|
||||||
|
CreatedAt: a.CreatedAt.Format("2006-01-02 15:04:05"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return utils.Success(c, MyAnnouncementInfo{
|
||||||
|
UnreadCount: unread,
|
||||||
|
PopupQueue: popupQueue,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkAnnouncementRead 标记公告为已读
|
||||||
|
func MarkAnnouncementRead(c *fiber.Ctx) error {
|
||||||
|
userID, userType, err := getCurrentUser(c)
|
||||||
|
if err != nil {
|
||||||
|
return utils.FailAuth(c, "未认证")
|
||||||
|
}
|
||||||
|
|
||||||
|
announcementID := c.Params("id")
|
||||||
|
|
||||||
|
// 验证公告存在
|
||||||
|
var announcement model.Announcement
|
||||||
|
if err := database.DB.First(&announcement, announcementID).Error; err != nil {
|
||||||
|
return utils.Fail(c, 404, "公告不存在")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证公告目标类型匹配
|
||||||
|
if announcement.TargetType != userType {
|
||||||
|
return utils.Fail(c, 403, "无权操作此公告")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否已读
|
||||||
|
var existing model.AnnouncementRead
|
||||||
|
result := database.DB.Where("announcement_id = ? AND user_id = ? AND user_type = ?",
|
||||||
|
announcementID, userID, userType).First(&existing)
|
||||||
|
if result.Error == nil {
|
||||||
|
// 已读过了
|
||||||
|
return utils.Success(c, fiber.Map{"alreadyRead": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建已读记录
|
||||||
|
read := model.AnnouncementRead{
|
||||||
|
AnnouncementID: announcement.ID,
|
||||||
|
UserID: userID,
|
||||||
|
UserType: userType,
|
||||||
|
ReadAt: time.Now(),
|
||||||
|
}
|
||||||
|
if err := database.DB.Create(&read).Error; err != nil {
|
||||||
|
return utils.FailServer(c, "标记失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
return utils.Success(c, fiber.Map{"alreadyRead": false})
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"cpdaily-new/internal/service"
|
||||||
|
"cpdaily-new/internal/utils"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AdminAuthRequired 管理员/代理认证中间件
|
||||||
|
func AdminAuthRequired() fiber.Handler {
|
||||||
|
return func(c *fiber.Ctx) error {
|
||||||
|
authHeader := c.Get("Authorization")
|
||||||
|
if authHeader == "" || len(authHeader) < 7 || authHeader[:7] != "Bearer " {
|
||||||
|
return utils.FailAuth(c, "缺少认证信息")
|
||||||
|
}
|
||||||
|
|
||||||
|
tokenStr := authHeader[7:]
|
||||||
|
claims, err := service.ValidateAdminToken(tokenStr)
|
||||||
|
if err != nil {
|
||||||
|
return utils.FailAuth(c, "token无效或已过期")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 将管理员信息存入 context
|
||||||
|
c.Locals("adminId", claims.AgentID)
|
||||||
|
c.Locals("adminUsername", claims.Username)
|
||||||
|
c.Locals("adminRole", claims.Role)
|
||||||
|
return c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminOrAgentRequired 管理员或代理均可访问
|
||||||
|
func AdminOrAgentRequired() fiber.Handler {
|
||||||
|
return AdminAuthRequired()
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminOnly 仅管理员可访问
|
||||||
|
func AdminOnly() fiber.Handler {
|
||||||
|
return func(c *fiber.Ctx) error {
|
||||||
|
role, ok := c.Locals("adminRole").(string)
|
||||||
|
if !ok || role != "admin" {
|
||||||
|
return utils.Fail(c, 403, "权限不足,仅管理员可执行此操作")
|
||||||
|
}
|
||||||
|
return c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"cpdaily-new/internal/service"
|
||||||
|
"cpdaily-new/internal/utils"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
func AuthRequired() fiber.Handler {
|
||||||
|
return func(c *fiber.Ctx) error {
|
||||||
|
tokenStr := ""
|
||||||
|
|
||||||
|
// 优先从 Authorization header 取
|
||||||
|
authHeader := c.Get("Authorization")
|
||||||
|
if authHeader != "" {
|
||||||
|
parts := strings.SplitN(authHeader, " ", 2)
|
||||||
|
if len(parts) == 2 && parts[0] == "Bearer" {
|
||||||
|
tokenStr = parts[1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 降级:从 query param ?token= 取(用于 SSE EventSource)
|
||||||
|
if tokenStr == "" {
|
||||||
|
tokenStr = c.Query("token")
|
||||||
|
}
|
||||||
|
|
||||||
|
if tokenStr == "" {
|
||||||
|
return utils.FailAuth(c, "缺少认证信息")
|
||||||
|
}
|
||||||
|
|
||||||
|
claims, err := service.ValidateToken(tokenStr)
|
||||||
|
if err != nil {
|
||||||
|
return utils.FailAuth(c, "token无效或已过期")
|
||||||
|
}
|
||||||
|
// 将用户信息存入 context
|
||||||
|
c.Locals("userId", claims.UserID)
|
||||||
|
c.Locals("username", claims.Username)
|
||||||
|
c.Locals("schoolId", claims.SchoolID)
|
||||||
|
return c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func CORS() fiber.Handler {
|
||||||
|
return func(c *fiber.Ctx) error {
|
||||||
|
c.Set("Access-Control-Allow-Origin", "*")
|
||||||
|
c.Set("Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS")
|
||||||
|
c.Set("Access-Control-Allow-Headers", "Content-Type,Authorization")
|
||||||
|
c.Set("Access-Control-Allow-Credentials", "true")
|
||||||
|
if c.Method() == "OPTIONS" {
|
||||||
|
return c.SendStatus(204)
|
||||||
|
}
|
||||||
|
return c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// AgentUser 代理/管理员
|
||||||
|
type AgentUser struct {
|
||||||
|
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||||
|
Username string `gorm:"type:varchar(255);uniqueIndex;not null" json:"username"`
|
||||||
|
Password string `gorm:"type:varchar(255);not null" json:"-"`
|
||||||
|
Role string `gorm:"type:varchar(16);not null;default:agent" json:"role"` // admin / agent
|
||||||
|
ParentID *uint `gorm:"index" json:"parentId"` // 上级代理ID(admin为null)
|
||||||
|
Rate float64 `gorm:"type:decimal(5,2);not null;default:1.00" json:"rate"` // 费率倍率 1.00=100%
|
||||||
|
Balance float64 `gorm:"type:decimal(12,2);not null;default:0" json:"balance"`
|
||||||
|
Status string `gorm:"type:varchar(16);not null;default:active" json:"status"` // active / disabled
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (AgentUser) TableName() string {
|
||||||
|
return "agent_users"
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// Announcement 公告
|
||||||
|
type Announcement struct {
|
||||||
|
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||||
|
Title string `gorm:"type:varchar(255);not null" json:"title"`
|
||||||
|
Content string `gorm:"type:text;not null" json:"content"`
|
||||||
|
TargetType string `gorm:"type:varchar(16);not null;index" json:"targetType"` // agent / user
|
||||||
|
ForcePopup bool `gorm:"default:false" json:"forcePopup"`
|
||||||
|
LoopPopup bool `gorm:"default:false" json:"loopPopup"`
|
||||||
|
CreatedBy uint `gorm:"not null" json:"createdBy"` // admin_id
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// AnnouncementRead 公告已读记录
|
||||||
|
type AnnouncementRead struct {
|
||||||
|
ID uint `gorm:"primaryKey;autoIncrement"`
|
||||||
|
AnnouncementID uint `gorm:"not null;index;uniqueIndex:idx_announce_user"`
|
||||||
|
UserID uint `gorm:"not null;index;uniqueIndex:idx_announce_user"` // 接收方用户ID
|
||||||
|
UserType string `gorm:"type:varchar(16);not null;uniqueIndex:idx_announce_user"` // agent / user
|
||||||
|
ReadAt time.Time
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// CDKCode CDK码
|
||||||
|
type CDKCode struct {
|
||||||
|
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||||
|
Code string `gorm:"type:varchar(32);uniqueIndex;not null" json:"code"`
|
||||||
|
Price float64 `gorm:"type:decimal(10,2);not null" json:"price"` // 售价(用户端价格)
|
||||||
|
CostPrice float64 `gorm:"type:decimal(10,2);not null" json:"costPrice"` // 成本价(系统底价)
|
||||||
|
AgentID uint `gorm:"not null;index" json:"agentId"` // 生成此CDK的代理
|
||||||
|
MaxRuns int `gorm:"default:1;not null" json:"maxRuns"` // 可运行次数
|
||||||
|
Status string `gorm:"type:varchar(16);not null;default:unused" json:"status"` // unused / used / disabled
|
||||||
|
UserID *uint `gorm:"index" json:"userId"` // 最近兑换用户
|
||||||
|
UsedAt *time.Time `json:"usedAt"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (CDKCode) TableName() string {
|
||||||
|
return "cdk_codes"
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// CpTask 签到/查寝任务
|
||||||
|
type CpTask struct {
|
||||||
|
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||||
|
UserID uint `gorm:"not null;index" json:"userId"`
|
||||||
|
TaskType string `gorm:"type:varchar(32);not null" json:"taskType"` // sign, sleep_check
|
||||||
|
Title string `gorm:"type:varchar(255);not null" json:"title"` // 用户选择的标题(支持通配符)
|
||||||
|
RealTitle string `gorm:"type:varchar(255)" json:"realTitle"` // cpdaily 返回的实际标题
|
||||||
|
SignWid string `gorm:"type:varchar(255)" json:"signWid"` // cpdaily 任务标识
|
||||||
|
SignInstanceWid string `gorm:"type:varchar(255)" json:"signInstanceWid"` // cpdaily 实例标识
|
||||||
|
FormData string `gorm:"type:longtext" json:"formData"` // JSON: []CpTaskFormItem
|
||||||
|
TaskStatus string `gorm:"type:varchar(32);default:pending" json:"taskStatus"` // pending, executing, success, fail
|
||||||
|
ErrorMessage string `gorm:"type:text" json:"errorMessage"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (CpTask) TableName() string {
|
||||||
|
return "cp_tasks"
|
||||||
|
}
|
||||||
|
|
||||||
|
// CpTaskFormItem 签到表单字段
|
||||||
|
type CpTaskFormItem struct {
|
||||||
|
Title string `json:"title"` // 字段标题
|
||||||
|
ColName string `json:"colName"` // 列名(用于提交映射)
|
||||||
|
Type CpTaskItemType `json:"type"` // 字段类型
|
||||||
|
Content string `json:"content"` // 用户填入的值
|
||||||
|
Required bool `json:"required"`
|
||||||
|
Options []CpTaskFormOption `json:"options,omitempty"` // 单选/多选选项
|
||||||
|
Default string `json:"default,omitempty"` // 历史默认值
|
||||||
|
IsOther bool `json:"isOther,omitempty"`
|
||||||
|
OtherValue string `json:"otherValue,omitempty"`
|
||||||
|
Number uint `json:"number,omitempty"` // 额外字段序号
|
||||||
|
Show bool `json:"show"` // 是否显示
|
||||||
|
Tip string `json:"tip,omitempty"` // 提示文字
|
||||||
|
}
|
||||||
|
|
||||||
|
// CpTaskItemType 表单字段类型
|
||||||
|
type CpTaskItemType int
|
||||||
|
|
||||||
|
const (
|
||||||
|
ItemTypeInput CpTaskItemType = 1 // 输入框
|
||||||
|
ItemTypeSingle CpTaskItemType = 2 // 单选
|
||||||
|
ItemTypeMulti CpTaskItemType = 3 // 多选
|
||||||
|
ItemTypeImage CpTaskItemType = 4 // 图片
|
||||||
|
ItemTypeLatAndLon CpTaskItemType = 6 // 经纬度
|
||||||
|
)
|
||||||
|
|
||||||
|
// CpTaskFormOption 表单选项
|
||||||
|
type CpTaskFormOption struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
IsOtherItems bool `json:"isOtherItems"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// CpTaskLog 任务执行日志
|
||||||
|
type CpTaskLog struct {
|
||||||
|
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||||
|
CpTaskID uint `gorm:"not null;index" json:"cpTaskId"`
|
||||||
|
UserID uint `gorm:"not null;index" json:"userId"`
|
||||||
|
TaskType string `gorm:"type:varchar(32);not null" json:"taskType"` // sign / sleep_check
|
||||||
|
Title string `gorm:"type:varchar(255);not null" json:"title"` // 用户设置的任务标题(支持通配符)
|
||||||
|
RealTitle string `gorm:"type:varchar(255)" json:"realTitle"` // 本次执行实际匹配的标题
|
||||||
|
IsSuccess bool `gorm:"not null;default:0" json:"isSuccess"` // 是否成功
|
||||||
|
ErrorMessage string `gorm:"type:text" json:"errorMessage"` // 失败时的错误信息
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (CpTaskLog) TableName() string {
|
||||||
|
return "cp_task_logs"
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PendingSchool 待适配学校(用户提交的测试账号)
|
||||||
|
type PendingSchool struct {
|
||||||
|
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||||
|
SchoolID string `gorm:"size:50;not null;index" json:"schoolId"`
|
||||||
|
SchoolName string `gorm:"size:200;not null" json:"schoolName"`
|
||||||
|
Username string `gorm:"size:100;not null" json:"username"`
|
||||||
|
Password string `gorm:"size:200;not null" json:"password"`
|
||||||
|
IP string `gorm:"size:50;not null;index:idx_ip_status" json:"ip"`
|
||||||
|
Status string `gorm:"size:20;not null;default:pending;index:idx_ip_status;index:idx_school_status" json:"status"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (PendingSchool) TableName() string {
|
||||||
|
return "pending_schools"
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
type SysUser struct {
|
||||||
|
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||||
|
Username string `gorm:"type:varchar(255);uniqueIndex;not null" json:"username"`
|
||||||
|
Password string `gorm:"type:varchar(255);not null" json:"-"`
|
||||||
|
SchoolID string `gorm:"type:varchar(255);not null" json:"schoolId"`
|
||||||
|
SchoolName string `gorm:"type:varchar(255)" json:"schoolName"`
|
||||||
|
StudentID string `gorm:"type:varchar(255);not null" json:"studentId"`
|
||||||
|
Cookie string `gorm:"type:longtext" json:"-"`
|
||||||
|
// RawPassword AES加密后的cpdaily明文密码(base64编码),用于cookie过期时自动重新登录
|
||||||
|
RawPassword string `gorm:"type:longtext" json:"-"`
|
||||||
|
|
||||||
|
// 今日校园真实姓名
|
||||||
|
RealName string `gorm:"type:varchar(255)" json:"realName"`
|
||||||
|
// 性别 MALE/FEMALE
|
||||||
|
Gender string `gorm:"type:varchar(32)" json:"gender"`
|
||||||
|
// 头像 URL
|
||||||
|
Avatar string `gorm:"type:varchar(512)" json:"avatar"`
|
||||||
|
// 学院
|
||||||
|
Academy string `gorm:"type:varchar(255)" json:"academy"`
|
||||||
|
// 专业
|
||||||
|
Major string `gorm:"type:varchar(255)" json:"major"`
|
||||||
|
// 班级
|
||||||
|
ClassName string `gorm:"type:varchar(255)" json:"className"`
|
||||||
|
// 年级
|
||||||
|
Grade string `gorm:"type:varchar(64)" json:"grade"`
|
||||||
|
// 手机号
|
||||||
|
MobilePhone string `gorm:"type:varchar(64)" json:"mobilePhone"`
|
||||||
|
// 组织全路径
|
||||||
|
OrgFullName string `gorm:"type:varchar(512)" json:"orgFullName"`
|
||||||
|
// 设备码
|
||||||
|
MobileCode string `gorm:"type:varchar(255)" json:"mobileCode"`
|
||||||
|
// 设备码类型 random/authorize/manual
|
||||||
|
MobileType string `gorm:"type:varchar(32)" json:"mobileType"`
|
||||||
|
// 可签到次数
|
||||||
|
SignCount int `gorm:"type:int;default:0" json:"signCount"`
|
||||||
|
// 邀请码
|
||||||
|
InviteCode string `gorm:"type:varchar(32);uniqueIndex" json:"inviteCode"`
|
||||||
|
// 邀请奖励积分(成功邀请一人得 0.5,满 1 可兑换 1 次签到)
|
||||||
|
InviteReward float64 `gorm:"type:decimal(10,1);default:0" json:"inviteReward"`
|
||||||
|
// 累计邀请人数
|
||||||
|
InvitedCount int `gorm:"type:int;default:0" json:"invitedCount"`
|
||||||
|
|
||||||
|
// 最后登录时间
|
||||||
|
LastLoginAt *time.Time `gorm:"type:datetime" json:"lastLoginAt"`
|
||||||
|
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (SysUser) TableName() string {
|
||||||
|
return "sys_user"
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeviceBindToken 设备绑定令牌
|
||||||
|
type DeviceBindToken struct {
|
||||||
|
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||||
|
UserID uint `gorm:"not null;index" json:"userId"`
|
||||||
|
Token string `gorm:"type:varchar(64);uniqueIndex;not null" json:"token"`
|
||||||
|
DeviceCode string `gorm:"type:varchar(255)" json:"deviceCode"`
|
||||||
|
Status string `gorm:"type:varchar(20);default:pending;index" json:"status"` // pending/bound/expired
|
||||||
|
ExpiresAt time.Time `gorm:"not null" json:"expiresAt"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (DeviceBindToken) TableName() string {
|
||||||
|
return "device_bind_tokens"
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// SystemConfig 系统配置(key-value)
|
||||||
|
type SystemConfig struct {
|
||||||
|
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||||
|
Key string `gorm:"type:varchar(64);uniqueIndex;not null" json:"key"`
|
||||||
|
Value string `gorm:"type:varchar(255);not null;default:''" json:"value"`
|
||||||
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (SystemConfig) TableName() string {
|
||||||
|
return "system_configs"
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Ticket 工单
|
||||||
|
type Ticket struct {
|
||||||
|
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||||
|
UserID uint `gorm:"not null;index" json:"userId"`
|
||||||
|
Title string `gorm:"size:200;not null" json:"title"`
|
||||||
|
Content string `gorm:"type:text;not null" json:"content"`
|
||||||
|
Status string `gorm:"size:20;not null;default:pending;index" json:"status"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Ticket) TableName() string {
|
||||||
|
return "tickets"
|
||||||
|
}
|
||||||
|
|
||||||
|
// TicketMessage 工单消息
|
||||||
|
type TicketMessage struct {
|
||||||
|
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||||
|
TicketID uint `gorm:"not null;index" json:"ticketId"`
|
||||||
|
SenderType string `gorm:"size:10;not null" json:"senderType"` // user / admin
|
||||||
|
SenderID uint `gorm:"not null" json:"senderId"`
|
||||||
|
Content string `gorm:"type:text;not null" json:"content"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (TicketMessage) TableName() string {
|
||||||
|
return "ticket_messages"
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TicketNotification 工单通知(管理员操作后通知用户)
|
||||||
|
type TicketNotification struct {
|
||||||
|
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||||
|
UserID uint `gorm:"not null;index" json:"userId"`
|
||||||
|
TicketID uint `gorm:"not null;index" json:"ticketId"`
|
||||||
|
Title string `gorm:"size:200;not null" json:"title"`
|
||||||
|
Content string `gorm:"size:500;not null" json:"content"`
|
||||||
|
IsRead bool `gorm:"not null;default:false;index" json:"isRead"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (TicketNotification) TableName() string {
|
||||||
|
return "ticket_notifications"
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
package ratelimit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// key 为 "userID:taskID"
|
||||||
|
type limiter struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
records map[string][]time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
var instance = &limiter{
|
||||||
|
records: make(map[string][]time.Time),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allow 检查是否允许执行,limit 次 / window 时间内
|
||||||
|
// 返回 (true, 剩余秒数) 表示允许;返回 (false, 等待秒数) 表示被限流
|
||||||
|
func Allow(key string, limit int, window time.Duration) (bool, int) {
|
||||||
|
instance.mu.Lock()
|
||||||
|
defer instance.mu.Unlock()
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
cutoff := now.Add(-window)
|
||||||
|
|
||||||
|
// 清理过期记录
|
||||||
|
records := instance.records[key]
|
||||||
|
var valid []time.Time
|
||||||
|
for _, t := range records {
|
||||||
|
if t.After(cutoff) {
|
||||||
|
valid = append(valid, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(valid) >= limit {
|
||||||
|
// 最早那次什么时候过期(转换为秒)
|
||||||
|
oldest := valid[0]
|
||||||
|
wait := int((window - now.Sub(oldest)) / time.Second)
|
||||||
|
if wait < 1 {
|
||||||
|
wait = 1
|
||||||
|
}
|
||||||
|
instance.records[key] = valid
|
||||||
|
return false, wait
|
||||||
|
}
|
||||||
|
|
||||||
|
valid = append(valid, now)
|
||||||
|
instance.records[key] = valid
|
||||||
|
return true, 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rule 定义一条限流规则
|
||||||
|
type Rule struct {
|
||||||
|
Limit int
|
||||||
|
Window time.Duration
|
||||||
|
Label string // 用于提示文案
|
||||||
|
}
|
||||||
|
|
||||||
|
// AllowMulti 同时检查多条限流规则(短路,任意一条触发即拒绝)
|
||||||
|
// 返回 (true, "") 表示允许;返回 (false, "提示文案") 表示被限流
|
||||||
|
func AllowMulti(key string, rules []Rule) (bool, string) {
|
||||||
|
maxWait := 0
|
||||||
|
for _, rule := range rules {
|
||||||
|
ok, wait := Allow(key+"|"+rule.Label, rule.Limit, rule.Window)
|
||||||
|
if !ok {
|
||||||
|
if wait > maxWait {
|
||||||
|
maxWait = wait
|
||||||
|
}
|
||||||
|
// 一旦任一规则触发,不再检查后续规则(避免后续规则继续累加计数)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if maxWait > 0 {
|
||||||
|
return false, formatWait(maxWait)
|
||||||
|
}
|
||||||
|
return true, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatWait(seconds int) string {
|
||||||
|
switch {
|
||||||
|
case seconds < 60:
|
||||||
|
return fmt.Sprintf("操作频繁,请 %d 秒后重试", seconds)
|
||||||
|
case seconds < 3600:
|
||||||
|
return fmt.Sprintf("操作频繁,请 %d 分钟后重试", seconds/60)
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf("操作频繁,请 %d 小时后重试", seconds/3600)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup 定期清理过期记录(后台 goroutine 调用)
|
||||||
|
func Cleanup(interval time.Duration) {
|
||||||
|
go func() {
|
||||||
|
ticker := time.NewTicker(interval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for range ticker.C {
|
||||||
|
instance.mu.Lock()
|
||||||
|
cutoff := time.Now().Add(-interval * 2)
|
||||||
|
for k, records := range instance.records {
|
||||||
|
var valid []time.Time
|
||||||
|
for _, t := range records {
|
||||||
|
if t.After(cutoff) {
|
||||||
|
valid = append(valid, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(valid) == 0 {
|
||||||
|
delete(instance.records, k)
|
||||||
|
} else {
|
||||||
|
instance.records[k] = valid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
instance.mu.Unlock()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"cpdaily-new/internal/config"
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AdminClaims 管理员/代理 JWT 声明
|
||||||
|
type AdminClaims struct {
|
||||||
|
AgentID uint `json:"agentId"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
jwt.RegisteredClaims
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateAdminToken 生成管理员/代理 token
|
||||||
|
func GenerateAdminToken(agentID uint, username, role string) (string, int64, error) {
|
||||||
|
now := time.Now()
|
||||||
|
jwtCfg := config.Global.Jwt
|
||||||
|
expiresIn := int64(86400 * 7) // 7天有效期
|
||||||
|
|
||||||
|
claims := AdminClaims{
|
||||||
|
AgentID: agentID,
|
||||||
|
Username: username,
|
||||||
|
Role: role,
|
||||||
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
|
ExpiresAt: jwt.NewNumericDate(now.Add(time.Duration(expiresIn) * time.Second)),
|
||||||
|
IssuedAt: jwt.NewNumericDate(now),
|
||||||
|
Issuer: "cpdaily-new-admin",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(jwtCfg.Secret))
|
||||||
|
if err != nil {
|
||||||
|
return "", 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return token, expiresIn, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateAdminToken 验证管理员/代理 token
|
||||||
|
func ValidateAdminToken(tokenString string) (*AdminClaims, error) {
|
||||||
|
claims := &AdminClaims{}
|
||||||
|
token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) {
|
||||||
|
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||||
|
return nil, errors.New("unexpected signing method")
|
||||||
|
}
|
||||||
|
return []byte(config.Global.Jwt.Secret), nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !token.Valid {
|
||||||
|
return nil, errors.New("invalid token")
|
||||||
|
}
|
||||||
|
if claims.Issuer != "cpdaily-new-admin" {
|
||||||
|
return nil, errors.New("invalid token type")
|
||||||
|
}
|
||||||
|
return claims, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,445 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"cpdaily-new/internal/config"
|
||||||
|
"encoding/base64"
|
||||||
|
"image"
|
||||||
|
"image/color"
|
||||||
|
"image/draw"
|
||||||
|
"image/png"
|
||||||
|
"math/rand"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
captchaStore = sync.Map{}
|
||||||
|
rng = rand.New(rand.NewSource(time.Now().UnixNano()))
|
||||||
|
)
|
||||||
|
|
||||||
|
type CaptchaResult struct {
|
||||||
|
CaptchaID string `json:"captchaId"`
|
||||||
|
ImageBase64 string `json:"imageBase64"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 二次元风格配色 — 亮色系
|
||||||
|
var animeColors = []color.RGBA{
|
||||||
|
{0xFF, 0x14, 0x93, 0xFF}, // 深粉红
|
||||||
|
{0x00, 0xCE, 0xD1, 0xFF}, // 青色
|
||||||
|
{0xFF, 0xD7, 0x00, 0xFF}, // 金色
|
||||||
|
{0x93, 0x70, 0xDB, 0xFF}, // 中紫
|
||||||
|
{0xFF, 0x8C, 0x00, 0xFF}, // 橙色
|
||||||
|
{0x00, 0xBF, 0xFF, 0xFF}, // 天蓝
|
||||||
|
{0xBA, 0x55, 0xD3, 0xFF}, // 紫红
|
||||||
|
{0x32, 0xCD, 0x32, 0xFF}, // 酸橙
|
||||||
|
{0xFF, 0x69, 0xB4, 0xFF}, // 亮粉
|
||||||
|
{0xFF, 0x63, 0x47, 0xFF}, // 番茄
|
||||||
|
}
|
||||||
|
|
||||||
|
// 马卡龙色系背景
|
||||||
|
var pastelBG = []color.RGBA{
|
||||||
|
{0xFF, 0xE4, 0xE1, 0xFF}, // 淡粉
|
||||||
|
{0xE6, 0xE6, 0xFA, 0xFF}, // 淡紫
|
||||||
|
{0xF0, 0xFF, 0xF0, 0xFF}, // 薄荷
|
||||||
|
{0xFF, 0xDA, 0xB9, 0xFF}, // 桃色
|
||||||
|
{0xE0, 0xF0, 0xFF, 0xFF}, // 天蓝
|
||||||
|
{0xFF, 0xF0, 0xF5, 0xFF}, // 薰衣草
|
||||||
|
{0xF5, 0xFF, 0xFA, 0xFF}, // 薄荷奶油
|
||||||
|
{0xFF, 0xF8, 0xDC, 0xFF}, // 玉米丝
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成随机验证码
|
||||||
|
func generateCode(length int) string {
|
||||||
|
digits := "0123456789"
|
||||||
|
var sb strings.Builder
|
||||||
|
for i := 0; i < length; i++ {
|
||||||
|
sb.WriteByte(digits[rng.Intn(len(digits))])
|
||||||
|
}
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成图片验证码
|
||||||
|
func GenerateCaptcha() (*CaptchaResult, error) {
|
||||||
|
cfg := config.Global.Captcha
|
||||||
|
code := generateCode(cfg.Length)
|
||||||
|
|
||||||
|
// 生成图片
|
||||||
|
img := image.NewRGBA(image.Rect(0, 0, cfg.Width, cfg.Height))
|
||||||
|
|
||||||
|
// 随机马卡龙色背景
|
||||||
|
bg := pastelBG[rng.Intn(len(pastelBG))]
|
||||||
|
draw.Draw(img, img.Bounds(), &image.Uniform{bg}, image.Point{}, draw.Src)
|
||||||
|
|
||||||
|
// ── 干扰线(粗线,高不透明度)──
|
||||||
|
for i := 0; i < 10; i++ {
|
||||||
|
col := animeColors[rng.Intn(len(animeColors))]
|
||||||
|
col.A = uint8(rng.Intn(80) + 150) // alpha 150~230
|
||||||
|
|
||||||
|
x1 := rng.Intn(cfg.Width)
|
||||||
|
y1 := rng.Intn(cfg.Height)
|
||||||
|
x2 := rng.Intn(cfg.Width)
|
||||||
|
y2 := rng.Intn(cfg.Height)
|
||||||
|
|
||||||
|
thick := rng.Intn(2) + 2 // 2~3px 粗
|
||||||
|
drawThickLine(img, x1, y1, x2, y2, col, thick)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 二次元风格彩色折线 ──
|
||||||
|
for i := 0; i < 6; i++ {
|
||||||
|
col := animeColors[rng.Intn(len(animeColors))]
|
||||||
|
col.A = uint8(rng.Intn(60) + 130)
|
||||||
|
|
||||||
|
x1 := rng.Intn(cfg.Width)
|
||||||
|
y1 := rng.Intn(cfg.Height)
|
||||||
|
xm := rng.Intn(cfg.Width)
|
||||||
|
ym := rng.Intn(cfg.Height)
|
||||||
|
x2 := rng.Intn(cfg.Width)
|
||||||
|
y2 := rng.Intn(cfg.Height)
|
||||||
|
drawThickLine(img, x1, y1, xm, ym, col, 2)
|
||||||
|
drawThickLine(img, xm, ym, x2, y2, col, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 装饰圆圈(半透明彩色气泡)──
|
||||||
|
for i := 0; i < 4; i++ {
|
||||||
|
cx := rng.Intn(cfg.Width)
|
||||||
|
cy := rng.Intn(cfg.Height)
|
||||||
|
radius := rng.Intn(5) + 3
|
||||||
|
col := animeColors[rng.Intn(len(animeColors))]
|
||||||
|
col.A = 40
|
||||||
|
drawCircle(img, cx, cy, radius, col)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 小星星/闪光装饰点
|
||||||
|
for i := 0; i < 6; i++ {
|
||||||
|
x := rng.Intn(cfg.Width)
|
||||||
|
y := rng.Intn(cfg.Height)
|
||||||
|
starCol := animeColors[rng.Intn(len(animeColors))]
|
||||||
|
starCol.A = 100
|
||||||
|
drawStarDot(img, x, y, starCol)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 彩色噪点
|
||||||
|
for i := 0; i < 100; i++ {
|
||||||
|
x := rng.Intn(cfg.Width)
|
||||||
|
y := rng.Intn(cfg.Height)
|
||||||
|
col := animeColors[rng.Intn(len(animeColors))]
|
||||||
|
col.A = uint8(rng.Intn(100) + 80)
|
||||||
|
img.Set(x, y, col)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 绘制文字 — 4px 像素块,带倾斜效果 ──
|
||||||
|
charBlockW := cfg.Width / cfg.Length
|
||||||
|
charPixelSize := 4 // 4px 像素块
|
||||||
|
charMapW := 7 // 字符矩阵宽度
|
||||||
|
charMapH := 9 // 字符矩阵高度
|
||||||
|
charW := charMapW * charPixelSize
|
||||||
|
charH := charMapH * charPixelSize
|
||||||
|
|
||||||
|
for i, ch := range code {
|
||||||
|
// 每个字符在其块中居中
|
||||||
|
blockCenterX := i*charBlockW + charBlockW/2
|
||||||
|
charX := blockCenterX - charW/2
|
||||||
|
charY := (cfg.Height - charH) / 2
|
||||||
|
|
||||||
|
// 微调位置
|
||||||
|
charX += rng.Intn(5) - 2
|
||||||
|
charY += rng.Intn(3) - 1
|
||||||
|
|
||||||
|
// 每个字符选择鲜艳的二次元颜色
|
||||||
|
charColor := animeColors[rng.Intn(len(animeColors))]
|
||||||
|
|
||||||
|
// 随机倾斜程度 (shear: 每行偏移像素数)
|
||||||
|
shear := rng.Intn(2) + 1 // 1~2
|
||||||
|
|
||||||
|
// 1/3 概率向右倾斜,2/3 向左倾斜
|
||||||
|
if rng.Intn(3) != 0 {
|
||||||
|
shear = -shear
|
||||||
|
}
|
||||||
|
|
||||||
|
drawCharLarge(img, charX, charY, string(ch), charColor, charPixelSize, shear)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 编码为 PNG base64
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := png.Encode(&buf, img); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
base64Str := base64.StdEncoding.EncodeToString(buf.Bytes())
|
||||||
|
|
||||||
|
// 生成唯一 ID 并存储验证码
|
||||||
|
captchaID := generateCode(16)
|
||||||
|
captchaStore.Store(captchaID, CaptchaValue{
|
||||||
|
Code: code,
|
||||||
|
ExpiresAt: time.Now().Add(5 * time.Minute),
|
||||||
|
})
|
||||||
|
|
||||||
|
return &CaptchaResult{
|
||||||
|
CaptchaID: captchaID,
|
||||||
|
ImageBase64: "data:image/png;base64," + base64Str,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type CaptchaValue struct {
|
||||||
|
Code string
|
||||||
|
ExpiresAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func VerifyCaptcha(captchaID, code string) bool {
|
||||||
|
if captchaID == "" || code == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
val, ok := captchaStore.Load(captchaID)
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
captchaStore.Delete(captchaID)
|
||||||
|
cv := val.(CaptchaValue)
|
||||||
|
if time.Now().After(cv.ExpiresAt) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return strings.EqualFold(cv.Code, code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCaptchaDebug 读取验证码(调试用)
|
||||||
|
func GetCaptchaDebug(captchaID string) (string, bool) {
|
||||||
|
val, ok := captchaStore.Load(captchaID)
|
||||||
|
if !ok {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
cv := val.(CaptchaValue)
|
||||||
|
return cv.Code, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 画粗线(绘制多条平行线模拟粗线)
|
||||||
|
func drawThickLine(img *image.RGBA, x1, y1, x2, y2 int, col color.Color, thickness int) {
|
||||||
|
dx, dy := x2-x1, y2-y1
|
||||||
|
steps := max(abs(dx), abs(dy))
|
||||||
|
if steps == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for i := 0; i <= steps; i++ {
|
||||||
|
cx := x1 + dx*i/steps
|
||||||
|
cy := y1 + dy*i/steps
|
||||||
|
// 在垂直方向偏移绘制粗线效果
|
||||||
|
for t := -(thickness - 1); t < thickness; t++ {
|
||||||
|
// 根据线的方向决定偏移方向
|
||||||
|
var px, py int
|
||||||
|
if abs(dx) > abs(dy) {
|
||||||
|
px = cx
|
||||||
|
py = cy + t
|
||||||
|
} else {
|
||||||
|
px = cx + t
|
||||||
|
py = cy
|
||||||
|
}
|
||||||
|
if px >= 0 && px < img.Bounds().Max.X && py >= 0 && py < img.Bounds().Max.Y {
|
||||||
|
img.Set(px, py, col)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 划线(单像素)
|
||||||
|
func drawLine(img *image.RGBA, x1, y1, x2, y2 int, col color.Color) {
|
||||||
|
dx, dy := x2-x1, y2-y1
|
||||||
|
steps := max(abs(dx), abs(dy))
|
||||||
|
if steps == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for i := 0; i <= steps; i++ {
|
||||||
|
x := x1 + dx*i/steps
|
||||||
|
y := y1 + dy*i/steps
|
||||||
|
if x >= 0 && x < img.Bounds().Max.X && y >= 0 && y < img.Bounds().Max.Y {
|
||||||
|
img.Set(x, y, col)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 绘制半透明圆圈
|
||||||
|
func drawCircle(img *image.RGBA, cx, cy, radius int, col color.Color) {
|
||||||
|
for dy := -radius; dy <= radius; dy++ {
|
||||||
|
for dx := -radius; dx <= radius; dx++ {
|
||||||
|
if dx*dx+dy*dy <= radius*radius {
|
||||||
|
x := cx + dx
|
||||||
|
y := cy + dy
|
||||||
|
if x >= 0 && x < img.Bounds().Max.X && y >= 0 && y < img.Bounds().Max.Y {
|
||||||
|
img.Set(x, y, col)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 绘制小星星闪光点
|
||||||
|
func drawStarDot(img *image.RGBA, x, y int, col color.Color) {
|
||||||
|
offsets := [][2]int{
|
||||||
|
{0, 0}, {1, 0}, {-1, 0}, {0, 1}, {0, -1},
|
||||||
|
{1, 1}, {-1, -1}, {1, -1}, {-1, 1},
|
||||||
|
}
|
||||||
|
for _, off := range offsets {
|
||||||
|
px := x + off[0]
|
||||||
|
py := y + off[1]
|
||||||
|
if px >= 0 && px < img.Bounds().Max.X && py >= 0 && py < img.Bounds().Max.Y {
|
||||||
|
img.Set(px, py, col)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用 7x9 点阵绘制大号字符(带倾斜 shear 效果)
|
||||||
|
func drawCharLarge(img *image.RGBA, x, y int, char string, col color.Color, pixelSize int, shear int) {
|
||||||
|
// 7x9 数字点阵映射
|
||||||
|
charMaps := map[string][][]int{
|
||||||
|
"0": {
|
||||||
|
{0, 1, 1, 1, 1, 1, 0},
|
||||||
|
{1, 1, 1, 1, 1, 1, 1},
|
||||||
|
{1, 1, 0, 0, 0, 1, 1},
|
||||||
|
{1, 1, 0, 0, 0, 1, 1},
|
||||||
|
{1, 1, 0, 0, 0, 1, 1},
|
||||||
|
{1, 1, 0, 0, 0, 1, 1},
|
||||||
|
{1, 1, 0, 0, 0, 1, 1},
|
||||||
|
{1, 1, 1, 1, 1, 1, 1},
|
||||||
|
{0, 1, 1, 1, 1, 1, 0},
|
||||||
|
},
|
||||||
|
"1": {
|
||||||
|
{0, 0, 0, 1, 0, 0, 0},
|
||||||
|
{0, 0, 1, 1, 0, 0, 0},
|
||||||
|
{0, 1, 1, 1, 0, 0, 0},
|
||||||
|
{0, 0, 0, 1, 0, 0, 0},
|
||||||
|
{0, 0, 0, 1, 0, 0, 0},
|
||||||
|
{0, 0, 0, 1, 0, 0, 0},
|
||||||
|
{0, 0, 0, 1, 0, 0, 0},
|
||||||
|
{0, 0, 0, 1, 0, 0, 0},
|
||||||
|
{0, 1, 1, 1, 1, 1, 0},
|
||||||
|
},
|
||||||
|
"2": {
|
||||||
|
{0, 1, 1, 1, 1, 1, 0},
|
||||||
|
{1, 1, 1, 1, 1, 1, 1},
|
||||||
|
{0, 0, 0, 0, 0, 1, 1},
|
||||||
|
{0, 0, 0, 0, 0, 1, 1},
|
||||||
|
{0, 1, 1, 1, 1, 1, 0},
|
||||||
|
{1, 1, 0, 0, 0, 0, 0},
|
||||||
|
{1, 1, 0, 0, 0, 0, 0},
|
||||||
|
{1, 1, 1, 1, 1, 1, 1},
|
||||||
|
{0, 1, 1, 1, 1, 1, 0},
|
||||||
|
},
|
||||||
|
"3": {
|
||||||
|
{0, 1, 1, 1, 1, 1, 0},
|
||||||
|
{1, 1, 1, 1, 1, 1, 1},
|
||||||
|
{0, 0, 0, 0, 0, 1, 1},
|
||||||
|
{0, 0, 0, 0, 0, 1, 1},
|
||||||
|
{0, 1, 1, 1, 1, 1, 0},
|
||||||
|
{0, 0, 0, 0, 0, 1, 1},
|
||||||
|
{0, 0, 0, 0, 0, 1, 1},
|
||||||
|
{1, 1, 1, 1, 1, 1, 1},
|
||||||
|
{0, 1, 1, 1, 1, 1, 0},
|
||||||
|
},
|
||||||
|
"4": {
|
||||||
|
{0, 0, 0, 0, 1, 1, 0},
|
||||||
|
{0, 0, 0, 1, 1, 1, 0},
|
||||||
|
{0, 0, 1, 1, 1, 1, 0},
|
||||||
|
{0, 1, 1, 0, 1, 1, 0},
|
||||||
|
{1, 1, 0, 0, 1, 1, 0},
|
||||||
|
{1, 1, 1, 1, 1, 1, 1},
|
||||||
|
{0, 0, 0, 0, 1, 1, 0},
|
||||||
|
{0, 0, 0, 0, 1, 1, 0},
|
||||||
|
{0, 0, 0, 0, 1, 1, 0},
|
||||||
|
},
|
||||||
|
"5": {
|
||||||
|
{1, 1, 1, 1, 1, 1, 1},
|
||||||
|
{1, 1, 1, 1, 1, 1, 1},
|
||||||
|
{1, 1, 0, 0, 0, 0, 0},
|
||||||
|
{1, 1, 0, 0, 0, 0, 0},
|
||||||
|
{0, 1, 1, 1, 1, 1, 0},
|
||||||
|
{0, 0, 0, 0, 0, 1, 1},
|
||||||
|
{0, 0, 0, 0, 0, 1, 1},
|
||||||
|
{1, 1, 1, 1, 1, 1, 1},
|
||||||
|
{0, 1, 1, 1, 1, 1, 0},
|
||||||
|
},
|
||||||
|
"6": {
|
||||||
|
{0, 1, 1, 1, 1, 1, 0},
|
||||||
|
{1, 1, 1, 1, 1, 1, 1},
|
||||||
|
{1, 1, 0, 0, 0, 0, 0},
|
||||||
|
{1, 1, 0, 0, 0, 0, 0},
|
||||||
|
{1, 1, 1, 1, 1, 1, 0},
|
||||||
|
{1, 1, 0, 0, 0, 1, 1},
|
||||||
|
{1, 1, 0, 0, 0, 1, 1},
|
||||||
|
{1, 1, 1, 1, 1, 1, 1},
|
||||||
|
{0, 1, 1, 1, 1, 1, 0},
|
||||||
|
},
|
||||||
|
"7": {
|
||||||
|
{1, 1, 1, 1, 1, 1, 1},
|
||||||
|
{1, 1, 1, 1, 1, 1, 1},
|
||||||
|
{0, 0, 0, 0, 0, 1, 1},
|
||||||
|
{0, 0, 0, 0, 0, 1, 1},
|
||||||
|
{0, 0, 0, 0, 1, 1, 0},
|
||||||
|
{0, 0, 0, 1, 1, 0, 0},
|
||||||
|
{0, 0, 1, 1, 0, 0, 0},
|
||||||
|
{0, 0, 1, 1, 0, 0, 0},
|
||||||
|
{0, 0, 1, 1, 0, 0, 0},
|
||||||
|
},
|
||||||
|
"8": {
|
||||||
|
{0, 1, 1, 1, 1, 1, 0},
|
||||||
|
{1, 1, 1, 1, 1, 1, 1},
|
||||||
|
{1, 1, 0, 0, 0, 1, 1},
|
||||||
|
{1, 1, 0, 0, 0, 1, 1},
|
||||||
|
{0, 1, 1, 1, 1, 1, 0},
|
||||||
|
{1, 1, 0, 0, 0, 1, 1},
|
||||||
|
{1, 1, 0, 0, 0, 1, 1},
|
||||||
|
{1, 1, 1, 1, 1, 1, 1},
|
||||||
|
{0, 1, 1, 1, 1, 1, 0},
|
||||||
|
},
|
||||||
|
"9": {
|
||||||
|
{0, 1, 1, 1, 1, 1, 0},
|
||||||
|
{1, 1, 1, 1, 1, 1, 1},
|
||||||
|
{1, 1, 0, 0, 0, 1, 1},
|
||||||
|
{1, 1, 0, 0, 0, 1, 1},
|
||||||
|
{0, 1, 1, 1, 1, 1, 1},
|
||||||
|
{0, 0, 0, 0, 0, 1, 1},
|
||||||
|
{0, 0, 0, 0, 0, 1, 1},
|
||||||
|
{1, 1, 1, 1, 1, 1, 1},
|
||||||
|
{0, 1, 1, 1, 1, 1, 0},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
pattern, ok := charMaps[char]
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// shear: 每行相对中心行(4)的水平偏移
|
||||||
|
// shear=1: 行0偏移-4, 行8偏移+4 → 右倾
|
||||||
|
// shear=-1: 行0偏移+4, 行8偏移-4 → 左倾
|
||||||
|
for py, row := range pattern {
|
||||||
|
// 行偏移量 = (py - 4) * shear
|
||||||
|
rowShear := (py - 4) * shear
|
||||||
|
|
||||||
|
for px, v := range row {
|
||||||
|
if v == 1 {
|
||||||
|
ix := x + px*pixelSize + rowShear
|
||||||
|
iy := y + py*pixelSize
|
||||||
|
for dx := 0; dx < pixelSize; dx++ {
|
||||||
|
for dy := 0; dy < pixelSize; dy++ {
|
||||||
|
if ix+dx < img.Bounds().Max.X && iy+dy < img.Bounds().Max.Y {
|
||||||
|
img.Set(ix+dx, iy+dy, col)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func abs(x int) int {
|
||||||
|
if x < 0 {
|
||||||
|
return -x
|
||||||
|
}
|
||||||
|
return x
|
||||||
|
}
|
||||||
|
|
||||||
|
func max(a, b int) int {
|
||||||
|
if a > b {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package cpdaily
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// simpleCookieJar 简化的 CookieJar 实现
|
||||||
|
type simpleCookieJar struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
cookies map[string][]*http.Cookie // key: host
|
||||||
|
}
|
||||||
|
|
||||||
|
func newCookieJar() *simpleCookieJar {
|
||||||
|
return &simpleCookieJar{
|
||||||
|
cookies: make(map[string][]*http.Cookie),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j *simpleCookieJar) SetCookies(u *url.URL, cookies []*http.Cookie) {
|
||||||
|
j.mu.Lock()
|
||||||
|
defer j.mu.Unlock()
|
||||||
|
|
||||||
|
host := u.Host
|
||||||
|
existing := j.cookies[host]
|
||||||
|
cookieMap := make(map[string]*http.Cookie)
|
||||||
|
for _, c := range existing {
|
||||||
|
cookieMap[c.Name] = c
|
||||||
|
}
|
||||||
|
for _, c := range cookies {
|
||||||
|
cookieMap[c.Name] = c
|
||||||
|
}
|
||||||
|
|
||||||
|
var merged []*http.Cookie
|
||||||
|
for _, c := range cookieMap {
|
||||||
|
merged = append(merged, c)
|
||||||
|
}
|
||||||
|
j.cookies[host] = merged
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j *simpleCookieJar) Cookies(u *url.URL) []*http.Cookie {
|
||||||
|
j.mu.RLock()
|
||||||
|
defer j.mu.RUnlock()
|
||||||
|
|
||||||
|
host := u.Host
|
||||||
|
var result []*http.Cookie
|
||||||
|
result = append(result, j.cookies[host]...)
|
||||||
|
// 也返回全局 cookie
|
||||||
|
result = append(result, j.cookies[""]...)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// AllCookies 返回所有域名下的所有 cookie
|
||||||
|
func (j *simpleCookieJar) AllCookies() []*http.Cookie {
|
||||||
|
j.mu.RLock()
|
||||||
|
defer j.mu.RUnlock()
|
||||||
|
var result []*http.Cookie
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
for _, cookies := range j.cookies {
|
||||||
|
for _, c := range cookies {
|
||||||
|
if !seen[c.Name] {
|
||||||
|
result = append(result, c)
|
||||||
|
seen[c.Name] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
package cpdaily
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/aes"
|
||||||
|
"crypto/cipher"
|
||||||
|
"crypto/des"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"math/big"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ========== AES-128-CBC-PKCS7 ==========
|
||||||
|
|
||||||
|
var (
|
||||||
|
AesKey = []byte("fafUY9659kyengTR")
|
||||||
|
AesKeyForCat = []byte("hru89rwr87Nthifa")
|
||||||
|
AesIv = []byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 1, 2, 3, 4, 5, 6, 7}
|
||||||
|
)
|
||||||
|
|
||||||
|
func AesEncrypt(data []byte) []byte {
|
||||||
|
return aesEncrypt(data, AesKey, AesIv)
|
||||||
|
}
|
||||||
|
|
||||||
|
func AesDecrypt(data []byte) []byte {
|
||||||
|
return aesDecrypt(data, AesKey, AesIv)
|
||||||
|
}
|
||||||
|
|
||||||
|
func AesEncryptForCat(data []byte) []byte {
|
||||||
|
return aesEncrypt(data, AesKeyForCat, AesIv)
|
||||||
|
}
|
||||||
|
|
||||||
|
func AesDecryptForCat(data []byte) []byte {
|
||||||
|
return aesDecrypt(data, AesKeyForCat, AesIv)
|
||||||
|
}
|
||||||
|
|
||||||
|
func aesEncrypt(data, key, iv []byte) []byte {
|
||||||
|
block, _ := aes.NewCipher(key)
|
||||||
|
padded := pkcs7Pad(data, block.BlockSize())
|
||||||
|
ciphertext := make([]byte, len(padded))
|
||||||
|
mode := cipher.NewCBCEncrypter(block, iv)
|
||||||
|
mode.CryptBlocks(ciphertext, padded)
|
||||||
|
return ciphertext
|
||||||
|
}
|
||||||
|
|
||||||
|
func aesDecrypt(data, key, iv []byte) []byte {
|
||||||
|
block, _ := aes.NewCipher(key)
|
||||||
|
plaintext := make([]byte, len(data))
|
||||||
|
mode := cipher.NewCBCDecrypter(block, iv)
|
||||||
|
mode.CryptBlocks(plaintext, data)
|
||||||
|
return pkcs7Unpad(plaintext)
|
||||||
|
}
|
||||||
|
|
||||||
|
func pkcs7Pad(data []byte, blockSize int) []byte {
|
||||||
|
padding := blockSize - len(data)%blockSize
|
||||||
|
padText := bytes.Repeat([]byte{byte(padding)}, padding)
|
||||||
|
return append(data, padText...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func pkcs7Unpad(data []byte) []byte {
|
||||||
|
if len(data) == 0 {
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
padLen := int(data[len(data)-1])
|
||||||
|
if padLen > len(data) || padLen == 0 {
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
return data[:len(data)-padLen]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== CAS 登录密码加密 ==========
|
||||||
|
|
||||||
|
// EncryptCasPassword AES-128-CBC 加密密码
|
||||||
|
// key=salt(从页面提取), iv=16位随机, 明文=64位随机字符串+密码, 输出base64
|
||||||
|
func EncryptCasPassword(password, salt string) string {
|
||||||
|
const randStrLen = 64
|
||||||
|
const randIvLen = 16
|
||||||
|
|
||||||
|
randomIv := randomString(randIvLen)
|
||||||
|
randomStr := randomString(randStrLen)
|
||||||
|
plaintext := []byte(randomStr + password)
|
||||||
|
key := []byte(salt)
|
||||||
|
iv := []byte(randomIv)
|
||||||
|
|
||||||
|
encrypted := aesEncrypt(plaintext, key, iv)
|
||||||
|
return base64.StdEncoding.EncodeToString(encrypted)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== RSA (IAP 登录用) ==========
|
||||||
|
|
||||||
|
// 硬编码 IAP RSA 公钥
|
||||||
|
const IapRsaPubKey = "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDCpbRy8ZoyQvRPpUDIXycglTwVDcYrLcv7P9HI4E7/TAPZiI3GN0ckTUVVWRvoo/re3uCOOJK+F+Ufh19XiRIdoPYCULsSyEjcLLiyAS09mVOzhAQco84E/lM24T7rRTVID7LIgWPewyN8Nd5vzHET4Q3qTthoL8n82BwC1iXuVQIDAQAB"
|
||||||
|
|
||||||
|
type RSAKeyPair struct {
|
||||||
|
n *big.Int
|
||||||
|
e *big.Int
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRSAKeyPairFromPublicKey(base64Key string) (*RSAKeyPair, error) {
|
||||||
|
der, err := base64.StdEncoding.DecodeString(base64Key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("base64 decode error: %w", err)
|
||||||
|
}
|
||||||
|
// 简单的 DER 解析 (PKCS#1 Public Key)
|
||||||
|
// 跳过 ASN.1 头部,提取 n 和 e
|
||||||
|
return parsePKCS1PublicKey(der)
|
||||||
|
}
|
||||||
|
|
||||||
|
func parsePKCS1PublicKey(der []byte) (*RSAKeyPair, error) {
|
||||||
|
// 简化的 PKCS#1 解析
|
||||||
|
// 实际上应该用 crypto/x509,但为了轻量级,手动解析
|
||||||
|
// 格式: SEQUENCE { SEQUENCE { OID, NULL }, BIT STRING { SEQUENCE { INTEGER n, INTEGER e } } }
|
||||||
|
// 或者直接 PKCS#1: SEQUENCE { INTEGER n, INTEGER e }
|
||||||
|
|
||||||
|
// 找最后的两个 INTEGER
|
||||||
|
data := string(der)
|
||||||
|
nParts := strings.Split(data, "\x02")
|
||||||
|
if len(nParts) < 3 {
|
||||||
|
return nil, fmt.Errorf("invalid RSA key format")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提取 n (倒数第二个 INTEGER)
|
||||||
|
nBytes := extractIntegerBytes(nParts[len(nParts)-2])
|
||||||
|
// 提取 e (最后一个 INTEGER)
|
||||||
|
eBytes := extractIntegerBytes(nParts[len(nParts)-1])
|
||||||
|
|
||||||
|
r := &RSAKeyPair{
|
||||||
|
n: new(big.Int).SetBytes(nBytes),
|
||||||
|
e: new(big.Int).SetBytes(eBytes),
|
||||||
|
}
|
||||||
|
return r, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractIntegerBytes(s string) []byte {
|
||||||
|
if len(s) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// 第一个字节是长度
|
||||||
|
data := []byte(s)
|
||||||
|
if len(data) < 2 {
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
length := int(data[0])
|
||||||
|
if length > 128 {
|
||||||
|
// 长格式
|
||||||
|
numLenBytes := length & 0x7F
|
||||||
|
length = 0
|
||||||
|
for i := 1; i <= numLenBytes; i++ {
|
||||||
|
length = length*256 + int(data[i])
|
||||||
|
}
|
||||||
|
data = data[1+numLenBytes:]
|
||||||
|
} else {
|
||||||
|
data = data[1:]
|
||||||
|
}
|
||||||
|
if length > len(data) {
|
||||||
|
length = len(data)
|
||||||
|
}
|
||||||
|
return data[:length]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *RSAKeyPair) Encrypt(plaintext string) (string, error) {
|
||||||
|
// PKCS#1 v1.5 加密
|
||||||
|
k := (r.n.BitLen() + 7) / 8
|
||||||
|
if k < 12 {
|
||||||
|
return "", fmt.Errorf("key too short")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 填充: 0x00 || 0x02 || PS || 0x00 || M
|
||||||
|
// PS 是随机非零字节
|
||||||
|
plainBytes := []byte(plaintext)
|
||||||
|
if len(plainBytes) > k-11 {
|
||||||
|
return "", fmt.Errorf("plaintext too long")
|
||||||
|
}
|
||||||
|
|
||||||
|
padded := make([]byte, k)
|
||||||
|
padded[0] = 0x00
|
||||||
|
padded[1] = 0x02
|
||||||
|
|
||||||
|
// PS: 随机非零字节
|
||||||
|
for i := 2; i < k-len(plainBytes)-1; i++ {
|
||||||
|
bt := byte(0)
|
||||||
|
for bt == 0 {
|
||||||
|
b := make([]byte, 1)
|
||||||
|
rand.Read(b)
|
||||||
|
bt = b[0]
|
||||||
|
}
|
||||||
|
padded[i] = bt
|
||||||
|
}
|
||||||
|
|
||||||
|
padded[k-len(plainBytes)-1] = 0x00
|
||||||
|
copy(padded[k-len(plainBytes):], plainBytes)
|
||||||
|
|
||||||
|
// c = m^e mod n
|
||||||
|
m := new(big.Int).SetBytes(padded)
|
||||||
|
c := new(big.Int).Exp(m, r.e, r.n)
|
||||||
|
|
||||||
|
result := c.Text(16)
|
||||||
|
if len(result)%2 != 0 {
|
||||||
|
result = "0" + result
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 工具函数 ==========
|
||||||
|
|
||||||
|
func randomString(length int) string {
|
||||||
|
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
|
||||||
|
result := make([]byte, length)
|
||||||
|
for i := 0; i < length; i++ {
|
||||||
|
n, _ := rand.Read(make([]byte, 1))
|
||||||
|
result[i] = chars[int(n)%len(chars)]
|
||||||
|
}
|
||||||
|
return string(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func randomStringV2(length int) string {
|
||||||
|
b := make([]byte, length)
|
||||||
|
rand.Read(b)
|
||||||
|
return hex.EncodeToString(b)[:length]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== DES-CBC-PKCS7 (用于 Cpdaily-Extension) ==========
|
||||||
|
|
||||||
|
var (
|
||||||
|
DesKeyForCat = []byte("b3L26XNL")
|
||||||
|
DesIv = []byte{1, 2, 3, 4, 5, 6, 7, 8}
|
||||||
|
)
|
||||||
|
|
||||||
|
func DesEncryptForCat(data []byte) []byte {
|
||||||
|
block, _ := des.NewCipher(DesKeyForCat)
|
||||||
|
padded := pkcs7Pad(data, block.BlockSize())
|
||||||
|
ciphertext := make([]byte, len(padded))
|
||||||
|
mode := cipher.NewCBCEncrypter(block, DesIv)
|
||||||
|
mode.CryptBlocks(ciphertext, padded)
|
||||||
|
return ciphertext
|
||||||
|
}
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
package cpdaily
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/tls"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"mime/multipart"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-resty/resty/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CpImageUploadPolicy 今日校园 OBS 上传策略
|
||||||
|
type CpImageUploadPolicy struct {
|
||||||
|
AccessId string `json:"accessid"`
|
||||||
|
FileName string `json:"fileName"`
|
||||||
|
Policy string `json:"policy"`
|
||||||
|
Host string `json:"host"`
|
||||||
|
Signature string `json:"signature"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// getImageUrlPrefix 根据任务类型返回图片上传的 URL 前缀
|
||||||
|
func getImageUrlPrefix(taskType TaskType) string {
|
||||||
|
switch taskType {
|
||||||
|
case TaskTypeSleepCheck:
|
||||||
|
return "/wec-counselor-attendance-apps/student/attendance"
|
||||||
|
case TaskTypeSign:
|
||||||
|
return "/wec-counselor-sign-apps/stu"
|
||||||
|
default:
|
||||||
|
return "/wec-counselor-sign-apps/stu"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUploadPolicy 从今日校园获取 OBS 上传策略(前端用,浏览器直传到 OBS)
|
||||||
|
func GetUploadPolicy(cookieMap map[string]string, host string, taskType TaskType) (*CpImageUploadPolicy, error) {
|
||||||
|
client := newImgClient()
|
||||||
|
cookieStr := cookiesToStr(cookieMap)
|
||||||
|
urlPrefix := getImageUrlPrefix(taskType)
|
||||||
|
|
||||||
|
var apiPath string
|
||||||
|
if taskType == TaskTypeSleepCheck {
|
||||||
|
apiPath = "/getUploadPolicy"
|
||||||
|
} else {
|
||||||
|
apiPath = "/obs/getUploadPolicy"
|
||||||
|
}
|
||||||
|
fullUrl := host + urlPrefix + apiPath
|
||||||
|
|
||||||
|
resp, err := client.R().
|
||||||
|
SetHeader("User-Agent", "Mozilla/5.0 (Linux; Android 15; Redmi Note 8 Pro) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/131.0.6778.81 Mobile Safari/537.36 cpdaily/9.9.6 wisedu/9.9.6").
|
||||||
|
SetHeader("CpdailyClientType", "CPDAILY").
|
||||||
|
SetHeader("Content-Type", "application/json; charset=UTF-8").
|
||||||
|
SetHeader("Cookie", cookieStr).
|
||||||
|
SetBody(map[string]interface{}{"fileType": 1}).
|
||||||
|
Post(fullUrl)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("获取上传策略失败: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.RawBody().Close()
|
||||||
|
|
||||||
|
body, _ := io.ReadAll(resp.RawBody())
|
||||||
|
var result struct {
|
||||||
|
ErrCode int `json:"errCode"`
|
||||||
|
ErrMsg string `json:"errMsg"`
|
||||||
|
Datas *CpImageUploadPolicy `json:"datas"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &result); err != nil {
|
||||||
|
return nil, fmt.Errorf("解析上传策略失败: %w", err)
|
||||||
|
}
|
||||||
|
if result.ErrCode != 0 || result.Datas == nil {
|
||||||
|
return nil, fmt.Errorf("获取上传策略错误(%d): %s", result.ErrCode, result.ErrMsg)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.Datas, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// uploadToOBS 将文件上传到今日校园的 OBS
|
||||||
|
func uploadToOBS(policy *CpImageUploadPolicy, fileData []byte, contentType string) error {
|
||||||
|
client := resty.New().SetTimeout(30 * time.Second).SetTLSClientConfig(&tls.Config{InsecureSkipVerify: true})
|
||||||
|
|
||||||
|
if contentType == "" {
|
||||||
|
contentType = "image/jpeg"
|
||||||
|
}
|
||||||
|
|
||||||
|
// 手动构造 multipart form
|
||||||
|
body := &bytes.Buffer{}
|
||||||
|
writer := multipart.NewWriter(body)
|
||||||
|
|
||||||
|
writer.WriteField("x-obs-acl", "public-read")
|
||||||
|
writer.WriteField("AccessKeyId", policy.AccessId)
|
||||||
|
writer.WriteField("key", policy.FileName)
|
||||||
|
writer.WriteField("signature", policy.Signature)
|
||||||
|
writer.WriteField("policy", policy.Policy)
|
||||||
|
writer.WriteField("content-type", contentType)
|
||||||
|
|
||||||
|
part, _ := writer.CreateFormFile("file", "file")
|
||||||
|
part.Write(fileData)
|
||||||
|
writer.Close()
|
||||||
|
|
||||||
|
resp, err := client.R().
|
||||||
|
SetHeader("Content-Type", writer.FormDataContentType()).
|
||||||
|
SetBody(body.Bytes()).
|
||||||
|
Post(policy.Host)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("上传文件到 OBS 失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode() > 299 {
|
||||||
|
return fmt.Errorf("上传文件到 OBS 返回错误(%d): %s", resp.StatusCode(), string(resp.Body()))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPreviewUrl 从今日校园获取图片预览 URL
|
||||||
|
func GetPreviewUrl(cookieMap map[string]string, host string, taskType TaskType, fileKey string) (string, error) {
|
||||||
|
client := newImgClient()
|
||||||
|
cookieStr := cookiesToStr(cookieMap)
|
||||||
|
urlPrefix := getImageUrlPrefix(taskType)
|
||||||
|
|
||||||
|
var apiPath string
|
||||||
|
switch taskType {
|
||||||
|
case TaskTypeSleepCheck:
|
||||||
|
apiPath = "/previewAttachment"
|
||||||
|
case TaskTypeSign:
|
||||||
|
apiPath = "/sign/previewAttachment"
|
||||||
|
default:
|
||||||
|
apiPath = "/sign/previewAttachment"
|
||||||
|
}
|
||||||
|
fullUrl := host + urlPrefix + apiPath
|
||||||
|
|
||||||
|
resp, err := client.R().
|
||||||
|
SetHeader("User-Agent", "Mozilla/5.0 (Linux; Android 15; Redmi Note 8 Pro) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/131.0.6778.81 Mobile Safari/537.36 cpdaily/9.9.6 wisedu/9.9.6").
|
||||||
|
SetHeader("CpdailyClientType", "CPDAILY").
|
||||||
|
SetHeader("Content-Type", "application/json; charset=UTF-8").
|
||||||
|
SetHeader("Cookie", cookieStr).
|
||||||
|
SetBody(map[string]interface{}{"ossKey": fileKey}).
|
||||||
|
Post(fullUrl)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("获取预览 URL 失败: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.RawBody().Close()
|
||||||
|
|
||||||
|
body, _ := io.ReadAll(resp.RawBody())
|
||||||
|
var result struct {
|
||||||
|
ErrCode int `json:"errCode"`
|
||||||
|
ErrMsg string `json:"errMsg"`
|
||||||
|
Datas json.RawMessage `json:"datas"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &result); err != nil {
|
||||||
|
return "", fmt.Errorf("解析预览 URL 响应失败: %w", err)
|
||||||
|
}
|
||||||
|
if result.ErrCode != 0 {
|
||||||
|
return "", fmt.Errorf("获取预览 URL 错误(%d): %s", result.ErrCode, result.ErrMsg)
|
||||||
|
}
|
||||||
|
|
||||||
|
var previewUrl string
|
||||||
|
if err := json.Unmarshal(result.Datas, &previewUrl); err != nil {
|
||||||
|
return "", fmt.Errorf("解析预览 URL 数据失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return previewUrl, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UploadImage 完整的图片上传流程:获取策略 → 上传 OBS → 获取预览 URL
|
||||||
|
func UploadImage(cookieMap map[string]string, host string, taskType TaskType, fileData []byte, contentType string) (string, error) {
|
||||||
|
policy, err := GetUploadPolicy(cookieMap, host, taskType)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := uploadToOBS(policy, fileData, contentType); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
previewUrl, err := GetPreviewUrl(cookieMap, host, taskType, policy.FileName)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return previewUrl, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func newImgClient() *resty.Client {
|
||||||
|
return resty.New().
|
||||||
|
SetTimeout(30*time.Second).
|
||||||
|
SetDoNotParseResponse(true).
|
||||||
|
SetTLSClientConfig(&tls.Config{InsecureSkipVerify: true})
|
||||||
|
}
|
||||||
@@ -0,0 +1,329 @@
|
|||||||
|
package cpdaily
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/PuerkitoBio/goquery"
|
||||||
|
"github.com/go-resty/resty/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ========== CAS 登录 ==========
|
||||||
|
|
||||||
|
// sessionClient 包装 resty.Client 并保存 cookie jar 引用
|
||||||
|
type sessionClient struct {
|
||||||
|
*resty.Client
|
||||||
|
jar *simpleCookieJar
|
||||||
|
}
|
||||||
|
|
||||||
|
func newSessionClient() *sessionClient {
|
||||||
|
jar := newCookieJar()
|
||||||
|
client := resty.New().
|
||||||
|
SetTimeout(30*time.Second).
|
||||||
|
SetTLSClientConfig(&tls.Config{
|
||||||
|
InsecureSkipVerify: true,
|
||||||
|
MinVersion: tls.VersionTLS10,
|
||||||
|
}).
|
||||||
|
SetHeader("User-Agent", "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1")
|
||||||
|
client.SetCookieJar(jar)
|
||||||
|
client.SetRedirectPolicy(resty.FlexibleRedirectPolicy(20))
|
||||||
|
|
||||||
|
return &sessionClient{
|
||||||
|
Client: client,
|
||||||
|
jar: jar,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func casLogin(ctx context.Context, school *SchoolDetailInfo, username, password string) ([]*http.Cookie, error) {
|
||||||
|
sc := newSessionClient()
|
||||||
|
|
||||||
|
// 1. 获取合法的登录 URL
|
||||||
|
loginUrl := getCasValidLoginUrl(school)
|
||||||
|
slog.Info("CAS登录", "loginUrl", loginUrl, "school", school.Name, "idsUrl", school.IdsUrl)
|
||||||
|
|
||||||
|
// 2. GET 登录页面 (使用带60s timeout的独立context)
|
||||||
|
loginCtx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
loginPageResp, err := sc.R().SetContext(loginCtx).Get(loginUrl)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("请求登录页面失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
loginPageBytes := loginPageResp.Body()
|
||||||
|
loginResponseUrl := loginPageResp.RawResponse.Request.URL
|
||||||
|
slog.Info("CAS登录页", "finalUrl", loginResponseUrl.String(), "bodyLen", len(loginPageBytes))
|
||||||
|
|
||||||
|
// 3. 禁用自动重定向
|
||||||
|
sc.SetRedirectPolicy(resty.NoRedirectPolicy())
|
||||||
|
|
||||||
|
// 4. 解析表单
|
||||||
|
allInputs := parseLoginForm(loginPageBytes, username, password)
|
||||||
|
slog.Info("CAS表单解析", "inputs", fmt.Sprintf("%v", allInputs))
|
||||||
|
|
||||||
|
// 5. 判断是否需要验证码
|
||||||
|
needCaptcha, loginType := detectCaptcha(ctx, loginResponseUrl, username, sc.Client)
|
||||||
|
slog.Info("CAS验证码检测", "needCaptcha", needCaptcha, "loginType", loginType)
|
||||||
|
if needCaptcha {
|
||||||
|
captchaCode := resolveOcrCaptcha(ctx, loginResponseUrl, loginType, sc.Client)
|
||||||
|
slog.Info("CAS验证码OCR", "code", captchaCode)
|
||||||
|
switch loginType {
|
||||||
|
case 0:
|
||||||
|
allInputs["captchaResponse"] = captchaCode
|
||||||
|
default:
|
||||||
|
allInputs["captcha"] = captchaCode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. POST 提交登录
|
||||||
|
loginResp, err := sc.R().
|
||||||
|
SetFormData(allInputs).
|
||||||
|
SetContext(loginCtx).
|
||||||
|
Post(loginResponseUrl.String())
|
||||||
|
if err != nil {
|
||||||
|
// NoRedirectPolicy 下 302 会返回 http.ErrUseLastResponse,此时 loginResp 仍然有效
|
||||||
|
if loginResp != nil && loginResp.StatusCode() == 302 {
|
||||||
|
slog.Info("CAS登录重定向(登录成功)", "location", loginResp.Header().Get("Location"))
|
||||||
|
} else {
|
||||||
|
return nil, fmt.Errorf("提交登录失败: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("CAS登录响应", "statusCode", loginResp.StatusCode())
|
||||||
|
|
||||||
|
// 7. 处理响应
|
||||||
|
switch loginResp.StatusCode() {
|
||||||
|
case 302:
|
||||||
|
slog.Info("CAS登录成功", "redirect", loginResp.Header().Get("Location"))
|
||||||
|
return handleCasLoginSuccess(ctx, loginResp, school, sc)
|
||||||
|
case 200, 401:
|
||||||
|
body := loginResp.Body()
|
||||||
|
slog.Info("CAS登录失败响应体", "body", string(body[:min(len(body), 2000)]))
|
||||||
|
doc, err := goquery.NewDocumentFromReader(bytes.NewReader(body))
|
||||||
|
if err == nil && isCasNeedKickOut(doc) {
|
||||||
|
slog.Info("CAS需要踢下线")
|
||||||
|
kickResp := submitCasKickOut(ctx, loginResponseUrl.String(), doc, sc.Client)
|
||||||
|
return handleCasLoginSuccess(ctx, kickResp, school, sc)
|
||||||
|
}
|
||||||
|
msg := getCasErrorMessage(doc)
|
||||||
|
if msg == "" {
|
||||||
|
msg = "登录失败,请检查账号密码"
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("%s", msg)
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("登录返回异常状态码: %d", loginResp.StatusCode())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取 CAS 合法登录 URL
|
||||||
|
func getCasValidLoginUrl(school *SchoolDetailInfo) string {
|
||||||
|
if school.IdsUrl != "" {
|
||||||
|
if strings.Contains(school.IdsUrl, "campusphere") || strings.Contains(school.IdsUrl, "wec-portal-mobile") {
|
||||||
|
return school.IdsUrl
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, u := range []string{school.AmpUrl, school.AmpUrl2, school.AmpUrl3} {
|
||||||
|
if u != "" {
|
||||||
|
return u
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析登录表单
|
||||||
|
func parseLoginForm(page []byte, username, password string) map[string]string {
|
||||||
|
inputs := make(map[string]string)
|
||||||
|
doc, err := goquery.NewDocumentFromReader(bytes.NewReader(page))
|
||||||
|
if err != nil {
|
||||||
|
return inputs
|
||||||
|
}
|
||||||
|
|
||||||
|
// 优先选择密码登录表单(pwdFromId),其次是其他已知CAS表单ID
|
||||||
|
selectors := []string{"#pwdFromId", "#phoneFromId", "#casLoginForm", "#loginFromId", "#fm1", "form"}
|
||||||
|
var formSel *goquery.Selection
|
||||||
|
for _, sel := range selectors {
|
||||||
|
formSel = doc.Find(sel)
|
||||||
|
if formSel.Length() > 0 {
|
||||||
|
slog.Info("CAS表单选择", "selector", sel, "count", formSel.Length())
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
formSel.Find("input").Each(func(_ int, s *goquery.Selection) {
|
||||||
|
name, _ := s.Attr("name")
|
||||||
|
value, _ := s.Attr("value")
|
||||||
|
if name != "" {
|
||||||
|
inputs[name] = value
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
inputs["username"] = username
|
||||||
|
|
||||||
|
// 清除表单中不必要或可能干扰的字段
|
||||||
|
delete(inputs, "userPassword")
|
||||||
|
delete(inputs, "passwordText")
|
||||||
|
|
||||||
|
salt := inputs["encryptsalt"]
|
||||||
|
if salt == "" {
|
||||||
|
salt, _ = doc.Find("#pwdEncryptSalt").Attr("value")
|
||||||
|
}
|
||||||
|
if salt == "" {
|
||||||
|
matches := saltRegexp.FindStringSubmatch(string(page))
|
||||||
|
if len(matches) > 1 {
|
||||||
|
salt = matches[1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if salt != "" {
|
||||||
|
slog.Info("CAS密码加密", "salt", salt)
|
||||||
|
inputs["password"] = EncryptCasPassword(password, salt)
|
||||||
|
} else {
|
||||||
|
slog.Info("CAS密码未加密")
|
||||||
|
inputs["password"] = password
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, exists := inputs["rememberMe"]; exists {
|
||||||
|
inputs["rememberMe"] = "on"
|
||||||
|
}
|
||||||
|
|
||||||
|
return inputs
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检测是否需要验证码
|
||||||
|
func detectCaptcha(ctx context.Context, loginUrl *url.URL, username string, client *resty.Client) (bool, int) {
|
||||||
|
checkUrl := fmt.Sprintf("%s://%s/authserver/needCaptcha.html?username=%s",
|
||||||
|
loginUrl.Scheme, loginUrl.Host, username)
|
||||||
|
resp, err := client.R().SetContext(ctx).Get(checkUrl)
|
||||||
|
if err == nil {
|
||||||
|
bodyStr := strings.TrimSpace(string(resp.Body()))
|
||||||
|
// 只当响应体是简单文本(不含HTML标签)时才判断,否则可能是非法请求页面
|
||||||
|
if !strings.Contains(bodyStr, "<!DOCTYPE") && !strings.Contains(bodyStr, "<html") {
|
||||||
|
slog.Info("CAS检测验证码", "url", checkUrl, "body", bodyStr)
|
||||||
|
if strings.Contains(bodyStr, "true") {
|
||||||
|
return true, 0
|
||||||
|
}
|
||||||
|
return false, 0
|
||||||
|
}
|
||||||
|
slog.Info("CAS检测验证码-非法请求", "url", checkUrl, "bodyLen", len(bodyStr))
|
||||||
|
}
|
||||||
|
|
||||||
|
checkUrl2 := fmt.Sprintf("%s://%s/authserver/checkNeedCaptcha.htl?username=%s",
|
||||||
|
loginUrl.Scheme, loginUrl.Host, username)
|
||||||
|
resp2, err := client.R().SetContext(ctx).Get(checkUrl2)
|
||||||
|
if err == nil {
|
||||||
|
bodyStr := string(resp2.Body())
|
||||||
|
// 同样检查是否返回了非法请求页面
|
||||||
|
if !strings.Contains(bodyStr, "<!DOCTYPE") && !strings.Contains(bodyStr, "<html") {
|
||||||
|
slog.Info("CAS检测验证码-方法2", "url", checkUrl2, "body", bodyStr)
|
||||||
|
if strings.Contains(bodyStr, `"isNeed":true`) {
|
||||||
|
return true, 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false, 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理 CAS 验证码
|
||||||
|
func resolveOcrCaptcha(ctx context.Context, loginUrl *url.URL, loginType int, client *resty.Client) string {
|
||||||
|
var captchaUrl string
|
||||||
|
if loginType == 0 {
|
||||||
|
captchaUrl = fmt.Sprintf("%s://%s/authserver/captcha", loginUrl.Scheme, loginUrl.Host)
|
||||||
|
} else {
|
||||||
|
// loginType == 1: 使用 getCaptcha.htl(安徽师范大学等学校使用此路径)
|
||||||
|
captchaUrl = fmt.Sprintf("%s://%s/authserver/getCaptcha.htl", loginUrl.Scheme, loginUrl.Host)
|
||||||
|
}
|
||||||
|
|
||||||
|
imgResp, err := client.R().SetContext(ctx).Get(captchaUrl)
|
||||||
|
if err != nil {
|
||||||
|
slog.Info("CAS验证码获取失败", "url", captchaUrl, "err", err)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
imgData := imgResp.Body()
|
||||||
|
slog.Info("CAS验证码获取成功", "url", captchaUrl, "size", len(imgData))
|
||||||
|
return captchaOCR(imgData)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理 CAS 登录成功
|
||||||
|
func handleCasLoginSuccess(ctx context.Context, resp *resty.Response, school *SchoolDetailInfo, sc *sessionClient) ([]*http.Cookie, error) {
|
||||||
|
jumpUrl, err := resp.RawResponse.Location()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("获取重定向地址失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sc.SetRedirectPolicy(resty.FlexibleRedirectPolicy(20))
|
||||||
|
verifyResp, err := sc.R().SetContext(ctx).Get(jumpUrl.String())
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("跟随重定向失败: %w", err)
|
||||||
|
}
|
||||||
|
_ = verifyResp // body不需要读取
|
||||||
|
|
||||||
|
activateSession(ctx, school.WecloudUrl, sc.Client)
|
||||||
|
|
||||||
|
// 从 cookie jar 获取所有域名的 cookies
|
||||||
|
cookies := sc.jar.AllCookies()
|
||||||
|
return cookies, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 激活 Session
|
||||||
|
func activateSession(ctx context.Context, host string, client *resty.Client) {
|
||||||
|
if host == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
host = strings.TrimSuffix(host, "/")
|
||||||
|
client.SetRedirectPolicy(resty.FlexibleRedirectPolicy(20))
|
||||||
|
|
||||||
|
client.R().SetContext(ctx).
|
||||||
|
Get(host + "/wec-im-group/group/groupMember/checkImInitFlag")
|
||||||
|
|
||||||
|
client.R().SetContext(ctx).
|
||||||
|
SetBody(map[string]interface{}{}).
|
||||||
|
Post(host + "/wec-counselor-attendance-apps/student/attendance/getStuSignInfosByWeekMonth")
|
||||||
|
|
||||||
|
client.SetRedirectPolicy(resty.NoRedirectPolicy())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 辅助函数 ==========
|
||||||
|
|
||||||
|
func isCasNeedKickOut(doc *goquery.Document) bool {
|
||||||
|
return doc.Find("#continue").Length() > 0 || strings.Contains(doc.Text(), "踢下线")
|
||||||
|
}
|
||||||
|
|
||||||
|
func submitCasKickOut(ctx context.Context, loginUrl string, doc *goquery.Document, client *resty.Client) *resty.Response {
|
||||||
|
inputs := map[string]string{}
|
||||||
|
doc.Find("#continue input").Each(func(_ int, s *goquery.Selection) {
|
||||||
|
name, _ := s.Attr("name")
|
||||||
|
value, _ := s.Attr("value")
|
||||||
|
if name != "" {
|
||||||
|
inputs[name] = value
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if _, exists := inputs["continue"]; !exists {
|
||||||
|
inputs["continue"] = "确认继续"
|
||||||
|
}
|
||||||
|
resp, _ := client.R().SetContext(ctx).SetFormData(inputs).Post(loginUrl)
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
|
||||||
|
func getCasErrorMessage(doc *goquery.Document) string {
|
||||||
|
if doc == nil {
|
||||||
|
return "登录失败"
|
||||||
|
}
|
||||||
|
selectors := []string{
|
||||||
|
"#msg", "#loginError", ".login-error",
|
||||||
|
".error", "#error", "p.error",
|
||||||
|
}
|
||||||
|
for _, sel := range selectors {
|
||||||
|
msg := strings.TrimSpace(doc.Find(sel).Text())
|
||||||
|
if msg != "" {
|
||||||
|
return msg
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,265 @@
|
|||||||
|
package cpdaily
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"cpdaily-new/internal/config"
|
||||||
|
|
||||||
|
"github.com/go-resty/resty/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ========== IAP 登录 ==========
|
||||||
|
|
||||||
|
func iapLogin(ctx context.Context, school *SchoolDetailInfo, username, password string) ([]*http.Cookie, error) {
|
||||||
|
sc := newSessionClient()
|
||||||
|
loginUrl := getIapValidLoginUrl(school)
|
||||||
|
|
||||||
|
// 1. 获取 LT (Login Ticket)
|
||||||
|
lt, err := getIapLT(ctx, loginUrl, sc)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("获取LT失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 获取真实 _lt
|
||||||
|
realLT, err := getIapRealLT(ctx, loginUrl, lt, sc.Client)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("获取真实LT失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. RSA 加密密码
|
||||||
|
rsa, err := NewRSAKeyPairFromPublicKey(IapRsaPubKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("RSA密钥解析失败: %w", err)
|
||||||
|
}
|
||||||
|
encPwd, err := rsa.Encrypt(password)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("RSA加密失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 检测验证码
|
||||||
|
captchaCode := ""
|
||||||
|
if needIapCaptcha(ctx, loginUrl, realLT, sc.Client) {
|
||||||
|
captchaCode = resolveIapCaptcha(ctx, loginUrl, realLT, sc.Client)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 提交登录
|
||||||
|
submitData := map[string]string{
|
||||||
|
"lt": realLT,
|
||||||
|
"rememberMe": "true",
|
||||||
|
"dllt": "",
|
||||||
|
"mobile": "",
|
||||||
|
"captcha": captchaCode,
|
||||||
|
"username": username,
|
||||||
|
"password": "{rsa}" + encPwd,
|
||||||
|
}
|
||||||
|
|
||||||
|
submitUrls := []string{
|
||||||
|
loginUrl + "/iap/doLogin",
|
||||||
|
loginUrl + "/doLogin",
|
||||||
|
}
|
||||||
|
|
||||||
|
var loginResp *resty.Response
|
||||||
|
var lastErr error
|
||||||
|
for _, submitUrl := range submitUrls {
|
||||||
|
loginResp, lastErr = sc.R().
|
||||||
|
SetFormData(submitData).
|
||||||
|
SetContext(ctx).
|
||||||
|
Post(submitUrl)
|
||||||
|
if lastErr == nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if lastErr != nil {
|
||||||
|
return nil, fmt.Errorf("提交IAP登录失败: %w", lastErr)
|
||||||
|
}
|
||||||
|
defer loginResp.RawBody().Close()
|
||||||
|
|
||||||
|
// 6. 处理响应
|
||||||
|
switch loginResp.StatusCode() {
|
||||||
|
case 302:
|
||||||
|
return handleIapLoginSuccess(ctx, loginResp, school, sc)
|
||||||
|
case 200:
|
||||||
|
body, _ := io.ReadAll(loginResp.RawBody())
|
||||||
|
var result struct {
|
||||||
|
ResultCode int `json:"resultCode"`
|
||||||
|
ResultMsg string `json:"resultMsg"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &result); err == nil {
|
||||||
|
switch result.ResultCode {
|
||||||
|
case 1:
|
||||||
|
return sc.jar.AllCookies(), nil
|
||||||
|
case -4:
|
||||||
|
return nil, fmt.Errorf("验证码错误")
|
||||||
|
case -3:
|
||||||
|
return nil, fmt.Errorf("账号或密码错误")
|
||||||
|
}
|
||||||
|
if result.ResultMsg != "" {
|
||||||
|
return nil, fmt.Errorf("%s", result.ResultMsg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("IAP登录失败,未知错误")
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("IAP登录返回异常状态码: %d", loginResp.StatusCode())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getIapValidLoginUrl(school *SchoolDetailInfo) string {
|
||||||
|
if school.WecloudUrl != "" {
|
||||||
|
return school.WecloudUrl
|
||||||
|
}
|
||||||
|
return school.AmpUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
func getIapLT(ctx context.Context, baseUrl string, sc *sessionClient) (string, error) {
|
||||||
|
sc.SetRedirectPolicy(resty.NoRedirectPolicy())
|
||||||
|
resp, err := sc.R().SetContext(ctx).Get(baseUrl + "/portal/login")
|
||||||
|
if err != nil {
|
||||||
|
resp, err = sc.R().SetContext(ctx).Get(baseUrl + "/iap/login/mobile.html")
|
||||||
|
}
|
||||||
|
sc.SetRedirectPolicy(resty.FlexibleRedirectPolicy(20))
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer resp.RawBody().Close()
|
||||||
|
|
||||||
|
if resp.StatusCode() == 302 {
|
||||||
|
loc, _ := resp.RawResponse.Location()
|
||||||
|
if loc != nil {
|
||||||
|
lt := loc.Query().Get("lt")
|
||||||
|
if lt != "" {
|
||||||
|
return lt, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
body, _ := io.ReadAll(resp.RawBody())
|
||||||
|
return extractLTFromPage(string(body)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getIapRealLT(ctx context.Context, baseUrl, lt string, client *resty.Client) (string, error) {
|
||||||
|
resp, err := client.R().
|
||||||
|
SetFormData(map[string]string{"lt": lt}).
|
||||||
|
SetContext(ctx).
|
||||||
|
Post(baseUrl + "/iap/security/lt")
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer resp.RawBody().Close()
|
||||||
|
|
||||||
|
body, _ := io.ReadAll(resp.RawBody())
|
||||||
|
var result struct {
|
||||||
|
Result struct {
|
||||||
|
LT string `json:"_lt"`
|
||||||
|
} `json:"result"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &result); err != nil {
|
||||||
|
return "", fmt.Errorf("解析LT响应失败: %w", err)
|
||||||
|
}
|
||||||
|
if result.Result.LT == "" {
|
||||||
|
return "", fmt.Errorf("获取真实LT为空")
|
||||||
|
}
|
||||||
|
return result.Result.LT, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func needIapCaptcha(ctx context.Context, baseUrl, lt string, client *resty.Client) bool {
|
||||||
|
resp, err := client.R().
|
||||||
|
SetFormData(map[string]string{"ltId": lt}).
|
||||||
|
SetContext(ctx).
|
||||||
|
Post(baseUrl + "/iap/checkNeedCaptcha")
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
defer resp.RawBody().Close()
|
||||||
|
body, _ := io.ReadAll(resp.RawBody())
|
||||||
|
return strings.Contains(string(body), `"needCaptcha":true`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveIapCaptcha(ctx context.Context, baseUrl, lt string, client *resty.Client) string {
|
||||||
|
captchaUrl := fmt.Sprintf("%s/iap/generateCaptcha?ltId=%s", baseUrl, lt)
|
||||||
|
imgResp, err := client.R().SetContext(ctx).Get(captchaUrl)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
defer imgResp.RawBody().Close()
|
||||||
|
imgData, _ := io.ReadAll(imgResp.RawBody())
|
||||||
|
return captchaOCR(imgData)
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleIapLoginSuccess(ctx context.Context, resp *resty.Response, school *SchoolDetailInfo, sc *sessionClient) ([]*http.Cookie, error) {
|
||||||
|
jumpUrl, err := resp.RawResponse.Location()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("获取IAP重定向地址失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sc.SetRedirectPolicy(resty.FlexibleRedirectPolicy(20))
|
||||||
|
verifyResp, err := sc.R().SetContext(ctx).Get(jumpUrl.String())
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("IAP跟随重定向失败: %w", err)
|
||||||
|
}
|
||||||
|
defer verifyResp.RawBody().Close()
|
||||||
|
|
||||||
|
activateSession(ctx, school.WecloudUrl, sc.Client)
|
||||||
|
return sc.jar.AllCookies(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractLTFromPage(page string) string {
|
||||||
|
idx := strings.Index(page, `"lt":"`)
|
||||||
|
if idx > 0 {
|
||||||
|
end := strings.Index(page[idx+5:], `"`)
|
||||||
|
if end > 0 {
|
||||||
|
return page[idx+5 : idx+5+end]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func getHost(rawUrl string) string {
|
||||||
|
u, err := url.Parse(rawUrl)
|
||||||
|
if err != nil {
|
||||||
|
return rawUrl
|
||||||
|
}
|
||||||
|
return u.Host
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== OCR 验证码识别 ==========
|
||||||
|
|
||||||
|
func captchaOCR(imgData []byte) string {
|
||||||
|
// 创建专用客户端(不继承 DoNotParseResponse)
|
||||||
|
cfg := config.Global.Cpdaily
|
||||||
|
resp, err := resty.New().
|
||||||
|
SetTimeout(15 * time.Second).
|
||||||
|
R().
|
||||||
|
SetHeader("X-Api-Key", cfg.CaptchaApiKey).
|
||||||
|
SetFileReader("file", "captcha.jpg", bytes.NewReader(imgData)).
|
||||||
|
Post(cfg.CaptchaBaseUrl + "/ocr")
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("OCR请求失败", "err", err)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Data string `json:"data"`
|
||||||
|
}
|
||||||
|
bodyStr := resp.String()
|
||||||
|
if err := json.Unmarshal([]byte(bodyStr), &result); err != nil {
|
||||||
|
slog.Warn("OCR响应解析失败", "err", err, "body", bodyStr)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if result.Code != 200 {
|
||||||
|
slog.Warn("OCR识别失败", "code", result.Code, "data", result.Data)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
slog.Info("OCR识别结果", "data", result.Data)
|
||||||
|
return result.Data
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
package cpdaily
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/tls"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-resty/resty/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 创建 Resty 客户端
|
||||||
|
func newMobileClient() *resty.Client {
|
||||||
|
return resty.New().
|
||||||
|
SetTimeout(15*time.Second).
|
||||||
|
SetDoNotParseResponse(true).
|
||||||
|
SetTLSClientConfig(&tls.Config{InsecureSkipVerify: true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// FetchUserProfile 从 cpdaily 获取用户主页信息
|
||||||
|
// 使用 wecloud getCurrentUserId API 获取姓名+头像,不再尝试获取手机号
|
||||||
|
func FetchUserProfile(cookieMap map[string]string, wecloudUrl string) (*CpdailyUserProfile, error) {
|
||||||
|
if wecloudUrl != "" {
|
||||||
|
if p, err := fetchFromWecloud(wecloudUrl, cookieMap); err == nil && p.Name != "" {
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("无法获取用户信息")
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetchFromWecloud 通过 campusphere.net 获取用户基本信息
|
||||||
|
func fetchFromWecloud(wecloudUrl string, cookieMap map[string]string) (*CpdailyUserProfile, error) {
|
||||||
|
host := strings.TrimSuffix(wecloudUrl, "/")
|
||||||
|
cookieStr := cookiesToStr(cookieMap)
|
||||||
|
|
||||||
|
client := newMobileClient()
|
||||||
|
resp, err := client.R().
|
||||||
|
SetHeader("User-Agent", "Mozilla/5.0 (Linux; Android 15; Redmi Note 8 Pro) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/131.0.6778.81 Mobile Safari/537.36 cpdaily/9.9.6 wisedu/9.9.6").
|
||||||
|
SetHeader("CpdailyClientType", "CPDAILY").
|
||||||
|
SetHeader("Cookie", cookieStr).
|
||||||
|
Get(host + "/wec-im-group/group/groupMember/getCurrentUserId")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("请求 wecloud 用户信息失败: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.RawBody().Close()
|
||||||
|
|
||||||
|
body, _ := io.ReadAll(resp.RawBody())
|
||||||
|
var result struct {
|
||||||
|
ErrCode int `json:"errCode"`
|
||||||
|
ErrMsg string `json:"errMsg"`
|
||||||
|
Data *struct {
|
||||||
|
UserName string `json:"userName"`
|
||||||
|
AvatarUrl string `json:"avatarUrl"`
|
||||||
|
MobilePhone string `json:"mobilePhone"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &result); err != nil {
|
||||||
|
return nil, fmt.Errorf("解析 wecloud 用户信息失败: %w", err)
|
||||||
|
}
|
||||||
|
if result.ErrCode != 0 || result.Data == nil {
|
||||||
|
return nil, fmt.Errorf("wecloud 用户信息接口错误(%d): %s", result.ErrCode, result.ErrMsg)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &CpdailyUserProfile{
|
||||||
|
Name: result.Data.UserName,
|
||||||
|
Img: result.Data.AvatarUrl,
|
||||||
|
MobilePhone: result.Data.MobilePhone,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// cookiesToStr 将 cookie map 转为 cookie 字符串
|
||||||
|
func cookiesToStr(cookieMap map[string]string) string {
|
||||||
|
var parts []string
|
||||||
|
for k, v := range cookieMap {
|
||||||
|
parts = append(parts, k+"="+v)
|
||||||
|
}
|
||||||
|
return strings.Join(parts, "; ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseCookieJSON 将 JSON cookie 字符串解析为 map
|
||||||
|
func ParseCookieJSON(cookieJSON string) map[string]string {
|
||||||
|
cookieMap := make(map[string]string)
|
||||||
|
json.Unmarshal([]byte(cookieJSON), &cookieMap)
|
||||||
|
return cookieMap
|
||||||
|
}
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
package cpdaily
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-resty/resty/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ========== HTTP 客户端 ==========
|
||||||
|
|
||||||
|
var httpClient *resty.Client
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
httpClient = resty.New().
|
||||||
|
SetTimeout(30*time.Second).
|
||||||
|
SetDoNotParseResponse(true).
|
||||||
|
SetTLSClientConfig(&tls.Config{
|
||||||
|
InsecureSkipVerify: true,
|
||||||
|
MinVersion: tls.VersionTLS10,
|
||||||
|
}).
|
||||||
|
SetHeader("User-Agent", "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1").
|
||||||
|
SetHeader("CpdailyClientType", "CPDAILY").
|
||||||
|
SetHeader("deviceType", "2")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 公开 API ==========
|
||||||
|
|
||||||
|
// SearchSchools 搜索学校
|
||||||
|
func SearchSchools(name string) ([]SchoolBriefInfo, error) {
|
||||||
|
client := httpClient
|
||||||
|
resp, err := client.R().Get("https://static.campushoy.com/apicache/tenantListSort")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("请求学校列表失败: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.RawBody().Close()
|
||||||
|
|
||||||
|
body, _ := io.ReadAll(resp.RawBody())
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
ErrMsg string `json:"errMsg"`
|
||||||
|
ErrCode int `json:"errCode"`
|
||||||
|
Data json.RawMessage `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &result); err != nil {
|
||||||
|
return nil, fmt.Errorf("解析学校列表失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// data 是一个数组,每项包含 datas 字段
|
||||||
|
var datasets []struct {
|
||||||
|
Datas []SchoolBriefInfo `json:"datas"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(result.Data, &datasets); err != nil {
|
||||||
|
return nil, fmt.Errorf("解析学校数据失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var schools []SchoolBriefInfo
|
||||||
|
for _, ds := range datasets {
|
||||||
|
for _, s := range ds.Datas {
|
||||||
|
if name == "" || strings.Contains(s.Name, name) || strings.Contains(s.NamePinYin, name) {
|
||||||
|
schools = append(schools, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return schools, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSchoolDetail 获取学校详情
|
||||||
|
func GetSchoolDetail(tenantID string) (*SchoolDetailInfo, error) {
|
||||||
|
// AES 加密 tenantID
|
||||||
|
encrypted := AesEncrypt([]byte(tenantID))
|
||||||
|
aParam := base64.StdEncoding.EncodeToString(encrypted)
|
||||||
|
|
||||||
|
resp, err := httpClient.R().
|
||||||
|
SetQueryParam("a", aParam).
|
||||||
|
SetQueryParam("b", "first_v4").
|
||||||
|
Get("https://mobile.campushoy.com/v6/config/guest/tenant/info/v-8222")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("请求学校详情失败: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.RawBody().Close()
|
||||||
|
|
||||||
|
body, _ := io.ReadAll(resp.RawBody())
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
ErrMsg string `json:"errMsg"`
|
||||||
|
ErrCode int `json:"errCode"`
|
||||||
|
Data string `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &result); err != nil {
|
||||||
|
return nil, fmt.Errorf("解析学校详情失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.ErrCode != 0 {
|
||||||
|
return nil, fmt.Errorf("学校详情接口错误: %s", result.ErrMsg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 数据是 base64(AES_Encrypted(JSON)),需要解码+解密
|
||||||
|
encData, err := base64.StdEncoding.DecodeString(result.Data)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("base64解码失败: %w", err)
|
||||||
|
}
|
||||||
|
decData := AesDecrypt(encData)
|
||||||
|
|
||||||
|
// 有时候需要解密两次
|
||||||
|
if !json.Valid(decData) {
|
||||||
|
// 再解密一次
|
||||||
|
encData2, _ := base64.StdEncoding.DecodeString(string(decData))
|
||||||
|
if encData2 != nil {
|
||||||
|
decData = AesDecrypt(encData2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 注意: API 返回的可能是一个数组(部分学校返回单对象,部分返回数组)
|
||||||
|
var detail SchoolDetailInfo
|
||||||
|
if decData[0] == '[' {
|
||||||
|
var details []SchoolDetailInfo
|
||||||
|
if err := json.Unmarshal(decData, &details); err != nil {
|
||||||
|
return nil, fmt.Errorf("解析学校详情数组JSON失败: %w, data: %s", err, string(decData))
|
||||||
|
}
|
||||||
|
if len(details) == 0 {
|
||||||
|
return nil, fmt.Errorf("学校详情数组为空")
|
||||||
|
}
|
||||||
|
detail = details[0]
|
||||||
|
} else {
|
||||||
|
if err := json.Unmarshal(decData, &detail); err != nil {
|
||||||
|
return nil, fmt.Errorf("解析学校详情JSON失败: %w, data: %s", err, string(decData))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 填充额外的字段
|
||||||
|
detail.NamePinYin = getPinYinFromID(tenantID)
|
||||||
|
detail.NameAbbr = getAbbrFromName(detail.Name)
|
||||||
|
|
||||||
|
return &detail, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CpdailyLogin 执行 cpdaily 登录
|
||||||
|
// 返回登录成功后的所有 cookies
|
||||||
|
func CpdailyLogin(ctx context.Context, school *SchoolDetailInfo, username, password string) ([]*http.Cookie, error) {
|
||||||
|
if school.IsIap() {
|
||||||
|
return iapLogin(ctx, school, username, password)
|
||||||
|
}
|
||||||
|
return casLogin(ctx, school, username, password)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SerializeCookies 将 cookies 序列化为字符串
|
||||||
|
func SerializeCookies(cookies []*http.Cookie) string {
|
||||||
|
// 转为 map[string]string
|
||||||
|
cookieMap := make(map[string]string)
|
||||||
|
for _, c := range cookies {
|
||||||
|
cookieMap[c.Name] = c.Value
|
||||||
|
}
|
||||||
|
jsonBytes, _ := json.Marshal(cookieMap)
|
||||||
|
return string(jsonBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 学校辅助函数 ==========
|
||||||
|
|
||||||
|
// 从学校名取简称 (拼音首字母或截取)
|
||||||
|
func getAbbrFromName(name string) string {
|
||||||
|
// 简化: 取前几个字符
|
||||||
|
name = strings.TrimSpace(name)
|
||||||
|
if len(name) <= 4 {
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
// 取前4个字符作为简称
|
||||||
|
runes := []rune(name)
|
||||||
|
if len(runes) > 4 {
|
||||||
|
return string(runes[:4])
|
||||||
|
}
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
|
func getPinYinFromID(id string) string {
|
||||||
|
// 这是个简化函数,实际应该从学校列表获取
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 工具函数 ==========
|
||||||
|
|
||||||
|
// ParseCookieString 解析 Set-Cookie 头
|
||||||
|
func parseCookies(resp *resty.Response) []*http.Cookie {
|
||||||
|
return resp.RawResponse.Cookies()
|
||||||
|
}
|
||||||
|
|
||||||
|
// CombineCookies 从多个响应中收集 cookies
|
||||||
|
func combineCookies(responses ...*resty.Response) []*http.Cookie {
|
||||||
|
seen := make(map[string]*http.Cookie)
|
||||||
|
for _, resp := range responses {
|
||||||
|
if resp == nil || resp.RawResponse == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, c := range resp.RawResponse.Cookies() {
|
||||||
|
// 同名 cookie 用最新的
|
||||||
|
seen[c.Name] = c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var result []*http.Cookie
|
||||||
|
for _, c := range seen {
|
||||||
|
result = append(result, c)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从 URL 中提取值
|
||||||
|
var saltRegexp = regexp.MustCompile(`var\s+pwdDefaultEncryptSalt\s*=\s*["']([^"']+)["']`)
|
||||||
@@ -0,0 +1,314 @@
|
|||||||
|
package cpdaily
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"cpdaily-new/internal/config"
|
||||||
|
|
||||||
|
"github.com/go-resty/resty/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ======== 数据结构 ========
|
||||||
|
|
||||||
|
// ValidationInfo 验证信息结构体
|
||||||
|
type ValidationInfo struct {
|
||||||
|
Validation bool `json:"validation"`
|
||||||
|
SceneCode string `json:"sceneCode,omitempty"`
|
||||||
|
AccountKey string `json:"accountKey,omitempty"`
|
||||||
|
TenantId string `json:"tenantId,omitempty"`
|
||||||
|
UserId string `json:"userId,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CpSubmitCaptcha 今日校园提交验证码结构体
|
||||||
|
type CpSubmitCaptcha struct {
|
||||||
|
Code string `json:"code,omitempty"`
|
||||||
|
RawResponseJson json.RawMessage `json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// apiResponse 通用API响应结构体
|
||||||
|
type apiResponse struct {
|
||||||
|
Code json.Number `json:"code"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Data json.RawMessage `json:"data,omitempty"`
|
||||||
|
Datas json.RawMessage `json:"datas,omitempty"`
|
||||||
|
Result json.RawMessage `json:"result,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// itStarResponse ITStar验证码识别响应结构体
|
||||||
|
type itStarResponse struct {
|
||||||
|
Code json.Number `json:"code"`
|
||||||
|
Msg string `json:"msg"`
|
||||||
|
Data []string `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ======== 验证码解析器 ========
|
||||||
|
|
||||||
|
type submitCaptchaResolver struct {
|
||||||
|
cookieStr string
|
||||||
|
host string
|
||||||
|
deviceId string
|
||||||
|
taskType TaskType
|
||||||
|
}
|
||||||
|
|
||||||
|
func newSubmitCaptchaResolver(cookieStr, host, deviceId string, taskType TaskType) *submitCaptchaResolver {
|
||||||
|
return &submitCaptchaResolver{
|
||||||
|
cookieStr: cookieStr,
|
||||||
|
host: host,
|
||||||
|
deviceId: deviceId,
|
||||||
|
taskType: taskType,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// getValidateUrl 根据任务类型获取验证URL
|
||||||
|
func (r *submitCaptchaResolver) getValidateUrl() string {
|
||||||
|
baseURL := strings.TrimSuffix(r.host, "/")
|
||||||
|
switch r.taskType {
|
||||||
|
case TaskTypeSign:
|
||||||
|
return baseURL + "/wec-counselor-sign-apps/stu/sign/checkValidation"
|
||||||
|
case TaskTypeSleepCheck:
|
||||||
|
return baseURL + "/wec-counselor-attendance-apps/student/attendance/checkValidation"
|
||||||
|
default:
|
||||||
|
return baseURL + "/wec-counselor-sign-apps/stu/sign/checkValidation"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetValidationInfo 获取验证信息
|
||||||
|
func (r *submitCaptchaResolver) GetValidationInfo() (*ValidationInfo, error) {
|
||||||
|
client := newTaskClient()
|
||||||
|
|
||||||
|
reqData := map[string]string{
|
||||||
|
"deviceId": r.deviceId,
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := client.R().
|
||||||
|
SetHeader("Content-Type", "application/json; charset=UTF-8").
|
||||||
|
SetHeader("Cookie", r.cookieStr).
|
||||||
|
SetBody(reqData).
|
||||||
|
Post(r.getValidateUrl())
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("获取验证信息失败: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.RawBody().Close()
|
||||||
|
|
||||||
|
body, _ := io.ReadAll(resp.RawBody())
|
||||||
|
|
||||||
|
var apiResp apiResponse
|
||||||
|
if err := json.Unmarshal(body, &apiResp); err != nil {
|
||||||
|
return nil, fmt.Errorf("解析验证信息响应失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if apiResp.Datas == nil {
|
||||||
|
msg := "获取验证信息失败"
|
||||||
|
if apiResp.Message != "" {
|
||||||
|
msg = apiResp.Message
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("%s", msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
var info ValidationInfo
|
||||||
|
if err := json.Unmarshal(apiResp.Datas, &info); err != nil {
|
||||||
|
return nil, fmt.Errorf("解析验证信息失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &info, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCaptchaTicket 获取验证码票据
|
||||||
|
func (r *submitCaptchaResolver) GetCaptchaTicket(info *ValidationInfo) (string, error) {
|
||||||
|
// Step 1: 创建验证码
|
||||||
|
captchaInfo, err := r.createCaptcha(info)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("创建验证码失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: AI 识别
|
||||||
|
codes, err := r.getResultFromItStar(captchaInfo.RawResponseJson)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("AI识别验证码失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: 提交验证结果
|
||||||
|
ticket, err := r.submitCaptcha(info, captchaInfo, codes)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("提交验证码结果失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return ticket, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// createCaptcha 创建验证码(JSON body)
|
||||||
|
func (r *submitCaptchaResolver) createCaptcha(info *ValidationInfo) (*CpSubmitCaptcha, error) {
|
||||||
|
baseURL := strings.TrimSuffix(r.host, "/")
|
||||||
|
url := baseURL + "/captcha-open-api/v1/captcha/create/scenesImage"
|
||||||
|
|
||||||
|
client := newTaskClient()
|
||||||
|
|
||||||
|
reqData := map[string]string{
|
||||||
|
"accountKey": info.AccountKey,
|
||||||
|
"sceneCode": info.SceneCode,
|
||||||
|
"tenantId": info.TenantId,
|
||||||
|
"userId": info.UserId,
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := client.R().
|
||||||
|
SetHeader("Cookie", r.cookieStr).
|
||||||
|
SetHeader("deviceId", r.deviceId).
|
||||||
|
SetHeader("Content-Type", "application/json; charset=UTF-8").
|
||||||
|
SetBody(reqData).
|
||||||
|
Post(url)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("创建验证码请求失败: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.RawBody().Close()
|
||||||
|
|
||||||
|
body, _ := io.ReadAll(resp.RawBody())
|
||||||
|
|
||||||
|
var apiResp apiResponse
|
||||||
|
if err := json.Unmarshal(body, &apiResp); err != nil {
|
||||||
|
return nil, fmt.Errorf("解析创建验证码响应失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if apiResp.Result == nil {
|
||||||
|
msg := "创建验证码失败"
|
||||||
|
if apiResp.Message != "" {
|
||||||
|
msg = apiResp.Message
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("%s", msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
var result CpSubmitCaptcha
|
||||||
|
if err := json.Unmarshal(apiResp.Result, &result); err != nil {
|
||||||
|
return nil, fmt.Errorf("解析验证码数据失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result.RawResponseJson = apiResp.Result
|
||||||
|
return &result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getResultFromItStar 从ITStar获取识别结果
|
||||||
|
func (r *submitCaptchaResolver) getResultFromItStar(rawResponse json.RawMessage) ([]string, error) {
|
||||||
|
var responseMap map[string]interface{}
|
||||||
|
if err := json.Unmarshal(rawResponse, &responseMap); err != nil {
|
||||||
|
return nil, fmt.Errorf("解析验证码响应失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := config.Global.Cpdaily
|
||||||
|
url := cfg.CaptchaBaseUrl + "/classifier"
|
||||||
|
|
||||||
|
client := resty.New().SetTimeout(30 * time.Second)
|
||||||
|
|
||||||
|
resp, err := client.R().
|
||||||
|
SetHeader("Content-Type", "application/json").
|
||||||
|
SetHeader("X-Api-Key", cfg.CaptchaApiKey).
|
||||||
|
SetBody(responseMap).
|
||||||
|
Post(url)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("请求ITStar识别服务失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var itResp itStarResponse
|
||||||
|
if err := json.Unmarshal(resp.Body(), &itResp); err != nil {
|
||||||
|
return nil, fmt.Errorf("解析ITStar响应失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
codeStr, _ := itResp.Code.Float64()
|
||||||
|
if int64(codeStr) != 200 {
|
||||||
|
return nil, fmt.Errorf("验证码识别出错: %s", itResp.Msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
return itResp.Data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// submitCaptcha 提交验证码结果(application/x-www-form-urlencoded)
|
||||||
|
func (r *submitCaptchaResolver) submitCaptcha(info *ValidationInfo, captchaInfo *CpSubmitCaptcha, codes []string) (string, error) {
|
||||||
|
baseURL := strings.TrimSuffix(r.host, "/")
|
||||||
|
targetURL := baseURL + "/captcha-open-api/v1/captcha/validate/scenesImage"
|
||||||
|
|
||||||
|
// 构建 URL-encoded 表单数据
|
||||||
|
form := url.Values{}
|
||||||
|
form.Set("accountKey", info.AccountKey)
|
||||||
|
form.Set("sceneCode", info.SceneCode)
|
||||||
|
form.Set("tenantId", info.TenantId)
|
||||||
|
form.Set("userId", info.UserId)
|
||||||
|
form.Set("scenesImageCode", captchaInfo.Code)
|
||||||
|
for _, code := range codes {
|
||||||
|
form.Add("scenesImageCodes", code)
|
||||||
|
}
|
||||||
|
|
||||||
|
client := newTaskClient()
|
||||||
|
resp, err := client.R().
|
||||||
|
SetHeader("Cookie", r.cookieStr).
|
||||||
|
SetHeader("deviceId", r.deviceId).
|
||||||
|
SetHeader("Content-Type", "application/x-www-form-urlencoded").
|
||||||
|
SetBody(form.Encode()).
|
||||||
|
Post(targetURL)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("提交验证码请求失败: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.RawBody().Close()
|
||||||
|
|
||||||
|
body, _ := io.ReadAll(resp.RawBody())
|
||||||
|
|
||||||
|
var apiResp apiResponse
|
||||||
|
if err := json.Unmarshal(body, &apiResp); err != nil {
|
||||||
|
return "", fmt.Errorf("解析提交验证码响应失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
codeStr, _ := apiResp.Code.Float64()
|
||||||
|
if int64(codeStr) != 200 {
|
||||||
|
msg := "提交验证码失败"
|
||||||
|
if apiResp.Message != "" {
|
||||||
|
msg = apiResp.Message
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("%s", msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
if apiResp.Result == nil {
|
||||||
|
return "", fmt.Errorf("今日校园返回空验证码结果")
|
||||||
|
}
|
||||||
|
|
||||||
|
var ticket string
|
||||||
|
if err := json.Unmarshal(apiResp.Result, &ticket); err != nil {
|
||||||
|
return "", fmt.Errorf("解析验证码票据失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return ticket, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveSubmitCaptcha 一站式解决提交验证码:检查 → 识别 → 获取票据
|
||||||
|
// 返回 ticket(空字符串表示不需要验证码)
|
||||||
|
func ResolveSubmitCaptcha(cookieStr, host, deviceId string, taskType TaskType) (string, error) {
|
||||||
|
resolver := newSubmitCaptchaResolver(cookieStr, host, deviceId, taskType)
|
||||||
|
|
||||||
|
// 1. 检查是否需要验证码
|
||||||
|
info, err := resolver.GetValidationInfo()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("检查验证码状态失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 不需要验证码
|
||||||
|
if !info.Validation {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 需要验证码,重试获取 ticket(最多5次)
|
||||||
|
var lastErr error
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
if i > 0 {
|
||||||
|
time.Sleep(time.Duration(10+i*2) * time.Second)
|
||||||
|
}
|
||||||
|
|
||||||
|
ticket, err := resolver.GetCaptchaTicket(info)
|
||||||
|
if err == nil {
|
||||||
|
return ticket, nil
|
||||||
|
}
|
||||||
|
lastErr = err
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", fmt.Errorf("验证码识别失败(已重试5次): %w", lastErr)
|
||||||
|
}
|
||||||
@@ -0,0 +1,853 @@
|
|||||||
|
package cpdaily
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/md5"
|
||||||
|
"crypto/tls"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
cpdmodel "cpdaily-new/internal/model"
|
||||||
|
|
||||||
|
"github.com/go-resty/resty/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TaskType 任务类型
|
||||||
|
type TaskType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
TaskTypeSign TaskType = "sign"
|
||||||
|
TaskTypeSleepCheck TaskType = "sleep_check"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 各任务类型的 API 基础路径
|
||||||
|
func taskBasePath(host string, taskType TaskType) string {
|
||||||
|
switch taskType {
|
||||||
|
case TaskTypeSign:
|
||||||
|
return host + "/wec-counselor-sign-apps/stu/sign"
|
||||||
|
case TaskTypeSleepCheck:
|
||||||
|
return host + "/wec-counselor-attendance-apps/student/attendance"
|
||||||
|
default:
|
||||||
|
return host + "/wec-counselor-sign-apps/stu/sign"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TodayTaskInfo 任务信息
|
||||||
|
type TodayTaskInfo struct {
|
||||||
|
TaskName string `json:"taskName"`
|
||||||
|
SignWid string `json:"signWid"`
|
||||||
|
SignInstanceWid string `json:"signInstanceWid"`
|
||||||
|
IsSigned bool `json:"isSigned"`
|
||||||
|
TaskType string `json:"taskType,omitempty"` // sign / sleep_check
|
||||||
|
}
|
||||||
|
|
||||||
|
// TaskFormDetail 签到任务表单详情
|
||||||
|
type TaskFormDetail struct {
|
||||||
|
Rows map[string]interface{} `json:"rows"`
|
||||||
|
RealTitle string `json:"realTitle"`
|
||||||
|
SignWid string `json:"signWid"`
|
||||||
|
SignInstanceWid string `json:"signInstanceWid"`
|
||||||
|
IsPhoto bool `json:"isPhoto"`
|
||||||
|
IsNeedExtra bool `json:"isNeedExtra"`
|
||||||
|
ExtraField []interface{} `json:"extraField,omitempty"`
|
||||||
|
HistoryRows map[string]interface{} `json:"historyRows,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// newRestyClient 创建 resty 客户端
|
||||||
|
func newTaskClient() *resty.Client {
|
||||||
|
return resty.New().
|
||||||
|
SetTimeout(30*time.Second).
|
||||||
|
SetDoNotParseResponse(true).
|
||||||
|
SetTLSClientConfig(&tls.Config{InsecureSkipVerify: true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// FetchTodayTasks 获取今日所有签到/查寝任务
|
||||||
|
// 如果今日没有任务,自动降级查询本月任务作为近期任务供用户选择
|
||||||
|
func FetchTodayTasks(cookieMap map[string]string, host string, taskType TaskType) ([]TodayTaskInfo, error) {
|
||||||
|
tasks, err := fetchTasksByEndpoint(cookieMap, host, taskType, "")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 今日有任务,直接返回
|
||||||
|
if len(tasks) > 0 {
|
||||||
|
return tasks, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 今日无任务,降级查询本月任务
|
||||||
|
now := time.Now()
|
||||||
|
year := fmt.Sprintf("%d", now.Year())
|
||||||
|
month := fmt.Sprintf("%02d", int(now.Month()))
|
||||||
|
monthTasks, monthErr := fetchTasksByYearMonth(cookieMap, host, taskType, year, month)
|
||||||
|
if monthErr == nil && len(monthTasks) > 0 {
|
||||||
|
return monthTasks, nil
|
||||||
|
}
|
||||||
|
return []TodayTaskInfo{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetchTasksByEndpoint 通过指定路径(InOneDay / ByWeekMonth)获取任务列表
|
||||||
|
func fetchTasksByEndpoint(cookieMap map[string]string, host string, taskType TaskType, yearMonth string) ([]TodayTaskInfo, error) {
|
||||||
|
client := newTaskClient()
|
||||||
|
cookieStr := cookiesToStr(cookieMap)
|
||||||
|
baseURL := taskBasePath(host, taskType)
|
||||||
|
|
||||||
|
var endpoint string
|
||||||
|
var bodyMap map[string]interface{}
|
||||||
|
isMonthQuery := yearMonth != ""
|
||||||
|
|
||||||
|
switch taskType {
|
||||||
|
case TaskTypeSign:
|
||||||
|
if isMonthQuery {
|
||||||
|
endpoint = "/getStuSignInfosByWeekMonth"
|
||||||
|
bodyMap = map[string]interface{}{"statisticYearMonth": yearMonth}
|
||||||
|
} else {
|
||||||
|
endpoint = "/getStuSignInfosInOneDay"
|
||||||
|
bodyMap = map[string]interface{}{}
|
||||||
|
}
|
||||||
|
case TaskTypeSleepCheck:
|
||||||
|
if isMonthQuery {
|
||||||
|
endpoint = "/getStuSignInfosByWeekMonth"
|
||||||
|
bodyMap = map[string]interface{}{"statisticYearMonth": yearMonth}
|
||||||
|
} else {
|
||||||
|
endpoint = "/getStuAttendacesInOneDay"
|
||||||
|
bodyMap = map[string]interface{}{}
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
endpoint = "/getStuSignInfosInOneDay"
|
||||||
|
bodyMap = map[string]interface{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := client.R().
|
||||||
|
SetHeader("User-Agent", "Mozilla/5.0 (Linux; Android 15; Redmi Note 8 Pro) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/131.0.6778.81 Mobile Safari/537.36 cpdaily/9.9.6 wisedu/9.9.6").
|
||||||
|
SetHeader("CpdailyClientType", "CPDAILY").
|
||||||
|
SetHeader("Content-Type", "application/json; charset=UTF-8").
|
||||||
|
SetHeader("Cookie", cookieStr).
|
||||||
|
SetBody(bodyMap).
|
||||||
|
Post(baseURL + endpoint)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("请求任务列表失败: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.RawBody().Close()
|
||||||
|
|
||||||
|
body, _ := io.ReadAll(resp.RawBody())
|
||||||
|
var result struct {
|
||||||
|
ErrCode int `json:"errCode"`
|
||||||
|
ErrMsg string `json:"errMsg"`
|
||||||
|
Datas map[string]interface{} `json:"datas"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &result); err != nil {
|
||||||
|
return nil, fmt.Errorf("解析任务列表响应失败: %w", err)
|
||||||
|
}
|
||||||
|
if result.ErrCode != 0 {
|
||||||
|
return nil, fmt.Errorf("任务列表接口错误(%d): %s", result.ErrCode, result.ErrMsg)
|
||||||
|
}
|
||||||
|
|
||||||
|
return parseTasksFromDatas(result.Datas), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseTasksFromDatas 从 datas map 中解析任务列表(兼容 InOneDay 和 ByWeekMonth 两种响应)
|
||||||
|
func parseTasksFromDatas(datas map[string]interface{}) []TodayTaskInfo {
|
||||||
|
seen := map[string]bool{}
|
||||||
|
tasks := make([]TodayTaskInfo, 0)
|
||||||
|
|
||||||
|
addTasks := func(arr []interface{}, signed bool) {
|
||||||
|
for _, item := range arr {
|
||||||
|
if m, ok := item.(map[string]interface{}); ok {
|
||||||
|
name, _ := m["taskName"].(string)
|
||||||
|
wid, _ := m["signWid"].(string)
|
||||||
|
iwid, _ := m["signInstanceWid"].(string)
|
||||||
|
if name != "" && !seen[name] {
|
||||||
|
seen[name] = true
|
||||||
|
tasks = append(tasks, TodayTaskInfo{
|
||||||
|
TaskName: name,
|
||||||
|
SignWid: wid,
|
||||||
|
SignInstanceWid: iwid,
|
||||||
|
IsSigned: signed,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
parseGroup := func(d map[string]interface{}) {
|
||||||
|
if v, ok := d["unSignedTasks"]; ok {
|
||||||
|
if arr, ok := v.([]interface{}); ok {
|
||||||
|
addTasks(arr, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if v, ok := d["leaveTasks"]; ok {
|
||||||
|
if arr, ok := v.([]interface{}); ok {
|
||||||
|
addTasks(arr, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if v, ok := d["signedTasks"]; ok {
|
||||||
|
if arr, ok := v.([]interface{}); ok {
|
||||||
|
addTasks(arr, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if v, ok := d["codeRcvdTasks"]; ok {
|
||||||
|
if arr, ok := v.([]interface{}); ok {
|
||||||
|
addTasks(arr, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// InOneDay 格式:datas 直接包含 unSignedTasks/leaveTasks/signedTasks/codeRcvdTasks
|
||||||
|
if _, ok := datas["unSignedTasks"]; ok {
|
||||||
|
parseGroup(datas)
|
||||||
|
return tasks
|
||||||
|
}
|
||||||
|
|
||||||
|
// ByWeekMonth 格式:datas.rows 是数组,每行包含 unSignedTasks/leaveTasks/signedTasks/codeRcvdTasks
|
||||||
|
if rows, ok := datas["rows"].([]interface{}); ok {
|
||||||
|
for _, row := range rows {
|
||||||
|
if rowMap, ok := row.(map[string]interface{}); ok {
|
||||||
|
parseGroup(rowMap)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tasks
|
||||||
|
}
|
||||||
|
|
||||||
|
// FetchAllRecentTasks 并发查询 sign + sleep_check 两种任务类型,聚合去重后返回
|
||||||
|
func FetchAllRecentTasks(cookieMap map[string]string, host string) []TodayTaskInfo {
|
||||||
|
type result struct {
|
||||||
|
tasks []TodayTaskInfo
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
ch := make(chan result, 2)
|
||||||
|
|
||||||
|
types := []TaskType{TaskTypeSign, TaskTypeSleepCheck}
|
||||||
|
for _, tt := range types {
|
||||||
|
go func(t TaskType) {
|
||||||
|
tasks, err := FetchTodayTasks(cookieMap, host, t)
|
||||||
|
if err != nil {
|
||||||
|
ch <- result{nil, err}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 标记任务类型
|
||||||
|
for i := range tasks {
|
||||||
|
tasks[i].TaskType = string(t)
|
||||||
|
}
|
||||||
|
ch <- result{tasks, nil}
|
||||||
|
}(tt)
|
||||||
|
}
|
||||||
|
|
||||||
|
seen := map[string]bool{}
|
||||||
|
var all []TodayTaskInfo
|
||||||
|
|
||||||
|
for i := 0; i < 2; i++ {
|
||||||
|
r := <-ch
|
||||||
|
for _, t := range r.tasks {
|
||||||
|
key := t.TaskName + "|" + t.TaskType
|
||||||
|
if !seen[key] {
|
||||||
|
seen[key] = true
|
||||||
|
all = append(all, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(all) == 0 {
|
||||||
|
return []TodayTaskInfo{}
|
||||||
|
}
|
||||||
|
return all
|
||||||
|
}
|
||||||
|
func fetchTasksByYearMonth(cookieMap map[string]string, host string, taskType TaskType, year, month string) ([]TodayTaskInfo, error) {
|
||||||
|
yearMonth := fmt.Sprintf("%s-%s", year, month)
|
||||||
|
return fetchTasksByEndpoint(cookieMap, host, taskType, yearMonth)
|
||||||
|
}
|
||||||
|
|
||||||
|
// globMatch 简单通配符匹配(仅支持 *),用于标题匹配
|
||||||
|
func globMatch(str, pattern string) bool {
|
||||||
|
if pattern == "" {
|
||||||
|
return str == ""
|
||||||
|
}
|
||||||
|
parts := strings.Split(pattern, "*")
|
||||||
|
if len(parts) == 1 {
|
||||||
|
return str == pattern
|
||||||
|
}
|
||||||
|
if parts[0] != "" && !strings.HasPrefix(str, parts[0]) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
str = str[len(parts[0]):]
|
||||||
|
for i := 1; i < len(parts)-1; i++ {
|
||||||
|
part := parts[i]
|
||||||
|
if part == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
idx := strings.Index(str, part)
|
||||||
|
if idx < 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
str = str[idx+len(part):]
|
||||||
|
}
|
||||||
|
last := parts[len(parts)-1]
|
||||||
|
if last != "" && !strings.HasSuffix(str, last) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// FetchHistoryFormData 从今日校园获取历史表单数据用于预填充
|
||||||
|
// 先查今天已签到的同名任务,没有再查本月历史
|
||||||
|
func FetchHistoryFormData(cookieMap map[string]string, host string, taskType TaskType, realTitle, excludeSignInstanceWid string) map[string]interface{} {
|
||||||
|
// 1. 查今天已签到的同名任务
|
||||||
|
todayTasks, _ := FetchTodayTasks(cookieMap, host, taskType)
|
||||||
|
for _, t := range todayTasks {
|
||||||
|
if t.IsSigned && globMatch(t.TaskName, realTitle) && t.SignInstanceWid != excludeSignInstanceWid {
|
||||||
|
detail, err := FetchTaskDetail(cookieMap, host, taskType, t.SignWid, t.SignInstanceWid, t.TaskName)
|
||||||
|
if err == nil && detail != nil {
|
||||||
|
return detail.Rows
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 降级:查本月已签到的同名任务
|
||||||
|
now := time.Now()
|
||||||
|
year := fmt.Sprintf("%d", now.Year())
|
||||||
|
month := fmt.Sprintf("%02d", int(now.Month()))
|
||||||
|
monthTasks, _ := fetchTasksByYearMonth(cookieMap, host, taskType, year, month)
|
||||||
|
for _, t := range monthTasks {
|
||||||
|
if t.IsSigned && globMatch(t.TaskName, realTitle) && t.SignInstanceWid != excludeSignInstanceWid {
|
||||||
|
detail, err := FetchTaskDetail(cookieMap, host, taskType, t.SignWid, t.SignInstanceWid, t.TaskName)
|
||||||
|
if err == nil && detail != nil {
|
||||||
|
return detail.Rows
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FetchTaskDetail 获取签到任务详情(表单字段)
|
||||||
|
func FetchTaskDetail(cookieMap map[string]string, host string, taskType TaskType, signWid, signInstanceWid, title string) (*TaskFormDetail, error) {
|
||||||
|
client := newTaskClient()
|
||||||
|
cookieStr := cookiesToStr(cookieMap)
|
||||||
|
baseURL := taskBasePath(host, taskType)
|
||||||
|
|
||||||
|
resp, err := client.R().
|
||||||
|
SetHeader("User-Agent", "Mozilla/5.0 (Linux; Android 15; Redmi Note 8 Pro) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/131.0.6778.81 Mobile Safari/537.36 cpdaily/9.9.6 wisedu/9.9.6").
|
||||||
|
SetHeader("CpdailyClientType", "CPDAILY").
|
||||||
|
SetHeader("Content-Type", "application/json; charset=UTF-8").
|
||||||
|
SetHeader("Cookie", cookieStr).
|
||||||
|
SetBody(map[string]interface{}{
|
||||||
|
"taskName": title,
|
||||||
|
"signWid": signWid,
|
||||||
|
"signInstanceWid": signInstanceWid,
|
||||||
|
}).
|
||||||
|
Post(baseURL + "/detailSignInstance")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("请求任务详情失败: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.RawBody().Close()
|
||||||
|
|
||||||
|
body, _ := io.ReadAll(resp.RawBody())
|
||||||
|
var result struct {
|
||||||
|
ErrCode int `json:"errCode"`
|
||||||
|
ErrMsg string `json:"errMsg"`
|
||||||
|
Datas map[string]interface{} `json:"datas"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &result); err != nil {
|
||||||
|
return nil, fmt.Errorf("解析任务详情响应失败: %w", err)
|
||||||
|
}
|
||||||
|
if result.ErrCode != 0 {
|
||||||
|
return nil, fmt.Errorf("任务详情接口错误(%d): %s", result.ErrCode, result.ErrMsg)
|
||||||
|
}
|
||||||
|
|
||||||
|
rows := result.Datas
|
||||||
|
|
||||||
|
detail := &TaskFormDetail{
|
||||||
|
Rows: rows,
|
||||||
|
RealTitle: title,
|
||||||
|
SignWid: signWid,
|
||||||
|
SignInstanceWid: signInstanceWid,
|
||||||
|
}
|
||||||
|
|
||||||
|
if v, ok := rows["isPhoto"]; ok {
|
||||||
|
if f, ok := v.(float64); ok && f == 1 {
|
||||||
|
detail.IsPhoto = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if v, ok := rows["isNeedExtra"]; ok {
|
||||||
|
if f, ok := v.(float64); ok && f == 1 {
|
||||||
|
detail.IsNeedExtra = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if v, ok := rows["extraField"]; ok {
|
||||||
|
if arr, ok := v.([]interface{}); ok {
|
||||||
|
detail.ExtraField = arr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return detail, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseTaskForm 将任务详情解析为前端可渲染的表单项列表
|
||||||
|
func ParseTaskForm(detail *TaskFormDetail, historyRows map[string]interface{}) []cpdmodel.CpTaskFormItem {
|
||||||
|
rows := detail.Rows
|
||||||
|
histRows := rows
|
||||||
|
if historyRows != nil {
|
||||||
|
histRows = historyRows
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提取历史值(与旧版系统格式一致:lon/lat/address 分开存储)
|
||||||
|
histLon := ""
|
||||||
|
histLat := ""
|
||||||
|
histAddress := ""
|
||||||
|
histSignPhotoUrl := ""
|
||||||
|
histIsMalposition := ""
|
||||||
|
|
||||||
|
// 优先从 signPlaceSelected 提取(由 cpdaily 返回的历史签到地点 JSON 数组)
|
||||||
|
if sps, ok := histRows["signPlaceSelected"]; ok {
|
||||||
|
if spsStr, ok := sps.(string); ok && spsStr != "" && spsStr != "[]" {
|
||||||
|
var places []map[string]interface{}
|
||||||
|
if json.Unmarshal([]byte(spsStr), &places) == nil && len(places) > 0 {
|
||||||
|
lng, _ := places[0]["longitude"].(string)
|
||||||
|
lat, _ := places[0]["latitude"].(string)
|
||||||
|
addr, _ := places[0]["address"].(string)
|
||||||
|
if lng != "" {
|
||||||
|
histLon = lng
|
||||||
|
}
|
||||||
|
if lat != "" {
|
||||||
|
histLat = lat
|
||||||
|
}
|
||||||
|
if addr != "" {
|
||||||
|
histAddress = addr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 降级到 histRows 中的 longitude/latitude
|
||||||
|
if histLon == "" || histLat == "" {
|
||||||
|
if lon, ok := histRows["longitude"]; ok && lon != nil {
|
||||||
|
histLon = fmt.Sprintf("%v", lon)
|
||||||
|
}
|
||||||
|
if lat, ok := histRows["latitude"]; ok && lat != nil {
|
||||||
|
histLat = fmt.Sprintf("%v", lat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 如果还是空的,再降级到当前表单中的 longitude/latitude(任务默认坐标)
|
||||||
|
if histLon == "" || histLat == "" {
|
||||||
|
if lon, ok := rows["longitude"]; ok && lon != nil {
|
||||||
|
histLon = fmt.Sprintf("%v", lon)
|
||||||
|
}
|
||||||
|
if lat, ok := rows["latitude"]; ok && lat != nil {
|
||||||
|
histLat = fmt.Sprintf("%v", lat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 地址降级链:signPlaceSelected[].address → histRows.position → histRows.signAddress → rows.position → rows.signAddress
|
||||||
|
if histAddress == "" {
|
||||||
|
if pos, ok := histRows["position"]; ok && pos != nil {
|
||||||
|
histAddress = fmt.Sprintf("%v", pos)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if histAddress == "" {
|
||||||
|
if addr, ok := histRows["signAddress"]; ok && addr != nil {
|
||||||
|
histAddress = fmt.Sprintf("%v", addr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if histAddress == "" {
|
||||||
|
if pos, ok := rows["position"]; ok && pos != nil {
|
||||||
|
histAddress = fmt.Sprintf("%v", pos)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if histAddress == "" {
|
||||||
|
if addr, ok := rows["signAddress"]; ok && addr != nil {
|
||||||
|
histAddress = fmt.Sprintf("%v", addr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 签到照片
|
||||||
|
if photo, ok := histRows["signPhotoUrl"]; ok && photo != nil {
|
||||||
|
histSignPhotoUrl = fmt.Sprintf("%v", photo)
|
||||||
|
}
|
||||||
|
// 具体照片 URL 降级到 rows 中的 signPhotoUrl
|
||||||
|
if histSignPhotoUrl == "" {
|
||||||
|
if photo, ok := rows["signPhotoUrl"]; ok && photo != nil {
|
||||||
|
histSignPhotoUrl = fmt.Sprintf("%v", photo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 是否范围外签到
|
||||||
|
if mal, ok := histRows["isMalposition"]; ok {
|
||||||
|
switch v := mal.(type) {
|
||||||
|
case float64:
|
||||||
|
if v == 1 {
|
||||||
|
histIsMalposition = "是"
|
||||||
|
} else {
|
||||||
|
histIsMalposition = "否"
|
||||||
|
}
|
||||||
|
case string:
|
||||||
|
if v == "1" || v == "是" {
|
||||||
|
histIsMalposition = "是"
|
||||||
|
} else {
|
||||||
|
histIsMalposition = "否"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var list []cpdmodel.CpTaskFormItem
|
||||||
|
|
||||||
|
// 经度(与旧版系统一致,colName="lon")
|
||||||
|
list = append(list, cpdmodel.CpTaskFormItem{
|
||||||
|
Title: "经度",
|
||||||
|
ColName: "lon",
|
||||||
|
Type: cpdmodel.ItemTypeInput,
|
||||||
|
Required: true,
|
||||||
|
Content: histLon,
|
||||||
|
Default: histLon,
|
||||||
|
Show: true,
|
||||||
|
Tip: "签到地点经度坐标",
|
||||||
|
})
|
||||||
|
// 纬度(与旧版系统一致,colName="lat")
|
||||||
|
list = append(list, cpdmodel.CpTaskFormItem{
|
||||||
|
Title: "纬度",
|
||||||
|
ColName: "lat",
|
||||||
|
Type: cpdmodel.ItemTypeInput,
|
||||||
|
Required: true,
|
||||||
|
Content: histLat,
|
||||||
|
Default: histLat,
|
||||||
|
Show: true,
|
||||||
|
Tip: "签到地点纬度坐标",
|
||||||
|
})
|
||||||
|
// 签到具体地址
|
||||||
|
list = append(list, cpdmodel.CpTaskFormItem{
|
||||||
|
Title: "签到具体地址",
|
||||||
|
ColName: "address",
|
||||||
|
Type: cpdmodel.ItemTypeInput,
|
||||||
|
Required: true,
|
||||||
|
Content: histAddress,
|
||||||
|
Default: histAddress,
|
||||||
|
Show: true,
|
||||||
|
Tip: "签到完毕后查看时最底部那个最详细的地址信息",
|
||||||
|
})
|
||||||
|
|
||||||
|
// 签到照片(如果需要)
|
||||||
|
if detail.IsPhoto {
|
||||||
|
list = append(list, cpdmodel.CpTaskFormItem{
|
||||||
|
Title: "签到照片",
|
||||||
|
ColName: "signImg",
|
||||||
|
Type: cpdmodel.ItemTypeImage,
|
||||||
|
Content: histSignPhotoUrl,
|
||||||
|
Default: histSignPhotoUrl,
|
||||||
|
Show: true,
|
||||||
|
Tip: "您可以上传多张图片,会顺序轮换更新",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 是否范围外签到
|
||||||
|
list = append(list, cpdmodel.CpTaskFormItem{
|
||||||
|
Title: "是否范围外签到",
|
||||||
|
ColName: "isMalposition",
|
||||||
|
Type: cpdmodel.ItemTypeSingle,
|
||||||
|
Required: true,
|
||||||
|
Content: histIsMalposition,
|
||||||
|
Default: histIsMalposition,
|
||||||
|
Options: []cpdmodel.CpTaskFormOption{
|
||||||
|
{Content: "是", IsOtherItems: true},
|
||||||
|
{Content: "否"},
|
||||||
|
},
|
||||||
|
Show: true,
|
||||||
|
Tip: "一般在学校就是选否,如果在外实习、请假之类的才选是",
|
||||||
|
})
|
||||||
|
|
||||||
|
// 范围外签到原因
|
||||||
|
list = append(list, cpdmodel.CpTaskFormItem{
|
||||||
|
Title: "范围外签到原因",
|
||||||
|
ColName: "abnormalReason",
|
||||||
|
Type: cpdmodel.ItemTypeInput,
|
||||||
|
Show: histIsMalposition == "是",
|
||||||
|
})
|
||||||
|
|
||||||
|
// 额外字段
|
||||||
|
if detail.IsNeedExtra {
|
||||||
|
for i, field := range detail.ExtraField {
|
||||||
|
fm, ok := field.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
extraTitle, _ := fm["title"].(string)
|
||||||
|
isReq, _ := fm["isRequired"].(bool)
|
||||||
|
hasOther, _ := fm["hasOtherItems"].(bool)
|
||||||
|
|
||||||
|
item := cpdmodel.CpTaskFormItem{
|
||||||
|
Title: extraTitle,
|
||||||
|
ColName: extraTitle,
|
||||||
|
Type: cpdmodel.ItemTypeSingle,
|
||||||
|
Number: uint(i + 1),
|
||||||
|
Required: isReq,
|
||||||
|
IsOther: hasOther,
|
||||||
|
Show: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
if items, ok := fm["extraFieldItems"].([]interface{}); ok {
|
||||||
|
for _, it := range items {
|
||||||
|
if im, ok := it.(map[string]interface{}); ok {
|
||||||
|
content, _ := im["content"].(string)
|
||||||
|
otherItems, _ := im["isOtherItems"].(bool)
|
||||||
|
item.Options = append(item.Options, cpdmodel.CpTaskFormOption{
|
||||||
|
Content: content,
|
||||||
|
IsOtherItems: otherItems,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
list = append(list, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return list
|
||||||
|
}
|
||||||
|
|
||||||
|
// FillSubmitForm 根据用户配置的表单项填充提交表单
|
||||||
|
func FillSubmitForm(rows map[string]interface{}, formItems []cpdmodel.CpTaskFormItem, deviceId string) map[string]interface{} {
|
||||||
|
result := make(map[string]interface{})
|
||||||
|
|
||||||
|
// 提取经纬度和地址(兼容旧版 lon/lat 和新版 latAndLon 两种格式)
|
||||||
|
var lat, lon, address string
|
||||||
|
for _, v := range formItems {
|
||||||
|
switch v.ColName {
|
||||||
|
case "lon":
|
||||||
|
lon = v.Content
|
||||||
|
case "lat":
|
||||||
|
lat = v.Content
|
||||||
|
case "latAndLon":
|
||||||
|
parts := strings.Split(v.Content, ",")
|
||||||
|
if len(parts) == 2 {
|
||||||
|
if lon == "" {
|
||||||
|
lon = parts[0]
|
||||||
|
}
|
||||||
|
if lat == "" {
|
||||||
|
lat = parts[1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "address":
|
||||||
|
address = v.Content
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result["latitude"] = lat
|
||||||
|
result["longitude"] = lon
|
||||||
|
result["position"] = address
|
||||||
|
result["uaIsCpadaily"] = true
|
||||||
|
|
||||||
|
// signInstanceWid
|
||||||
|
if wid, ok := rows["signInstanceWid"]; ok {
|
||||||
|
result["signInstanceWid"] = wid
|
||||||
|
}
|
||||||
|
|
||||||
|
// 签到照片
|
||||||
|
isPhoto := false
|
||||||
|
if v, ok := rows["isPhoto"]; ok {
|
||||||
|
if f, ok := v.(float64); ok && f == 1 {
|
||||||
|
isPhoto = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if isPhoto {
|
||||||
|
for _, v := range formItems {
|
||||||
|
if v.ColName == "signImg" && v.Content != "" {
|
||||||
|
result["signPhotoUrl"] = v.Content
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 是否范围外签到
|
||||||
|
for _, v := range formItems {
|
||||||
|
if v.ColName == "isMalposition" {
|
||||||
|
if v.Content == "是" {
|
||||||
|
result["isMalposition"] = 1
|
||||||
|
// 找原因
|
||||||
|
for _, v2 := range formItems {
|
||||||
|
if v2.ColName == "abnormalReason" {
|
||||||
|
result["abnormalReason"] = v2.Content
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
result["isMalposition"] = 0
|
||||||
|
result["abnormalReason"] = ""
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 额外字段
|
||||||
|
isNeedExtra := false
|
||||||
|
if v, ok := rows["isNeedExtra"]; ok {
|
||||||
|
if f, ok := v.(float64); ok && f == 1 {
|
||||||
|
isNeedExtra = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if isNeedExtra {
|
||||||
|
result["isNeedExtra"] = 1
|
||||||
|
var extraValues []map[string]interface{}
|
||||||
|
if extraField, ok := rows["extraField"].([]interface{}); ok {
|
||||||
|
for i, field := range extraField {
|
||||||
|
fm, ok := field.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
num := uint(i + 1)
|
||||||
|
title, _ := fm["title"].(string)
|
||||||
|
// 找到用户输入的对应值
|
||||||
|
for _, v := range formItems {
|
||||||
|
if v.Number == num && v.Title == title {
|
||||||
|
ev := map[string]interface{}{
|
||||||
|
"extraFieldItemValue": v.Content,
|
||||||
|
}
|
||||||
|
if items, ok := fm["extraFieldItems"].([]interface{}); ok {
|
||||||
|
for _, it := range items {
|
||||||
|
if im, ok := it.(map[string]interface{}); ok {
|
||||||
|
if cnt, _ := im["content"].(string); cnt == v.Content {
|
||||||
|
ev["extraFieldItemWid"] = im["wid"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
extraValues = append(extraValues, ev)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result["extraFieldItems"] = extraValues
|
||||||
|
} else {
|
||||||
|
result["isNeedExtra"] = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetBodyString 加密提交表单为 bodyString
|
||||||
|
func GetBodyString(form map[string]interface{}) string {
|
||||||
|
data, _ := json.Marshal(form)
|
||||||
|
return base64.StdEncoding.EncodeToString(AesEncryptForCat(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetExtensionString 生成 Cpdaily-Extension 头
|
||||||
|
func GetExtensionString(lat, lon, deviceId, username string) string {
|
||||||
|
ext := map[string]interface{}{
|
||||||
|
"lon": lon,
|
||||||
|
"lat": lat,
|
||||||
|
"model": "cpdaily",
|
||||||
|
"appVersion": "9.9.6",
|
||||||
|
"systemVersion": "15",
|
||||||
|
"userId": username,
|
||||||
|
"systemName": "android",
|
||||||
|
"deviceId": deviceId,
|
||||||
|
"cpdailyVersion": "9.9.6",
|
||||||
|
}
|
||||||
|
data, _ := json.Marshal(ext)
|
||||||
|
return base64.StdEncoding.EncodeToString(DesEncryptForCat(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSubmitSign 生成提交签名
|
||||||
|
func GetSubmitSign(form map[string]interface{}) string {
|
||||||
|
const salt = "hru89rwr87Nthifa"
|
||||||
|
keys := []string{"appVersion", "bodyString", "deviceId", "lat", "lon", "model", "systemName", "systemVersion", "userId"}
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
for _, key := range keys {
|
||||||
|
val := ""
|
||||||
|
if v, ok := form[key]; ok && v != nil {
|
||||||
|
val = fmt.Sprintf("%v", v)
|
||||||
|
}
|
||||||
|
sb.WriteString(key)
|
||||||
|
sb.WriteString("=")
|
||||||
|
sb.WriteString(url.QueryEscape(val))
|
||||||
|
sb.WriteString("&")
|
||||||
|
}
|
||||||
|
sb.WriteString(salt)
|
||||||
|
|
||||||
|
hash := md5.Sum([]byte(sb.String()))
|
||||||
|
return hex.EncodeToString(hash[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// SubmitSign 提交签到(含9选5验证码处理)
|
||||||
|
func SubmitSign(cookieMap map[string]string, host string, taskType TaskType, submitForm map[string]interface{}, deviceId, username string) error {
|
||||||
|
client := newTaskClient()
|
||||||
|
cookieStr := cookiesToStr(cookieMap)
|
||||||
|
baseURL := taskBasePath(host, taskType)
|
||||||
|
|
||||||
|
// 处理 9选5 图片验证码
|
||||||
|
ticket, err := ResolveSubmitCaptcha(cookieStr, host, deviceId, taskType)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("验证码处理失败: %w", err)
|
||||||
|
}
|
||||||
|
if ticket != "" {
|
||||||
|
submitForm["ticket"] = ticket
|
||||||
|
} else {
|
||||||
|
delete(submitForm, "ticket")
|
||||||
|
}
|
||||||
|
|
||||||
|
bodyString := GetBodyString(submitForm)
|
||||||
|
|
||||||
|
// 构造提交数据
|
||||||
|
lat := ""
|
||||||
|
lon := ""
|
||||||
|
if v, ok := submitForm["latitude"]; ok {
|
||||||
|
lat = fmt.Sprintf("%v", v)
|
||||||
|
}
|
||||||
|
if v, ok := submitForm["longitude"]; ok {
|
||||||
|
lon = fmt.Sprintf("%v", v)
|
||||||
|
}
|
||||||
|
|
||||||
|
extensionString := GetExtensionString(lat, lon, deviceId, username)
|
||||||
|
|
||||||
|
submitData := map[string]interface{}{
|
||||||
|
"lon": lon,
|
||||||
|
"version": "first_v4",
|
||||||
|
"calVersion": "first_v4",
|
||||||
|
"deviceId": deviceId,
|
||||||
|
"systemName": "android",
|
||||||
|
"bodyString": bodyString,
|
||||||
|
"lat": lat,
|
||||||
|
"systemVersion": "15",
|
||||||
|
"appVersion": "9.9.6",
|
||||||
|
"model": "cpdaily",
|
||||||
|
}
|
||||||
|
// 添加 userId
|
||||||
|
submitData["userId"] = username
|
||||||
|
|
||||||
|
sign := GetSubmitSign(submitData)
|
||||||
|
submitData["sign"] = sign
|
||||||
|
|
||||||
|
resp, err := client.R().
|
||||||
|
SetHeader("User-Agent", "Mozilla/5.0 (Linux; Android 15; Redmi Note 8 Pro) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/131.0.6778.81 Mobile Safari/537.36 cpdaily/9.9.6 wisedu/9.9.6").
|
||||||
|
SetHeader("CpdailyClientType", "CPDAILY").
|
||||||
|
SetHeader("Content-Type", "application/json; charset=UTF-8").
|
||||||
|
SetHeader("Cookie", cookieStr).
|
||||||
|
SetHeader("Cpdaily-Extension", extensionString).
|
||||||
|
SetBody(submitData).
|
||||||
|
Post(baseURL + "/submitSign")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("提交签到请求失败: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.RawBody().Close()
|
||||||
|
|
||||||
|
body, _ := io.ReadAll(resp.RawBody())
|
||||||
|
var result struct {
|
||||||
|
Code json.Number `json:"code"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &result); err != nil {
|
||||||
|
return fmt.Errorf("解析签到响应失败: %w, body: %s", err, string(body))
|
||||||
|
}
|
||||||
|
code, _ := result.Code.Int64()
|
||||||
|
if code != 0 {
|
||||||
|
msg := result.Message
|
||||||
|
if msg == "" {
|
||||||
|
msg = "未知错误"
|
||||||
|
}
|
||||||
|
return fmt.Errorf("%s", msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package cpdaily
|
||||||
|
|
||||||
|
// SchoolBriefInfo 学校简要信息
|
||||||
|
type SchoolBriefInfo struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
NamePinYin string `json:"namePinYin"`
|
||||||
|
Img string `json:"img"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SchoolDetailInfo 学校详情
|
||||||
|
type SchoolDetailInfo struct {
|
||||||
|
AmpUrl string `json:"ampUrl"`
|
||||||
|
AmpUrl2 string `json:"ampUrl2"`
|
||||||
|
AmpUrl3 string `json:"ampUrl3"`
|
||||||
|
IdsUrl string `json:"idsUrl"`
|
||||||
|
Img string `json:"img"`
|
||||||
|
JoinType string `json:"joinType"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
AppId string `json:"appId"`
|
||||||
|
ID string `json:"id"`
|
||||||
|
WecloudUrl string `json:"wecloudUrl"`
|
||||||
|
NameAbbr string `json:"nameAbbr,omitempty"`
|
||||||
|
NamePinYin string `json:"namePinYin,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsIap 判断是否为 IAP 登录方式
|
||||||
|
func (s SchoolDetailInfo) IsIap() bool {
|
||||||
|
return s.JoinType == "cloud" || s.JoinType == "CLOUD"
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoginUrl 获取可访问的学校登录地址(用于用户找回密码等场景)
|
||||||
|
func (s SchoolDetailInfo) LoginUrl() string {
|
||||||
|
if s.IdsUrl != "" {
|
||||||
|
return s.IdsUrl
|
||||||
|
}
|
||||||
|
if s.WecloudUrl != "" {
|
||||||
|
return s.WecloudUrl
|
||||||
|
}
|
||||||
|
if s.AmpUrl != "" {
|
||||||
|
return s.AmpUrl
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// CpdailyUserProfile 今日校园用户主页信息
|
||||||
|
type CpdailyUserProfile struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Alias string `json:"alias"`
|
||||||
|
Img string `json:"img"`
|
||||||
|
Gender string `json:"gender"`
|
||||||
|
StudentNo string `json:"studentNo"`
|
||||||
|
MobilePhone string `json:"mobilePhone"`
|
||||||
|
Tenant string `json:"tenant"`
|
||||||
|
TenantImg string `json:"tenantImg"`
|
||||||
|
Academy string `json:"academy"`
|
||||||
|
Major string `json:"major"`
|
||||||
|
ClassName string `json:"className"`
|
||||||
|
Grade string `json:"grade"`
|
||||||
|
OrgFullName string `json:"orgFullName"`
|
||||||
|
Degree string `json:"degree"`
|
||||||
|
CloudUserId string `json:"cloudUserId"`
|
||||||
|
TenantId string `json:"tenantId,omitempty"`
|
||||||
|
OpenId string `json:"openId,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CpdailyMyMainPageResponse 用户主页 API 响应
|
||||||
|
type CpdailyMyMainPageResponse struct {
|
||||||
|
ErrCode int `json:"errCode"`
|
||||||
|
ErrMsg string `json:"errMsg"`
|
||||||
|
Data CpdailyUserProfile `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// BaseResponse cpdaily API 通用响应
|
||||||
|
type BaseResponse struct {
|
||||||
|
ErrMsg string `json:"errMsg"`
|
||||||
|
ErrCode int `json:"errCode"`
|
||||||
|
Data interface{} `json:"data"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"cpdaily-new/internal/config"
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Claims struct {
|
||||||
|
UserID uint `json:"userId"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
SchoolID string `json:"schoolId"`
|
||||||
|
jwt.RegisteredClaims
|
||||||
|
}
|
||||||
|
|
||||||
|
type TokenPair struct {
|
||||||
|
AccessToken string `json:"accessToken"`
|
||||||
|
RefreshToken string `json:"refreshToken"`
|
||||||
|
ExpiresIn int64 `json:"expiresIn"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func GenerateTokens(userID uint, username, schoolID string) (*TokenPair, error) {
|
||||||
|
now := time.Now()
|
||||||
|
jwtCfg := config.Global.Jwt
|
||||||
|
|
||||||
|
accessClaims := Claims{
|
||||||
|
UserID: userID,
|
||||||
|
Username: username,
|
||||||
|
SchoolID: schoolID,
|
||||||
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
|
ExpiresAt: jwt.NewNumericDate(now.Add(time.Duration(jwtCfg.AccessExpire) * time.Second)),
|
||||||
|
IssuedAt: jwt.NewNumericDate(now),
|
||||||
|
Issuer: "cpdaily-new",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
accessToken, err := jwt.NewWithClaims(jwt.SigningMethodHS256, accessClaims).SignedString([]byte(jwtCfg.Secret))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshClaims := Claims{
|
||||||
|
UserID: userID,
|
||||||
|
Username: username,
|
||||||
|
SchoolID: schoolID,
|
||||||
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
|
ExpiresAt: jwt.NewNumericDate(now.Add(time.Duration(jwtCfg.RefreshExpire) * time.Second)),
|
||||||
|
IssuedAt: jwt.NewNumericDate(now),
|
||||||
|
Issuer: "cpdaily-new-refresh",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshToken, err := jwt.NewWithClaims(jwt.SigningMethodHS256, refreshClaims).SignedString([]byte(jwtCfg.Secret))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &TokenPair{
|
||||||
|
AccessToken: accessToken,
|
||||||
|
RefreshToken: refreshToken,
|
||||||
|
ExpiresIn: int64(jwtCfg.AccessExpire),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ValidateToken(tokenString string) (*Claims, error) {
|
||||||
|
claims := &Claims{}
|
||||||
|
token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) {
|
||||||
|
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||||
|
return nil, errors.New("unexpected signing method")
|
||||||
|
}
|
||||||
|
return []byte(config.Global.Jwt.Secret), nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !token.Valid {
|
||||||
|
return nil, errors.New("invalid token")
|
||||||
|
}
|
||||||
|
return claims, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func RefreshTokens(refreshToken string) (*TokenPair, error) {
|
||||||
|
claims, err := ValidateToken(refreshToken)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("refresh token invalid or expired")
|
||||||
|
}
|
||||||
|
// 刷新时生成全新 token 对
|
||||||
|
return GenerateTokens(claims.UserID, claims.Username, claims.SchoolID)
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package utils
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FormatFloat 格式化浮点数为字符串保留2位小数
|
||||||
|
func FormatFloat(f float64) string {
|
||||||
|
return fmt.Sprintf("%.2f", f)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Response struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Data interface{} `json:"data,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func Success(c *fiber.Ctx, data interface{}) error {
|
||||||
|
return c.JSON(Response{
|
||||||
|
Code: 0,
|
||||||
|
Message: "success",
|
||||||
|
Data: data,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func Fail(c *fiber.Ctx, code int, msg string) error {
|
||||||
|
return c.Status(code).JSON(Response{
|
||||||
|
Code: code,
|
||||||
|
Message: msg,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func FailParam(c *fiber.Ctx, msg string) error {
|
||||||
|
return Fail(c, 400, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func FailAuth(c *fiber.Ctx, msg string) error {
|
||||||
|
return Fail(c, 401, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func FailServer(c *fiber.Ctx, msg string) error {
|
||||||
|
return Fail(c, 500, msg)
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
services:
|
||||||
|
cpdaily-xiaozhushou:
|
||||||
|
image: cpdaily-xiaozhushou:latest
|
||||||
|
container_name: cpdaily-xiaozhushou
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "8884:8093"
|
||||||
|
volumes:
|
||||||
|
- /opt/cpdaily-xiaozhushou/config.toml:/app/config.toml:ro
|
||||||
|
networks:
|
||||||
|
- likedream-net
|
||||||
|
|
||||||
|
networks:
|
||||||
|
likedream-net:
|
||||||
|
external: true
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
VITE_API_BASE_URL=/api
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||||
|
<title>小助手</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Generated
+2574
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"name": "tianshen-web-new",
|
||||||
|
"private": true,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"axios": "^1.13.6",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"lucide-vue-next": "^1.0.0",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
|
"tailwind-merge": "^3.5.0",
|
||||||
|
"vue": "^3.5.30",
|
||||||
|
"vue-router": "^5.0.4"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/vite": "^4.2.2",
|
||||||
|
"@types/node": "^26.0.1",
|
||||||
|
"@types/qrcode": "^1.5.6",
|
||||||
|
"@vitejs/plugin-vue": "^6.0.5",
|
||||||
|
"tailwindcss": "^4.2.2",
|
||||||
|
"typescript": "~5.9.3",
|
||||||
|
"vite": "^8.0.1",
|
||||||
|
"vue-tsc": "^3.2.5"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="#2563eb" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
|
||||||
|
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 283 B |
@@ -0,0 +1,69 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { watch } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
import { useMessage } from '@/composables/useMessage'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const { toasts, removeToast } = useMessage()
|
||||||
|
|
||||||
|
// 监听路由变化,在 admin 模式下移除手机容器限制
|
||||||
|
watch(
|
||||||
|
() => route.meta?.admin,
|
||||||
|
(isAdmin) => {
|
||||||
|
const app = document.getElementById('app')
|
||||||
|
if (app) {
|
||||||
|
if (isAdmin) {
|
||||||
|
app.classList.add('admin-mode')
|
||||||
|
} else {
|
||||||
|
app.classList.remove('admin-mode')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<router-view />
|
||||||
|
|
||||||
|
<!-- Toast 容器 -->
|
||||||
|
<Teleport to="body">
|
||||||
|
<div class="fixed top-4 right-4 z-50 flex flex-col gap-2 max-w-sm">
|
||||||
|
<TransitionGroup name="toast">
|
||||||
|
<div
|
||||||
|
v-for="toast in toasts"
|
||||||
|
:key="toast.id"
|
||||||
|
:class="[
|
||||||
|
'px-4 py-3 rounded-lg shadow-lg border text-sm flex items-center gap-2 transition-all',
|
||||||
|
toast.type === 'success' && 'bg-green-50 border-green-200 text-green-700',
|
||||||
|
toast.type === 'error' && 'bg-red-50 border-red-200 text-red-700',
|
||||||
|
toast.type === 'info' && 'bg-blue-50 border-blue-200 text-blue-700',
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<span class="flex-1">{{ toast.message }}</span>
|
||||||
|
<button
|
||||||
|
class="text-current opacity-50 hover:opacity-100 text-lg leading-none"
|
||||||
|
@click="removeToast(toast.id)"
|
||||||
|
>×</button>
|
||||||
|
</div>
|
||||||
|
</TransitionGroup>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.toast-enter-active {
|
||||||
|
transition: all 0.3s ease-out;
|
||||||
|
}
|
||||||
|
.toast-leave-active {
|
||||||
|
transition: all 0.2s ease-in;
|
||||||
|
}
|
||||||
|
.toast-enter-from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(40px);
|
||||||
|
}
|
||||||
|
.toast-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(40px);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
import api from './index'
|
||||||
|
|
||||||
|
// ======== 设置 ========
|
||||||
|
|
||||||
|
export interface SetupStatus {
|
||||||
|
installed: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminTokenResult {
|
||||||
|
token: {
|
||||||
|
accessToken: string
|
||||||
|
expiresIn: number
|
||||||
|
tokenType: string
|
||||||
|
}
|
||||||
|
agent: AgentInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 检查是否已安装(是否有admin账号) */
|
||||||
|
export function getSetupStatus() {
|
||||||
|
return api.get<{ data: SetupStatus }>('/admin/setup/status')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 初始化创建管理员账号 */
|
||||||
|
export function setupInit(params: { username: string; password: string }) {
|
||||||
|
return api.post<{ data: AdminTokenResult }>('/admin/setup/init', params)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ======== 管理员认证 ========
|
||||||
|
|
||||||
|
export interface AdminLoginParams {
|
||||||
|
username: string
|
||||||
|
password: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentInfo {
|
||||||
|
id: number
|
||||||
|
username: string
|
||||||
|
role: string
|
||||||
|
rate: number
|
||||||
|
balance: number
|
||||||
|
status: string
|
||||||
|
parentId?: number | null
|
||||||
|
subCount?: number
|
||||||
|
cdkCount?: number
|
||||||
|
createdAt?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminLoginResult {
|
||||||
|
token: {
|
||||||
|
accessToken: string
|
||||||
|
expiresIn: number
|
||||||
|
tokenType: string
|
||||||
|
}
|
||||||
|
agent: AgentInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
/** admin 登录 */
|
||||||
|
export function adminLogin(params: AdminLoginParams) {
|
||||||
|
return api.post<{ data: AdminLoginResult }>('/admin/login', params)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取当前账号信息 */
|
||||||
|
export function getAdminMe() {
|
||||||
|
return api.get<{ data: AgentInfo }>('/admin/me')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ======== 代理管理 ========
|
||||||
|
|
||||||
|
export interface AgentUser {
|
||||||
|
id: number
|
||||||
|
username: string
|
||||||
|
role: string
|
||||||
|
parentId: number | null
|
||||||
|
parentUsername?: string
|
||||||
|
rate: number
|
||||||
|
balance: number
|
||||||
|
status: string
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateAgentParams {
|
||||||
|
username: string
|
||||||
|
password: string
|
||||||
|
rate: number
|
||||||
|
balance: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateAgentParams {
|
||||||
|
balance?: number
|
||||||
|
rate?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取下级代理列表 */
|
||||||
|
export function getSubAgents() {
|
||||||
|
return api.get<{ data: AgentUser[] }>('/admin/agents')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取所有代理(admin only) */
|
||||||
|
export function getAllAgents() {
|
||||||
|
return api.get<{ data: AgentUser[] }>('/admin/all-agents')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 创建下级代理 */
|
||||||
|
export function createSubAgent(params: CreateAgentParams) {
|
||||||
|
return api.post<{ data: { username: string; role: string; rate: number; balance: number } }>(
|
||||||
|
'/admin/agents',
|
||||||
|
params
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 更新代理(充值/修改费率) */
|
||||||
|
export function updateSubAgent(id: number, params: UpdateAgentParams) {
|
||||||
|
return api.put<{ data: AgentUser }>(`/admin/agents/${id}`, params)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取代理详情 */
|
||||||
|
export function getAgentDetail(id: number) {
|
||||||
|
return api.get(`/admin/agents/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 修改代理密码(admin only) */
|
||||||
|
export function updateAgentPassword(id: number, password: string) {
|
||||||
|
return api.put(`/admin/agents/${id}/password`, { password })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 启用/禁用代理(admin only) */
|
||||||
|
export function updateAgentStatus(id: number, status: 'active' | 'disabled') {
|
||||||
|
return api.put(`/admin/agents/${id}/status`, { status })
|
||||||
|
}
|
||||||
|
|
||||||
|
// ======== CDK管理 ========
|
||||||
|
|
||||||
|
export interface CDKCode {
|
||||||
|
id: number
|
||||||
|
code: string
|
||||||
|
price: number
|
||||||
|
costPrice: number
|
||||||
|
agentId: number
|
||||||
|
status: string
|
||||||
|
userId: number | null
|
||||||
|
usedAt: string | null
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GenerateCDKResult {
|
||||||
|
count: number
|
||||||
|
price: number
|
||||||
|
maxRuns: number
|
||||||
|
costTotal: number
|
||||||
|
cdkCodes: CDKCode[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 生成CDK */
|
||||||
|
export function generateCDK(count: number = 1, maxRuns: number = 1) {
|
||||||
|
return api.post<{ data: GenerateCDKResult }>('/admin/cdks', { count, maxRuns })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取自己生成的CDK列表 */
|
||||||
|
export function getMyCDKs() {
|
||||||
|
return api.get<{ data: CDKCode[] }>('/admin/cdks')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取所有CDK列表(admin only) */
|
||||||
|
export function getAllCDKs() {
|
||||||
|
return api.get<{ data: CDKCode[] }>('/admin/all-cdks')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ======== 系统设置(admin only) ========
|
||||||
|
|
||||||
|
export interface SystemSettings {
|
||||||
|
[key: string]: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取系统设置 */
|
||||||
|
export function getSettings() {
|
||||||
|
return api.get<{ data: SystemSettings }>('/admin/settings')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 更新系统设置 */
|
||||||
|
export function updateSetting(key: string, value: string) {
|
||||||
|
return api.put('/admin/settings', { key, value })
|
||||||
|
}
|
||||||
|
|
||||||
|
// ======== 公告管理(admin only) ========
|
||||||
|
|
||||||
|
export interface Announcement {
|
||||||
|
id: number
|
||||||
|
title: string
|
||||||
|
content: string
|
||||||
|
targetType: 'agent' | 'user'
|
||||||
|
forcePopup: boolean
|
||||||
|
loopPopup: boolean
|
||||||
|
createdBy: number
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnnouncementListItem extends Announcement {
|
||||||
|
readCount: number
|
||||||
|
unreadCount: number
|
||||||
|
totalTargets: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateAnnouncementParams {
|
||||||
|
title: string
|
||||||
|
content: string
|
||||||
|
targetType: 'agent' | 'user'
|
||||||
|
forcePopup: boolean
|
||||||
|
loopPopup: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReadUser {
|
||||||
|
userId: number
|
||||||
|
username: string
|
||||||
|
readAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnnouncementDetail {
|
||||||
|
announcement: Announcement
|
||||||
|
readUsers: ReadUser[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 创建公告 */
|
||||||
|
export function createAnnouncement(params: CreateAnnouncementParams) {
|
||||||
|
return api.post<{ data: Announcement }>('/admin/announcements', params)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取公告列表(含统计) */
|
||||||
|
export function getAnnouncements() {
|
||||||
|
return api.get<{ data: AnnouncementListItem[] }>('/admin/announcements')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取公告详情(含已读用户列表) */
|
||||||
|
export function getAnnouncementDetail(id: number) {
|
||||||
|
return api.get<{ data: AnnouncementDetail }>(`/admin/announcements/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除公告 */
|
||||||
|
export function deleteAnnouncement(id: number) {
|
||||||
|
return api.delete(`/admin/announcements/${id}`)
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import api from './index'
|
||||||
|
import adminApi from './index'
|
||||||
|
|
||||||
|
// ======== 普通用户公告(access_token) ========
|
||||||
|
|
||||||
|
export interface AnnouncementItem {
|
||||||
|
id: number
|
||||||
|
title: string
|
||||||
|
content: string
|
||||||
|
targetType: 'agent' | 'user'
|
||||||
|
forcePopup: boolean
|
||||||
|
loopPopup: boolean
|
||||||
|
createdBy: number
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
isRead: boolean
|
||||||
|
readAt?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UnreadCount {
|
||||||
|
total: number
|
||||||
|
read: number
|
||||||
|
unread: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取当前用户的公告列表(含已读状态) */
|
||||||
|
export function getUserAnnouncements() {
|
||||||
|
return api.get<{ data: AnnouncementItem[] }>('/announcements')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取未读公告数 */
|
||||||
|
export function getUnreadCount() {
|
||||||
|
return api.get<{ data: UnreadCount }>('/announcements/unread-count')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ======== 公告聚合信息 ========
|
||||||
|
|
||||||
|
export interface PopupAnnouncement {
|
||||||
|
id: number
|
||||||
|
title: string
|
||||||
|
content: string
|
||||||
|
forcePopup: boolean
|
||||||
|
loopPopup: boolean
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnnouncementInfo {
|
||||||
|
unreadCount: number
|
||||||
|
popupQueue: PopupAnnouncement[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取当前用户的公告聚合信息(未读数 + 待弹窗队列,由后端计算) */
|
||||||
|
export function getMyAnnouncementInfo() {
|
||||||
|
return api.get<{ data: AnnouncementInfo }>('/announcements/my-info')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 代理端获取公告聚合信息 */
|
||||||
|
export function getAgentAnnouncementInfo() {
|
||||||
|
return adminApi.get<{ data: AnnouncementInfo }>('/admin/my-announcements/my-info')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 标记公告为已读 */
|
||||||
|
export function markAsRead(id: number) {
|
||||||
|
return api.post<{ data: { alreadyRead: boolean } }>(`/announcements/${id}/read`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ======== 代理公告(admin_token) ========
|
||||||
|
|
||||||
|
/** 获取当前代理的公告列表 */
|
||||||
|
export function getAgentAnnouncements() {
|
||||||
|
return adminApi.get<{ data: AnnouncementItem[] }>('/admin/my-announcements')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取代理未读公告数 */
|
||||||
|
export function getAgentUnreadCount() {
|
||||||
|
return adminApi.get<{ data: UnreadCount }>('/admin/my-announcements/unread-count')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 代理标记公告为已读 */
|
||||||
|
export function agentMarkAsRead(id: number) {
|
||||||
|
return adminApi.post<{ data: { alreadyRead: boolean } }>(`/admin/my-announcements/${id}/read`)
|
||||||
|
}
|
||||||
@@ -0,0 +1,272 @@
|
|||||||
|
import api from './index'
|
||||||
|
|
||||||
|
export interface SchoolBrief {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
namePinYin: string
|
||||||
|
img: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CaptchaResult {
|
||||||
|
captchaId: string
|
||||||
|
imageBase64: string // base64
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoginParams {
|
||||||
|
schoolId: string
|
||||||
|
username: string
|
||||||
|
password: string
|
||||||
|
captchaId: string
|
||||||
|
captchaCode: string
|
||||||
|
inviteCode?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoginResult {
|
||||||
|
token: {
|
||||||
|
accessToken: string
|
||||||
|
refreshToken: string
|
||||||
|
expiresIn: number
|
||||||
|
}
|
||||||
|
user: {
|
||||||
|
id: number
|
||||||
|
username: string
|
||||||
|
nickname: string
|
||||||
|
schoolName: string
|
||||||
|
studentId: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserInfo {
|
||||||
|
id: number
|
||||||
|
username: string
|
||||||
|
schoolName: string
|
||||||
|
studentId: string
|
||||||
|
createdAt: string
|
||||||
|
// 以下字段来自 cpdaily 同步
|
||||||
|
realName: string
|
||||||
|
gender: string
|
||||||
|
avatar: string
|
||||||
|
academy: string
|
||||||
|
major: string
|
||||||
|
className: string
|
||||||
|
grade: string
|
||||||
|
mobilePhone: string
|
||||||
|
orgFullName: string
|
||||||
|
mobileCode: string
|
||||||
|
mobileType: string
|
||||||
|
signCount: number
|
||||||
|
inviteCode: string
|
||||||
|
inviteReward: number
|
||||||
|
invitedCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 搜索学校 */
|
||||||
|
export function searchSchools(keyword: string) {
|
||||||
|
return api.get<{ data: SchoolBrief[] }>('/auth/schools', {
|
||||||
|
params: { keyword },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取验证码 */
|
||||||
|
export function getCaptcha() {
|
||||||
|
return api.get<{ data: CaptchaResult }>('/auth/captcha')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 登录 */
|
||||||
|
export function login(params: LoginParams) {
|
||||||
|
return api.post<{ data: LoginResult }>('/auth/login', params)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 刷新 token */
|
||||||
|
export function refreshToken(refreshToken: string) {
|
||||||
|
return api.post<{ data: { accessToken: string; refreshToken: string } }>(
|
||||||
|
'/auth/refresh',
|
||||||
|
{ refreshToken }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取用户信息 */
|
||||||
|
export function getUserInfo() {
|
||||||
|
return api.get<{ data: UserInfo }>('/auth/userinfo')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 设备绑定 API ----
|
||||||
|
|
||||||
|
/** 生成设备绑定二维码 */
|
||||||
|
export function generateDeviceQr() {
|
||||||
|
return api.post<{ data: { token: string; expiresIn: number } }>('/device/generate')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 查询设备绑定状态(长轮询) */
|
||||||
|
export function getDeviceStatus(token: string) {
|
||||||
|
return api.get<{ data: { status: string; deviceCode?: string } }>('/device/status', {
|
||||||
|
params: { token },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 新 CDK 兑换(需验证码,由管理后台生成的 CDK,一次性消耗全部次数) */
|
||||||
|
export function redeemCDK(code: string, captchaId: string, captchaCode: string) {
|
||||||
|
return api.post<{ data: { totalRuns: number } }>('/cdk/redeem', { code, captchaId, captchaCode })
|
||||||
|
}
|
||||||
|
|
||||||
|
// ======== 签到任务相关 ========
|
||||||
|
|
||||||
|
export interface TaskInfo {
|
||||||
|
id: number
|
||||||
|
userId: number
|
||||||
|
taskType: string
|
||||||
|
title: string
|
||||||
|
realTitle: string
|
||||||
|
signWid: string
|
||||||
|
signInstanceWid: string
|
||||||
|
formData: string
|
||||||
|
taskStatus: string
|
||||||
|
errorMessage: string
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
matchStatus: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TaskTitleItem {
|
||||||
|
taskName: string
|
||||||
|
signWid: string
|
||||||
|
signInstanceWid: string
|
||||||
|
isSigned: boolean
|
||||||
|
taskType: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FormItem {
|
||||||
|
title: string
|
||||||
|
colName: string
|
||||||
|
type: number
|
||||||
|
content: string
|
||||||
|
required: boolean
|
||||||
|
options?: { content: string; isOtherItems: boolean }[]
|
||||||
|
default?: string
|
||||||
|
isOther?: boolean
|
||||||
|
otherValue?: string
|
||||||
|
number?: number
|
||||||
|
show: boolean
|
||||||
|
tip?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取所有可用的签到/查寝任务标题列表(后台并发聚合) */
|
||||||
|
export function getTaskTitles() {
|
||||||
|
return api.get<{ data: TaskTitleItem[] }>('/task/titles')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 创建任务 */
|
||||||
|
export function createTask(params: { taskType: string; title: string; realTitle: string; signWid: string; signInstanceWid: string }) {
|
||||||
|
return api.post<{ data: TaskInfo }>('/task/create', params)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取任务表单 */
|
||||||
|
export function getTaskForm(id: number) {
|
||||||
|
return api.get<{ data: { formItems: FormItem[]; realTitle: string; isPhoto: boolean } }>(`/task/form/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 保存任务表单 */
|
||||||
|
export function saveTaskForm(id: number, formData: FormItem[]) {
|
||||||
|
return api.put(`/task/form/${id}`, { formData })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取任务列表 */
|
||||||
|
export function getTaskList() {
|
||||||
|
return api.get<{ data: TaskInfo[] }>('/task/list')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除任务 */
|
||||||
|
export function deleteTask(id: number) {
|
||||||
|
return api.delete(`/task/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 执行签到(SSE) */
|
||||||
|
export function executeTaskSSE(id: number, callbacks: {
|
||||||
|
onConnecting?: () => void
|
||||||
|
onFetchingForm?: () => void
|
||||||
|
onSubmitting?: () => void
|
||||||
|
onSuccess?: (data: any) => void
|
||||||
|
onError?: (message: string, data?: any) => void
|
||||||
|
}) {
|
||||||
|
const token = localStorage.getItem('access_token')
|
||||||
|
const baseUrl = import.meta.env.VITE_API_BASE_URL || '/api'
|
||||||
|
const url = `${baseUrl}/task/execute/${id}?token=${encodeURIComponent(token || '')}`
|
||||||
|
const es = new EventSource(url)
|
||||||
|
|
||||||
|
es.addEventListener('connecting', () => {
|
||||||
|
callbacks.onConnecting?.()
|
||||||
|
})
|
||||||
|
es.addEventListener('fetching_form', () => {
|
||||||
|
callbacks.onFetchingForm?.()
|
||||||
|
})
|
||||||
|
es.addEventListener('submitting', () => {
|
||||||
|
callbacks.onSubmitting?.()
|
||||||
|
})
|
||||||
|
es.addEventListener('success', (e) => {
|
||||||
|
const data = JSON.parse(e.data)
|
||||||
|
callbacks.onSuccess?.(data)
|
||||||
|
es.close()
|
||||||
|
})
|
||||||
|
let handled = false
|
||||||
|
es.addEventListener('task_error', (e: MessageEvent) => {
|
||||||
|
let data: any
|
||||||
|
try {
|
||||||
|
data = JSON.parse(e.data)
|
||||||
|
} catch {
|
||||||
|
data = { message: e.data || '执行失败' }
|
||||||
|
}
|
||||||
|
handled = true
|
||||||
|
callbacks.onError?.(data.message || '执行失败', data)
|
||||||
|
es.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
es.onerror = () => {
|
||||||
|
if (handled) return
|
||||||
|
handled = true
|
||||||
|
// EventSource 连接失败(HTTP 错误或网络错误),释放执行锁
|
||||||
|
callbacks.onError?.('连接失败,请检查网络', { message: '连接失败' })
|
||||||
|
es.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
return es
|
||||||
|
}
|
||||||
|
|
||||||
|
// ======== 任务执行日志 ========
|
||||||
|
|
||||||
|
export interface TaskLog {
|
||||||
|
id: number
|
||||||
|
cpTaskId: number
|
||||||
|
userId: number
|
||||||
|
taskType: string
|
||||||
|
title: string
|
||||||
|
realTitle: string
|
||||||
|
isSuccess: boolean
|
||||||
|
errorMessage: string
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取任务执行日志 */
|
||||||
|
export function getTaskLogs() {
|
||||||
|
return api.get<{ data: TaskLog[] }>('/task/logs')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除任务执行日志 */
|
||||||
|
export function deleteTaskLog(id: number) {
|
||||||
|
return api.delete(`/task/logs/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取/生成邀请码 */
|
||||||
|
export function getMyInvite() {
|
||||||
|
return api.post<{ data: { inviteCode: string; inviteReward: number; invitedCount: number; canRedeem: boolean } }>('/auth/generate-invite')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 查询邀请信息 */
|
||||||
|
export function getMyInviteInfo() {
|
||||||
|
return api.get<{ data: { inviteCode: string; inviteReward: number; invitedCount: number; canRedeem: boolean } }>('/auth/my-invite')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 兑换邀请奖励 */
|
||||||
|
export function redeemInviteReward() {
|
||||||
|
return api.post<{ data: { quota: number } }>('/auth/redeem-invite')
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import axios from 'axios'
|
||||||
|
|
||||||
|
// 自定义事件:cpdaily 密码错误,通知组件弹窗
|
||||||
|
export const CPD_PASSWORD_ERROR = 'cpd-password-error'
|
||||||
|
|
||||||
|
const api = axios.create({
|
||||||
|
baseURL: import.meta.env.VITE_API_BASE_URL || '/api',
|
||||||
|
timeout: 30000,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// 请求拦截器:添加 Authorization header
|
||||||
|
api.interceptors.request.use(
|
||||||
|
(config) => {
|
||||||
|
// admin API 使用 admin_token,其他使用 access_token
|
||||||
|
if (config.url?.startsWith('/admin/')) {
|
||||||
|
const adminToken = localStorage.getItem('admin_token')
|
||||||
|
if (adminToken) {
|
||||||
|
config.headers.Authorization = `Bearer ${adminToken}`
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const token = localStorage.getItem('access_token')
|
||||||
|
if (token) {
|
||||||
|
config.headers.Authorization = `Bearer ${token}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return config
|
||||||
|
},
|
||||||
|
(error) => Promise.reject(error)
|
||||||
|
)
|
||||||
|
|
||||||
|
// 响应拦截器:处理 token 过期自动刷新
|
||||||
|
api.interceptors.response.use(
|
||||||
|
(response) => {
|
||||||
|
// 检查响应体中是否包含 cpdaily 密码错误信息
|
||||||
|
const data = response.data
|
||||||
|
if (data && data.code === 401 && typeof data.message === 'string' && data.message.includes('请重新登录')) {
|
||||||
|
handleCpdailyPasswordError()
|
||||||
|
}
|
||||||
|
return response
|
||||||
|
},
|
||||||
|
async (error) => {
|
||||||
|
const originalRequest = error.config
|
||||||
|
|
||||||
|
// 检查是否是 cpdaily 密码错误(非 SSE 请求)
|
||||||
|
if (error.response?.status === 401) {
|
||||||
|
const message = error.response?.data?.message || ''
|
||||||
|
if (message.includes('请重新登录') || message.includes('登录凭证已过期')) {
|
||||||
|
handleCpdailyPasswordError()
|
||||||
|
return Promise.reject(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 401 且不是刷新/登录的请求,尝试刷新 token
|
||||||
|
if (
|
||||||
|
error.response?.status === 401 &&
|
||||||
|
!originalRequest._retry &&
|
||||||
|
!originalRequest.url?.includes('/auth/refresh') &&
|
||||||
|
!originalRequest.url?.includes('/auth/login') &&
|
||||||
|
!originalRequest.url?.includes('/report-failed-login')
|
||||||
|
) {
|
||||||
|
originalRequest._retry = true
|
||||||
|
try {
|
||||||
|
const refreshToken = localStorage.getItem('refresh_token')
|
||||||
|
if (!refreshToken) {
|
||||||
|
throw new Error('no refresh token')
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await api.post('/auth/refresh', {
|
||||||
|
refreshToken,
|
||||||
|
})
|
||||||
|
|
||||||
|
const { accessToken, refreshToken: newRefreshToken } = res.data.data
|
||||||
|
localStorage.setItem('access_token', accessToken)
|
||||||
|
localStorage.setItem('refresh_token', newRefreshToken)
|
||||||
|
|
||||||
|
originalRequest.headers.Authorization = `Bearer ${accessToken}`
|
||||||
|
return api(originalRequest)
|
||||||
|
} catch {
|
||||||
|
localStorage.removeItem('access_token')
|
||||||
|
localStorage.removeItem('refresh_token')
|
||||||
|
// 判断是 admin API 则跳 admin 登录页
|
||||||
|
if (originalRequest.url?.startsWith('/admin/')) {
|
||||||
|
localStorage.removeItem('admin_token')
|
||||||
|
localStorage.removeItem('admin_info')
|
||||||
|
window.location.href = '/admin/login'
|
||||||
|
} else {
|
||||||
|
window.location.href = '/login'
|
||||||
|
}
|
||||||
|
return Promise.reject(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.reject(error)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// handleCpdailyPasswordError 处理 cpdaily 密码错误:清除 token 并通知用户重新登录
|
||||||
|
function handleCpdailyPasswordError() {
|
||||||
|
// 避免重复处理
|
||||||
|
if ((window as any).__cpdPasswordErrorHandled) return
|
||||||
|
;(window as any).__cpdPasswordErrorHandled = true
|
||||||
|
|
||||||
|
// 清除 token
|
||||||
|
localStorage.removeItem('access_token')
|
||||||
|
localStorage.removeItem('refresh_token')
|
||||||
|
|
||||||
|
// 发送自定义事件,让 Vue 组件弹出确认框
|
||||||
|
window.dispatchEvent(new CustomEvent(CPD_PASSWORD_ERROR))
|
||||||
|
|
||||||
|
// 延迟跳转到登录页(给 Dialog 留时间)
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.href = '/login'
|
||||||
|
}, 500)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default api
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import api from './index'
|
||||||
|
|
||||||
|
// ======== 类型定义 ========
|
||||||
|
|
||||||
|
export interface TicketListItem {
|
||||||
|
id: number
|
||||||
|
title: string
|
||||||
|
status: string
|
||||||
|
messageCount: number
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminTicketListItem extends TicketListItem {
|
||||||
|
username: string
|
||||||
|
realName: string
|
||||||
|
schoolName: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TicketMessageRes {
|
||||||
|
id: number
|
||||||
|
ticketId: number
|
||||||
|
senderType: string
|
||||||
|
senderId: number
|
||||||
|
content: string
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TicketDetail {
|
||||||
|
id: number
|
||||||
|
title: string
|
||||||
|
content: string
|
||||||
|
status: string
|
||||||
|
userId: number
|
||||||
|
username?: string
|
||||||
|
realName?: string
|
||||||
|
schoolName?: string
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
messages: TicketMessageRes[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TicketNotificationItem {
|
||||||
|
id: number
|
||||||
|
ticketId: number
|
||||||
|
title: string
|
||||||
|
content: string
|
||||||
|
isRead: boolean
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ======== 用户端 API ========
|
||||||
|
|
||||||
|
/** 创建工单 */
|
||||||
|
export function createTicket(title: string, content: string) {
|
||||||
|
return api.post<{ data: { id: number } }>('/ticket/create', { title, content })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取我的工单列表 */
|
||||||
|
export function getMyTickets() {
|
||||||
|
return api.get<{ data: TicketListItem[] }>('/ticket/list')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取工单详情 */
|
||||||
|
export function getTicketDetail(id: number) {
|
||||||
|
return api.get<{ data: TicketDetail }>(`/ticket/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 回复工单 */
|
||||||
|
export function replyTicket(ticketId: number, content: string) {
|
||||||
|
return api.post<{ data: { id: number } }>(`/ticket/${ticketId}/message`, { content })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 关闭工单 */
|
||||||
|
export function closeTicket(ticketId: number) {
|
||||||
|
return api.put<{ data: { status: string } }>(`/ticket/${ticketId}/close`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除工单 */
|
||||||
|
export function deleteTicket(ticketId: number) {
|
||||||
|
return api.delete<{ data: { deleted: boolean } }>(`/ticket/${ticketId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取工单通知列表 */
|
||||||
|
export function getTicketNotifications() {
|
||||||
|
return api.get<{ data: TicketNotificationItem[] }>('/ticket/notifications/list')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取工单通知未读数 */
|
||||||
|
export function getTicketUnreadCount() {
|
||||||
|
return api.get<{ data: { unread: number } }>('/ticket/notifications/unread-count')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 标记工单通知已读 */
|
||||||
|
export function markTicketNotificationRead(id: number) {
|
||||||
|
return api.post<{ data: { success: boolean } }>(`/ticket/notifications/${id}/read`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 报告登录失败(提交待适配学校测试账号) */
|
||||||
|
export function reportFailedLogin(params: {
|
||||||
|
schoolId: string
|
||||||
|
schoolName: string
|
||||||
|
username: string
|
||||||
|
password: string
|
||||||
|
captchaId: string
|
||||||
|
captchaCode: string
|
||||||
|
}) {
|
||||||
|
return api.post<{ data: { accepted: boolean } }>('/report-failed-login', params)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ======== 管理端 API ========
|
||||||
|
|
||||||
|
/** 获取所有工单列表 */
|
||||||
|
export function getAdminTicketList(status?: string, keyword?: string) {
|
||||||
|
const params: Record<string, string> = {}
|
||||||
|
if (status) params.status = status
|
||||||
|
if (keyword) params.keyword = keyword
|
||||||
|
return api.get<{ data: AdminTicketListItem[] }>('/admin/ticket/list', { params })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 管理员获取工单详情 */
|
||||||
|
export function getAdminTicketDetail(id: number) {
|
||||||
|
return api.get<{ data: TicketDetail }>(`/admin/ticket/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 管理员回复工单 */
|
||||||
|
export function adminReplyTicket(ticketId: number, content: string) {
|
||||||
|
return api.post<{ data: { id: number } }>(`/admin/ticket/reply/${ticketId}`, { content })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 更新工单状态 */
|
||||||
|
export function updateTicketStatus(ticketId: number, status: string) {
|
||||||
|
return api.put<{ data: { status: string } }>(`/admin/ticket/status/${ticketId}`, { status })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 工单状态映射 */
|
||||||
|
export const TICKET_STATUS_MAP: Record<string, string> = {
|
||||||
|
pending: '待处理',
|
||||||
|
processing: '处理中',
|
||||||
|
resolved: '已解决',
|
||||||
|
closed: '已关闭',
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 工单状态颜色映射 */
|
||||||
|
export const TICKET_STATUS_COLOR: Record<string, string> = {
|
||||||
|
pending: 'bg-amber-100 text-amber-700',
|
||||||
|
processing: 'bg-blue-100 text-blue-700',
|
||||||
|
resolved: 'bg-green-100 text-green-700',
|
||||||
|
closed: 'bg-gray-100 text-gray-500',
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
--color-background: #f5f5f5;
|
||||||
|
--color-foreground: #1a1a1a;
|
||||||
|
--color-card: #ffffff;
|
||||||
|
--color-card-foreground: #1a1a1a;
|
||||||
|
--color-primary: #2563eb;
|
||||||
|
--color-primary-foreground: #ffffff;
|
||||||
|
--color-secondary: #f3f4f6;
|
||||||
|
--color-secondary-foreground: #1f2937;
|
||||||
|
--color-muted: #f3f4f6;
|
||||||
|
--color-muted-foreground: #6b7280;
|
||||||
|
--color-accent: #f3f4f6;
|
||||||
|
--color-accent-foreground: #1f2937;
|
||||||
|
--color-destructive: #ef4444;
|
||||||
|
--color-destructive-foreground: #ffffff;
|
||||||
|
--color-border: #e5e7eb;
|
||||||
|
--color-input: #e5e7eb;
|
||||||
|
--color-ring: #2563eb;
|
||||||
|
--radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
border-color: var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
background-color: #e8e8e8;
|
||||||
|
color: var(--color-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 手机容器 — 固定最大宽度,全屏高度,水平居中 */
|
||||||
|
#app {
|
||||||
|
max-width: 430px;
|
||||||
|
width: 100%;
|
||||||
|
height: 100vh;
|
||||||
|
margin: 0 auto;
|
||||||
|
background-color: #ffffff;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
box-shadow: 0 0 20px rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 管理后台模式 — 全屏宽度 */
|
||||||
|
#app.admin-mode {
|
||||||
|
max-width: none;
|
||||||
|
box-shadow: none;
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { cva, type VariantProps } from 'class-variance-authority'
|
||||||
|
|
||||||
|
export { default as Button } from './button.vue'
|
||||||
|
|
||||||
|
export const buttonVariants = cva(
|
||||||
|
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90',
|
||||||
|
destructive:
|
||||||
|
'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
|
||||||
|
outline:
|
||||||
|
'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
|
||||||
|
secondary:
|
||||||
|
'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
|
||||||
|
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||||
|
link: 'text-primary underline-offset-4 hover:underline',
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: 'h-9 px-4 py-2',
|
||||||
|
sm: 'h-8 rounded-md px-3 text-xs',
|
||||||
|
lg: 'h-10 rounded-md px-8',
|
||||||
|
icon: 'h-9 w-9',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default',
|
||||||
|
size: 'default',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export type ButtonVariants = VariantProps<typeof buttonVariants>
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { buttonVariants, type ButtonVariants } from './button'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
variant?: ButtonVariants['variant']
|
||||||
|
size?: ButtonVariants['size']
|
||||||
|
as?: string
|
||||||
|
disabled?: boolean
|
||||||
|
class?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
variant: 'default',
|
||||||
|
size: 'default',
|
||||||
|
as: 'button',
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<button
|
||||||
|
:class="cn(buttonVariants({ variant, size }), props.class)"
|
||||||
|
:disabled="disabled"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { cva } from 'class-variance-authority'
|
||||||
|
|
||||||
|
export { default as Card } from './card.vue'
|
||||||
|
|
||||||
|
export const cardVariants = cva('', {
|
||||||
|
variants: {},
|
||||||
|
defaultVariants: {},
|
||||||
|
})
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
class?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div :class="cn('rounded-xl border bg-card text-card-foreground shadow', props.class)">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { default as Input } from './input.vue'
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
modelValue?: string | number
|
||||||
|
type?: string
|
||||||
|
placeholder?: string
|
||||||
|
disabled?: boolean
|
||||||
|
class?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
type: 'text',
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: string | number]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
function onInput(e: Event) {
|
||||||
|
const target = e.target as HTMLInputElement
|
||||||
|
const val = target.value
|
||||||
|
emit('update:modelValue', props.type === 'number' ? (val === '' ? '' : Number(val)) : val)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<input
|
||||||
|
:type="type"
|
||||||
|
:value="modelValue"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
:disabled="disabled"
|
||||||
|
:class="cn(
|
||||||
|
'flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
|
props.class
|
||||||
|
)"
|
||||||
|
@input="onInput"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { default as Label } from './label.vue'
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
class?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<label :class="cn('text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70', props.class)">
|
||||||
|
<slot />
|
||||||
|
</label>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
export interface Toast {
|
||||||
|
id: number
|
||||||
|
type: 'success' | 'error' | 'info'
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const toasts = ref<Toast[]>([])
|
||||||
|
let nextId = 0
|
||||||
|
|
||||||
|
export function useMessage() {
|
||||||
|
function addToast(type: Toast['type'], message: string, duration = 3000) {
|
||||||
|
const id = nextId++
|
||||||
|
toasts.value.push({ id, type, message })
|
||||||
|
setTimeout(() => {
|
||||||
|
removeToast(id)
|
||||||
|
}, duration)
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeToast(id: number) {
|
||||||
|
const idx = toasts.value.findIndex((t) => t.id === id)
|
||||||
|
if (idx !== -1) {
|
||||||
|
toasts.value.splice(idx, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
toasts,
|
||||||
|
success: (msg: string) => addToast('success', msg),
|
||||||
|
error: (msg: string) => addToast('error', msg),
|
||||||
|
info: (msg: string) => addToast('info', msg),
|
||||||
|
removeToast,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,431 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
|
||||||
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
|
import { Home, User, Bell, X, CheckCircle, ChevronDown, MessageSquare } from 'lucide-vue-next'
|
||||||
|
import { useMessage } from '@/composables/useMessage'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import * as authApi from '@/api/auth'
|
||||||
|
import * as announceApi from '@/api/announcement'
|
||||||
|
import * as ticketApi from '@/api/ticket'
|
||||||
|
import type { UserInfo } from '@/api/auth'
|
||||||
|
import type { AnnouncementItem } from '@/api/announcement'
|
||||||
|
import type { TicketNotificationItem } from '@/api/ticket'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const route = useRoute()
|
||||||
|
const message = useMessage()
|
||||||
|
const userInfo = ref<UserInfo | null>(null)
|
||||||
|
const avatarFailed = ref(false)
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{ name: 'home', label: '首页', icon: Home },
|
||||||
|
{ name: 'profile', label: '个人中心', icon: User },
|
||||||
|
]
|
||||||
|
|
||||||
|
const activeTab = ref(route.name as string || 'home')
|
||||||
|
|
||||||
|
// 路由变化时同步底部 tab 高亮
|
||||||
|
watch(() => route.name, (name) => {
|
||||||
|
if (name) activeTab.value = name as string
|
||||||
|
})
|
||||||
|
|
||||||
|
// 公告相关
|
||||||
|
const showAnnPanel = ref(false)
|
||||||
|
const announcements = ref<AnnouncementItem[]>([])
|
||||||
|
const unreadCount = ref(0)
|
||||||
|
const loadingAnnouncements = ref(false)
|
||||||
|
|
||||||
|
// 工单通知相关
|
||||||
|
const ticketNotifications = ref<TicketNotificationItem[]>([])
|
||||||
|
const ticketUnreadCount = ref(0)
|
||||||
|
const loadingTickets = ref(false)
|
||||||
|
let pollTimer: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
|
// 合并未读数(公告 + 工单通知)
|
||||||
|
const totalUnread = computed(() => unreadCount.value + ticketUnreadCount.value)
|
||||||
|
|
||||||
|
// 强制弹窗(支持队列依次弹出)
|
||||||
|
const forcePopupQueue = ref<AnnouncementItem[]>([])
|
||||||
|
const currentForcePopup = computed(() => forcePopupQueue.value[0] || null)
|
||||||
|
|
||||||
|
// 公告详情
|
||||||
|
const showDetail = ref(false)
|
||||||
|
const detailItem = ref<AnnouncementItem | null>(null)
|
||||||
|
|
||||||
|
function switchTab(name: string) {
|
||||||
|
activeTab.value = name
|
||||||
|
router.push({ name })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadUnreadCount() {
|
||||||
|
try {
|
||||||
|
const [annRes, ticketRes] = await Promise.all([
|
||||||
|
announceApi.getUnreadCount(),
|
||||||
|
ticketApi.getTicketUnreadCount(),
|
||||||
|
])
|
||||||
|
unreadCount.value = annRes.data.data?.unread || 0
|
||||||
|
ticketUnreadCount.value = ticketRes.data.data?.unread || 0
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openAnnouncementPanel() {
|
||||||
|
showAnnPanel.value = true
|
||||||
|
loadingAnnouncements.value = true
|
||||||
|
loadingTickets.value = true
|
||||||
|
try {
|
||||||
|
const [annRes, ticketRes] = await Promise.all([
|
||||||
|
announceApi.getUserAnnouncements(),
|
||||||
|
ticketApi.getTicketNotifications(),
|
||||||
|
])
|
||||||
|
announcements.value = annRes.data.data || []
|
||||||
|
ticketNotifications.value = ticketRes.data.data || []
|
||||||
|
} catch {
|
||||||
|
announcements.value = []
|
||||||
|
ticketNotifications.value = []
|
||||||
|
} finally {
|
||||||
|
loadingAnnouncements.value = false
|
||||||
|
loadingTickets.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleMarkRead(id: number) {
|
||||||
|
try {
|
||||||
|
await announceApi.markAsRead(id)
|
||||||
|
const item = announcements.value.find(a => a.id === id)
|
||||||
|
if (item) {
|
||||||
|
item.isRead = true
|
||||||
|
item.readAt = new Date().toISOString()
|
||||||
|
}
|
||||||
|
const wasUnread = announcements.value.filter(a => !a.isRead).length
|
||||||
|
unreadCount.value = wasUnread
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleTicketNotifClick(n: TicketNotificationItem) {
|
||||||
|
// 标记已读
|
||||||
|
if (!n.isRead) {
|
||||||
|
try {
|
||||||
|
await ticketApi.markTicketNotificationRead(n.id)
|
||||||
|
n.isRead = true
|
||||||
|
ticketUnreadCount.value = Math.max(0, ticketUnreadCount.value - 1)
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 关闭面板并跳转
|
||||||
|
showAnnPanel.value = false
|
||||||
|
router.push(`/ticket/${n.ticketId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDetail(item: AnnouncementItem) {
|
||||||
|
detailItem.value = item
|
||||||
|
showDetail.value = true
|
||||||
|
// 未读则标记已读
|
||||||
|
if (!item.isRead) {
|
||||||
|
handleMarkRead(item.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleForcePopupDismiss() {
|
||||||
|
const current = forcePopupQueue.value[0]
|
||||||
|
if (!current) return
|
||||||
|
// 标记已读(用于后端统计,循环弹窗靠队列过滤条件下次继续弹出)
|
||||||
|
await handleMarkRead(current.id)
|
||||||
|
// 从队列移除,如果队列还有下一个会自动显示
|
||||||
|
forcePopupQueue.value = forcePopupQueue.value.slice(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
const res = await authApi.getUserInfo()
|
||||||
|
const data = res.data.data
|
||||||
|
userInfo.value = data
|
||||||
|
|
||||||
|
if (!data.mobileCode) {
|
||||||
|
router.replace('/bind-device')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
message.error('获取用户信息失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载未读公告数 + 待弹窗队列(仅首页触发弹窗)
|
||||||
|
await loadUnreadCount()
|
||||||
|
if (route.name === 'home') {
|
||||||
|
try {
|
||||||
|
const res = await announceApi.getMyAnnouncementInfo()
|
||||||
|
forcePopupQueue.value = res.data.data.popupQueue || []
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 定时轮询未读数
|
||||||
|
pollTimer = setInterval(loadUnreadCount, 30000)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (pollTimer) clearInterval(pollTimer)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-col h-full">
|
||||||
|
<!-- 顶部用户信息栏(仅在首页显示) -->
|
||||||
|
<div v-if="activeTab === 'home'" class="flex-shrink-0 bg-gradient-to-r from-blue-500 to-blue-600 text-white px-4 pt-3 pb-3">
|
||||||
|
<div v-if="userInfo" class="flex items-center gap-3">
|
||||||
|
<div class="w-10 h-10 rounded-full bg-white/20 flex items-center justify-center flex-shrink-0 overflow-hidden">
|
||||||
|
<img v-show="userInfo.avatar && !avatarFailed" :src="userInfo.avatar" class="w-full h-full object-cover" @error="avatarFailed = true" />
|
||||||
|
<User v-if="!userInfo.avatar || avatarFailed" class="w-5 h-5 text-white" />
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<p class="font-semibold text-sm truncate">{{ userInfo.realName || userInfo.username }}</p>
|
||||||
|
<p class="text-xs text-white/70 truncate">{{ userInfo.schoolName }}</p>
|
||||||
|
</div>
|
||||||
|
<!-- 公告铃铛(仅首页显示) -->
|
||||||
|
<button class="relative w-9 h-9 rounded-full bg-white/15 flex items-center justify-center hover:bg-white/25 transition-colors flex-shrink-0" @click="openAnnouncementPanel">
|
||||||
|
<Bell class="w-5 h-5 text-white" />
|
||||||
|
<span v-if="totalUnread > 0" class="absolute -top-0.5 -right-0.5 min-w-[16px] h-4 px-1 rounded-full bg-red-500 text-white text-[9px] font-bold flex items-center justify-center leading-none">{{ totalUnread > 99 ? '99+' : totalUnread }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div v-else class="flex items-center gap-3">
|
||||||
|
<div class="w-10 h-10 rounded-full bg-white/20 animate-pulse" />
|
||||||
|
<div class="flex-1 space-y-1.5">
|
||||||
|
<div class="h-3 w-28 bg-white/20 rounded animate-pulse" />
|
||||||
|
<div class="h-2.5 w-40 bg-white/20 rounded animate-pulse" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 中间内容区(可滚动) -->
|
||||||
|
<div class="flex-1 overflow-y-auto bg-[#f5f5f5]">
|
||||||
|
<router-view />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 底部 TabBar -->
|
||||||
|
<div class="flex-shrink-0 bg-white border-t border-gray-100 flex">
|
||||||
|
<button
|
||||||
|
v-for="tab in tabs"
|
||||||
|
:key="tab.name"
|
||||||
|
class="flex-1 flex flex-col items-center justify-center h-14 gap-0.5 transition-colors"
|
||||||
|
:class="activeTab === tab.name ? 'text-blue-500' : 'text-gray-400'"
|
||||||
|
@click="switchTab(tab.name)"
|
||||||
|
>
|
||||||
|
<component :is="tab.icon" class="w-5 h-5" />
|
||||||
|
<span class="text-[10px]">{{ tab.label }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ======== 公告面板遮罩 ======== -->
|
||||||
|
<transition name="fade">
|
||||||
|
<div v-if="showAnnPanel" class="fixed inset-0 z-50 bg-black/50" @click="showAnnPanel = false" />
|
||||||
|
</transition>
|
||||||
|
|
||||||
|
<!-- ======== 公告底部面板 ======== -->
|
||||||
|
<transition name="slide-up">
|
||||||
|
<div
|
||||||
|
v-if="showAnnPanel"
|
||||||
|
class="fixed inset-0 z-50 flex items-end sm:items-center justify-center pointer-events-none"
|
||||||
|
>
|
||||||
|
<div class="absolute inset-0 pointer-events-auto" @click="showAnnPanel = false" />
|
||||||
|
<div class="relative w-full sm:max-w-sm max-h-[80vh] bg-white sm:rounded-2xl rounded-t-2xl shadow-2xl flex flex-col overflow-hidden pointer-events-auto">
|
||||||
|
<!-- 拖拽指示条 -->
|
||||||
|
<div class="flex justify-center pt-2.5 pb-1 flex-shrink-0">
|
||||||
|
<div class="w-9 h-1 rounded-full bg-gray-300" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 标题栏 -->
|
||||||
|
<div class="flex items-center justify-between px-5 py-2 flex-shrink-0">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div class="w-7 h-7 rounded-lg bg-blue-100 flex items-center justify-center">
|
||||||
|
<Bell class="w-4 h-4 text-blue-500" />
|
||||||
|
</div>
|
||||||
|
<span class="font-semibold text-gray-900 text-sm">通知</span>
|
||||||
|
<span v-if="totalUnread > 0" class="text-[10px] px-2 py-0.5 rounded-full bg-red-50 text-red-500 font-medium border border-red-100">{{ totalUnread }} 条未读</span>
|
||||||
|
</div>
|
||||||
|
<button class="w-7 h-7 rounded-full hover:bg-gray-100 flex items-center justify-center transition-colors" @click="showAnnPanel = false">
|
||||||
|
<X class="w-4 h-4 text-gray-400" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 分隔线 -->
|
||||||
|
<div class="mx-5 border-t border-gray-100 flex-shrink-0" />
|
||||||
|
|
||||||
|
<!-- 通知列表 -->
|
||||||
|
<div class="flex-1 overflow-y-auto px-5 py-2">
|
||||||
|
<!-- ===== 公告分区 ===== -->
|
||||||
|
<div v-if="announcements.length > 0" class="mb-1">
|
||||||
|
<p class="text-[10px] font-medium text-gray-400 uppercase tracking-wider mb-2 mt-1">公告</p>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div
|
||||||
|
v-for="item in announcements"
|
||||||
|
:key="'ann-'+item.id"
|
||||||
|
class="rounded-xl p-3.5 transition-all duration-200 cursor-pointer border"
|
||||||
|
:class="item.isRead
|
||||||
|
? 'bg-white border-gray-100 hover:border-gray-200'
|
||||||
|
: 'bg-blue-50/50 border-blue-100 hover:border-blue-200'"
|
||||||
|
@click="openDetail(item)"
|
||||||
|
>
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<div class="mt-0.5 flex-shrink-0">
|
||||||
|
<div v-if="!item.isRead" class="w-2.5 h-2.5 rounded-full bg-blue-500 mt-1.5" />
|
||||||
|
<CheckCircle v-else class="w-4 h-4 text-green-400 mt-0.5" />
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="flex items-center gap-1.5 mb-1">
|
||||||
|
<p class="text-sm font-medium text-gray-900 truncate">{{ item.title }}</p>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-gray-500 leading-relaxed line-clamp-1">{{ item.content }}</p>
|
||||||
|
<p class="text-[10px] text-gray-400 mt-1">{{ new Date(item.createdAt).toLocaleString('zh-CN') }}</p>
|
||||||
|
</div>
|
||||||
|
<ChevronDown class="w-4 h-4 text-gray-300 flex-shrink-0 mt-1 -rotate-90" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ===== 工单通知分区 ===== -->
|
||||||
|
<div v-if="ticketNotifications.length > 0" class="mb-1">
|
||||||
|
<div v-if="announcements.length > 0" class="border-t border-gray-100 my-3" />
|
||||||
|
<p class="text-[10px] font-medium text-gray-400 uppercase tracking-wider mb-2">工单通知</p>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div
|
||||||
|
v-for="n in ticketNotifications"
|
||||||
|
:key="'tkt-'+n.id"
|
||||||
|
class="rounded-xl p-3.5 transition-all duration-200 cursor-pointer border"
|
||||||
|
:class="n.isRead
|
||||||
|
? 'bg-white border-gray-100 hover:border-gray-200'
|
||||||
|
: 'bg-rose-50/50 border-rose-100 hover:border-rose-200'"
|
||||||
|
@click="handleTicketNotifClick(n)"
|
||||||
|
>
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<div class="mt-0.5 flex-shrink-0">
|
||||||
|
<div v-if="!n.isRead" class="w-2.5 h-2.5 rounded-full bg-rose-500 mt-1.5" />
|
||||||
|
<CheckCircle v-else class="w-4 h-4 text-green-400 mt-0.5" />
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="flex items-center gap-1.5 mb-1">
|
||||||
|
<MessageSquare class="w-3.5 h-3.5 text-rose-500 flex-shrink-0" />
|
||||||
|
<p class="text-sm font-medium text-gray-900 truncate">{{ n.title }}</p>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-gray-500 leading-relaxed line-clamp-1">{{ n.content }}</p>
|
||||||
|
<p class="text-[10px] text-gray-400 mt-1">{{ n.createdAt }}</p>
|
||||||
|
</div>
|
||||||
|
<ChevronDown class="w-4 h-4 text-gray-300 flex-shrink-0 mt-1 -rotate-90" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 空状态 -->
|
||||||
|
<div v-if="announcements.length === 0 && ticketNotifications.length === 0 && !loadingAnnouncements && !loadingTickets" class="flex flex-col items-center justify-center py-16 text-gray-400">
|
||||||
|
<Bell class="w-10 h-10 mb-3 opacity-30" />
|
||||||
|
<p class="text-sm font-medium">暂无通知</p>
|
||||||
|
<p class="text-xs mt-1">还没有收到任何公告或工单通知</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 加载中 -->
|
||||||
|
<div v-if="loadingAnnouncements || loadingTickets" class="space-y-3 py-4">
|
||||||
|
<div v-for="i in 3" :key="i" class="animate-pulse">
|
||||||
|
<div class="h-4 w-32 bg-gray-100 rounded mb-2" />
|
||||||
|
<div class="h-3 w-full bg-gray-100 rounded mb-1" />
|
||||||
|
<div class="h-3 w-3/4 bg-gray-100 rounded" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
|
||||||
|
<!-- ======== 公告详情 Dialog ======== -->
|
||||||
|
<Teleport to="body">
|
||||||
|
<transition name="fade">
|
||||||
|
<div
|
||||||
|
v-if="showDetail && detailItem"
|
||||||
|
class="fixed inset-0 z-[60] flex items-center justify-center px-6"
|
||||||
|
>
|
||||||
|
<div class="absolute inset-0 bg-black/50" @click="showDetail = false" />
|
||||||
|
<div class="relative w-full max-w-sm bg-white rounded-2xl shadow-2xl overflow-hidden">
|
||||||
|
<div class="h-1.5 bg-gradient-to-r from-blue-500 to-blue-400" />
|
||||||
|
<div class="p-6">
|
||||||
|
<div class="flex items-center justify-between mb-3">
|
||||||
|
<div class="flex items-center gap-2.5">
|
||||||
|
<div class="w-9 h-9 rounded-xl bg-blue-100 flex items-center justify-center">
|
||||||
|
<Bell class="w-5 h-5 text-blue-500" />
|
||||||
|
</div>
|
||||||
|
<h3 class="text-base font-bold text-gray-900">{{ detailItem.title }}</h3>
|
||||||
|
</div>
|
||||||
|
<button class="w-7 h-7 rounded-full hover:bg-gray-100 flex items-center justify-center flex-shrink-0" @click="showDetail = false">
|
||||||
|
<X class="w-4 h-4 text-gray-400" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 mb-4">
|
||||||
|
<span class="text-[10px] text-gray-400">{{ new Date(detailItem.createdAt).toLocaleString('zh-CN') }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="bg-gray-50 rounded-xl p-4 max-h-[50vh] overflow-y-auto">
|
||||||
|
<p class="text-sm text-gray-600 leading-relaxed whitespace-pre-wrap">{{ detailItem.content }}</p>
|
||||||
|
</div>
|
||||||
|
<Button class="mt-4 w-full h-11 text-sm font-medium rounded-xl" @click="showDetail = false">
|
||||||
|
关闭
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
</Teleport>
|
||||||
|
|
||||||
|
<!-- ======== 强制弹出公告 Dialog ======== -->
|
||||||
|
<Teleport to="body">
|
||||||
|
<transition name="fade">
|
||||||
|
<div
|
||||||
|
v-if="currentForcePopup"
|
||||||
|
class="fixed inset-0 z-[60] flex items-center justify-center px-6"
|
||||||
|
>
|
||||||
|
<div class="absolute inset-0 bg-black/50" @click="handleForcePopupDismiss" />
|
||||||
|
<div class="relative w-full max-w-sm bg-white rounded-2xl shadow-2xl overflow-hidden">
|
||||||
|
<!-- 顶部装饰条 -->
|
||||||
|
<div class="h-1.5 bg-gradient-to-r from-blue-500 to-blue-400" />
|
||||||
|
<div class="p-6">
|
||||||
|
<div class="flex items-center gap-2.5 mb-2">
|
||||||
|
<div class="w-9 h-9 rounded-xl bg-blue-100 flex items-center justify-center">
|
||||||
|
<Bell class="w-5 h-5 text-blue-500" />
|
||||||
|
</div>
|
||||||
|
<h3 class="text-base font-bold text-gray-900">{{ currentForcePopup.title }}</h3>
|
||||||
|
</div>
|
||||||
|
<div class="bg-gray-50 rounded-xl p-4 mb-4">
|
||||||
|
<p class="text-sm text-gray-600 leading-relaxed whitespace-pre-wrap">{{ currentForcePopup.content }}</p>
|
||||||
|
</div>
|
||||||
|
<Button class="w-full h-11 text-sm font-medium rounded-xl" @click="handleForcePopupDismiss">
|
||||||
|
我知道了
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
</Teleport>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.fade-enter-active,
|
||||||
|
.fade-leave-active {
|
||||||
|
transition: opacity 0.25s ease;
|
||||||
|
}
|
||||||
|
.fade-enter-from,
|
||||||
|
.fade-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-up-enter-active,
|
||||||
|
.slide-up-leave-active {
|
||||||
|
transition: transform 0.3s cubic-bezier(0.32, 0.72, 0, 1);
|
||||||
|
}
|
||||||
|
.slide-up-enter-from,
|
||||||
|
.slide-up-leave-to {
|
||||||
|
transition: translateY(100%);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { type ClassValue, clsx } from 'clsx'
|
||||||
|
import { twMerge } from 'tailwind-merge'
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs))
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import App from './App.vue'
|
||||||
|
import router from './router'
|
||||||
|
import './assets/styles/globals.css'
|
||||||
|
|
||||||
|
const app = createApp(App)
|
||||||
|
app.use(router)
|
||||||
|
app.mount('#app')
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
|
import LoginView from '@/views/login/LoginView.vue'
|
||||||
|
import HomeView from '@/views/home/HomeView.vue'
|
||||||
|
import ProfileView from '@/views/profile/ProfileView.vue'
|
||||||
|
import BindDeviceView from '@/views/bind/BindDeviceView.vue'
|
||||||
|
import MobileLayout from '@/layouts/MobileLayout.vue'
|
||||||
|
|
||||||
|
import TicketList from '@/views/ticket/TicketList.vue'
|
||||||
|
import TicketDetail from '@/views/ticket/TicketDetail.vue'
|
||||||
|
|
||||||
|
// Admin views
|
||||||
|
import AdminSetup from '@/views/admin/AdminSetup.vue'
|
||||||
|
import AdminLogin from '@/views/admin/AdminLogin.vue'
|
||||||
|
import AdminLayout from '@/views/admin/AdminLayout.vue'
|
||||||
|
import AdminDashboard from '@/views/admin/AdminDashboard.vue'
|
||||||
|
import AdminAgents from '@/views/admin/AdminAgents.vue'
|
||||||
|
import AdminCdks from '@/views/admin/AdminCdks.vue'
|
||||||
|
import AdminSettings from '@/views/admin/AdminSettings.vue'
|
||||||
|
import AdminAnnouncements from '@/views/admin/AdminAnnouncements.vue'
|
||||||
|
import AdminTicketList from '@/views/admin/AdminTicketList.vue'
|
||||||
|
import AdminTicketDetail from '@/views/admin/AdminTicketDetail.vue'
|
||||||
|
import AdminPendingSchools from '@/views/admin/AdminPendingSchools.vue'
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
history: createWebHistory(),
|
||||||
|
routes: [
|
||||||
|
// ======== 管理后台路由(放在最前面确保优先级)========
|
||||||
|
{
|
||||||
|
path: '/admin/setup',
|
||||||
|
name: 'admin-setup',
|
||||||
|
component: AdminSetup,
|
||||||
|
meta: { admin: true, noAuth: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/admin/login',
|
||||||
|
name: 'admin-login',
|
||||||
|
component: AdminLogin,
|
||||||
|
meta: { admin: true, noAuth: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/admin',
|
||||||
|
component: AdminLayout,
|
||||||
|
meta: { admin: true, requiresAdminAuth: true },
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: '',
|
||||||
|
redirect: { name: 'admin-dashboard' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'dashboard',
|
||||||
|
name: 'admin-dashboard',
|
||||||
|
component: AdminDashboard,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'agents',
|
||||||
|
name: 'admin-agents',
|
||||||
|
component: AdminAgents,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'cdks',
|
||||||
|
name: 'admin-cdks',
|
||||||
|
component: AdminCdks,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'settings',
|
||||||
|
name: 'admin-settings',
|
||||||
|
component: AdminSettings,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'announcements',
|
||||||
|
name: 'admin-announcements',
|
||||||
|
component: AdminAnnouncements,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'tickets',
|
||||||
|
name: 'admin-tickets',
|
||||||
|
component: AdminTicketList,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'tickets/:id',
|
||||||
|
name: 'admin-ticket-detail',
|
||||||
|
component: AdminTicketDetail,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'pending-schools',
|
||||||
|
name: 'admin-pending-schools',
|
||||||
|
component: AdminPendingSchools,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
// ======== 普通用户路由 ========
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
redirect: '/login',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/login',
|
||||||
|
name: 'login',
|
||||||
|
component: LoginView,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/bind-device',
|
||||||
|
name: 'bind-device',
|
||||||
|
component: BindDeviceView,
|
||||||
|
meta: { requiresAuth: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/ticket',
|
||||||
|
name: 'ticket-list',
|
||||||
|
component: TicketList,
|
||||||
|
meta: { requiresAuth: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/ticket/:id',
|
||||||
|
name: 'ticket-detail',
|
||||||
|
component: TicketDetail,
|
||||||
|
meta: { requiresAuth: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '',
|
||||||
|
component: MobileLayout,
|
||||||
|
meta: { requiresAuth: true },
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: 'home',
|
||||||
|
name: 'home',
|
||||||
|
component: HomeView,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'profile',
|
||||||
|
name: 'profile',
|
||||||
|
component: ProfileView,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
router.beforeEach(async (to, _from) => {
|
||||||
|
// 调试:记录当前路径
|
||||||
|
try {
|
||||||
|
await fetch(`/api/admin/setup/debug?path=${encodeURIComponent(to.path)}`)
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
const token = localStorage.getItem('access_token')
|
||||||
|
const adminToken = localStorage.getItem('admin_token')
|
||||||
|
const path = to.path
|
||||||
|
|
||||||
|
// ======== 管理后台路由控制(基于路径前缀,不依赖 meta 继承)========
|
||||||
|
if (path.startsWith('/admin')) {
|
||||||
|
// 把 /admin 精确转发到 /admin/dashboard
|
||||||
|
if (path === '/admin') {
|
||||||
|
return '/admin/dashboard'
|
||||||
|
}
|
||||||
|
|
||||||
|
// admin-setup 和 admin-login 不需要登录
|
||||||
|
if (path === '/admin/setup' || path === '/admin/login') {
|
||||||
|
// 已登录直接进后台
|
||||||
|
if (adminToken) {
|
||||||
|
return '/admin/dashboard'
|
||||||
|
}
|
||||||
|
// 检测安装状态做自动跳转
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/setup/status')
|
||||||
|
const data = await res.json()
|
||||||
|
const installed = data?.data?.installed
|
||||||
|
if (path === '/admin/login' && !installed) {
|
||||||
|
return '/admin/setup'
|
||||||
|
}
|
||||||
|
if (path === '/admin/setup' && installed) {
|
||||||
|
return '/admin/login'
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 请求失败留在当前页
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 其他 /admin/* 路径需要管理员认证
|
||||||
|
if (!adminToken) {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/setup/status')
|
||||||
|
const data = await res.json()
|
||||||
|
if (data?.data?.installed) {
|
||||||
|
return '/admin/login'
|
||||||
|
} else {
|
||||||
|
return '/admin/setup'
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return '/admin/login'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// adminToken 存在但可能已过期,尝试验证
|
||||||
|
try {
|
||||||
|
const meRes = await fetch('/api/admin/me', {
|
||||||
|
headers: { Authorization: `Bearer ${adminToken}` },
|
||||||
|
})
|
||||||
|
if (!meRes.ok) {
|
||||||
|
// token 失效,清除并重定向
|
||||||
|
localStorage.removeItem('admin_token')
|
||||||
|
localStorage.removeItem('admin_info')
|
||||||
|
return '/admin/login'
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 网络错误,放行让组件自己处理
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// ======== 普通用户认证 ========
|
||||||
|
if (to.meta.requiresAuth && !token) {
|
||||||
|
return '/login'
|
||||||
|
}
|
||||||
|
if (to.name === 'login' && token) {
|
||||||
|
return '/home'
|
||||||
|
}
|
||||||
|
// 已登录用户访问根路径直接跳到 /home,避免路由级 redirect 链导致空白
|
||||||
|
if (to.path === '/' && token) {
|
||||||
|
return '/home'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
@@ -0,0 +1,442 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, computed } from 'vue'
|
||||||
|
import {
|
||||||
|
Users,
|
||||||
|
Search,
|
||||||
|
UserPlus,
|
||||||
|
DollarSign,
|
||||||
|
Percent,
|
||||||
|
X,
|
||||||
|
Eye,
|
||||||
|
EyeOff,
|
||||||
|
CheckCircle,
|
||||||
|
XCircle,
|
||||||
|
LoaderCircle,
|
||||||
|
Ban,
|
||||||
|
KeyRound,
|
||||||
|
} from 'lucide-vue-next'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { } from '@/components/ui/card'
|
||||||
|
import { useMessage } from '@/composables/useMessage'
|
||||||
|
import * as adminApi from '@/api/admin'
|
||||||
|
|
||||||
|
const message = useMessage()
|
||||||
|
|
||||||
|
const agents = ref<adminApi.AgentUser[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const searchQuery = ref('')
|
||||||
|
|
||||||
|
const adminInfo = ref<adminApi.AgentInfo | null>(null)
|
||||||
|
const isAdmin = computed(() => adminInfo.value?.role === 'admin')
|
||||||
|
|
||||||
|
// 创建代理弹窗
|
||||||
|
const showCreateModal = ref(false)
|
||||||
|
const createForm = ref({ username: '', password: '', rate: 1.2, balance: 0 })
|
||||||
|
const createLoading = ref(false)
|
||||||
|
|
||||||
|
// 充值弹窗
|
||||||
|
const showRechargeModal = ref(false)
|
||||||
|
const rechargeTarget = ref<adminApi.AgentUser | null>(null)
|
||||||
|
const rechargeAmount = ref(0)
|
||||||
|
const rechargeLoading = ref(false)
|
||||||
|
|
||||||
|
// 修改费率弹窗
|
||||||
|
const showRateModal = ref(false)
|
||||||
|
const rateTarget = ref<adminApi.AgentUser | null>(null)
|
||||||
|
const rateValue = ref(1.0)
|
||||||
|
const rateLoading = ref(false)
|
||||||
|
|
||||||
|
// 修改密码弹窗(admin only)
|
||||||
|
const showPasswordModal = ref(false)
|
||||||
|
const passwordTarget = ref<adminApi.AgentUser | null>(null)
|
||||||
|
const newPassword = ref('')
|
||||||
|
const showNewPassword = ref(false)
|
||||||
|
const passwordLoading = ref(false)
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
const infoStr = localStorage.getItem('admin_info')
|
||||||
|
if (infoStr) adminInfo.value = JSON.parse(infoStr)
|
||||||
|
} catch {}
|
||||||
|
await loadAgents()
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadAgents() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
if (isAdmin.value) {
|
||||||
|
const res = await adminApi.getAllAgents()
|
||||||
|
agents.value = res.data?.data || []
|
||||||
|
} else {
|
||||||
|
const res = await adminApi.getSubAgents()
|
||||||
|
agents.value = res.data?.data || []
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
message.error('加载代理列表失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredAgents = computed(() => {
|
||||||
|
if (!searchQuery.value.trim()) return agents.value
|
||||||
|
const q = searchQuery.value.toLowerCase()
|
||||||
|
return agents.value.filter((a) => a.username.toLowerCase().includes(q))
|
||||||
|
})
|
||||||
|
|
||||||
|
// 创建代理
|
||||||
|
async function handleCreateAgent() {
|
||||||
|
if (!createForm.value.username.trim()) { message.info('请输入用户名'); return }
|
||||||
|
if (!createForm.value.password || createForm.value.password.length < 6) { message.info('密码不能少于6位'); return }
|
||||||
|
if (createForm.value.rate <= 0) { message.info('费率必须大于0'); return }
|
||||||
|
if (createForm.value.balance < 0) { message.info('充值金额不能为负数'); return }
|
||||||
|
|
||||||
|
createLoading.value = true
|
||||||
|
try {
|
||||||
|
await adminApi.createSubAgent({
|
||||||
|
username: createForm.value.username.trim(),
|
||||||
|
password: createForm.value.password,
|
||||||
|
rate: createForm.value.rate,
|
||||||
|
balance: createForm.value.balance,
|
||||||
|
})
|
||||||
|
message.success('代理创建成功')
|
||||||
|
showCreateModal.value = false
|
||||||
|
createForm.value = { username: '', password: '', rate: 1.2, balance: 0 }
|
||||||
|
await loadAgents()
|
||||||
|
} catch (e: any) {
|
||||||
|
message.error(e?.response?.data?.message || e?.message || '创建失败')
|
||||||
|
} finally {
|
||||||
|
createLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 充值
|
||||||
|
function openRecharge(agent: adminApi.AgentUser) {
|
||||||
|
rechargeTarget.value = agent
|
||||||
|
rechargeAmount.value = 0
|
||||||
|
showRechargeModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRecharge() {
|
||||||
|
if (!rechargeTarget.value || rechargeAmount.value <= 0) { message.info('请输入有效金额'); return }
|
||||||
|
rechargeLoading.value = true
|
||||||
|
try {
|
||||||
|
await adminApi.updateSubAgent(rechargeTarget.value.id, { balance: rechargeAmount.value })
|
||||||
|
message.success(`已充值 ${rechargeAmount.value.toFixed(2)} 元`)
|
||||||
|
showRechargeModal.value = false
|
||||||
|
await loadAgents()
|
||||||
|
} catch (e: any) {
|
||||||
|
message.error(e?.response?.data?.message || e?.message || '充值失败')
|
||||||
|
} finally {
|
||||||
|
rechargeLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 修改费率
|
||||||
|
function openRate(agent: adminApi.AgentUser) {
|
||||||
|
rateTarget.value = agent
|
||||||
|
rateValue.value = agent.rate
|
||||||
|
showRateModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUpdateRate() {
|
||||||
|
if (!rateTarget.value || rateValue.value <= 0) { message.info('费率必须大于0'); return }
|
||||||
|
rateLoading.value = true
|
||||||
|
try {
|
||||||
|
await adminApi.updateSubAgent(rateTarget.value.id, { rate: rateValue.value })
|
||||||
|
message.success('费率已更新')
|
||||||
|
showRateModal.value = false
|
||||||
|
await loadAgents()
|
||||||
|
} catch (e: any) {
|
||||||
|
message.error(e?.response?.data?.message || e?.message || '更新失败')
|
||||||
|
} finally {
|
||||||
|
rateLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 修改密码 (admin only)
|
||||||
|
function openPassword(agent: adminApi.AgentUser) {
|
||||||
|
passwordTarget.value = agent
|
||||||
|
newPassword.value = ''
|
||||||
|
showPasswordModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUpdatePassword() {
|
||||||
|
if (!passwordTarget.value || !newPassword.value || newPassword.value.length < 6) {
|
||||||
|
message.info('密码不能少于6位'); return
|
||||||
|
}
|
||||||
|
passwordLoading.value = true
|
||||||
|
try {
|
||||||
|
await adminApi.updateAgentPassword(passwordTarget.value.id, newPassword.value)
|
||||||
|
message.success('密码已修改')
|
||||||
|
showPasswordModal.value = false
|
||||||
|
} catch (e: any) {
|
||||||
|
message.error(e?.response?.data?.message || e?.message || '修改失败')
|
||||||
|
} finally {
|
||||||
|
passwordLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 禁用/启用 (admin only)
|
||||||
|
async function handleToggleStatus(agent: adminApi.AgentUser) {
|
||||||
|
const newStatus = agent.status === 'active' ? 'disabled' : 'active'
|
||||||
|
try {
|
||||||
|
await adminApi.updateAgentStatus(agent.id, newStatus)
|
||||||
|
message.success(newStatus === 'active' ? '已启用' : '已禁用')
|
||||||
|
await loadAgents()
|
||||||
|
} catch (e: any) {
|
||||||
|
message.error(e?.response?.data?.message || e?.message || '操作失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="p-6">
|
||||||
|
<!-- 顶部 -->
|
||||||
|
<div class="flex items-center justify-between mb-6">
|
||||||
|
<h1 class="text-xl font-bold text-gray-900">代理管理</h1>
|
||||||
|
<Button variant="default" size="sm" @click="showCreateModal = true">
|
||||||
|
<UserPlus class="w-4 h-4 mr-1" />
|
||||||
|
创建代理
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 搜索 -->
|
||||||
|
<div class="relative mb-4">
|
||||||
|
<Search class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||||
|
<Input v-model="searchQuery" placeholder="搜索用户名..." class="pl-9 h-10 text-sm max-w-xs" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 列表 -->
|
||||||
|
<div class="overflow-x-auto rounded-xl border border-gray-200 bg-white shadow">
|
||||||
|
<div v-if="loading" class="flex items-center justify-center py-16">
|
||||||
|
<LoaderCircle class="w-6 h-6 animate-spin text-blue-500" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="filteredAgents.length === 0" class="text-center py-16 text-sm text-gray-400">
|
||||||
|
<Users class="w-12 h-12 mx-auto mb-3 text-gray-200" />
|
||||||
|
<p>暂无代理数据</p>
|
||||||
|
<p class="text-xs mt-1">点击右上角"创建代理"开始</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table v-else class="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr class="bg-gray-50 text-left text-xs text-gray-500">
|
||||||
|
<th class="px-4 py-3 font-medium">用户名</th>
|
||||||
|
<th class="px-4 py-3 font-medium">费率</th>
|
||||||
|
<th class="px-4 py-3 font-medium">余额</th>
|
||||||
|
<th class="px-4 py-3 font-medium">状态</th>
|
||||||
|
<th class="px-4 py-3 font-medium">上级</th>
|
||||||
|
<th class="px-4 py-3 font-medium">创建时间</th>
|
||||||
|
<th class="px-4 py-3 font-medium text-right">操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="agent in filteredAgents" :key="agent.id" class="border-t border-gray-50 hover:bg-gray-50/50 transition-colors">
|
||||||
|
<td class="px-4 py-3 font-medium text-gray-900 whitespace-nowrap">{{ agent.username }}</td>
|
||||||
|
<td class="px-4 py-3 whitespace-nowrap">{{ (agent.rate * 100).toFixed(0) }}%</td>
|
||||||
|
<td class="px-4 py-3 font-mono whitespace-nowrap">{{ agent.balance.toFixed(2) }}</td>
|
||||||
|
<td class="px-4 py-3 whitespace-nowrap">
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium whitespace-nowrap"
|
||||||
|
:class="agent.status === 'active' ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'"
|
||||||
|
>
|
||||||
|
<CheckCircle v-if="agent.status === 'active'" class="w-3 h-3 flex-shrink-0" />
|
||||||
|
<XCircle v-else class="w-3 h-3 flex-shrink-0" />
|
||||||
|
{{ agent.status === 'active' ? '正常' : '禁用' }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-gray-500 text-xs">
|
||||||
|
{{ agent.parentUsername || (agent.parentId ? 'ID:'+agent.parentId : '-') }}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-gray-500 text-xs">{{ new Date(agent.createdAt).toLocaleDateString() }}</td>
|
||||||
|
<td class="px-4 py-3 text-right">
|
||||||
|
<div class="flex items-center justify-end gap-1">
|
||||||
|
<button
|
||||||
|
class="p-1.5 rounded-lg hover:bg-blue-50 text-blue-600 transition-colors"
|
||||||
|
title="充值"
|
||||||
|
@click="openRecharge(agent)"
|
||||||
|
>
|
||||||
|
<DollarSign class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="p-1.5 rounded-lg hover:bg-green-50 text-green-600 transition-colors"
|
||||||
|
title="修改费率"
|
||||||
|
@click="openRate(agent)"
|
||||||
|
>
|
||||||
|
<Percent class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<template v-if="isAdmin">
|
||||||
|
<button
|
||||||
|
class="p-1.5 rounded-lg hover:bg-orange-50 text-orange-600 transition-colors"
|
||||||
|
title="修改密码"
|
||||||
|
@click="openPassword(agent)"
|
||||||
|
>
|
||||||
|
<KeyRound class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
<button
|
||||||
|
class="p-1.5 rounded-lg transition-colors"
|
||||||
|
:class="agent.status === 'active' ? 'hover:bg-red-50 text-red-600' : 'hover:bg-green-50 text-green-600'"
|
||||||
|
:title="agent.status === 'active' ? '禁用' : '启用'"
|
||||||
|
@click="handleToggleStatus(agent)"
|
||||||
|
>
|
||||||
|
<Ban v-if="agent.status === 'active'" class="w-4 h-4" />
|
||||||
|
<CheckCircle v-else class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 创建代理弹窗 -->
|
||||||
|
<Teleport to="body">
|
||||||
|
<div v-if="showCreateModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/40" @click.self="showCreateModal = false">
|
||||||
|
<div class="bg-white rounded-xl shadow-xl w-full max-w-md mx-4 p-6">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900">创建下级代理</h2>
|
||||||
|
<button class="text-gray-400 hover:text-gray-600" @click="showCreateModal = false">
|
||||||
|
<X class="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div>
|
||||||
|
<Label class="mb-1 block text-sm text-gray-600">用户名</Label>
|
||||||
|
<Input v-model="createForm.username" placeholder="代理登录名" class="h-10 text-sm" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label class="mb-1 block text-sm text-gray-600">密码</Label>
|
||||||
|
<Input v-model="createForm.password" type="password" placeholder="至少6位" class="h-10 text-sm" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label class="mb-1 block text-sm text-gray-600">费率倍率</Label>
|
||||||
|
<Input v-model="createForm.rate" type="number" step="0.1" min="1" class="h-10 text-sm" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label class="mb-1 block text-sm text-gray-600">初始充值金额(元)</Label>
|
||||||
|
<Input v-model="createForm.balance" type="number" step="10" min="0" class="h-10 text-sm" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-2 mt-6">
|
||||||
|
<Button variant="outline" class="flex-1 h-10" @click="showCreateModal = false">取消</Button>
|
||||||
|
<Button class="flex-1 h-10" :disabled="createLoading" @click="handleCreateAgent">
|
||||||
|
<LoaderCircle v-if="createLoading" class="w-4 h-4 animate-spin mr-1" />
|
||||||
|
创建
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
|
||||||
|
<!-- 充值弹窗 -->
|
||||||
|
<Teleport to="body">
|
||||||
|
<div v-if="showRechargeModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/40" @click.self="showRechargeModal = false">
|
||||||
|
<div class="bg-white rounded-xl shadow-xl w-full max-w-sm mx-4 p-6">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900">
|
||||||
|
充值 - {{ rechargeTarget?.username }}
|
||||||
|
<span class="text-sm font-normal text-gray-500 ml-2">当前余额: {{ rechargeTarget?.balance.toFixed(2) }}</span>
|
||||||
|
</h2>
|
||||||
|
<button class="text-gray-400 hover:text-gray-600" @click="showRechargeModal = false">
|
||||||
|
<X class="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label class="mb-1 block text-sm text-gray-600">充值金额(元)</Label>
|
||||||
|
<Input v-model.number="rechargeAmount" type="number" step="10" min="0" class="h-10 text-sm" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-2 mt-6">
|
||||||
|
<Button variant="outline" class="flex-1 h-10" @click="showRechargeModal = false">取消</Button>
|
||||||
|
<Button class="flex-1 h-10" :disabled="rechargeLoading || rechargeAmount <= 0" @click="handleRecharge">
|
||||||
|
<LoaderCircle v-if="rechargeLoading" class="w-4 h-4 animate-spin mr-1" />
|
||||||
|
充值
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
|
||||||
|
<!-- 修改费率弹窗 -->
|
||||||
|
<Teleport to="body">
|
||||||
|
<div v-if="showRateModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/40" @click.self="showRateModal = false">
|
||||||
|
<div class="bg-white rounded-xl shadow-xl w-full max-w-sm mx-4 p-6">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900">
|
||||||
|
修改费率 - {{ rateTarget?.username }}
|
||||||
|
<span class="text-sm font-normal text-gray-500 ml-2">当前: {{ rateTarget ? (rateTarget.rate * 100).toFixed(0) + '%' : '' }}</span>
|
||||||
|
</h2>
|
||||||
|
<button class="text-gray-400 hover:text-gray-600" @click="showRateModal = false">
|
||||||
|
<X class="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label class="mb-1 block text-sm text-gray-600">新费率倍率</Label>
|
||||||
|
<Input v-model.number="rateValue" type="number" step="0.1" min="1" class="h-10 text-sm" />
|
||||||
|
<p class="text-xs text-gray-400 mt-1">不得低于自身费率</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-2 mt-6">
|
||||||
|
<Button variant="outline" class="flex-1 h-10" @click="showRateModal = false">取消</Button>
|
||||||
|
<Button class="flex-1 h-10" :disabled="rateLoading || rateValue <= 0" @click="handleUpdateRate">
|
||||||
|
<LoaderCircle v-if="rateLoading" class="w-4 h-4 animate-spin mr-1" />
|
||||||
|
保存
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
|
||||||
|
<!-- 修改密码弹窗 (admin only) -->
|
||||||
|
<Teleport to="body">
|
||||||
|
<div v-if="showPasswordModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/40" @click.self="showPasswordModal = false">
|
||||||
|
<div class="bg-white rounded-xl shadow-xl w-full max-w-sm mx-4 p-6">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900">
|
||||||
|
修改密码 - {{ passwordTarget?.username }}
|
||||||
|
</h2>
|
||||||
|
<button class="text-gray-400 hover:text-gray-600" @click="showPasswordModal = false">
|
||||||
|
<X class="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label class="mb-1 block text-sm text-gray-600">新密码</Label>
|
||||||
|
<div class="relative">
|
||||||
|
<Input
|
||||||
|
:type="showNewPassword ? 'text' : 'password'"
|
||||||
|
v-model="newPassword"
|
||||||
|
placeholder="至少6位"
|
||||||
|
class="h-10 text-sm pr-9"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||||
|
@click="showNewPassword = !showNewPassword"
|
||||||
|
>
|
||||||
|
<Eye v-if="showNewPassword" class="h-4 w-4" />
|
||||||
|
<EyeOff v-else class="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-2 mt-6">
|
||||||
|
<Button variant="outline" class="flex-1 h-10" @click="showPasswordModal = false">取消</Button>
|
||||||
|
<Button class="flex-1 h-10" :disabled="passwordLoading || !newPassword || newPassword.length < 6" @click="handleUpdatePassword">
|
||||||
|
<LoaderCircle v-if="passwordLoading" class="w-4 h-4 animate-spin mr-1" />
|
||||||
|
确认修改
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,302 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { Megaphone, Plus, Trash2, X, Bell } from 'lucide-vue-next'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { useMessage } from '@/composables/useMessage'
|
||||||
|
import * as adminApi from '@/api/admin'
|
||||||
|
|
||||||
|
const message = useMessage()
|
||||||
|
|
||||||
|
const announcements = ref<adminApi.AnnouncementListItem[]>([])
|
||||||
|
const loading = ref(true)
|
||||||
|
const showCreateModal = ref(false)
|
||||||
|
|
||||||
|
// 创建表单
|
||||||
|
const formTitle = ref('')
|
||||||
|
const formContent = ref('')
|
||||||
|
const formTargetType = ref<'agent' | 'user'>('user')
|
||||||
|
const formForcePopup = ref(false)
|
||||||
|
const formLoopPopup = ref(false)
|
||||||
|
const creating = ref(false)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadAnnouncements()
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadAnnouncements() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await adminApi.getAnnouncements()
|
||||||
|
announcements.value = res.data.data || []
|
||||||
|
} catch {
|
||||||
|
announcements.value = []
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
formTitle.value = ''
|
||||||
|
formContent.value = ''
|
||||||
|
formTargetType.value = 'user'
|
||||||
|
formForcePopup.value = false
|
||||||
|
formLoopPopup.value = false
|
||||||
|
showCreateModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCreate() {
|
||||||
|
if (!formTitle.value.trim()) {
|
||||||
|
message.info('请输入公告标题')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!formContent.value.trim()) {
|
||||||
|
message.info('请输入公告内容')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
creating.value = true
|
||||||
|
try {
|
||||||
|
await adminApi.createAnnouncement({
|
||||||
|
title: formTitle.value.trim(),
|
||||||
|
content: formContent.value.trim(),
|
||||||
|
targetType: formTargetType.value,
|
||||||
|
forcePopup: formForcePopup.value,
|
||||||
|
loopPopup: formLoopPopup.value,
|
||||||
|
})
|
||||||
|
message.success('公告创建成功')
|
||||||
|
showCreateModal.value = false
|
||||||
|
await loadAnnouncements()
|
||||||
|
} catch (e: any) {
|
||||||
|
message.error(e?.response?.data?.message || '创建失败')
|
||||||
|
} finally {
|
||||||
|
creating.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(id: number) {
|
||||||
|
if (!confirm('确定要删除此公告吗?')) return
|
||||||
|
try {
|
||||||
|
await adminApi.deleteAnnouncement(id)
|
||||||
|
message.success('已删除')
|
||||||
|
await loadAnnouncements()
|
||||||
|
} catch {
|
||||||
|
message.error('删除失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function targetLabel(t: string) {
|
||||||
|
return t === 'agent' ? '代理' : '普通用户'
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="p-6">
|
||||||
|
<!-- 标题栏 -->
|
||||||
|
<div class="flex items-center justify-between mb-6">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="w-10 h-10 rounded-xl bg-purple-100 flex items-center justify-center">
|
||||||
|
<Megaphone class="w-5 h-5 text-purple-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 class="text-lg font-bold text-gray-900">公告管理</h1>
|
||||||
|
<p class="text-sm text-gray-400">发布与管理系统公告</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button @click="openCreate" class="h-10 text-sm">
|
||||||
|
<Plus class="w-4 h-4" />
|
||||||
|
新建公告
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 表格 -->
|
||||||
|
<div class="bg-white rounded-xl border border-gray-200 overflow-hidden">
|
||||||
|
<!-- 加载骨架 -->
|
||||||
|
<div v-if="loading" class="p-6 space-y-4">
|
||||||
|
<div v-for="i in 4" :key="i" class="h-10 bg-gray-50 rounded animate-pulse" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 空状态 -->
|
||||||
|
<div v-else-if="announcements.length === 0" class="text-center py-20 text-gray-400">
|
||||||
|
<Megaphone class="w-12 h-12 mx-auto mb-3 opacity-30" />
|
||||||
|
<p class="text-sm">暂无公告</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 表格 -->
|
||||||
|
<table v-else class="w-full text-sm table-fixed">
|
||||||
|
<thead>
|
||||||
|
<tr class="bg-gray-50 text-left text-xs text-gray-500">
|
||||||
|
<th class="px-4 py-3 font-medium w-[20%]">标题</th>
|
||||||
|
<th class="px-4 py-3 font-medium w-[28%]">内容</th>
|
||||||
|
<th class="px-4 py-3 font-medium w-[10%]">推送对象</th>
|
||||||
|
<th class="px-4 py-3 font-medium w-[8%]">强制弹窗</th>
|
||||||
|
<th class="px-4 py-3 font-medium w-[8%]">循环弹窗</th>
|
||||||
|
<th class="px-4 py-3 font-medium w-[14%]">阅读人数</th>
|
||||||
|
<th class="px-4 py-3 font-medium w-[18%]">创建时间</th>
|
||||||
|
<th class="px-4 py-3 font-medium text-center w-[10%]">操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr
|
||||||
|
v-for="item in announcements"
|
||||||
|
:key="item.id"
|
||||||
|
class="border-t border-gray-50 hover:bg-gray-50/50 transition-colors"
|
||||||
|
>
|
||||||
|
<!-- 标题 -->
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<span class="font-medium text-gray-900 truncate block">{{ item.title }}</span>
|
||||||
|
</td>
|
||||||
|
<!-- 内容 -->
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<p class="text-gray-500 truncate block">{{ item.content }}</p>
|
||||||
|
</td>
|
||||||
|
<!-- 推送对象 -->
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<span
|
||||||
|
class="text-[10px] px-1.5 py-0.5 rounded-full font-medium"
|
||||||
|
:class="item.targetType === 'agent' ? 'bg-amber-100 text-amber-700' : 'bg-blue-100 text-blue-700'"
|
||||||
|
>
|
||||||
|
{{ targetLabel(item.targetType) }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<!-- 强制弹窗 -->
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<span
|
||||||
|
class="text-xs font-medium"
|
||||||
|
:class="item.forcePopup ? 'text-red-500' : 'text-gray-400'"
|
||||||
|
>
|
||||||
|
{{ item.forcePopup ? '是' : '否' }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<!-- 循环弹窗 -->
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<span
|
||||||
|
class="text-xs font-medium"
|
||||||
|
:class="item.loopPopup ? 'text-blue-500' : 'text-gray-400'"
|
||||||
|
>
|
||||||
|
{{ item.loopPopup ? '是' : '否' }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<!-- 阅读人数 -->
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<div class="flex items-center gap-1.5">
|
||||||
|
<div class="flex-1 h-1.5 rounded-full bg-gray-100 overflow-hidden max-w-[50px]">
|
||||||
|
<div
|
||||||
|
class="h-full rounded-full transition-all"
|
||||||
|
:class="item.totalTargets > 0 && item.readCount >= item.totalTargets ? 'bg-green-400' : 'bg-blue-400'"
|
||||||
|
:style="{ width: item.totalTargets > 0 ? (item.readCount / item.totalTargets * 100) + '%' : '0%' }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span class="text-gray-500 text-xs whitespace-nowrap">{{ item.readCount }}/{{ item.totalTargets }}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<!-- 创建时间 -->
|
||||||
|
<td class="px-4 py-3 text-gray-500 text-xs whitespace-nowrap">{{ new Date(item.createdAt).toLocaleString('zh-CN') }}</td>
|
||||||
|
<!-- 操作 -->
|
||||||
|
<td class="px-4 py-3 text-center whitespace-nowrap">
|
||||||
|
<button
|
||||||
|
class="inline-flex items-center gap-1 px-2 py-1 rounded-lg text-xs text-red-500 hover:bg-red-50 transition-colors"
|
||||||
|
@click="handleDelete(item.id)"
|
||||||
|
>
|
||||||
|
<Trash2 class="w-3.5 h-3.5" />
|
||||||
|
删除
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 创建弹窗 -->
|
||||||
|
<Teleport to="body">
|
||||||
|
<div
|
||||||
|
v-if="showCreateModal"
|
||||||
|
class="fixed inset-0 z-50 flex items-center justify-center"
|
||||||
|
>
|
||||||
|
<div class="absolute inset-0 bg-black/40" @click="showCreateModal = false" />
|
||||||
|
<div class="relative w-[85%] sm:max-w-md bg-white rounded-2xl shadow-xl overflow-hidden">
|
||||||
|
<div class="flex items-center justify-between px-5 pt-5 pb-3">
|
||||||
|
<h3 class="text-base font-semibold text-gray-900">新建公告</h3>
|
||||||
|
<button class="w-7 h-7 rounded-full bg-gray-100 flex items-center justify-center text-gray-400 hover:text-gray-600" @click="showCreateModal = false">
|
||||||
|
<X class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="px-5 pb-5 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-medium text-gray-500 mb-1.5">公告标题</label>
|
||||||
|
<Input v-model="formTitle" placeholder="请输入公告标题" class="h-10 text-sm" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-medium text-gray-500 mb-1.5">公告内容</label>
|
||||||
|
<textarea
|
||||||
|
v-model="formContent"
|
||||||
|
placeholder="请输入公告内容"
|
||||||
|
class="w-full h-24 px-3 py-2 text-sm border border-gray-200 rounded-lg resize-none focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-medium text-gray-500 mb-1.5">推送对象</label>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button
|
||||||
|
class="flex-1 h-10 rounded-lg text-sm font-medium transition-colors"
|
||||||
|
:class="formTargetType === 'user' ? 'bg-blue-500 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'"
|
||||||
|
@click="formTargetType = 'user'"
|
||||||
|
>普通用户</button>
|
||||||
|
<button
|
||||||
|
class="flex-1 h-10 rounded-lg text-sm font-medium transition-colors"
|
||||||
|
:class="formTargetType === 'agent' ? 'bg-blue-500 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'"
|
||||||
|
@click="formTargetType = 'agent'"
|
||||||
|
>代理</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
class="relative w-10 h-5 rounded-full transition-colors flex-shrink-0"
|
||||||
|
:class="formForcePopup ? 'bg-blue-500' : 'bg-gray-300'"
|
||||||
|
@click="formForcePopup = !formForcePopup"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="absolute top-0.5 w-4 h-4 rounded-full bg-white shadow transition-transform"
|
||||||
|
:class="formForcePopup ? 'translate-x-[22px]' : 'translate-x-0.5'"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<div>
|
||||||
|
<label class="text-sm font-medium text-gray-700">强制弹出</label>
|
||||||
|
<p class="text-[10px] text-gray-400">用户未读时自动弹出公告对话框</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
class="relative w-10 h-5 rounded-full transition-colors flex-shrink-0"
|
||||||
|
:class="formLoopPopup ? 'bg-blue-500' : 'bg-gray-300'"
|
||||||
|
@click="formLoopPopup = !formLoopPopup"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="absolute top-0.5 w-4 h-4 rounded-full bg-white shadow transition-transform"
|
||||||
|
:class="formLoopPopup ? 'translate-x-[22px]' : 'translate-x-0.5'"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<div>
|
||||||
|
<label class="text-sm font-medium text-gray-700">循环弹窗</label>
|
||||||
|
<p class="text-[10px] text-gray-400">每次刷新页面或进入首页都会强制弹窗(无视已读状态,需同时开启强制弹窗)</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
class="w-full h-10 text-sm font-medium mt-2"
|
||||||
|
:disabled="creating"
|
||||||
|
@click="handleCreate"
|
||||||
|
>
|
||||||
|
{{ creating ? '创建中...' : '发布公告' }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, computed } from 'vue'
|
||||||
|
import { QrCode, Plus, Search, X, LoaderCircle, Copy, CheckCheck } from 'lucide-vue-next'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { useMessage } from '@/composables/useMessage'
|
||||||
|
import * as adminApi from '@/api/admin'
|
||||||
|
|
||||||
|
const message = useMessage()
|
||||||
|
|
||||||
|
const cdks = ref<adminApi.CDKCode[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const searchQuery = ref('')
|
||||||
|
const copiedIndex = ref<number | null>(null)
|
||||||
|
|
||||||
|
const adminInfo = ref<adminApi.AgentInfo | null>(null)
|
||||||
|
const isAdmin = computed(() => adminInfo.value?.role === 'admin')
|
||||||
|
|
||||||
|
// 生成CDK弹窗
|
||||||
|
const showGenerateModal = ref(false)
|
||||||
|
const generateCount = ref(1)
|
||||||
|
const generateMaxRuns = ref(1)
|
||||||
|
const generateLoading = ref(false)
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
const infoStr = localStorage.getItem('admin_info')
|
||||||
|
if (infoStr) adminInfo.value = JSON.parse(infoStr)
|
||||||
|
} catch {}
|
||||||
|
await loadCDKs()
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadCDKs() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
if (isAdmin.value) {
|
||||||
|
const res = await adminApi.getAllCDKs()
|
||||||
|
cdks.value = res.data?.data || []
|
||||||
|
} else {
|
||||||
|
const res = await adminApi.getMyCDKs()
|
||||||
|
cdks.value = res.data?.data || []
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
message.error('加载CDK列表失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredCDKs = computed(() => {
|
||||||
|
if (!searchQuery.value.trim()) return cdks.value
|
||||||
|
const q = searchQuery.value.toLowerCase()
|
||||||
|
return cdks.value.filter((c) => c.code.toLowerCase().includes(q))
|
||||||
|
})
|
||||||
|
|
||||||
|
async function handleGenerate() {
|
||||||
|
if (generateCount.value <= 0 || generateCount.value > 100) {
|
||||||
|
message.info('数量范围为 1-100')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (generateMaxRuns.value <= 0 || generateMaxRuns.value > 999) {
|
||||||
|
message.info('运行次数范围为 1-999')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
generateLoading.value = true
|
||||||
|
try {
|
||||||
|
const res = await adminApi.generateCDK(generateCount.value, generateMaxRuns.value)
|
||||||
|
const data = res.data?.data
|
||||||
|
message.success(`成功生成 ${data?.count || 0} 个CDK(每张 ${data?.maxRuns || 1} 次)`)
|
||||||
|
showGenerateModal.value = false
|
||||||
|
generateCount.value = 1
|
||||||
|
generateMaxRuns.value = 1
|
||||||
|
await loadCDKs()
|
||||||
|
} catch (e: any) {
|
||||||
|
message.error(e?.response?.data?.message || e?.message || '生成失败')
|
||||||
|
} finally {
|
||||||
|
generateLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyCode(code: string, index: number) {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(code)
|
||||||
|
copiedIndex.value = index
|
||||||
|
setTimeout(() => { copiedIndex.value = null }, 2000)
|
||||||
|
} catch {
|
||||||
|
// fallback
|
||||||
|
const ta = document.createElement('textarea')
|
||||||
|
ta.value = code
|
||||||
|
document.body.appendChild(ta)
|
||||||
|
ta.select()
|
||||||
|
document.execCommand('copy')
|
||||||
|
document.body.removeChild(ta)
|
||||||
|
copiedIndex.value = index
|
||||||
|
setTimeout(() => { copiedIndex.value = null }, 2000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 统计 */
|
||||||
|
const stats = computed(() => ({
|
||||||
|
total: cdks.value.length,
|
||||||
|
used: cdks.value.filter((c) => c.status === 'used').length,
|
||||||
|
unused: cdks.value.filter((c) => c.status === 'unused').length,
|
||||||
|
}))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="p-6">
|
||||||
|
<!-- 顶部 -->
|
||||||
|
<div class="flex items-center justify-between mb-6">
|
||||||
|
<h1 class="text-xl font-bold text-gray-900">CDK管理</h1>
|
||||||
|
<Button variant="default" size="sm" @click="showGenerateModal = true">
|
||||||
|
<Plus class="w-4 h-4 mr-1" />
|
||||||
|
生成CDK
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 统计 -->
|
||||||
|
<div class="flex gap-4 mb-4">
|
||||||
|
<div class="bg-white rounded-lg border border-gray-200 px-4 py-2.5 flex items-center gap-2">
|
||||||
|
<span class="text-xs text-gray-500">总计</span>
|
||||||
|
<span class="text-lg font-bold text-gray-900">{{ stats.total }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white rounded-lg border border-green-200 px-4 py-2.5 flex items-center gap-2">
|
||||||
|
<span class="text-xs text-green-600">未使用</span>
|
||||||
|
<span class="text-lg font-bold text-green-700">{{ stats.unused }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white rounded-lg border border-gray-200 px-4 py-2.5 flex items-center gap-2">
|
||||||
|
<span class="text-xs text-gray-500">已使用</span>
|
||||||
|
<span class="text-lg font-bold text-gray-700">{{ stats.used }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 搜索 -->
|
||||||
|
<div class="relative mb-4">
|
||||||
|
<Search class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||||
|
<Input v-model="searchQuery" placeholder="搜索CDK码..." class="pl-9 h-10 text-sm max-w-xs" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 列表 -->
|
||||||
|
<div class="overflow-x-auto rounded-xl border border-gray-200 bg-white shadow">
|
||||||
|
<div v-if="loading" class="flex items-center justify-center py-16">
|
||||||
|
<LoaderCircle class="w-6 h-6 animate-spin text-blue-500" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="filteredCDKs.length === 0" class="text-center py-16 text-sm text-gray-400">
|
||||||
|
<QrCode class="w-12 h-12 mx-auto mb-3 text-gray-200" />
|
||||||
|
<p>暂无CDK数据</p>
|
||||||
|
<p class="text-xs mt-1">点击右上角"生成CDK"开始创建</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table v-else class="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr class="bg-gray-50 text-left text-xs text-gray-500">
|
||||||
|
<th class="px-4 py-3 font-medium">CDK码</th>
|
||||||
|
<th class="px-4 py-3 font-medium">售价</th>
|
||||||
|
<th class="px-4 py-3 font-medium">次数</th>
|
||||||
|
<th class="px-4 py-3 font-medium">状态</th>
|
||||||
|
<th class="px-4 py-3 font-medium">创建时间</th>
|
||||||
|
<th class="px-4 py-3 font-medium">操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="(cdk, idx) in filteredCDKs" :key="cdk.id" class="border-t border-gray-50 hover:bg-gray-50/50 transition-colors">
|
||||||
|
<td class="px-4 py-3 whitespace-nowrap">
|
||||||
|
<code class="font-mono text-xs bg-gray-100 px-2 py-1 rounded">{{ cdk.code }}</code>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 font-mono whitespace-nowrap">{{ cdk.price.toFixed(2) }} 元</td>
|
||||||
|
<td class="px-4 py-3 text-xs text-gray-600 whitespace-nowrap">
|
||||||
|
{{ cdk.maxRuns ?? 1 }}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 whitespace-nowrap">
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium whitespace-nowrap"
|
||||||
|
:class="cdk.status === 'used' ? 'bg-gray-100 text-gray-600' : 'bg-green-100 text-green-700'"
|
||||||
|
>
|
||||||
|
{{ cdk.status === 'used' ? '已使用' : '未使用' }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-gray-500 text-xs whitespace-nowrap">{{ new Date(cdk.createdAt).toLocaleString() }}</td>
|
||||||
|
<td class="px-4 py-3 whitespace-nowrap">
|
||||||
|
<button
|
||||||
|
class="inline-flex items-center gap-1 px-2 py-1 rounded-lg text-xs transition-colors"
|
||||||
|
:class="copiedIndex === idx ? 'bg-green-100 text-green-700' : 'bg-blue-50 text-blue-600 hover:bg-blue-100'"
|
||||||
|
@click="copyCode(cdk.code, idx)"
|
||||||
|
>
|
||||||
|
<Copy v-if="copiedIndex !== idx" class="w-3 h-3" />
|
||||||
|
<CheckCheck v-else class="w-3 h-3" />
|
||||||
|
{{ copiedIndex === idx ? '已复制' : '复制' }}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 生成CDK弹窗 -->
|
||||||
|
<Teleport to="body">
|
||||||
|
<div v-if="showGenerateModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/40" @click.self="showGenerateModal = false">
|
||||||
|
<div class="bg-white rounded-xl shadow-xl w-full max-w-sm mx-4 p-6">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900">生成CDK</h2>
|
||||||
|
<button class="text-gray-400 hover:text-gray-600" @click="showGenerateModal = false">
|
||||||
|
<X class="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div>
|
||||||
|
<Label class="mb-1 block text-sm text-gray-600">生成数量</Label>
|
||||||
|
<Input v-model.number="generateCount" type="number" min="1" max="100" class="h-10 text-sm" />
|
||||||
|
<p class="text-xs text-gray-400 mt-1">单次最多100个</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label class="mb-1 block text-sm text-gray-600">每张可运行次数</Label>
|
||||||
|
<Input v-model.number="generateMaxRuns" type="number" min="1" max="999" class="h-10 text-sm" />
|
||||||
|
<p class="text-xs text-gray-400 mt-1">每张CDK可兑换使用的次数,成本 = 基础单价 × 次数 × 费率</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-2 mt-6">
|
||||||
|
<Button variant="outline" class="flex-1 h-10" @click="showGenerateModal = false">取消</Button>
|
||||||
|
<Button class="flex-1 h-10" :disabled="generateLoading" @click="handleGenerate">
|
||||||
|
<LoaderCircle v-if="generateLoading" class="w-4 h-4 animate-spin mr-1" />
|
||||||
|
{{ generateLoading ? '生成中...' : '生成' }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { Users, QrCode, DollarSign, Activity } from 'lucide-vue-next'
|
||||||
|
import { Card } from '@/components/ui/card'
|
||||||
|
import { useMessage } from '@/composables/useMessage'
|
||||||
|
import * as adminApi from '@/api/admin'
|
||||||
|
|
||||||
|
const message = useMessage()
|
||||||
|
|
||||||
|
const me = ref<adminApi.AgentInfo | null>(null)
|
||||||
|
const subAgents = ref<adminApi.AgentUser[]>([])
|
||||||
|
const cdks = ref<adminApi.CDKCode[]>([])
|
||||||
|
|
||||||
|
const stats = ref({
|
||||||
|
totalAgents: 0,
|
||||||
|
activeAgents: 0,
|
||||||
|
totalCDKs: 0,
|
||||||
|
usedCDKs: 0,
|
||||||
|
balance: 0,
|
||||||
|
rate: 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
// 并行加载
|
||||||
|
const [meRes, agentRes, cdkRes] = await Promise.all([
|
||||||
|
adminApi.getAdminMe(),
|
||||||
|
adminApi.getSubAgents(),
|
||||||
|
adminApi.getMyCDKs(),
|
||||||
|
])
|
||||||
|
|
||||||
|
me.value = meRes.data?.data || null
|
||||||
|
subAgents.value = agentRes.data?.data || []
|
||||||
|
cdks.value = cdkRes.data?.data || []
|
||||||
|
|
||||||
|
const agents = agentRes.data?.data || []
|
||||||
|
stats.value = {
|
||||||
|
totalAgents: agents.length,
|
||||||
|
activeAgents: agents.filter((a) => a.status === 'active').length,
|
||||||
|
totalCDKs: cdks.value.length,
|
||||||
|
usedCDKs: cdks.value.filter((c) => c.status === 'used').length,
|
||||||
|
balance: me.value?.balance || 0,
|
||||||
|
rate: me.value?.rate || 1,
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
message.error('加载数据失败')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="p-6">
|
||||||
|
<h1 class="text-xl font-bold text-gray-900 mb-6">仪表盘</h1>
|
||||||
|
|
||||||
|
<!-- 统计卡片 -->
|
||||||
|
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
|
||||||
|
<Card class="p-4">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="w-10 h-10 rounded-lg bg-blue-100 text-blue-600 flex items-center justify-center">
|
||||||
|
<DollarSign class="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-xs text-gray-500">账户余额</p>
|
||||||
|
<p class="text-lg font-bold text-gray-900">{{ stats.balance.toFixed(2) }} 元</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card class="p-4">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="w-10 h-10 rounded-lg bg-green-100 text-green-600 flex items-center justify-center">
|
||||||
|
<Activity class="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-xs text-gray-500">费率</p>
|
||||||
|
<p class="text-lg font-bold text-gray-900">{{ (stats.rate * 100).toFixed(0) }}%</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card class="p-4">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="w-10 h-10 rounded-lg bg-purple-100 text-purple-600 flex items-center justify-center">
|
||||||
|
<Users class="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-xs text-gray-500">下级代理</p>
|
||||||
|
<p class="text-lg font-bold text-gray-900">{{ stats.totalAgents }}</p>
|
||||||
|
<p class="text-xs text-green-600">{{ stats.activeAgents }} 个活跃</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card class="p-4">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="w-10 h-10 rounded-lg bg-orange-100 text-orange-600 flex items-center justify-center">
|
||||||
|
<QrCode class="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-xs text-gray-500">CDK总数</p>
|
||||||
|
<p class="text-lg font-bold text-gray-900">{{ stats.totalCDKs }}</p>
|
||||||
|
<p class="text-xs text-green-600">{{ stats.usedCDKs }} 个已使用</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 最近CDK -->
|
||||||
|
<Card class="p-5">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<h2 class="font-semibold text-gray-900">最近生成的CDK</h2>
|
||||||
|
<router-link to="/admin/cdks" class="text-xs text-blue-600 hover:underline">查看全部</router-link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="cdks.length === 0" class="text-center py-8 text-sm text-gray-400">
|
||||||
|
暂无CDK数据
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="overflow-x-auto">
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr class="text-left text-xs text-gray-500 border-b border-gray-100">
|
||||||
|
<th class="pb-2 font-medium">CDK码</th>
|
||||||
|
<th class="pb-2 font-medium">售价</th>
|
||||||
|
<th class="pb-2 font-medium">状态</th>
|
||||||
|
<th class="pb-2 font-medium">创建时间</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="cdk in cdks.slice(0, 10)" :key="cdk.id" class="border-b border-gray-50">
|
||||||
|
<td class="py-2.5 font-mono text-xs">{{ cdk.code }}</td>
|
||||||
|
<td class="py-2.5">{{ cdk.price.toFixed(2) }} 元</td>
|
||||||
|
<td class="py-2.5">
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium"
|
||||||
|
:class="cdk.status === 'used' ? 'bg-gray-100 text-gray-600' : 'bg-green-100 text-green-700'"
|
||||||
|
>
|
||||||
|
{{ cdk.status === 'used' ? '已使用' : '未使用' }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="py-2.5 text-gray-500">{{ new Date(cdk.createdAt).toLocaleString() }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,394 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
|
import {
|
||||||
|
LayoutDashboard,
|
||||||
|
Users,
|
||||||
|
QrCode,
|
||||||
|
LogOut,
|
||||||
|
ChevronDown,
|
||||||
|
Shield,
|
||||||
|
Menu,
|
||||||
|
X,
|
||||||
|
Settings,
|
||||||
|
Megaphone,
|
||||||
|
Bell,
|
||||||
|
CheckCircle,
|
||||||
|
Circle,
|
||||||
|
MessageSquare,
|
||||||
|
School,
|
||||||
|
} from 'lucide-vue-next'
|
||||||
|
import { useMessage } from '@/composables/useMessage'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import type { AgentInfo } from '@/api/admin'
|
||||||
|
import * as announceApi from '@/api/announcement'
|
||||||
|
import type { AnnouncementItem } from '@/api/announcement'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const route = useRoute()
|
||||||
|
const message = useMessage()
|
||||||
|
|
||||||
|
const adminInfo = ref<AgentInfo | null>(null)
|
||||||
|
const showUserMenu = ref(false)
|
||||||
|
const showSidebar = ref(false)
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ name: 'admin-dashboard', label: '仪表盘', icon: LayoutDashboard, path: '/admin/dashboard' },
|
||||||
|
{ name: 'admin-agents', label: '代理管理', icon: Users, path: '/admin/agents' },
|
||||||
|
{ name: 'admin-cdks', label: 'CDK管理', icon: QrCode, path: '/admin/cdks' },
|
||||||
|
{ name: 'admin-announcements', label: '公告管理', icon: Megaphone, path: '/admin/announcements' },
|
||||||
|
{ name: 'admin-tickets', label: '工单管理', icon: MessageSquare, path: '/admin/tickets' },
|
||||||
|
{ name: 'admin-pending-schools', label: '待适配学校', icon: School, path: '/admin/pending-schools' },
|
||||||
|
{ name: 'admin-settings', label: '系统设置', icon: Settings, path: '/admin/settings' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const activeNav = computed(() => route.name as string)
|
||||||
|
|
||||||
|
// 公告相关(代理端)
|
||||||
|
const showAnnPanel = ref(false)
|
||||||
|
const announcements = ref<AnnouncementItem[]>([])
|
||||||
|
const loadingAnnouncements = ref(false)
|
||||||
|
const unreadCount = ref(0)
|
||||||
|
// 强制弹窗(支持队列依次弹出)
|
||||||
|
const forcePopupQueue = ref<AnnouncementItem[]>([])
|
||||||
|
const currentForcePopup = computed(() => forcePopupQueue.value[0] || null)
|
||||||
|
|
||||||
|
// 公告详情
|
||||||
|
const showDetail = ref(false)
|
||||||
|
const detailItem = ref<AnnouncementItem | null>(null)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
try {
|
||||||
|
const info = localStorage.getItem('admin_info')
|
||||||
|
if (info) {
|
||||||
|
adminInfo.value = JSON.parse(info)
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
// 加载公告
|
||||||
|
loadAgentAnnouncements()
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadAgentAnnouncements() {
|
||||||
|
try {
|
||||||
|
// 获取公告聚合信息(未读数 + 待弹窗队列,由后端计算)
|
||||||
|
const res = await announceApi.getAgentAnnouncementInfo()
|
||||||
|
unreadCount.value = res.data.data.unreadCount
|
||||||
|
forcePopupQueue.value = res.data.data.popupQueue || []
|
||||||
|
|
||||||
|
// 同时加载完整列表供面板展示
|
||||||
|
const listRes = await announceApi.getAgentAnnouncements()
|
||||||
|
announcements.value = listRes.data.data || []
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openAnnPanel() {
|
||||||
|
showAnnPanel.value = true
|
||||||
|
loadingAnnouncements.value = true
|
||||||
|
try {
|
||||||
|
const [listRes, countRes] = await Promise.all([
|
||||||
|
announceApi.getAgentAnnouncements(),
|
||||||
|
announceApi.getAgentUnreadCount(),
|
||||||
|
])
|
||||||
|
announcements.value = listRes.data.data || []
|
||||||
|
unreadCount.value = countRes.data.data?.unread || 0
|
||||||
|
} catch {
|
||||||
|
announcements.value = []
|
||||||
|
} finally {
|
||||||
|
loadingAnnouncements.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleMarkRead(id: number) {
|
||||||
|
try {
|
||||||
|
await announceApi.agentMarkAsRead(id)
|
||||||
|
const item = announcements.value.find(a => a.id === id)
|
||||||
|
if (item) item.isRead = true
|
||||||
|
unreadCount.value = announcements.value.filter(a => !a.isRead).length
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openAgentDetail(item: AnnouncementItem) {
|
||||||
|
detailItem.value = item
|
||||||
|
showDetail.value = true
|
||||||
|
if (!item.isRead) {
|
||||||
|
handleMarkRead(item.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function dismissForcePopup() {
|
||||||
|
const current = forcePopupQueue.value[0]
|
||||||
|
if (!current) return
|
||||||
|
// 标记已读(用于后端统计,循环弹窗靠队列过滤条件下次继续弹出)
|
||||||
|
handleMarkRead(current.id)
|
||||||
|
forcePopupQueue.value = forcePopupQueue.value.slice(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function navigateTo(item: typeof navItems[0]) {
|
||||||
|
router.push({ name: item.name })
|
||||||
|
showSidebar.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSidebar() {
|
||||||
|
showSidebar.value = !showSidebar.value
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleLogout() {
|
||||||
|
localStorage.removeItem('admin_token')
|
||||||
|
localStorage.removeItem('admin_info')
|
||||||
|
message.success('已退出')
|
||||||
|
router.replace('/admin/login')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="min-h-screen bg-gray-100 flex">
|
||||||
|
<!-- 手机端顶栏 -->
|
||||||
|
<div class="fixed top-0 left-0 right-0 h-14 bg-white border-b border-gray-200 flex items-center px-4 gap-3 z-30 md:hidden">
|
||||||
|
<button
|
||||||
|
class="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-gray-100 transition-colors"
|
||||||
|
@click="toggleSidebar"
|
||||||
|
>
|
||||||
|
<Menu v-if="!showSidebar" class="w-5 h-5 text-gray-700" />
|
||||||
|
<X v-else class="w-5 h-5 text-gray-700" />
|
||||||
|
</button>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div class="w-7 h-7 rounded-md bg-blue-600 flex items-center justify-center">
|
||||||
|
<Shield class="w-3.5 h-3.5 text-white" />
|
||||||
|
</div>
|
||||||
|
<span class="font-medium text-gray-900 text-sm">管理后台</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1" />
|
||||||
|
<!-- 手机端铃铛 -->
|
||||||
|
<button class="relative w-8 h-8 flex items-center justify-center rounded-lg hover:bg-gray-100" @click="openAnnPanel">
|
||||||
|
<Bell class="w-5 h-5 text-gray-600" />
|
||||||
|
<span v-if="unreadCount > 0" class="absolute -top-0.5 -right-0.5 min-w-[16px] h-4 px-1 rounded-full bg-red-500 text-white text-[9px] font-bold flex items-center justify-center leading-none">{{ unreadCount > 99 ? '99+' : unreadCount }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 手机端遮罩层 -->
|
||||||
|
<transition name="fade">
|
||||||
|
<div
|
||||||
|
v-if="showSidebar"
|
||||||
|
class="fixed inset-0 bg-black/40 z-40 md:hidden"
|
||||||
|
@click="showSidebar = false"
|
||||||
|
/>
|
||||||
|
</transition>
|
||||||
|
|
||||||
|
<!-- 左侧导航 -->
|
||||||
|
<aside
|
||||||
|
class="bg-white border-r border-gray-200 flex flex-col flex-shrink-0 z-50
|
||||||
|
fixed inset-y-0 left-0 w-60 transition-transform duration-200 ease-in-out
|
||||||
|
md:relative md:inset-auto md:z-auto md:translate-x-0"
|
||||||
|
:class="showSidebar ? 'translate-x-0' : '-translate-x-full md:translate-x-0'"
|
||||||
|
>
|
||||||
|
<!-- Logo(桌面) -->
|
||||||
|
<div class="h-16 flex items-center gap-2.5 px-5 border-b border-gray-100 hidden md:flex">
|
||||||
|
<div class="w-8 h-8 rounded-lg bg-blue-600 flex items-center justify-center">
|
||||||
|
<Shield class="w-4 h-4 text-white" />
|
||||||
|
</div>
|
||||||
|
<span class="font-semibold text-gray-900 text-sm flex-1">管理后台</span>
|
||||||
|
<!-- 桌面端铃铛 -->
|
||||||
|
<button class="relative w-8 h-8 flex items-center justify-center rounded-lg hover:bg-gray-100" @click="openAnnPanel">
|
||||||
|
<Bell class="w-4 h-4 text-gray-500" />
|
||||||
|
<span v-if="unreadCount > 0" class="absolute -top-0.5 -right-0.5 min-w-[14px] h-3.5 px-1 rounded-full bg-red-500 text-white text-[8px] font-bold flex items-center justify-center leading-none">{{ unreadCount > 99 ? '99+' : unreadCount }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 手机端顶部关闭 -->
|
||||||
|
<div class="h-14 flex items-center justify-between px-4 border-b border-gray-100 md:hidden">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div class="w-7 h-7 rounded-md bg-blue-600 flex items-center justify-center">
|
||||||
|
<Shield class="w-3.5 h-3.5 text-white" />
|
||||||
|
</div>
|
||||||
|
<span class="font-medium text-gray-900 text-sm">管理后台</span>
|
||||||
|
</div>
|
||||||
|
<button class="w-8 h-8 flex items-center justify-center rounded-lg hover:bg-gray-100" @click="showSidebar = false">
|
||||||
|
<X class="w-4 h-4 text-gray-500" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 导航菜单 -->
|
||||||
|
<nav class="flex-1 py-3 px-3 space-y-1 overflow-y-auto">
|
||||||
|
<button
|
||||||
|
v-for="item in navItems"
|
||||||
|
:key="item.name"
|
||||||
|
class="w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm transition-colors"
|
||||||
|
:class="activeNav === item.name
|
||||||
|
? 'bg-blue-50 text-blue-700 font-medium'
|
||||||
|
: 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'"
|
||||||
|
@click="navigateTo(item)"
|
||||||
|
>
|
||||||
|
<component :is="item.icon" class="w-4 h-4 flex-shrink-0" />
|
||||||
|
<span>{{ item.label }}</span>
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- 底部用户信息 -->
|
||||||
|
<div class="border-t border-gray-100 p-3 relative">
|
||||||
|
<button
|
||||||
|
class="w-full flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-gray-50 transition-colors"
|
||||||
|
@click="showUserMenu = !showUserMenu"
|
||||||
|
>
|
||||||
|
<div class="w-8 h-8 rounded-full bg-blue-100 text-blue-600 flex items-center justify-center text-sm font-medium flex-shrink-0">
|
||||||
|
{{ (adminInfo?.username || 'A')[0].toUpperCase() }}
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 min-w-0 text-left">
|
||||||
|
<p class="text-sm font-medium text-gray-900 truncate">{{ adminInfo?.username }}</p>
|
||||||
|
<p class="text-xs text-gray-400 truncate">{{ adminInfo?.role === 'admin' ? '管理员' : '代理' }}</p>
|
||||||
|
</div>
|
||||||
|
<ChevronDown class="w-4 h-4 text-gray-400" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="showUserMenu"
|
||||||
|
class="absolute bottom-full left-3 right-3 mb-1 bg-white rounded-lg border border-gray-200 shadow-lg overflow-hidden"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="w-full flex items-center gap-2 px-3 py-2.5 text-sm text-red-600 hover:bg-red-50 transition-colors"
|
||||||
|
@click="handleLogout"
|
||||||
|
>
|
||||||
|
<LogOut class="w-4 h-4" />
|
||||||
|
<span>退出登录</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- 右侧内容区 -->
|
||||||
|
<main class="flex-1 overflow-y-auto pt-14 md:pt-0">
|
||||||
|
<router-view />
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- 公告面板遮罩 -->
|
||||||
|
<transition name="fade">
|
||||||
|
<div v-if="showAnnPanel" class="fixed inset-0 z-50 bg-black/40" @click="showAnnPanel = false" />
|
||||||
|
</transition>
|
||||||
|
|
||||||
|
<!-- 公告面板 -->
|
||||||
|
<transition name="slide-right">
|
||||||
|
<div v-if="showAnnPanel" class="fixed top-0 right-0 bottom-0 z-50 w-[85%] max-w-sm bg-white shadow-xl flex flex-col">
|
||||||
|
<div class="flex items-center justify-between px-4 py-3 border-b border-gray-100">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Bell class="w-5 h-5 text-blue-500" />
|
||||||
|
<span class="font-semibold text-gray-900 text-sm">公告</span>
|
||||||
|
<span v-if="unreadCount > 0" class="text-[10px] px-1.5 py-0.5 rounded-full bg-red-100 text-red-600 font-medium">{{ unreadCount }} 条未读</span>
|
||||||
|
</div>
|
||||||
|
<button class="w-7 h-7 rounded-full hover:bg-gray-100 flex items-center justify-center" @click="showAnnPanel = false">
|
||||||
|
<X class="w-4 h-4 text-gray-400" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-1 overflow-y-auto">
|
||||||
|
<div v-if="loadingAnnouncements" class="p-4 space-y-3">
|
||||||
|
<div v-for="i in 3" :key="i" class="animate-pulse">
|
||||||
|
<div class="h-4 w-32 bg-gray-100 rounded mb-2" />
|
||||||
|
<div class="h-3 w-full bg-gray-100 rounded mb-1" />
|
||||||
|
<div class="h-3 w-3/4 bg-gray-100 rounded" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="announcements.length === 0" class="flex flex-col items-center justify-center py-20 text-gray-400">
|
||||||
|
<Bell class="w-10 h-10 mb-2 opacity-30" />
|
||||||
|
<p class="text-sm">暂无公告</p>
|
||||||
|
</div>
|
||||||
|
<div v-else class="divide-y divide-gray-50">
|
||||||
|
<div
|
||||||
|
v-for="item in announcements"
|
||||||
|
:key="item.id"
|
||||||
|
class="px-4 py-3.5 hover:bg-gray-50 transition-colors cursor-pointer"
|
||||||
|
:class="{ 'bg-blue-50/30': !item.isRead }"
|
||||||
|
@click="openAgentDetail(item)"
|
||||||
|
>
|
||||||
|
<div class="flex items-start gap-2.5">
|
||||||
|
<div class="mt-0.5 flex-shrink-0">
|
||||||
|
<Circle v-if="!item.isRead" class="w-3 h-3 text-blue-500" />
|
||||||
|
<CheckCircle v-else class="w-3 h-3 text-green-400" />
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<p class="text-sm font-medium text-gray-900" :class="{ 'font-semibold': !item.isRead }">{{ item.title }}</p>
|
||||||
|
<p class="text-xs text-gray-500 leading-relaxed mt-0.5 line-clamp-1">{{ item.content }}</p>
|
||||||
|
<p class="text-[10px] text-gray-400 mt-1">{{ new Date(item.createdAt).toLocaleString('zh-CN') }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
|
||||||
|
<!-- 代理端公告详情弹窗 -->
|
||||||
|
<Teleport to="body">
|
||||||
|
<transition name="fade">
|
||||||
|
<div v-if="showDetail && detailItem" class="fixed inset-0 z-[60] flex items-center justify-center px-4">
|
||||||
|
<div class="absolute inset-0 bg-black/50" @click="showDetail = false" />
|
||||||
|
<div class="relative w-[85%] sm:max-w-sm bg-white rounded-2xl shadow-2xl overflow-hidden">
|
||||||
|
<div class="h-1.5 bg-gradient-to-r from-blue-500 to-blue-400" />
|
||||||
|
<div class="p-6">
|
||||||
|
<div class="flex items-center justify-between mb-3">
|
||||||
|
<div class="flex items-center gap-2.5">
|
||||||
|
<div class="w-9 h-9 rounded-xl bg-blue-100 flex items-center justify-center">
|
||||||
|
<Bell class="w-5 h-5 text-blue-500" />
|
||||||
|
</div>
|
||||||
|
<h3 class="text-base font-bold text-gray-900">{{ detailItem.title }}</h3>
|
||||||
|
</div>
|
||||||
|
<button class="w-7 h-7 rounded-full hover:bg-gray-100 flex items-center justify-center flex-shrink-0" @click="showDetail = false">
|
||||||
|
<X class="w-4 h-4 text-gray-400" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 mb-4">
|
||||||
|
<span class="text-[10px] text-gray-400">{{ new Date(detailItem.createdAt).toLocaleString('zh-CN') }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="bg-gray-50 rounded-xl p-4 max-h-[50vh] overflow-y-auto">
|
||||||
|
<p class="text-sm text-gray-600 leading-relaxed whitespace-pre-wrap">{{ detailItem.content }}</p>
|
||||||
|
</div>
|
||||||
|
<Button class="mt-4 w-full h-11 text-sm font-medium rounded-xl" @click="showDetail = false">关闭</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
</Teleport>
|
||||||
|
|
||||||
|
<!-- 强制弹出公告 Dialog -->
|
||||||
|
<Teleport to="body">
|
||||||
|
<transition name="fade">
|
||||||
|
<div v-if="currentForcePopup" class="fixed inset-0 z-[60] flex items-center justify-center px-4">
|
||||||
|
<div class="absolute inset-0 bg-black/40" @click="dismissForcePopup" />
|
||||||
|
<div class="relative w-[85%] sm:max-w-sm bg-white rounded-2xl shadow-xl overflow-hidden">
|
||||||
|
<div class="p-6">
|
||||||
|
<div class="flex items-center gap-2 mb-1">
|
||||||
|
<Bell class="w-5 h-5 text-blue-500" />
|
||||||
|
<h3 class="text-base font-semibold text-gray-900">{{ currentForcePopup.title }}</h3>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-gray-600 leading-relaxed mt-3 whitespace-pre-wrap">{{ currentForcePopup.content }}</p>
|
||||||
|
<Button class="mt-5 w-full h-10 text-sm font-medium" @click="dismissForcePopup">我知道了</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
</Teleport>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.fade-enter-active,
|
||||||
|
.fade-leave-active {
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
.fade-enter-from,
|
||||||
|
.fade-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-right-enter-active,
|
||||||
|
.slide-right-leave-active {
|
||||||
|
transition: transform 0.25s ease;
|
||||||
|
}
|
||||||
|
.slide-right-enter-from,
|
||||||
|
.slide-right-leave-to {
|
||||||
|
transform: translateX(100%);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { Shield, User, Lock, Eye, EyeOff, LoaderCircle, LogIn } from 'lucide-vue-next'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { useMessage } from '@/composables/useMessage'
|
||||||
|
import * as adminApi from '@/api/admin'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const message = useMessage()
|
||||||
|
|
||||||
|
const username = ref('')
|
||||||
|
const password = ref('')
|
||||||
|
const showPassword = ref(false)
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
async function handleLogin() {
|
||||||
|
if (!username.value.trim()) {
|
||||||
|
message.info('请输入用户名')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!password.value) {
|
||||||
|
message.info('请输入密码')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await adminApi.adminLogin({
|
||||||
|
username: username.value.trim(),
|
||||||
|
password: password.value,
|
||||||
|
})
|
||||||
|
const data = res.data?.data
|
||||||
|
if (data?.token?.accessToken) {
|
||||||
|
localStorage.setItem('admin_token', data.token.accessToken)
|
||||||
|
localStorage.setItem('admin_info', JSON.stringify(data.agent))
|
||||||
|
message.success('登录成功')
|
||||||
|
router.replace('/admin/dashboard')
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
const msg = e?.response?.data?.message || e?.message || '登录失败'
|
||||||
|
message.error(msg)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-indigo-100 p-4">
|
||||||
|
<div class="w-full max-w-md">
|
||||||
|
<!-- 标题 -->
|
||||||
|
<div class="text-center mb-8">
|
||||||
|
<div class="w-16 h-16 rounded-2xl bg-blue-600 flex items-center justify-center mx-auto mb-4 shadow-lg shadow-blue-200">
|
||||||
|
<Shield class="w-8 h-8 text-white" />
|
||||||
|
</div>
|
||||||
|
<h1 class="text-2xl font-bold text-gray-900">管理后台</h1>
|
||||||
|
<p class="text-sm text-gray-500 mt-1">请登录以管理系统</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 表单 -->
|
||||||
|
<div class="bg-white rounded-xl shadow-sm border border-gray-100 p-6 space-y-4">
|
||||||
|
<div>
|
||||||
|
<Label class="mb-1.5 block text-sm font-medium text-gray-600">用户名</Label>
|
||||||
|
<div class="relative">
|
||||||
|
<User class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||||
|
<Input v-model="username" placeholder="请输入用户名" class="pl-9 h-10 text-sm" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label class="mb-1.5 block text-sm font-medium text-gray-600">密码</Label>
|
||||||
|
<div class="relative">
|
||||||
|
<Lock class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||||
|
<Input
|
||||||
|
:type="showPassword ? 'text' : 'password'"
|
||||||
|
v-model="password"
|
||||||
|
placeholder="请输入密码"
|
||||||
|
class="pl-9 pr-9 h-10 text-sm"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||||
|
@click="showPassword = !showPassword"
|
||||||
|
>
|
||||||
|
<Eye v-if="showPassword" class="h-4 w-4" />
|
||||||
|
<EyeOff v-else class="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button class="w-full h-11 mt-2 text-sm font-medium" :disabled="loading" @click="handleLogin">
|
||||||
|
<LogIn v-if="!loading" class="h-4 w-4" />
|
||||||
|
<LoaderCircle v-else class="h-4 w-4 animate-spin" />
|
||||||
|
{{ loading ? '登录中...' : '登录' }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { Shield, RefreshCw, CheckCircle, Search, Eye, EyeOff, Copy } from 'lucide-vue-next'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { useMessage } from '@/composables/useMessage'
|
||||||
|
import api from '@/api/index'
|
||||||
|
|
||||||
|
interface PendingSchoolItem {
|
||||||
|
id: number
|
||||||
|
schoolId: string
|
||||||
|
schoolName: string
|
||||||
|
username: string
|
||||||
|
password: string
|
||||||
|
ip: string
|
||||||
|
status: string
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SchoolGroup {
|
||||||
|
schoolId: string
|
||||||
|
schoolName: string
|
||||||
|
records: PendingSchoolItem[]
|
||||||
|
count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = useMessage()
|
||||||
|
const records = ref<PendingSchoolItem[]>([])
|
||||||
|
const groups = ref<SchoolGroup[]>([])
|
||||||
|
const loading = ref(true)
|
||||||
|
const statusFilter = ref('pending')
|
||||||
|
const searchKeyword = ref('')
|
||||||
|
const showPasswordMap = ref<Record<number, boolean>>({})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadData()
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadData() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await api.get<{ data: PendingSchoolItem[] }>('/admin/pending-schools', {
|
||||||
|
params: { status: statusFilter.value },
|
||||||
|
})
|
||||||
|
records.value = res.data?.data || []
|
||||||
|
buildGroups()
|
||||||
|
} catch {
|
||||||
|
records.value = []
|
||||||
|
groups.value = []
|
||||||
|
message.error('加载失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildGroups() {
|
||||||
|
const map = new Map<string, SchoolGroup>()
|
||||||
|
for (const r of records.value) {
|
||||||
|
if (searchKeyword.value && !r.schoolName.includes(searchKeyword.value) && !r.schoolId.includes(searchKeyword.value)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (!map.has(r.schoolId)) {
|
||||||
|
map.set(r.schoolId, { schoolId: r.schoolId, schoolName: r.schoolName, records: [], count: 0 })
|
||||||
|
}
|
||||||
|
const g = map.get(r.schoolId)!
|
||||||
|
g.records.push(r)
|
||||||
|
g.count++
|
||||||
|
}
|
||||||
|
groups.value = Array.from(map.values()).sort((a, b) => b.count - a.count)
|
||||||
|
}
|
||||||
|
|
||||||
|
function togglePassword(id: number) {
|
||||||
|
showPasswordMap.value[id] = !showPasswordMap.value[id]
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleMarkDone(record: PendingSchoolItem) {
|
||||||
|
try {
|
||||||
|
await api.post(`/admin/pending-schools/${record.id}/mark-done`)
|
||||||
|
message.success(`已标记 ${record.schoolName} 为适配完成`)
|
||||||
|
await loadData()
|
||||||
|
} catch (err: any) {
|
||||||
|
message.error(err?.response?.data?.message || '操作失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyText(text: string) {
|
||||||
|
navigator.clipboard.writeText(text).then(() => {
|
||||||
|
message.success('已复制')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="p-6">
|
||||||
|
<!-- 标题栏 -->
|
||||||
|
<div class="flex items-center justify-between mb-6">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="w-10 h-10 rounded-xl bg-amber-100 flex items-center justify-center">
|
||||||
|
<Shield class="w-5 h-5 text-amber-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 class="text-lg font-bold text-gray-900">待适配学校</h1>
|
||||||
|
<p class="text-sm text-gray-400">用户提交的登录失败测试账号,用于适配新学校</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<!-- 筛选 -->
|
||||||
|
<div class="flex gap-1 bg-gray-100 rounded-lg p-0.5 mr-3">
|
||||||
|
<button
|
||||||
|
class="px-3 py-1.5 text-xs font-medium rounded-md transition-colors"
|
||||||
|
:class="statusFilter === 'pending' ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-500 hover:text-gray-700'"
|
||||||
|
@click="statusFilter = 'pending'; loadData()"
|
||||||
|
>待处理</button>
|
||||||
|
<button
|
||||||
|
class="px-3 py-1.5 text-xs font-medium rounded-md transition-colors"
|
||||||
|
:class="statusFilter === 'completed' ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-500 hover:text-gray-700'"
|
||||||
|
@click="statusFilter = 'completed'; loadData()"
|
||||||
|
>已完成</button>
|
||||||
|
<button
|
||||||
|
class="px-3 py-1.5 text-xs font-medium rounded-md transition-colors"
|
||||||
|
:class="statusFilter === '' ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-500 hover:text-gray-700'"
|
||||||
|
@click="statusFilter = ''; loadData()"
|
||||||
|
>全部</button>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" size="sm" class="h-9 text-xs" @click="loadData">
|
||||||
|
<RefreshCw class="w-3.5 h-3.5 mr-1" />
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 搜索 -->
|
||||||
|
<div class="relative max-w-xs mb-6">
|
||||||
|
<Search class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||||
|
<Input
|
||||||
|
v-model="searchKeyword"
|
||||||
|
placeholder="搜索学校名称..."
|
||||||
|
class="pl-9 h-9 text-sm"
|
||||||
|
@input="buildGroups"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 主卡片 -->
|
||||||
|
<div class="bg-white rounded-xl border border-gray-200 overflow-hidden">
|
||||||
|
<!-- 加载骨架 -->
|
||||||
|
<div v-if="loading" class="p-6 space-y-4">
|
||||||
|
<div v-for="i in 4" :key="i" class="h-10 bg-gray-50 rounded animate-pulse" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 空状态 -->
|
||||||
|
<div v-else-if="groups.length === 0" class="text-center py-20 text-gray-400">
|
||||||
|
<Shield class="w-12 h-12 mx-auto mb-3 opacity-30" />
|
||||||
|
<p class="text-sm">暂无待适配学校</p>
|
||||||
|
<p class="text-xs text-gray-300 mt-1">当用户提交登录失败的测试账号后,将在这里显示</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 学校列表 -->
|
||||||
|
<div v-else class="divide-y divide-gray-100">
|
||||||
|
<div
|
||||||
|
v-for="group in groups"
|
||||||
|
:key="group.schoolId"
|
||||||
|
>
|
||||||
|
<!-- 学校标题栏 -->
|
||||||
|
<div class="px-5 py-3 bg-gray-50/80 flex items-center justify-between border-b border-gray-100">
|
||||||
|
<div class="flex items-center gap-2.5">
|
||||||
|
<Shield class="w-4 h-4 text-amber-500" />
|
||||||
|
<span class="text-sm font-semibold text-gray-900">{{ group.schoolName }}</span>
|
||||||
|
<span class="text-[10px] px-1.5 py-0.5 rounded-full bg-amber-50 text-amber-600 font-medium border border-amber-100">
|
||||||
|
{{ group.count }} 条
|
||||||
|
</span>
|
||||||
|
<span class="text-[10px] text-gray-400 font-mono">{{ group.schoolId }}</span>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
v-if="statusFilter !== 'completed'"
|
||||||
|
size="sm"
|
||||||
|
class="h-7 text-xs"
|
||||||
|
@click="handleMarkDone(group.records[0])"
|
||||||
|
>
|
||||||
|
<CheckCircle class="w-3 h-3 mr-1" />
|
||||||
|
标记完成
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 账号列表 -->
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr class="text-left text-xs text-gray-400 border-b border-gray-50">
|
||||||
|
<th class="px-5 py-2.5 font-medium w-[20%]">账号</th>
|
||||||
|
<th class="px-5 py-2.5 font-medium w-[25%]">密码</th>
|
||||||
|
<th class="px-5 py-2.5 font-medium w-[12%]">IP</th>
|
||||||
|
<th class="px-5 py-2.5 font-medium w-[15%]">状态</th>
|
||||||
|
<th class="px-5 py-2.5 font-medium w-[18%]">提交时间</th>
|
||||||
|
<th class="px-5 py-2.5 font-medium w-[10%]">操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr
|
||||||
|
v-for="r in group.records"
|
||||||
|
:key="r.id"
|
||||||
|
class="border-t border-gray-50 hover:bg-gray-50/50 transition-colors"
|
||||||
|
>
|
||||||
|
<!-- 账号 -->
|
||||||
|
<td class="px-5 py-3">
|
||||||
|
<span class="text-gray-900 font-medium">{{ r.username }}</span>
|
||||||
|
</td>
|
||||||
|
<!-- 密码 -->
|
||||||
|
<td class="px-5 py-3">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
class="text-gray-600 font-mono text-xs cursor-pointer select-all"
|
||||||
|
:class="showPasswordMap[r.id] ? '' : 'text-transparent bg-gray-100 rounded px-1'"
|
||||||
|
@click="togglePassword(r.id)"
|
||||||
|
>
|
||||||
|
{{ showPasswordMap[r.id] ? r.password : r.password }}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
class="inline-flex items-center gap-0.5 text-gray-400 hover:text-blue-500 transition-colors"
|
||||||
|
@click="togglePassword(r.id)"
|
||||||
|
>
|
||||||
|
<Eye v-if="!showPasswordMap[r.id]" class="w-3.5 h-3.5" />
|
||||||
|
<EyeOff v-else class="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="inline-flex items-center gap-0.5 text-gray-400 hover:text-blue-500 transition-colors"
|
||||||
|
@click="copyText(r.password)"
|
||||||
|
>
|
||||||
|
<Copy class="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<!-- IP -->
|
||||||
|
<td class="px-5 py-3">
|
||||||
|
<span class="text-gray-500 text-xs">{{ r.ip }}</span>
|
||||||
|
</td>
|
||||||
|
<!-- 状态 -->
|
||||||
|
<td class="px-5 py-3">
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center px-1.5 py-0.5 rounded-full text-[10px] font-medium"
|
||||||
|
:class="r.status === 'pending' ? 'bg-amber-50 text-amber-600' : 'bg-green-50 text-green-600'"
|
||||||
|
>
|
||||||
|
{{ r.status === 'pending' ? '待处理' : '已完成' }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<!-- 提交时间 -->
|
||||||
|
<td class="px-5 py-3 text-gray-500 text-xs">{{ r.createdAt }}</td>
|
||||||
|
<!-- 操作 -->
|
||||||
|
<td class="px-5 py-3">
|
||||||
|
<button
|
||||||
|
v-if="r.status === 'pending'"
|
||||||
|
class="inline-flex items-center gap-1 px-2 py-1 rounded-lg text-xs text-blue-500 hover:bg-blue-50 transition-colors"
|
||||||
|
@click="handleMarkDone(r)"
|
||||||
|
>
|
||||||
|
<CheckCircle class="w-3 h-3" />
|
||||||
|
完成
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { Save, LoaderCircle, Euro } from 'lucide-vue-next'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { Card } from '@/components/ui/card'
|
||||||
|
import { useMessage } from '@/composables/useMessage'
|
||||||
|
import * as adminApi from '@/api/admin'
|
||||||
|
|
||||||
|
const message = useMessage()
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const saving = ref(false)
|
||||||
|
const baseDeduction = ref('')
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await loadSettings()
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadSettings() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await adminApi.getSettings()
|
||||||
|
const settings = res.data?.data || {}
|
||||||
|
baseDeduction.value = settings['base_deduction_per_run'] || '1.00'
|
||||||
|
} catch {
|
||||||
|
message.error('加载设置失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
const val = parseFloat(baseDeduction.value)
|
||||||
|
if (isNaN(val) || val <= 0) {
|
||||||
|
message.info('请输入有效的正数')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
await adminApi.updateSetting('base_deduction_per_run', val.toFixed(2))
|
||||||
|
message.success('保存成功')
|
||||||
|
} catch (e: any) {
|
||||||
|
message.error(e?.response?.data?.message || e?.message || '保存失败')
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="p-6">
|
||||||
|
<h1 class="text-xl font-bold text-gray-900 mb-6">系统设置</h1>
|
||||||
|
|
||||||
|
<Card class="max-w-lg">
|
||||||
|
<div class="border-b border-gray-100 px-6 py-4 flex items-center gap-2">
|
||||||
|
<Euro class="w-4 h-4 text-blue-500" />
|
||||||
|
<span class="font-semibold text-gray-900 text-sm">基础扣费设置</span>
|
||||||
|
</div>
|
||||||
|
<div class="p-6 space-y-4">
|
||||||
|
<div v-if="loading" class="flex items-center justify-center py-8">
|
||||||
|
<LoaderCircle class="w-5 h-5 animate-spin text-blue-500" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<div>
|
||||||
|
<Label class="mb-1 block text-sm text-gray-600">每次运行基础抵扣金额(元)</Label>
|
||||||
|
<Input
|
||||||
|
v-model="baseDeduction"
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min="0.01"
|
||||||
|
class="h-10 text-sm max-w-[200px]"
|
||||||
|
/>
|
||||||
|
<p class="text-xs text-gray-400 mt-1">
|
||||||
|
生成CDK时的成本基价,实际成本 = 基价 × 次数 × 代理费率
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-2 pt-2">
|
||||||
|
<Button :disabled="saving" @click="handleSave">
|
||||||
|
<Save class="w-4 h-4 mr-1" />
|
||||||
|
{{ saving ? '保存中...' : '保存设置' }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { Shield, Eye, EyeOff, LoaderCircle } from 'lucide-vue-next'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { useMessage } from '@/composables/useMessage'
|
||||||
|
import * as adminApi from '@/api/admin'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const message = useMessage()
|
||||||
|
|
||||||
|
const username = ref('')
|
||||||
|
const password = ref('')
|
||||||
|
const confirmPassword = ref('')
|
||||||
|
const showPassword = ref(false)
|
||||||
|
const showConfirm = ref(false)
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
async function handleSetup() {
|
||||||
|
if (!username.value.trim()) {
|
||||||
|
message.info('请输入管理员用户名')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!password.value) {
|
||||||
|
message.info('请输入管理员密码')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (password.value.length < 6) {
|
||||||
|
message.info('密码长度不能少于6位')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (password.value !== confirmPassword.value) {
|
||||||
|
message.info('两次输入的密码不一致')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await adminApi.setupInit({
|
||||||
|
username: username.value.trim(),
|
||||||
|
password: password.value,
|
||||||
|
})
|
||||||
|
const data = res.data?.data
|
||||||
|
if (data?.token?.accessToken) {
|
||||||
|
localStorage.setItem('admin_token', data.token.accessToken)
|
||||||
|
localStorage.setItem('admin_info', JSON.stringify(data.agent))
|
||||||
|
message.success('管理员创建成功')
|
||||||
|
router.replace('/admin/dashboard')
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
const msg = e?.response?.data?.message || e?.message || '创建失败'
|
||||||
|
message.error(msg)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-indigo-100 p-4">
|
||||||
|
<div class="w-full max-w-md">
|
||||||
|
<!-- 标题 -->
|
||||||
|
<div class="text-center mb-8">
|
||||||
|
<div class="w-16 h-16 rounded-2xl bg-blue-600 flex items-center justify-center mx-auto mb-4 shadow-lg shadow-blue-200">
|
||||||
|
<Shield class="w-8 h-8 text-white" />
|
||||||
|
</div>
|
||||||
|
<h1 class="text-2xl font-bold text-gray-900">初始化管理员</h1>
|
||||||
|
<p class="text-sm text-gray-500 mt-1">首次使用,请创建管理员账号</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 表单 -->
|
||||||
|
<div class="bg-white rounded-xl shadow-sm border border-gray-100 p-6 space-y-4">
|
||||||
|
<div>
|
||||||
|
<Label class="mb-1.5 block text-sm font-medium text-gray-600">管理员用户名</Label>
|
||||||
|
<Input v-model="username" placeholder="如:admin" class="h-10 text-sm" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label class="mb-1.5 block text-sm font-medium text-gray-600">密码</Label>
|
||||||
|
<div class="relative">
|
||||||
|
<Input
|
||||||
|
:type="showPassword ? 'text' : 'password'"
|
||||||
|
v-model="password"
|
||||||
|
placeholder="至少6位"
|
||||||
|
class="h-10 text-sm pr-9"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||||
|
@click="showPassword = !showPassword"
|
||||||
|
>
|
||||||
|
<Eye v-if="showPassword" class="h-4 w-4" />
|
||||||
|
<EyeOff v-else class="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label class="mb-1.5 block text-sm font-medium text-gray-600">确认密码</Label>
|
||||||
|
<div class="relative">
|
||||||
|
<Input
|
||||||
|
:type="showConfirm ? 'text' : 'password'"
|
||||||
|
v-model="confirmPassword"
|
||||||
|
placeholder="再次输入密码"
|
||||||
|
class="h-10 text-sm pr-9"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||||
|
@click="showConfirm = !showConfirm"
|
||||||
|
>
|
||||||
|
<Eye v-if="showConfirm" class="h-4 w-4" />
|
||||||
|
<EyeOff v-else class="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button class="w-full h-11 mt-2 text-sm font-medium" :disabled="loading" @click="handleSetup">
|
||||||
|
<LoaderCircle v-if="loading" class="h-4 w-4 animate-spin" />
|
||||||
|
{{ loading ? '创建中...' : '创建管理员' }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, nextTick } from 'vue'
|
||||||
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
|
import { ArrowLeft, Send } from 'lucide-vue-next'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { useMessage } from '@/composables/useMessage'
|
||||||
|
import * as ticketApi from '@/api/ticket'
|
||||||
|
import type { TicketDetail as TicketDetailType } from '@/api/ticket'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const route = useRoute()
|
||||||
|
const message = useMessage()
|
||||||
|
|
||||||
|
const ticket = ref<TicketDetailType | null>(null)
|
||||||
|
const loading = ref(true)
|
||||||
|
const replyContent = ref('')
|
||||||
|
const replying = ref(false)
|
||||||
|
const messagesEnd = ref<HTMLDivElement | null>(null)
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
const id = Number(route.params.id)
|
||||||
|
if (!id) {
|
||||||
|
message.error('无效的工单ID')
|
||||||
|
router.back()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await loadTicket(id)
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadTicket(id: number) {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await ticketApi.getAdminTicketDetail(id)
|
||||||
|
ticket.value = res.data?.data || null
|
||||||
|
await nextTick()
|
||||||
|
scrollToBottom()
|
||||||
|
} catch {
|
||||||
|
message.error('加载工单失败')
|
||||||
|
router.back()
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollToBottom() {
|
||||||
|
if (messagesEnd.value) {
|
||||||
|
messagesEnd.value.scrollIntoView({ behavior: 'smooth' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleReply() {
|
||||||
|
if (!replyContent.value.trim() || !ticket.value) return
|
||||||
|
replying.value = true
|
||||||
|
try {
|
||||||
|
await ticketApi.adminReplyTicket(ticket.value.id, replyContent.value.trim())
|
||||||
|
replyContent.value = ''
|
||||||
|
await loadTicket(ticket.value.id)
|
||||||
|
} catch (err: any) {
|
||||||
|
message.error(err.response?.data?.message || '回复失败')
|
||||||
|
} finally {
|
||||||
|
replying.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function changeStatus(status: string) {
|
||||||
|
if (!ticket.value) return
|
||||||
|
try {
|
||||||
|
await ticketApi.updateTicketStatus(ticket.value.id, status)
|
||||||
|
message.success('状态已更新')
|
||||||
|
await loadTicket(ticket.value.id)
|
||||||
|
} catch (err: any) {
|
||||||
|
message.error(err.response?.data?.message || '更新失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-col min-h-full bg-gray-50">
|
||||||
|
<!-- 顶部导航 -->
|
||||||
|
<div class="sticky top-0 z-10 bg-white border-b border-gray-100">
|
||||||
|
<div class="flex items-center px-4 h-12">
|
||||||
|
<button class="w-8 h-8 flex items-center justify-center -ml-1 text-gray-600" @click="router.back()">
|
||||||
|
<ArrowLeft class="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
<h1 class="flex-1 text-base font-semibold text-gray-900 truncate mr-7">
|
||||||
|
{{ ticket?.title || '工单详情' }}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loading" class="flex-1 flex items-center justify-center">
|
||||||
|
<p class="text-sm text-gray-400">加载中...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-else-if="ticket">
|
||||||
|
<!-- 用户信息卡片 -->
|
||||||
|
<div class="bg-white border-b border-gray-100 px-4 py-3">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-gray-900">{{ ticket.realName || ticket.username }}</p>
|
||||||
|
<p class="text-xs text-gray-400">{{ ticket.schoolName }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium"
|
||||||
|
:class="ticketApi.TICKET_STATUS_COLOR[ticket.status] || 'bg-gray-100 text-gray-600'"
|
||||||
|
>
|
||||||
|
{{ ticketApi.TICKET_STATUS_MAP[ticket.status] || ticket.status }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- 状态操作按钮 -->
|
||||||
|
<div class="flex gap-2 mt-3">
|
||||||
|
<button
|
||||||
|
v-if="ticket.status === 'pending'"
|
||||||
|
class="px-3 py-1 rounded-lg text-xs font-medium bg-blue-500 text-white hover:bg-blue-600 transition-colors"
|
||||||
|
@click="changeStatus('processing')"
|
||||||
|
>
|
||||||
|
开始处理
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="ticket.status === 'processing'"
|
||||||
|
class="px-3 py-1 rounded-lg text-xs font-medium bg-green-500 text-white hover:bg-green-600 transition-colors"
|
||||||
|
@click="changeStatus('resolved')"
|
||||||
|
>
|
||||||
|
标记已解决
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="ticket.status !== 'closed' && ticket.status !== 'resolved'"
|
||||||
|
class="px-3 py-1 rounded-lg text-xs font-medium bg-green-100 text-green-700 hover:bg-green-200 transition-colors"
|
||||||
|
@click="changeStatus('resolved')"
|
||||||
|
>
|
||||||
|
直接解决
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="ticket.status !== 'closed'"
|
||||||
|
class="px-3 py-1 rounded-lg text-xs font-medium bg-gray-100 text-gray-600 hover:bg-gray-200 transition-colors"
|
||||||
|
@click="changeStatus('closed')"
|
||||||
|
>
|
||||||
|
关闭工单
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 消息区域 -->
|
||||||
|
<div class="flex-1 overflow-y-auto px-4 py-4 space-y-4">
|
||||||
|
<!-- 初始内容 -->
|
||||||
|
<div class="flex justify-start">
|
||||||
|
<div class="max-w-[85%]">
|
||||||
|
<div class="flex items-center gap-2 mb-1">
|
||||||
|
<span class="w-6 h-6 rounded-full bg-blue-500 text-white text-xs flex items-center justify-center font-medium">
|
||||||
|
{{ ticket.realName?.[0] || ticket.username?.[0] || '用' }}
|
||||||
|
</span>
|
||||||
|
<span class="text-xs text-gray-400">{{ ticket.createdAt }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white rounded-2xl rounded-tl-sm px-4 py-3 border border-gray-100 shadow-sm">
|
||||||
|
<p class="text-sm font-medium text-gray-900 mb-1">{{ ticket.title }}</p>
|
||||||
|
<p class="text-sm text-gray-600 whitespace-pre-wrap">{{ ticket.content }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 对话消息 -->
|
||||||
|
<div
|
||||||
|
v-for="msg in ticket.messages"
|
||||||
|
:key="msg.id"
|
||||||
|
class="flex"
|
||||||
|
:class="msg.senderType === 'admin' ? 'justify-start' : 'justify-end'"
|
||||||
|
>
|
||||||
|
<div class="max-w-[85%]">
|
||||||
|
<div class="flex items-center gap-2 mb-1" :class="msg.senderType === 'user' ? 'flex-row-reverse' : ''">
|
||||||
|
<span
|
||||||
|
class="w-6 h-6 rounded-full text-white text-xs flex items-center justify-center font-medium flex-shrink-0"
|
||||||
|
:class="msg.senderType === 'admin' ? 'bg-green-500' : 'bg-blue-500'"
|
||||||
|
>
|
||||||
|
{{ msg.senderType === 'admin' ? '管' : ticket.realName?.[0] || ticket.username?.[0] || '用' }}
|
||||||
|
</span>
|
||||||
|
<span class="text-xs text-gray-400">{{ msg.createdAt }}</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="px-4 py-3 border shadow-sm"
|
||||||
|
:class="
|
||||||
|
msg.senderType === 'admin'
|
||||||
|
? 'bg-green-50 rounded-2xl rounded-tr-sm border-green-100'
|
||||||
|
: 'bg-white rounded-2xl rounded-tl-sm border-gray-100'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<p class="text-sm text-gray-700 whitespace-pre-wrap">{{ msg.content }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div ref="messagesEnd" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 回复输入 -->
|
||||||
|
<div v-if="ticket.status !== 'closed'" class="bg-white border-t border-gray-100 px-4 py-3">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<textarea
|
||||||
|
v-model="replyContent"
|
||||||
|
placeholder="输入回复内容..."
|
||||||
|
class="flex-1 h-10 max-h-24 px-3 py-2.5 text-sm border border-gray-200 rounded-xl resize-none focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-400"
|
||||||
|
rows="1"
|
||||||
|
@keydown.ctrl.enter="handleReply"
|
||||||
|
></textarea>
|
||||||
|
<Button
|
||||||
|
class="h-10 w-10 rounded-xl flex-shrink-0"
|
||||||
|
:disabled="!replyContent.trim() || replying"
|
||||||
|
@click="handleReply"
|
||||||
|
>
|
||||||
|
<Send class="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, computed } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { Search, MessageSquare } from 'lucide-vue-next'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { useMessage } from '@/composables/useMessage'
|
||||||
|
import * as ticketApi from '@/api/ticket'
|
||||||
|
import type { AdminTicketListItem } from '@/api/ticket'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const message = useMessage()
|
||||||
|
|
||||||
|
const tickets = ref<AdminTicketListItem[]>([])
|
||||||
|
const loading = ref(true)
|
||||||
|
const keyword = ref('')
|
||||||
|
const filterStatus = ref('')
|
||||||
|
|
||||||
|
const filteredTickets = computed(() => {
|
||||||
|
let list = tickets.value
|
||||||
|
if (filterStatus.value) {
|
||||||
|
list = list.filter((t) => t.status === filterStatus.value)
|
||||||
|
}
|
||||||
|
if (keyword.value.trim()) {
|
||||||
|
const kw = keyword.value.trim().toLowerCase()
|
||||||
|
list = list.filter(
|
||||||
|
(t) =>
|
||||||
|
t.title.toLowerCase().includes(kw) ||
|
||||||
|
t.username?.toLowerCase().includes(kw) ||
|
||||||
|
t.realName?.toLowerCase().includes(kw)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return list
|
||||||
|
})
|
||||||
|
|
||||||
|
const statusOptions = [
|
||||||
|
{ value: '', label: '全部' },
|
||||||
|
{ value: 'pending', label: '待处理' },
|
||||||
|
{ value: 'processing', label: '处理中' },
|
||||||
|
{ value: 'resolved', label: '已解决' },
|
||||||
|
{ value: 'closed', label: '已关闭' },
|
||||||
|
]
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await loadTickets()
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadTickets() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await ticketApi.getAdminTicketList()
|
||||||
|
tickets.value = res.data?.data || []
|
||||||
|
} catch {
|
||||||
|
message.error('加载工单列表失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function goDetail(id: number) {
|
||||||
|
router.push(`/admin/tickets/${id}`)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="p-6">
|
||||||
|
<h1 class="text-xl font-bold text-gray-900 mb-6">工单管理</h1>
|
||||||
|
|
||||||
|
<!-- 筛选栏 -->
|
||||||
|
<div class="flex flex-col sm:flex-row gap-3 mb-4">
|
||||||
|
<div class="relative flex-1">
|
||||||
|
<Search class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||||
|
<Input
|
||||||
|
v-model="keyword"
|
||||||
|
placeholder="搜索标题/用户名..."
|
||||||
|
class="h-10 pl-9 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2 overflow-x-auto">
|
||||||
|
<button
|
||||||
|
v-for="opt in statusOptions"
|
||||||
|
:key="opt.value"
|
||||||
|
class="px-3 py-1.5 rounded-lg text-xs font-medium whitespace-nowrap transition-colors"
|
||||||
|
:class="
|
||||||
|
filterStatus === opt.value
|
||||||
|
? 'bg-blue-500 text-white'
|
||||||
|
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||||
|
"
|
||||||
|
@click="filterStatus = opt.value"
|
||||||
|
>
|
||||||
|
{{ opt.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 工单列表 -->
|
||||||
|
<div v-if="loading" class="text-center py-12 text-sm text-gray-400">加载中...</div>
|
||||||
|
<div v-else-if="filteredTickets.length === 0" class="text-center py-12">
|
||||||
|
<MessageSquare class="w-12 h-12 mx-auto text-gray-300 mb-2" />
|
||||||
|
<p class="text-sm text-gray-400">暂无工单</p>
|
||||||
|
</div>
|
||||||
|
<div v-else class="space-y-2">
|
||||||
|
<div
|
||||||
|
v-for="t in filteredTickets"
|
||||||
|
:key="t.id"
|
||||||
|
class="bg-white rounded-xl border border-gray-100 px-4 py-3.5 hover:bg-gray-50 transition-colors cursor-pointer"
|
||||||
|
@click="goDetail(t.id)"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between mb-1">
|
||||||
|
<h3 class="text-sm font-medium text-gray-900 truncate flex-1">{{ t.title }}</h3>
|
||||||
|
<span
|
||||||
|
class="ml-2 inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium flex-shrink-0"
|
||||||
|
:class="ticketApi.TICKET_STATUS_COLOR[t.status] || 'bg-gray-100 text-gray-600'"
|
||||||
|
>
|
||||||
|
{{ ticketApi.TICKET_STATUS_MAP[t.status] || t.status }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3 text-xs text-gray-400">
|
||||||
|
<span class="text-gray-500 font-medium">{{ t.realName || t.username }}</span>
|
||||||
|
<span>{{ t.schoolName }}</span>
|
||||||
|
<span>{{ t.createdAt }}</span>
|
||||||
|
<span class="flex items-center gap-1">
|
||||||
|
<MessageSquare class="w-3 h-3" />
|
||||||
|
{{ t.messageCount }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, onUnmounted } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { Smartphone, RefreshCw, CheckCircle, AlertCircle, Download } from 'lucide-vue-next'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { useMessage } from '@/composables/useMessage'
|
||||||
|
import * as authApi from '@/api/auth'
|
||||||
|
import QRCode from 'qrcode'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const message = useMessage()
|
||||||
|
|
||||||
|
const qrToken = ref('')
|
||||||
|
const qrUrl = ref('')
|
||||||
|
const qrImageDataUrl = ref('')
|
||||||
|
const status = ref<'loading' | 'show-qr' | 'polling' | 'success' | 'error'>('loading')
|
||||||
|
const expiresIn = ref(300)
|
||||||
|
const countdown = ref(0)
|
||||||
|
const qrOrigin = window.location.origin
|
||||||
|
|
||||||
|
let pollTimer: ReturnType<typeof setInterval> | null = null
|
||||||
|
let countdownTimer: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
|
/** 在 canvas 上绘制二维码 + 中央 logo,输出单一图片 dataUrl */
|
||||||
|
async function generateQrImage(url: string): Promise<string> {
|
||||||
|
const canvas = document.createElement('canvas')
|
||||||
|
canvas.width = 200
|
||||||
|
canvas.height = 200
|
||||||
|
|
||||||
|
// 1. 生成二维码
|
||||||
|
await QRCode.toCanvas(canvas, url, {
|
||||||
|
width: 200,
|
||||||
|
margin: 2,
|
||||||
|
})
|
||||||
|
|
||||||
|
const ctx = canvas.getContext('2d')!
|
||||||
|
const cx = 100
|
||||||
|
const cy = 100
|
||||||
|
|
||||||
|
// 2. 中央白色圆形背景
|
||||||
|
const logoBgRadius = 20
|
||||||
|
ctx.save()
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.arc(cx, cy, logoBgRadius, 0, Math.PI * 2)
|
||||||
|
ctx.fillStyle = '#ffffff'
|
||||||
|
ctx.fill()
|
||||||
|
ctx.restore()
|
||||||
|
|
||||||
|
// 3. 绘制蓝色手机图标(SVG → Image)
|
||||||
|
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#3b82f6" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="5" y="2" width="14" height="20" rx="2"/><line x1="12" y1="18" x2="12.01" y2="18"/></svg>`
|
||||||
|
const svgBlob = new Blob([svg], { type: 'image/svg+xml' })
|
||||||
|
const svgUrl = URL.createObjectURL(svgBlob)
|
||||||
|
|
||||||
|
const iconImg = await new Promise<HTMLImageElement>((resolve, reject) => {
|
||||||
|
const img = new Image()
|
||||||
|
img.onload = () => resolve(img)
|
||||||
|
img.onerror = reject
|
||||||
|
img.src = svgUrl
|
||||||
|
})
|
||||||
|
|
||||||
|
const iconSize = 22
|
||||||
|
ctx.drawImage(iconImg, cx - iconSize / 2, cy - iconSize / 2, iconSize, iconSize)
|
||||||
|
URL.revokeObjectURL(svgUrl)
|
||||||
|
|
||||||
|
return canvas.toDataURL('image/png')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateQr() {
|
||||||
|
status.value = 'loading'
|
||||||
|
qrImageDataUrl.value = ''
|
||||||
|
try {
|
||||||
|
const res = await authApi.generateDeviceQr()
|
||||||
|
qrToken.value = res.data.data.token
|
||||||
|
expiresIn.value = res.data.data.expiresIn
|
||||||
|
countdown.value = expiresIn.value
|
||||||
|
qrUrl.value = `${qrOrigin}/api/device/authorize/${qrToken.value}`
|
||||||
|
|
||||||
|
// 生成本地二维码图片(含 logo)
|
||||||
|
qrImageDataUrl.value = await generateQrImage(qrUrl.value)
|
||||||
|
|
||||||
|
// 显示二维码并自动开始轮询
|
||||||
|
status.value = 'polling'
|
||||||
|
startPolling()
|
||||||
|
|
||||||
|
// 倒计时
|
||||||
|
countdownTimer = setInterval(() => {
|
||||||
|
countdown.value--
|
||||||
|
if (countdown.value <= 0) {
|
||||||
|
stopPolling()
|
||||||
|
clearInterval(countdownTimer!)
|
||||||
|
status.value = 'error'
|
||||||
|
message.error('二维码已过期,请重新生成')
|
||||||
|
}
|
||||||
|
}, 1000)
|
||||||
|
} catch {
|
||||||
|
status.value = 'error'
|
||||||
|
message.error('生成二维码失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveQrCode() {
|
||||||
|
if (!qrImageDataUrl.value) return
|
||||||
|
try {
|
||||||
|
// 将 dataUrl 转 blob 下载
|
||||||
|
const resp = await fetch(qrImageDataUrl.value)
|
||||||
|
const blob = await resp.blob()
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = '设备绑定二维码.png'
|
||||||
|
document.body.appendChild(a)
|
||||||
|
a.click()
|
||||||
|
document.body.removeChild(a)
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
message.success('二维码已保存')
|
||||||
|
} catch {
|
||||||
|
message.error('保存失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startPolling() {
|
||||||
|
pollTimer = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const res = await authApi.getDeviceStatus(qrToken.value)
|
||||||
|
if (res.data.data.status === 'bound') {
|
||||||
|
stopPolling()
|
||||||
|
clearInterval(countdownTimer!)
|
||||||
|
status.value = 'success'
|
||||||
|
message.success('设备绑定成功!')
|
||||||
|
setTimeout(() => {
|
||||||
|
router.replace('/home')
|
||||||
|
}, 1500)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 继续轮询
|
||||||
|
}
|
||||||
|
}, 2000)
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopPolling() {
|
||||||
|
if (pollTimer) {
|
||||||
|
clearInterval(pollTimer)
|
||||||
|
pollTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
generateQr()
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
stopPolling()
|
||||||
|
if (countdownTimer) clearInterval(countdownTimer)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="min-h-full flex flex-col bg-gradient-to-b from-blue-50 to-white">
|
||||||
|
<!-- 顶部说明 -->
|
||||||
|
<div class="px-6 pt-6 pb-4 text-center">
|
||||||
|
<div class="w-14 h-14 rounded-2xl bg-blue-500 flex items-center justify-center mx-auto mb-3 shadow-lg shadow-blue-200">
|
||||||
|
<Smartphone class="w-7 h-7 text-white" />
|
||||||
|
</div>
|
||||||
|
<h1 class="text-xl font-bold text-gray-900">绑定设备</h1>
|
||||||
|
<p class="text-sm text-gray-400 mt-1">请用今日校园 App 扫描下方二维码完成绑定</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-1 flex flex-col items-center px-6">
|
||||||
|
<!-- 加载中 -->
|
||||||
|
<div v-if="status === 'loading'" class="flex flex-col items-center justify-center py-12">
|
||||||
|
<RefreshCw class="w-8 h-8 text-blue-400 animate-spin mb-3" />
|
||||||
|
<p class="text-sm text-gray-400">正在生成二维码...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 二维码 + 自动轮询 -->
|
||||||
|
<div v-else-if="status === 'show-qr' || status === 'polling'" class="w-full max-w-xs">
|
||||||
|
<div class="bg-white rounded-2xl border border-gray-100 shadow-lg p-6 text-center">
|
||||||
|
<!-- QR Code(单一合成图片,含 logo) -->
|
||||||
|
<div class="w-48 h-48 mx-auto bg-white border-2 border-gray-100 rounded-xl flex items-center justify-center mb-4">
|
||||||
|
<img
|
||||||
|
v-if="qrImageDataUrl"
|
||||||
|
:src="qrImageDataUrl"
|
||||||
|
alt="QR Code"
|
||||||
|
class="w-full h-full object-contain p-2"
|
||||||
|
/>
|
||||||
|
<RefreshCw v-else class="w-6 h-6 text-blue-400 animate-spin" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 轮询状态 -->
|
||||||
|
<div class="flex items-center justify-center gap-2 text-sm text-blue-500 mb-3">
|
||||||
|
<RefreshCw class="w-4 h-4 animate-spin" />
|
||||||
|
<span>等待今日校园 App 扫码...</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 倒计时 + 保存按钮 -->
|
||||||
|
<div class="space-y-2">
|
||||||
|
<p class="text-xs text-gray-400">二维码有效期:{{ countdown }}秒</p>
|
||||||
|
<button
|
||||||
|
class="inline-flex items-center gap-1.5 px-4 py-2 rounded-lg bg-blue-50 text-blue-600 text-xs font-medium hover:bg-blue-100 transition-colors"
|
||||||
|
@click="saveQrCode"
|
||||||
|
>
|
||||||
|
<Download class="w-3.5 h-3.5" />
|
||||||
|
保存二维码
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 使用说明 -->
|
||||||
|
<div class="mt-4 bg-white rounded-xl border border-gray-100 p-4 shadow-sm">
|
||||||
|
<h3 class="text-sm font-semibold text-gray-900 mb-3">操作步骤</h3>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<div class="w-6 h-6 rounded-full bg-blue-500 text-white text-xs font-bold flex items-center justify-center flex-shrink-0 mt-0.5">1</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-800">打开今日校园 App</p>
|
||||||
|
<p class="text-xs text-gray-400">在手机上启动今日校园并登录</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<div class="w-6 h-6 rounded-full bg-blue-500 text-white text-xs font-bold flex items-center justify-center flex-shrink-0 mt-0.5">2</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-800">点击右上角扫一扫</p>
|
||||||
|
<p class="text-xs text-gray-400">扫描电脑屏幕上的二维码</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<div class="w-6 h-6 rounded-full bg-blue-500 text-white text-xs font-bold flex items-center justify-center flex-shrink-0 mt-0.5">3</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-800">等待自动完成</p>
|
||||||
|
<p class="text-xs text-gray-400">扫码后自动获取设备码,绑定成功后自动跳转</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 绑定成功 -->
|
||||||
|
<div v-else-if="status === 'success'" class="flex flex-col items-center justify-center py-12">
|
||||||
|
<div class="w-16 h-16 rounded-full bg-green-100 flex items-center justify-center mb-4">
|
||||||
|
<CheckCircle class="w-8 h-8 text-green-500" />
|
||||||
|
</div>
|
||||||
|
<h2 class="text-lg font-bold text-gray-900 mb-1">绑定成功</h2>
|
||||||
|
<p class="text-sm text-gray-400">即将进入首页...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 错误 / 过期 -->
|
||||||
|
<div v-else-if="status === 'error'" class="flex flex-col items-center justify-center py-12">
|
||||||
|
<div class="w-16 h-16 rounded-full bg-red-100 flex items-center justify-center mb-4">
|
||||||
|
<AlertCircle class="w-8 h-8 text-red-500" />
|
||||||
|
</div>
|
||||||
|
<h2 class="text-lg font-bold text-gray-900 mb-1">二维码已过期</h2>
|
||||||
|
<p class="text-sm text-gray-400 mb-4">请重新生成二维码</p>
|
||||||
|
<Button class="h-10 text-sm" @click="generateQr">
|
||||||
|
<RefreshCw class="w-4 h-4" />
|
||||||
|
重新生成
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,533 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, watch } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { Search, User, Lock, Eye, EyeOff, RefreshCw, LogIn, GraduationCap, LoaderCircle, AlertTriangle, ExternalLink, FileText } from 'lucide-vue-next'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { useMessage } from '@/composables/useMessage'
|
||||||
|
import * as authApi from '@/api/auth'
|
||||||
|
import * as ticketApi from '@/api/ticket'
|
||||||
|
import type { SchoolBrief } from '@/api/auth'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const message = useMessage()
|
||||||
|
|
||||||
|
const schoolKeyword = ref('')
|
||||||
|
const schools = ref<SchoolBrief[]>([])
|
||||||
|
const selectedSchool = ref<SchoolBrief | null>(null)
|
||||||
|
const showSchoolList = ref(false)
|
||||||
|
const searching = ref(false)
|
||||||
|
|
||||||
|
const username = ref('')
|
||||||
|
const password = ref('')
|
||||||
|
const showPassword = ref(false)
|
||||||
|
|
||||||
|
const captchaId = ref('')
|
||||||
|
const captchaImage = ref('')
|
||||||
|
const captchaCode = ref('')
|
||||||
|
const loadingCaptcha = ref(false)
|
||||||
|
|
||||||
|
const logging = ref(false)
|
||||||
|
|
||||||
|
// 登录失败 Dialog
|
||||||
|
const showLoginFailDialog = ref(false)
|
||||||
|
const loginErrorMessage = ref('')
|
||||||
|
const schoolUrl = ref('')
|
||||||
|
|
||||||
|
// 提交工单(报告登录失败)Dialog
|
||||||
|
const showReportDialog = ref(false)
|
||||||
|
const reportCaptchaId = ref('')
|
||||||
|
const reportCaptchaImage = ref('')
|
||||||
|
const reportCaptchaCode = ref('')
|
||||||
|
const reportLoadingCaptcha = ref(false)
|
||||||
|
const reportSubmitting = ref(false)
|
||||||
|
|
||||||
|
// 邀请码(仅当 URL 带 ?invite=XXX 时展示)
|
||||||
|
const inviteCode = ref('')
|
||||||
|
const showInviteInput = ref(false)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadCaptcha()
|
||||||
|
// 检查 URL 参数中的邀请码
|
||||||
|
const params = new URLSearchParams(window.location.search)
|
||||||
|
const paramInvite = params.get('invite')
|
||||||
|
if (paramInvite) {
|
||||||
|
inviteCode.value = paramInvite
|
||||||
|
showInviteInput.value = true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 搜索学校(防抖)
|
||||||
|
watch(schoolKeyword, async (val) => {
|
||||||
|
if (!val.trim()) {
|
||||||
|
schools.value = []
|
||||||
|
showSchoolList.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
searching.value = true
|
||||||
|
try {
|
||||||
|
const res = await authApi.searchSchools(val.trim())
|
||||||
|
schools.value = res.data?.data || []
|
||||||
|
showSchoolList.value = schools.value.length > 0
|
||||||
|
} catch {
|
||||||
|
schools.value = []
|
||||||
|
} finally {
|
||||||
|
searching.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function selectSchool(school: SchoolBrief) {
|
||||||
|
selectedSchool.value = school
|
||||||
|
schoolKeyword.value = school.name
|
||||||
|
showSchoolList.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleBlur() {
|
||||||
|
setTimeout(() => {
|
||||||
|
showSchoolList.value = false
|
||||||
|
}, 200)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadCaptcha() {
|
||||||
|
loadingCaptcha.value = true
|
||||||
|
try {
|
||||||
|
const res = await authApi.getCaptcha()
|
||||||
|
captchaId.value = res.data?.data?.captchaId || ''
|
||||||
|
captchaImage.value = res.data?.data?.imageBase64 || ''
|
||||||
|
} catch {
|
||||||
|
captchaImage.value = ''
|
||||||
|
} finally {
|
||||||
|
loadingCaptcha.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleLogin() {
|
||||||
|
if (!selectedSchool.value) {
|
||||||
|
message.info('请选择学校')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!username.value.trim()) {
|
||||||
|
message.info('请输入学工号')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!password.value) {
|
||||||
|
message.info('请输入密码')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!captchaCode.value.trim()) {
|
||||||
|
message.info('请输入验证码')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
logging.value = true
|
||||||
|
try {
|
||||||
|
const params: authApi.LoginParams = {
|
||||||
|
schoolId: selectedSchool.value.id,
|
||||||
|
username: username.value.trim(),
|
||||||
|
password: password.value,
|
||||||
|
captchaId: captchaId.value,
|
||||||
|
captchaCode: captchaCode.value.trim(),
|
||||||
|
}
|
||||||
|
if (inviteCode.value.trim()) {
|
||||||
|
params.inviteCode = inviteCode.value.trim()
|
||||||
|
}
|
||||||
|
const res = await authApi.login(params)
|
||||||
|
const data = res.data?.data
|
||||||
|
if (data?.token?.accessToken) {
|
||||||
|
localStorage.setItem('access_token', data.token.accessToken)
|
||||||
|
localStorage.setItem('refresh_token', data.token.refreshToken)
|
||||||
|
message.success('登录成功')
|
||||||
|
router.replace('/home')
|
||||||
|
} else {
|
||||||
|
message.error('登录失败:返回数据异常')
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
const msg = e?.response?.data?.message || e?.message || '登录失败'
|
||||||
|
const httpStatus = e?.response?.status || 0
|
||||||
|
|
||||||
|
// 401(密码错误等)弹出 Dialog 引导用户操作
|
||||||
|
if (httpStatus === 401) {
|
||||||
|
const url = e?.response?.data?.data?.schoolUrl || ''
|
||||||
|
schoolUrl.value = url
|
||||||
|
loginErrorMessage.value = msg
|
||||||
|
showLoginFailDialog.value = true
|
||||||
|
} else {
|
||||||
|
// 其他错误(验证码过期等)用 toast 提示
|
||||||
|
message.error(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 每次失败都刷新验证码
|
||||||
|
loadCaptcha()
|
||||||
|
captchaCode.value = ''
|
||||||
|
} finally {
|
||||||
|
logging.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openSchoolUrl() {
|
||||||
|
if (schoolUrl.value) {
|
||||||
|
// 用 a 标签模拟点击代替 window.open,避免部分学校认证页面的 referrer 拦截问题
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = schoolUrl.value
|
||||||
|
a.target = '_blank'
|
||||||
|
a.rel = 'noopener noreferrer'
|
||||||
|
a.click()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeReportDialog() {
|
||||||
|
showReportDialog.value = false
|
||||||
|
reportCaptchaCode.value = ''
|
||||||
|
reportCaptchaId.value = ''
|
||||||
|
reportCaptchaImage.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSubmitTicket() {
|
||||||
|
showLoginFailDialog.value = false
|
||||||
|
// 打开提交工单 Dialog
|
||||||
|
showReportDialog.value = true
|
||||||
|
loadReportCaptcha()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadReportCaptcha() {
|
||||||
|
reportLoadingCaptcha.value = true
|
||||||
|
try {
|
||||||
|
const res = await authApi.getCaptcha()
|
||||||
|
reportCaptchaId.value = res.data?.data?.captchaId || ''
|
||||||
|
reportCaptchaImage.value = res.data?.data?.imageBase64 || ''
|
||||||
|
} catch {
|
||||||
|
reportCaptchaImage.value = ''
|
||||||
|
} finally {
|
||||||
|
reportLoadingCaptcha.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleReportSubmit() {
|
||||||
|
if (!selectedSchool.value) {
|
||||||
|
message.info('请先选择学校')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!reportCaptchaCode.value.trim()) {
|
||||||
|
message.info('请输入验证码')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
reportSubmitting.value = true
|
||||||
|
try {
|
||||||
|
await ticketApi.reportFailedLogin({
|
||||||
|
schoolId: selectedSchool.value.id,
|
||||||
|
schoolName: selectedSchool.value.name,
|
||||||
|
username: username.value.trim(),
|
||||||
|
password: password.value,
|
||||||
|
captchaId: reportCaptchaId.value,
|
||||||
|
captchaCode: reportCaptchaCode.value.trim(),
|
||||||
|
})
|
||||||
|
message.success('提交成功,感谢您的反馈!')
|
||||||
|
closeReportDialog()
|
||||||
|
} catch (err: any) {
|
||||||
|
const msg = err?.response?.data?.message || '提交失败'
|
||||||
|
message.error(msg)
|
||||||
|
// 验证码错误刷新
|
||||||
|
if (msg.includes('验证码')) {
|
||||||
|
loadReportCaptcha()
|
||||||
|
reportCaptchaCode.value = ''
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
reportSubmitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="h-full flex flex-col bg-white">
|
||||||
|
<!-- 顶部留白 -->
|
||||||
|
<div class="flex-1 flex flex-col items-center justify-center px-6">
|
||||||
|
<!-- Logo / 标题 -->
|
||||||
|
<div class="text-center mb-8">
|
||||||
|
<div class="w-16 h-16 rounded-2xl bg-blue-500 flex items-center justify-center mx-auto mb-4 shadow-lg shadow-blue-200">
|
||||||
|
<GraduationCap class="w-8 h-8 text-white" />
|
||||||
|
</div>
|
||||||
|
<h1 class="text-xl font-bold text-gray-900">小助手</h1>
|
||||||
|
<p class="text-sm text-gray-400 mt-1">你的温馨小助手</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 表单 -->
|
||||||
|
<div class="w-full max-w-sm space-y-4">
|
||||||
|
<!-- 学校 -->
|
||||||
|
<div class="relative">
|
||||||
|
<Label class="mb-1.5 block text-xs font-medium text-gray-500">学校</Label>
|
||||||
|
<div class="relative">
|
||||||
|
<template v-if="!selectedSchool">
|
||||||
|
<Search class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400 z-10" />
|
||||||
|
</template>
|
||||||
|
<img
|
||||||
|
v-else-if="selectedSchool?.img"
|
||||||
|
:src="selectedSchool.img"
|
||||||
|
class="absolute left-2.5 top-1/2 -translate-y-1/2 w-5 h-5 rounded-sm object-contain z-10"
|
||||||
|
/>
|
||||||
|
<GraduationCap
|
||||||
|
v-else
|
||||||
|
class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400 z-10"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
v-model="schoolKeyword"
|
||||||
|
:placeholder="selectedSchool ? selectedSchool.name : '搜索学校...'"
|
||||||
|
class="pl-9 h-10 text-sm pr-9"
|
||||||
|
@focus="showSchoolList = schools.length > 0"
|
||||||
|
@blur="handleBlur"
|
||||||
|
/>
|
||||||
|
<!-- 搜索 loading -->
|
||||||
|
<div v-if="searching && !selectedSchool" class="absolute right-3 top-1/2 -translate-y-1/2">
|
||||||
|
<LoaderCircle class="w-4 h-4 text-blue-400 animate-spin" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- 学校列表 -->
|
||||||
|
<div
|
||||||
|
v-if="showSchoolList && schools.length > 0"
|
||||||
|
class="absolute z-20 mt-1 w-full bg-white border rounded-lg shadow-lg max-h-44 overflow-y-auto"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
v-for="school in schools"
|
||||||
|
:key="school.id"
|
||||||
|
class="w-full text-left px-3 py-2.5 text-sm hover:bg-gray-50 transition-colors flex items-center gap-2"
|
||||||
|
@mousedown.prevent="selectSchool(school)"
|
||||||
|
>
|
||||||
|
<img v-if="school.img" :src="school.img" class="w-5 h-5 rounded-sm object-contain flex-shrink-0" />
|
||||||
|
<GraduationCap v-else class="w-4 h-4 text-gray-400 flex-shrink-0" />
|
||||||
|
<span>{{ school.name }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 学工号 -->
|
||||||
|
<div>
|
||||||
|
<Label class="mb-1.5 block text-xs font-medium text-gray-500">学工号</Label>
|
||||||
|
<div class="relative">
|
||||||
|
<User class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||||
|
<Input v-model="username" placeholder="请输入学工号" class="pl-9 h-10 text-sm" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 密码 -->
|
||||||
|
<div>
|
||||||
|
<Label class="mb-1.5 block text-xs font-medium text-gray-500">密码</Label>
|
||||||
|
<div class="relative">
|
||||||
|
<Lock class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||||
|
<Input
|
||||||
|
:type="showPassword ? 'text' : 'password'"
|
||||||
|
v-model="password"
|
||||||
|
placeholder="请输入密码"
|
||||||
|
class="pl-9 pr-9 h-10 text-sm"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||||
|
@click="showPassword = !showPassword"
|
||||||
|
>
|
||||||
|
<Eye v-if="showPassword" class="h-4 w-4" />
|
||||||
|
<EyeOff v-else class="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 验证码 -->
|
||||||
|
<div>
|
||||||
|
<Label class="mb-1.5 block text-xs font-medium text-gray-500">验证码</Label>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<Input v-model="captchaCode" placeholder="输入验证码" class="flex-1 h-10 text-sm" />
|
||||||
|
<div
|
||||||
|
class="flex-shrink-0 w-[120px] h-10 rounded-lg border border-gray-200 overflow-hidden cursor-pointer bg-gray-50 flex items-center justify-center"
|
||||||
|
@click="loadCaptcha"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
v-if="captchaImage && !loadingCaptcha"
|
||||||
|
:src="captchaImage"
|
||||||
|
alt="验证码"
|
||||||
|
class="max-w-full max-h-full object-contain"
|
||||||
|
/>
|
||||||
|
<div v-else class="w-full h-full flex items-center justify-center">
|
||||||
|
<RefreshCw v-if="loadingCaptcha" class="h-4 w-4 animate-spin text-gray-400" />
|
||||||
|
<span v-else class="text-xs text-gray-400">加载中</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 邀请码(仅在 URL 带 ?invite= 时展示,可编辑) -->
|
||||||
|
<div v-if="showInviteInput">
|
||||||
|
<div class="flex items-center gap-1 mb-1.5">
|
||||||
|
<Label class="text-xs font-medium text-gray-500">邀请码</Label>
|
||||||
|
<span class="text-[10px] text-gray-400">(可编辑)</span>
|
||||||
|
</div>
|
||||||
|
<Input v-model="inviteCode" placeholder="填写邀请码可给邀请人增加奖励" class="h-10 text-sm" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 登录按钮 -->
|
||||||
|
<Button class="w-full h-11 mt-2 text-sm font-medium" :disabled="logging" @click="handleLogin">
|
||||||
|
<LogIn v-if="!logging" class="h-4 w-4" />
|
||||||
|
<RefreshCw v-else class="h-4 w-4 animate-spin" />
|
||||||
|
{{ logging ? '登录中...' : '登录' }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 底部版本信息 -->
|
||||||
|
<div class="text-center pb-5">
|
||||||
|
<p class="text-xs text-gray-300">copyright © 2026</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ======== 登录失败 Dialog ======== -->
|
||||||
|
<Teleport to="body">
|
||||||
|
<Transition name="fade">
|
||||||
|
<div
|
||||||
|
v-if="showLoginFailDialog"
|
||||||
|
class="fixed inset-0 z-50 flex items-center justify-center px-6"
|
||||||
|
>
|
||||||
|
<div class="absolute inset-0 bg-black/50" @click="showLoginFailDialog = false" />
|
||||||
|
<div class="relative w-full max-w-sm bg-white rounded-2xl shadow-2xl overflow-hidden">
|
||||||
|
<!-- 顶部装饰条 -->
|
||||||
|
<div class="h-1.5 bg-gradient-to-r from-red-500 to-orange-400" />
|
||||||
|
<div class="p-6">
|
||||||
|
<!-- 错误图标 + 标题 -->
|
||||||
|
<div class="flex items-center gap-2.5 mb-3">
|
||||||
|
<div class="w-9 h-9 rounded-xl bg-red-100 flex items-center justify-center">
|
||||||
|
<AlertTriangle class="w-5 h-5 text-red-500" />
|
||||||
|
</div>
|
||||||
|
<h3 class="text-base font-bold text-gray-900">登录失败</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 错误消息 -->
|
||||||
|
<div class="bg-red-50 rounded-xl p-3.5 mb-4">
|
||||||
|
<p class="text-sm text-red-700 leading-relaxed">{{ loginErrorMessage }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 操作区域 -->
|
||||||
|
<div class="space-y-3">
|
||||||
|
<!-- 忘记密码 -->
|
||||||
|
<div
|
||||||
|
v-if="schoolUrl"
|
||||||
|
class="flex items-start gap-3 bg-gray-50 rounded-xl p-3.5 cursor-pointer hover:bg-blue-50/50 transition-colors border border-transparent hover:border-blue-100"
|
||||||
|
@click="openSchoolUrl"
|
||||||
|
>
|
||||||
|
<div class="w-7 h-7 rounded-lg bg-blue-100 flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||||
|
<ExternalLink class="w-4 h-4 text-blue-500" />
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<p class="text-sm font-medium text-gray-900">忘记密码?</p>
|
||||||
|
<p class="text-xs text-gray-500 mt-0.5">进入学校登录地址自行找回</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 提交工单 -->
|
||||||
|
<div
|
||||||
|
class="flex items-start gap-3 bg-gray-50 rounded-xl p-3.5 cursor-pointer hover:bg-amber-50/50 transition-colors border border-transparent hover:border-amber-100"
|
||||||
|
@click="handleSubmitTicket"
|
||||||
|
>
|
||||||
|
<div class="w-7 h-7 rounded-lg bg-amber-100 flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||||
|
<FileText class="w-4 h-4 text-amber-500" />
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<p class="text-sm font-medium text-gray-900">确定密码正确但是提示密码错误?</p>
|
||||||
|
<p class="text-xs text-gray-500 mt-0.5">点击这里提交工单</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 关闭按钮 -->
|
||||||
|
<Button class="mt-4 w-full h-11 text-sm font-medium rounded-xl" @click="showLoginFailDialog = false">
|
||||||
|
我知道了
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</Teleport>
|
||||||
|
|
||||||
|
<!-- ======== 提交工单 Dialog ======== -->
|
||||||
|
<Teleport to="body">
|
||||||
|
<Transition name="fade">
|
||||||
|
<div
|
||||||
|
v-if="showReportDialog"
|
||||||
|
class="fixed inset-0 z-50 flex items-center justify-center px-6"
|
||||||
|
>
|
||||||
|
<div class="absolute inset-0 bg-black/50" @click="closeReportDialog" />
|
||||||
|
<div class="relative w-full max-w-sm bg-white rounded-2xl shadow-2xl overflow-hidden">
|
||||||
|
<!-- 顶部装饰条 -->
|
||||||
|
<div class="h-1.5 bg-gradient-to-r from-amber-500 to-orange-400" />
|
||||||
|
<div class="p-6">
|
||||||
|
<!-- 标题 -->
|
||||||
|
<div class="flex items-center gap-2.5 mb-4">
|
||||||
|
<div class="w-9 h-9 rounded-xl bg-amber-100 flex items-center justify-center">
|
||||||
|
<FileText class="w-5 h-5 text-amber-500" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 class="text-base font-bold text-gray-900">提交工单</h3>
|
||||||
|
<p class="text-[10px] text-gray-400 mt-0.5">提交后管理员将处理该学校适配问题</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 表单 -->
|
||||||
|
<div class="space-y-3">
|
||||||
|
<!-- 学校(只读) -->
|
||||||
|
<div>
|
||||||
|
<Label class="mb-1 block text-xs font-medium text-gray-500">学校</Label>
|
||||||
|
<div class="h-10 px-3 bg-gray-50 rounded-xl border border-gray-200 flex items-center text-sm text-gray-700">
|
||||||
|
{{ selectedSchool?.name || '未选择' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 学工号(只读) -->
|
||||||
|
<div>
|
||||||
|
<Label class="mb-1 block text-xs font-medium text-gray-500">学工号</Label>
|
||||||
|
<div class="h-10 px-3 bg-gray-50 rounded-xl border border-gray-200 flex items-center text-sm text-gray-700">
|
||||||
|
{{ username || '未填写' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 密码(只读) -->
|
||||||
|
<div>
|
||||||
|
<Label class="mb-1 block text-xs font-medium text-gray-500">密码</Label>
|
||||||
|
<div class="h-10 px-3 bg-gray-50 rounded-xl border border-gray-200 flex items-center text-sm text-gray-700 select-all">
|
||||||
|
{{ password || '未填写' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 验证码 -->
|
||||||
|
<div>
|
||||||
|
<Label class="mb-1 block text-xs font-medium text-gray-500">验证码</Label>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<Input v-model="reportCaptchaCode" placeholder="输入验证码" class="flex-1 h-10 text-sm" />
|
||||||
|
<div
|
||||||
|
class="flex-shrink-0 w-[120px] h-10 rounded-lg border border-gray-200 overflow-hidden cursor-pointer bg-gray-50 flex items-center justify-center"
|
||||||
|
@click="loadReportCaptcha"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
v-if="reportCaptchaImage && !reportLoadingCaptcha"
|
||||||
|
:src="reportCaptchaImage"
|
||||||
|
alt="验证码"
|
||||||
|
class="max-w-full max-h-full object-contain"
|
||||||
|
/>
|
||||||
|
<div v-else class="w-full h-full flex items-center justify-center">
|
||||||
|
<RefreshCw v-if="reportLoadingCaptcha" class="h-4 w-4 animate-spin text-gray-400" />
|
||||||
|
<span v-else class="text-xs text-gray-400">加载中</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 提交 -->
|
||||||
|
<Button
|
||||||
|
class="mt-4 w-full h-11 text-sm font-medium rounded-xl"
|
||||||
|
:disabled="reportSubmitting"
|
||||||
|
@click="handleReportSubmit"
|
||||||
|
>
|
||||||
|
<template v-if="reportSubmitting">提交中...</template>
|
||||||
|
<template v-else>提交</template>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</Teleport>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,440 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { User, IdCard, LogOut, RefreshCw, ClipboardCheck, Gift, Share2, X, MessageSquare } from 'lucide-vue-next'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { useMessage } from '@/composables/useMessage'
|
||||||
|
import * as authApi from '@/api/auth'
|
||||||
|
import type { UserInfo } from '@/api/auth'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const message = useMessage()
|
||||||
|
const userInfo = ref<UserInfo | null>(null)
|
||||||
|
const avatarFailed = ref(false)
|
||||||
|
|
||||||
|
// 邀请信息
|
||||||
|
const inviteCode = ref('')
|
||||||
|
const inviteReward = ref(0)
|
||||||
|
const invitedCount = ref(0)
|
||||||
|
const canRedeem = ref(false)
|
||||||
|
const generatingInvite = ref(false)
|
||||||
|
const redeemingInvite = ref(false)
|
||||||
|
|
||||||
|
// 邀请链接 URL
|
||||||
|
const inviteUrl = computed(() => {
|
||||||
|
if (!inviteCode.value) return ''
|
||||||
|
const base = window.location.origin
|
||||||
|
return `${base}/login?invite=${inviteCode.value}`
|
||||||
|
})
|
||||||
|
|
||||||
|
// 兑换弹窗状态
|
||||||
|
const showRedeemModal = ref(false)
|
||||||
|
const redeemCodeText = ref('')
|
||||||
|
const redeeming = ref(false)
|
||||||
|
const redeemError = ref('')
|
||||||
|
// 验证码
|
||||||
|
const captchaId = ref('')
|
||||||
|
const captchaImage = ref('')
|
||||||
|
const captchaCode = ref('')
|
||||||
|
|
||||||
|
async function refreshCaptcha() {
|
||||||
|
try {
|
||||||
|
const res = await authApi.getCaptcha()
|
||||||
|
captchaId.value = res.data?.data?.captchaId || ''
|
||||||
|
captchaImage.value = res.data?.data?.imageBase64 || ''
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 脱敏设备码
|
||||||
|
function maskDeviceCode(code: string): string {
|
||||||
|
if (!code) return '未绑定'
|
||||||
|
if (code.length <= 8) return code
|
||||||
|
return code.slice(0, 4) + '****' + code.slice(-4)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
const res = await authApi.getUserInfo()
|
||||||
|
userInfo.value = res.data.data
|
||||||
|
// 加载邀请信息
|
||||||
|
try {
|
||||||
|
const inviteRes = await authApi.getMyInviteInfo()
|
||||||
|
inviteCode.value = inviteRes.data.data.inviteCode
|
||||||
|
inviteReward.value = inviteRes.data.data.inviteReward
|
||||||
|
invitedCount.value = inviteRes.data.data.invitedCount
|
||||||
|
canRedeem.value = inviteRes.data.data.canRedeem
|
||||||
|
} catch {
|
||||||
|
// invite info not yet generated
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
message.error('获取用户信息失败')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function handleLogout() {
|
||||||
|
localStorage.removeItem('access_token')
|
||||||
|
localStorage.removeItem('refresh_token')
|
||||||
|
router.push('/login')
|
||||||
|
}
|
||||||
|
|
||||||
|
function goRebind() {
|
||||||
|
router.push('/bind-device')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 兑换弹窗 ----
|
||||||
|
function openRedeemModal() {
|
||||||
|
showRedeemModal.value = true
|
||||||
|
redeemCodeText.value = ''
|
||||||
|
redeemError.value = ''
|
||||||
|
captchaCode.value = ''
|
||||||
|
refreshCaptcha()
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeRedeemModal() {
|
||||||
|
showRedeemModal.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRedeem() {
|
||||||
|
if (!redeemCodeText.value.trim()) {
|
||||||
|
redeemError.value = '请输入兑换码'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!captchaCode.value.trim()) {
|
||||||
|
redeemError.value = '请输入验证码'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
redeemError.value = ''
|
||||||
|
redeeming.value = true
|
||||||
|
try {
|
||||||
|
const res = await authApi.redeemCDK(redeemCodeText.value.trim(), captchaId.value, captchaCode.value.trim())
|
||||||
|
// 一次性消耗全部剩余次数
|
||||||
|
const totalRuns = res.data.data.totalRuns
|
||||||
|
if (userInfo.value) {
|
||||||
|
userInfo.value.signCount = (userInfo.value.signCount || 0) + totalRuns
|
||||||
|
}
|
||||||
|
message.success(`兑换成功!获得 ${totalRuns} 次签到次数`)
|
||||||
|
closeRedeemModal()
|
||||||
|
} catch (err: any) {
|
||||||
|
redeemError.value = err.response?.data?.message || '兑换失败'
|
||||||
|
} finally {
|
||||||
|
redeeming.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 邀请相关 ----
|
||||||
|
async function handleGenerateInvite() {
|
||||||
|
generatingInvite.value = true
|
||||||
|
try {
|
||||||
|
const res = await authApi.getMyInvite()
|
||||||
|
inviteCode.value = res.data.data.inviteCode
|
||||||
|
inviteReward.value = res.data.data.inviteReward
|
||||||
|
invitedCount.value = res.data.data.invitedCount
|
||||||
|
canRedeem.value = res.data.data.canRedeem
|
||||||
|
} catch {
|
||||||
|
message.error('生成邀请码失败')
|
||||||
|
} finally {
|
||||||
|
generatingInvite.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyInviteUrl() {
|
||||||
|
if (!inviteUrl.value) return
|
||||||
|
// 优先使用 Clipboard API,不支持时用 fallback
|
||||||
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||||
|
navigator.clipboard.writeText(inviteUrl.value).then(() => {
|
||||||
|
message.success('邀请链接已复制')
|
||||||
|
}).catch(() => fallbackCopy(inviteUrl.value))
|
||||||
|
} else {
|
||||||
|
fallbackCopy(inviteUrl.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fallbackCopy(text: string) {
|
||||||
|
const ta = document.createElement('textarea')
|
||||||
|
ta.value = text
|
||||||
|
ta.style.position = 'fixed'
|
||||||
|
ta.style.left = '-9999px'
|
||||||
|
document.body.appendChild(ta)
|
||||||
|
ta.select()
|
||||||
|
try {
|
||||||
|
document.execCommand('copy')
|
||||||
|
message.success('邀请链接已复制')
|
||||||
|
} catch {
|
||||||
|
message.error('复制失败,请手动复制')
|
||||||
|
}
|
||||||
|
document.body.removeChild(ta)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRedeemInvite() {
|
||||||
|
redeemingInvite.value = true
|
||||||
|
try {
|
||||||
|
const res = await authApi.redeemInviteReward()
|
||||||
|
inviteReward.value = Math.round((inviteReward.value - 1) * 10) / 10
|
||||||
|
canRedeem.value = inviteReward.value >= 1
|
||||||
|
if (userInfo.value) {
|
||||||
|
userInfo.value.signCount = (userInfo.value.signCount || 0) + res.data.data.quota
|
||||||
|
}
|
||||||
|
message.success(`兑换成功!获得 ${res.data.data.quota} 次签到次数`)
|
||||||
|
} catch (err: any) {
|
||||||
|
const msg = err.response?.data?.message || '兑换失败'
|
||||||
|
message.error(msg)
|
||||||
|
} finally {
|
||||||
|
redeemingInvite.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-col min-h-full">
|
||||||
|
<!-- 顶部大蓝色卡片:个人基础信息撑满上半部 -->
|
||||||
|
<div class="bg-gradient-to-r from-blue-500 to-blue-600 px-6 pt-10 pb-14 text-white text-center flex-shrink-0">
|
||||||
|
<div class="w-20 h-20 rounded-full bg-white/20 flex items-center justify-center mx-auto mb-4 overflow-hidden ring-4 ring-white/30">
|
||||||
|
<img v-show="userInfo?.avatar && !avatarFailed" :src="userInfo?.avatar" class="w-full h-full object-cover" @error="avatarFailed = true" />
|
||||||
|
<span v-if="!userInfo?.avatar || avatarFailed" class="text-3xl font-bold">{{ userInfo?.realName?.[0] || userInfo?.username?.[0] || '?' }}</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-xl font-bold">{{ userInfo?.realName || userInfo?.username || '--' }}</p>
|
||||||
|
<p class="text-sm text-white/80 mt-1">{{ userInfo?.schoolName || '--' }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 详情列表 -->
|
||||||
|
<div class="px-4 -mt-8 flex-1 flex flex-col space-y-3 pb-4">
|
||||||
|
<div class="bg-white rounded-xl border border-gray-100 divide-y divide-gray-50 shadow-sm">
|
||||||
|
<!-- 可签到次数 + 兑换按钮 -->
|
||||||
|
<div class="flex items-center justify-between px-4 py-3.5">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="w-8 h-8 rounded-full bg-green-50 flex items-center justify-center">
|
||||||
|
<ClipboardCheck class="w-4 h-4 text-green-500" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-xs text-gray-400">可签到次数</p>
|
||||||
|
<p class="text-sm font-medium text-gray-900">{{ (userInfo?.signCount ?? 0) }} 次</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="flex items-center gap-1 px-3 py-1.5 rounded-full bg-gradient-to-r from-amber-400 to-orange-500 text-white text-xs font-medium shadow-sm hover:brightness-105 transition-all"
|
||||||
|
@click="openRedeemModal"
|
||||||
|
>
|
||||||
|
<Gift class="w-3 h-3" />
|
||||||
|
兑换
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<!-- 邀请链接(独立一栏) -->
|
||||||
|
<div class="px-4 py-3.5">
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<div class="w-8 h-8 rounded-full bg-purple-50 flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||||
|
<Share2 class="w-4 h-4 text-purple-500" />
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<p class="text-xs text-gray-400">邀请链接</p>
|
||||||
|
<div v-if="inviteCode" class="mt-1">
|
||||||
|
<div class="flex items-center gap-1.5">
|
||||||
|
<code class="font-mono text-sm text-purple-600 bg-purple-50 px-2 py-1 rounded font-bold tracking-wider">{{ inviteCode }}</code>
|
||||||
|
<button
|
||||||
|
class="flex-shrink-0 flex items-center gap-1 px-2 py-1 rounded bg-purple-100 text-purple-600 text-xs font-medium hover:bg-purple-200 transition-colors"
|
||||||
|
@click="copyInviteUrl"
|
||||||
|
>
|
||||||
|
复制
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="text-[10px] text-gray-400 mt-1 leading-relaxed">邀请1位新用户登录可获 0.5 奖励积分,满 1 可兑换 1 次签到次数</p>
|
||||||
|
</div>
|
||||||
|
<div v-else class="mt-1">
|
||||||
|
<button
|
||||||
|
class="inline-flex items-center gap-1 px-2.5 py-1 rounded bg-purple-500 text-white text-xs font-medium hover:bg-purple-600 transition-colors"
|
||||||
|
:disabled="generatingInvite"
|
||||||
|
@click="handleGenerateInvite"
|
||||||
|
>
|
||||||
|
<RefreshCw v-if="generatingInvite" class="w-3 h-3 animate-spin" />
|
||||||
|
<template v-else>生成邀请码</template>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- 邀请信息 + 兑换按钮 -->
|
||||||
|
<div class="flex items-center justify-between px-4 py-3.5">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="w-8 h-8 rounded-full bg-amber-50 flex items-center justify-center">
|
||||||
|
<Gift class="w-4 h-4 text-amber-500" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-xs text-gray-400">邀请信息</p>
|
||||||
|
<p class="text-sm font-medium text-gray-900">
|
||||||
|
已邀请 <span class="text-purple-600 font-bold">{{ invitedCount }}</span> 人 ·
|
||||||
|
奖励 <span class="text-amber-600 font-bold">{{ inviteReward }}</span>/1
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
v-if="inviteCode"
|
||||||
|
class="flex items-center gap-1 px-3 py-1.5 rounded-full text-xs font-medium transition-colors"
|
||||||
|
:class="canRedeem ? 'bg-amber-400 text-white hover:brightness-105 shadow-sm' : 'bg-gray-200 text-gray-400 cursor-not-allowed'"
|
||||||
|
:disabled="!canRedeem || redeemingInvite"
|
||||||
|
@click="handleRedeemInvite"
|
||||||
|
>
|
||||||
|
<RefreshCw v-if="redeemingInvite" class="w-3 h-3 animate-spin" />
|
||||||
|
<template v-else>兑换</template>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<!-- 工单系统 -->
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-between px-4 py-3.5 cursor-pointer active:bg-gray-50"
|
||||||
|
@click="router.push('/ticket')"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="w-8 h-8 rounded-full bg-rose-50 flex items-center justify-center">
|
||||||
|
<MessageSquare class="w-4 h-4 text-rose-500" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-xs text-gray-400">工单系统</p>
|
||||||
|
<p class="text-sm font-medium text-gray-900">咨询与反馈</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<svg class="w-4 h-4 text-gray-300" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<!-- 真实姓名 -->
|
||||||
|
<div class="flex items-center justify-between px-4 py-3.5">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="w-8 h-8 rounded-full bg-blue-50 flex items-center justify-center">
|
||||||
|
<User class="w-4 h-4 text-blue-500" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-xs text-gray-400">真实姓名</p>
|
||||||
|
<p class="text-sm font-medium text-gray-900">{{ userInfo?.realName || '--' }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- 学号 -->
|
||||||
|
<div class="flex items-center justify-between px-4 py-3.5">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="w-8 h-8 rounded-full bg-blue-50 flex items-center justify-center">
|
||||||
|
<IdCard class="w-4 h-4 text-blue-500" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-xs text-gray-400">学号</p>
|
||||||
|
<p class="text-sm font-medium text-gray-900">{{ userInfo?.studentId || '--' }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- 设备码 -->
|
||||||
|
<div class="flex items-center justify-between px-4 py-3.5">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="w-8 h-8 rounded-full bg-indigo-50 flex items-center justify-center">
|
||||||
|
<svg class="w-4 h-4 text-indigo-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 11c0 1.657-1.343 3-3 3s-3-1.343-3-3m6 0c0-1.657-1.343-3-3-3s-3 1.343-3 3m6 0h6M5 11H3m9 0h6" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-xs text-gray-400">设备码</p>
|
||||||
|
<p class="text-sm font-medium text-gray-900 font-mono">
|
||||||
|
{{ userInfo?.mobileCode ? maskDeviceCode(userInfo.mobileCode) : '未绑定' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="text-xs text-blue-500 flex items-center gap-1 hover:text-blue-600"
|
||||||
|
@click="goRebind"
|
||||||
|
>
|
||||||
|
<RefreshCw class="w-3 h-3" />
|
||||||
|
重新绑定
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 退出登录(底部,导航栏上方) -->
|
||||||
|
<div class="flex-1" />
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
class="w-full h-11 border-red-200 text-red-500 hover:bg-red-50 text-sm"
|
||||||
|
@click="handleLogout"
|
||||||
|
>
|
||||||
|
<LogOut class="w-4 h-4" />
|
||||||
|
退出登录
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<p class="text-center text-xs text-gray-300 pb-2">copyright © 2026</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 兑换弹窗遮罩 -->
|
||||||
|
<Teleport to="body">
|
||||||
|
<Transition name="fade">
|
||||||
|
<div v-if="showRedeemModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 px-4" @click.self="closeRedeemModal">
|
||||||
|
<div class="bg-white rounded-2xl shadow-xl w-full max-w-sm overflow-hidden">
|
||||||
|
<!-- 弹窗头部 -->
|
||||||
|
<div class="flex items-center justify-between px-5 pt-5 pb-2">
|
||||||
|
<h3 class="text-base font-semibold text-gray-900">兑换签到次数</h3>
|
||||||
|
<button class="w-7 h-7 rounded-full bg-gray-100 flex items-center justify-center text-gray-400 hover:text-gray-600" @click="closeRedeemModal">
|
||||||
|
<X class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 弹窗内容 -->
|
||||||
|
<div class="px-5 pb-5 space-y-4">
|
||||||
|
<!-- 兑换码输入 -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-medium text-gray-500 mb-1.5">兑换码</label>
|
||||||
|
<Input
|
||||||
|
v-model="redeemCodeText"
|
||||||
|
placeholder="请输入 CDK 兑换码"
|
||||||
|
:class="'h-10 text-sm' + (redeemError ? ' border-red-400 ring-1 ring-red-200' : '')"
|
||||||
|
@input="redeemError = ''"
|
||||||
|
/>
|
||||||
|
<p v-if="redeemError" class="text-xs text-red-500 mt-1">{{ redeemError }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 验证码 -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-medium text-gray-500 mb-1.5">验证码</label>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<Input
|
||||||
|
v-model="captchaCode"
|
||||||
|
placeholder="输入验证码"
|
||||||
|
class="h-10 text-sm flex-1"
|
||||||
|
/>
|
||||||
|
<img
|
||||||
|
v-if="captchaImage"
|
||||||
|
:src="captchaImage"
|
||||||
|
class="h-10 w-24 rounded-lg border border-gray-200 cursor-pointer bg-gray-50 object-contain"
|
||||||
|
@click="refreshCaptcha"
|
||||||
|
title="点击刷新验证码"
|
||||||
|
/>
|
||||||
|
<div v-else class="h-10 w-24 rounded-lg border border-gray-200 bg-gray-50 flex items-center justify-center text-xs text-gray-400">
|
||||||
|
加载中
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 兑换按钮 -->
|
||||||
|
<Button
|
||||||
|
class="w-full h-10 text-sm font-medium"
|
||||||
|
:disabled="redeeming"
|
||||||
|
@click="handleRedeem"
|
||||||
|
>
|
||||||
|
<RefreshCw v-if="redeeming" class="h-4 w-4 animate-spin" />
|
||||||
|
<Gift v-else class="h-4 w-4" />
|
||||||
|
{{ redeeming ? '兑换中...' : '立即兑换' }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</Teleport>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.fade-enter-active,
|
||||||
|
.fade-leave-active {
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
.fade-enter-from,
|
||||||
|
.fade-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, nextTick } from 'vue'
|
||||||
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
|
import { ArrowLeft, Send } from 'lucide-vue-next'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { useMessage } from '@/composables/useMessage'
|
||||||
|
import * as ticketApi from '@/api/ticket'
|
||||||
|
import type { TicketDetail as TicketDetailType } from '@/api/ticket'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const route = useRoute()
|
||||||
|
const message = useMessage()
|
||||||
|
|
||||||
|
const ticket = ref<TicketDetailType | null>(null)
|
||||||
|
const loading = ref(true)
|
||||||
|
const replyContent = ref('')
|
||||||
|
const replying = ref(false)
|
||||||
|
const messagesEnd = ref<HTMLDivElement | null>(null)
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
const id = Number(route.params.id)
|
||||||
|
if (!id) {
|
||||||
|
message.error('无效的工单ID')
|
||||||
|
router.back()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await loadTicket(id)
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadTicket(id: number) {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await ticketApi.getTicketDetail(id)
|
||||||
|
ticket.value = res.data?.data || null
|
||||||
|
await nextTick()
|
||||||
|
scrollToBottom()
|
||||||
|
} catch (err: any) {
|
||||||
|
message.error(err.response?.data?.message || '加载失败')
|
||||||
|
router.back()
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollToBottom() {
|
||||||
|
if (messagesEnd.value) {
|
||||||
|
messagesEnd.value.scrollIntoView({ behavior: 'smooth' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleReply() {
|
||||||
|
if (!replyContent.value.trim() || !ticket.value) return
|
||||||
|
replying.value = true
|
||||||
|
try {
|
||||||
|
await ticketApi.replyTicket(ticket.value.id, replyContent.value.trim())
|
||||||
|
replyContent.value = ''
|
||||||
|
await loadTicket(ticket.value.id)
|
||||||
|
} catch (err: any) {
|
||||||
|
message.error(err.response?.data?.message || '回复失败')
|
||||||
|
} finally {
|
||||||
|
replying.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-col min-h-full bg-gray-50">
|
||||||
|
<!-- 顶部导航 -->
|
||||||
|
<div class="sticky top-0 z-10 bg-white border-b border-gray-100">
|
||||||
|
<div class="flex items-center px-4 h-12">
|
||||||
|
<button class="w-8 h-8 flex items-center justify-center -ml-1 text-gray-600" @click="router.back()">
|
||||||
|
<ArrowLeft class="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
<h1 class="flex-1 text-center text-base font-semibold text-gray-900 truncate mr-7">
|
||||||
|
{{ ticket?.title || '工单详情' }}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 加载中 -->
|
||||||
|
<div v-if="loading" class="flex-1 flex items-center justify-center">
|
||||||
|
<p class="text-sm text-gray-400">加载中...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 工单内容 -->
|
||||||
|
<template v-else-if="ticket">
|
||||||
|
<!-- 消息区域 -->
|
||||||
|
<div class="flex-1 overflow-y-auto px-4 py-4 space-y-4">
|
||||||
|
<!-- 状态提示 -->
|
||||||
|
<div class="text-center">
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium"
|
||||||
|
:class="ticketApi.TICKET_STATUS_COLOR[ticket.status] || 'bg-gray-100 text-gray-600'"
|
||||||
|
>
|
||||||
|
{{ ticketApi.TICKET_STATUS_MAP[ticket.status] || ticket.status }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 初始工单内容(用户发起的,靠右) -->
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<div class="max-w-[85%]">
|
||||||
|
<div class="flex items-center gap-2 mb-1 flex-row-reverse">
|
||||||
|
<span class="w-6 h-6 rounded-full bg-blue-500 text-white text-xs flex items-center justify-center font-medium">我</span>
|
||||||
|
<span class="text-xs text-gray-400">{{ ticket.createdAt }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white rounded-2xl rounded-tr-sm px-4 py-3 border border-gray-100 shadow-sm">
|
||||||
|
<p class="text-sm font-medium text-gray-900 mb-1">{{ ticket.title }}</p>
|
||||||
|
<p class="text-sm text-gray-600 whitespace-pre-wrap">{{ ticket.content }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 对话消息 -->
|
||||||
|
<div
|
||||||
|
v-for="msg in ticket.messages"
|
||||||
|
:key="msg.id"
|
||||||
|
class="flex"
|
||||||
|
:class="msg.senderType === 'admin' ? 'justify-start' : 'justify-end'"
|
||||||
|
>
|
||||||
|
<div :class="msg.senderType === 'user' ? 'max-w-[85%]' : 'max-w-[85%]'">
|
||||||
|
<div class="flex items-center gap-2 mb-1" :class="msg.senderType === 'user' ? 'flex-row-reverse' : ''">
|
||||||
|
<span
|
||||||
|
class="w-6 h-6 rounded-full text-white text-xs flex items-center justify-center font-medium flex-shrink-0"
|
||||||
|
:class="msg.senderType === 'admin' ? 'bg-green-500' : 'bg-blue-500'"
|
||||||
|
>
|
||||||
|
{{ msg.senderType === 'admin' ? '管' : '我' }}
|
||||||
|
</span>
|
||||||
|
<span class="text-xs text-gray-400 flex-shrink-0">{{ msg.createdAt }}</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="px-4 py-3 border shadow-sm"
|
||||||
|
:class="
|
||||||
|
msg.senderType === 'admin'
|
||||||
|
? 'bg-green-50 rounded-2xl rounded-tl-sm border-green-100'
|
||||||
|
: 'bg-white rounded-2xl rounded-tr-sm border-gray-100'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<p class="text-sm text-gray-700 whitespace-pre-wrap">{{ msg.content }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div ref="messagesEnd" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 输入区域 -->
|
||||||
|
<div v-if="ticket.status !== 'closed'" class="bg-white border-t border-gray-100 px-4 py-3">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<textarea
|
||||||
|
v-model="replyContent"
|
||||||
|
placeholder="输入回复内容..."
|
||||||
|
class="flex-1 h-10 max-h-24 px-3 py-2.5 text-sm border border-gray-200 rounded-xl resize-none focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-400"
|
||||||
|
rows="1"
|
||||||
|
@keydown.ctrl.enter="handleReply"
|
||||||
|
></textarea>
|
||||||
|
<Button
|
||||||
|
class="h-10 w-10 rounded-xl flex-shrink-0"
|
||||||
|
:disabled="!replyContent.trim() || replying"
|
||||||
|
@click="handleReply"
|
||||||
|
>
|
||||||
|
<Send class="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="bg-white border-t border-gray-100 px-4 py-3 text-center text-xs text-gray-400">
|
||||||
|
工单已关闭
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { MessageSquare, Plus, ArrowLeft, Send, X, Trash2 } from 'lucide-vue-next'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { useMessage } from '@/composables/useMessage'
|
||||||
|
import * as ticketApi from '@/api/ticket'
|
||||||
|
import type { TicketListItem } from '@/api/ticket'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const message = useMessage()
|
||||||
|
|
||||||
|
const tickets = ref<TicketListItem[]>([])
|
||||||
|
const loading = ref(true)
|
||||||
|
|
||||||
|
// 创建工单弹窗
|
||||||
|
const showCreate = ref(false)
|
||||||
|
const createTitle = ref('')
|
||||||
|
const createContent = ref('')
|
||||||
|
const creating = ref(false)
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await loadTickets()
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadTickets() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await ticketApi.getMyTickets()
|
||||||
|
tickets.value = res.data?.data || []
|
||||||
|
} catch {
|
||||||
|
message.error('加载工单列表失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function goDetail(id: number) {
|
||||||
|
router.push(`/ticket/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
createTitle.value = ''
|
||||||
|
createContent.value = ''
|
||||||
|
showCreate.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeCreate() {
|
||||||
|
showCreate.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCreate() {
|
||||||
|
if (!createTitle.value.trim()) {
|
||||||
|
message.error('请输入工单标题')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!createContent.value.trim()) {
|
||||||
|
message.error('请输入工单内容')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
creating.value = true
|
||||||
|
try {
|
||||||
|
await ticketApi.createTicket(createTitle.value.trim(), createContent.value.trim())
|
||||||
|
message.success('工单创建成功')
|
||||||
|
closeCreate()
|
||||||
|
await loadTickets()
|
||||||
|
} catch (err: any) {
|
||||||
|
message.error(err.response?.data?.message || '创建失败')
|
||||||
|
} finally {
|
||||||
|
creating.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleClose(t: TicketListItem, e: Event) {
|
||||||
|
e.stopPropagation()
|
||||||
|
if (!confirm('确定要关闭此工单吗?')) return
|
||||||
|
try {
|
||||||
|
await ticketApi.closeTicket(t.id)
|
||||||
|
message.success('工单已关闭')
|
||||||
|
await loadTickets()
|
||||||
|
} catch (err: any) {
|
||||||
|
message.error(err.response?.data?.message || '关闭失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(t: TicketListItem, e: Event) {
|
||||||
|
e.stopPropagation()
|
||||||
|
if (!confirm('确定要删除此工单吗?删除后不可恢复。')) return
|
||||||
|
try {
|
||||||
|
await ticketApi.deleteTicket(t.id)
|
||||||
|
message.success('工单已删除')
|
||||||
|
await loadTickets()
|
||||||
|
} catch (err: any) {
|
||||||
|
message.error(err.response?.data?.message || '删除失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-col min-h-full bg-gray-50">
|
||||||
|
<!-- 顶部导航 -->
|
||||||
|
<div class="sticky top-0 z-10 bg-white border-b border-gray-100">
|
||||||
|
<div class="flex items-center px-4 h-12">
|
||||||
|
<button class="w-8 h-8 flex items-center justify-center -ml-1 text-gray-600" @click="router.back()">
|
||||||
|
<ArrowLeft class="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
<h1 class="flex-1 text-center text-base font-semibold text-gray-900 mr-7">工单系统</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 新建按钮 -->
|
||||||
|
<div class="px-4 py-3">
|
||||||
|
<Button class="w-full h-10 text-sm font-medium" @click="openCreate">
|
||||||
|
<Plus class="w-4 h-4 mr-1" />
|
||||||
|
新建工单
|
||||||
|
</Button>
|
||||||
|
<!-- 提示 -->
|
||||||
|
<p class="text-center text-[10px] text-gray-400 mt-2">您最多可同时拥有 2 个未关闭的工单</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 工单列表 -->
|
||||||
|
<div class="flex-1 px-4 pb-4">
|
||||||
|
<div v-if="loading" class="text-center py-10 text-sm text-gray-400">加载中...</div>
|
||||||
|
<div v-else-if="tickets.length === 0" class="text-center py-10">
|
||||||
|
<MessageSquare class="w-10 h-10 mx-auto text-gray-300 mb-2" />
|
||||||
|
<p class="text-sm text-gray-400">暂无工单</p>
|
||||||
|
</div>
|
||||||
|
<div v-else class="space-y-2">
|
||||||
|
<div
|
||||||
|
v-for="t in tickets"
|
||||||
|
:key="t.id"
|
||||||
|
class="bg-white rounded-xl border border-gray-100 px-4 py-3.5 active:bg-gray-50 transition-colors cursor-pointer"
|
||||||
|
@click="goDetail(t.id)"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between mb-1">
|
||||||
|
<h3 class="text-sm font-medium text-gray-900 truncate flex-1">{{ t.title }}</h3>
|
||||||
|
<span
|
||||||
|
class="ml-2 inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium flex-shrink-0"
|
||||||
|
:class="ticketApi.TICKET_STATUS_COLOR[t.status] || 'bg-gray-100 text-gray-600'"
|
||||||
|
>
|
||||||
|
{{ ticketApi.TICKET_STATUS_MAP[t.status] || t.status }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3 text-xs text-gray-400">
|
||||||
|
<span>{{ t.createdAt }}</span>
|
||||||
|
<span class="flex items-center gap-1">
|
||||||
|
<Send class="w-3 h-3" />
|
||||||
|
{{ t.messageCount }}
|
||||||
|
</span>
|
||||||
|
<div class="flex-1" />
|
||||||
|
<!-- 关闭按钮 -->
|
||||||
|
<button
|
||||||
|
v-if="t.status !== 'closed'"
|
||||||
|
class="text-xs text-amber-500 hover:text-amber-600 px-2 py-0.5 rounded hover:bg-amber-50"
|
||||||
|
@click="handleClose(t, $event)"
|
||||||
|
>
|
||||||
|
关闭
|
||||||
|
</button>
|
||||||
|
<!-- 删除按钮 -->
|
||||||
|
<button
|
||||||
|
class="text-xs text-red-400 hover:text-red-500 px-2 py-0.5 rounded hover:bg-red-50"
|
||||||
|
@click="handleDelete(t, $event)"
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 新建工单弹窗 -->
|
||||||
|
<Teleport to="body">
|
||||||
|
<Transition name="fade">
|
||||||
|
<div v-if="showCreate" class="fixed inset-0 z-50 flex items-end sm:items-center justify-center bg-black/40" @click.self="closeCreate">
|
||||||
|
<div class="bg-white rounded-t-2xl sm:rounded-2xl shadow-xl w-full max-w-sm overflow-hidden max-h-[85vh] flex flex-col">
|
||||||
|
<!-- 弹窗头部 -->
|
||||||
|
<div class="flex items-center justify-between px-5 pt-5 pb-2 flex-shrink-0">
|
||||||
|
<h3 class="text-base font-semibold text-gray-900">新建工单</h3>
|
||||||
|
<button class="w-7 h-7 rounded-full bg-gray-100 flex items-center justify-center text-gray-400 hover:text-gray-600" @click="closeCreate">
|
||||||
|
<X class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 弹窗内容 -->
|
||||||
|
<div class="px-5 pb-5 space-y-4 overflow-y-auto">
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-medium text-gray-500 mb-1.5">工单标题</label>
|
||||||
|
<Input
|
||||||
|
v-model="createTitle"
|
||||||
|
placeholder="请简要描述您的问题"
|
||||||
|
class="h-10 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-medium text-gray-500 mb-1.5">具体内容</label>
|
||||||
|
<textarea
|
||||||
|
v-model="createContent"
|
||||||
|
placeholder="请详细描述您遇到的问题..."
|
||||||
|
class="w-full h-32 px-3 py-2.5 text-sm border border-gray-200 rounded-lg resize-none focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-400"
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
class="w-full h-10 text-sm font-medium"
|
||||||
|
:disabled="creating"
|
||||||
|
@click="handleCreate"
|
||||||
|
>
|
||||||
|
<template v-if="creating">提交中...</template>
|
||||||
|
<template v-else>提交工单</template>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</Teleport>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.fade-enter-active,
|
||||||
|
.fade-leave-active {
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
.fade-enter-from,
|
||||||
|
.fade-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Vendored
+7
@@ -0,0 +1,7 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
declare module '*.vue' {
|
||||||
|
import type { DefineComponent } from 'vue'
|
||||||
|
const component: DefineComponent<{}, {}, any>
|
||||||
|
export default component
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
|
||||||
|
"module": "ESNext",
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"types": ["node"]
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
|
import { fileURLToPath, URL } from 'node:url'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue(), tailwindcss()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 3300,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:8093',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user