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:
becomer
2026-07-03 15:37:55 +08:00
commit 2e2c161770
101 changed files with 18247 additions and 0 deletions
+23
View File
@@ -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
+188
View File
@@ -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
View File
@@ -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"]
+34
View File
@@ -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)
}
}
}
}
+32
View File
@@ -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)
}
+172
View File
@@ -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)
}
}
+26
View File
@@ -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)
}
}
+31
View File
@@ -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
+37
View File
@@ -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
View File
@@ -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=
+87
View File
@@ -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"
}
+35
View File
@@ -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)
}
+183
View File
@@ -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,
})
}
+381
View File
@@ -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(&currentAgent, 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,
},
})
}
+381
View File
@@ -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)
}
+133
View File
@@ -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
}
+248
View File
@@ -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)
}
+390
View File
@@ -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,
})
}
+216
View File
@@ -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})
}
+80
View File
@@ -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,
})
}
+639
View File
@@ -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,
})
}
+548
View File
@@ -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(&notif)
}
// ---------- 用户端 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(&notifs)
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})
}
+46
View File
@@ -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()
}
}
+56
View File
@@ -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()
}
}
+21
View File
@@ -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"
}
+16
View File
@@ -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
}
+22
View File
@@ -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"
}
+56
View File
@@ -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"`
}
+21
View File
@@ -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"
}
+21
View File
@@ -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"
}
+72
View File
@@ -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"
}
+15
View File
@@ -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"
}
+34
View File
@@ -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"
}
+115
View File
@@ -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()
}
}()
}
+63
View File
@@ -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
}
+445
View File
@@ -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
}
+240
View File
@@ -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
}
+190
View File
@@ -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
}
+215
View File
@@ -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)
}
+853
View File
@@ -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
}
+79
View File
@@ -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"`
}
+91
View File
@@ -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)
}
+45
View File
@@ -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)
}
+15
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
VITE_API_BASE_URL=/api
+13
View File
@@ -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>
+2574
View File
File diff suppressed because it is too large Load Diff
+31
View File
@@ -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"
}
}
+4
View File
@@ -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

+69
View File
@@ -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)"
>&times;</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>
+243
View File
@@ -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}`)
}
+82
View File
@@ -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`)
}
+272
View File
@@ -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')
}
+119
View File
@@ -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
+150
View File
@@ -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',
}
+59
View File
@@ -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;
}
+34
View File
@@ -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>
+27
View File
@@ -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>
+8
View File
@@ -0,0 +1,8 @@
import { cva } from 'class-variance-authority'
export { default as Card } from './card.vue'
export const cardVariants = cva('', {
variants: {},
defaultVariants: {},
})
+15
View File
@@ -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>
+1
View File
@@ -0,0 +1 @@
export { default as Input } from './input.vue'
+39
View File
@@ -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>
+1
View File
@@ -0,0 +1 @@
export { default as Label } from './label.vue'
+15
View File
@@ -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>
+35
View File
@@ -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,
}
}
+431
View File
@@ -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>
+6
View File
@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
+8
View File
@@ -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')
+224
View File
@@ -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
+442
View File
@@ -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>
+233
View File
@@ -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>
+148
View File
@@ -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>
+394
View File
@@ -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>
+102
View File
@@ -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>
+127
View File
@@ -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>
+260
View File
@@ -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
+533
View File
@@ -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>
+440
View File
@@ -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>
+170
View File
@@ -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>
+228
View File
@@ -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>
+7
View File
@@ -0,0 +1,7 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}
+23
View File
@@ -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"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+19
View File
@@ -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"]
}
+22
View File
@@ -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