Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8de7470b9d | |||
| c90784de67 | |||
| 186af6b604 | |||
| a3ec85f4d3 | |||
| ee466b253d | |||
| 2effdbaaf9 | |||
| e979e3cc9f | |||
| 4ec3e55030 | |||
| 0bd354cfee | |||
| 6e145f75e1 | |||
| 879628d0eb | |||
| 8435095b4f | |||
| 200edd75bb | |||
| b39f57b879 | |||
| f20a6b57c1 | |||
| 55eff6e80a | |||
| b436781d7d | |||
| ac743eda88 | |||
| 53cbedeb95 | |||
| 42d3b332a5 | |||
| 021ac7f7bd | |||
| b75e1f9bd9 | |||
| c41176af8d | |||
| 26c5115a32 | |||
| 7639912d7c | |||
| 2f20eebccd | |||
| 3aee45be80 | |||
| 46c8a554ff |
+112
-63
@@ -29,37 +29,67 @@ steps:
|
||||
# 应用配置由宿主机 /opt/cpdaily-xiaozhushou/config.toml 运维维护
|
||||
# (已移出版本库,避免密钥入库;容器以只读挂载方式读取)
|
||||
|
||||
# 两个固定槽位(蓝/绿),任一时刻都保持"两个容器都活着":
|
||||
# 活跃槽 = 反代当前指向的端口;备用槽 = 另一个端口(上一版,供秒级回滚)
|
||||
SLOT_BLUE=8884
|
||||
SLOT_GREEN=8885
|
||||
|
||||
# ============================================================
|
||||
# 第一步:读取当前反代端口,确定蓝绿状态
|
||||
# 第一步:读取当前反代指向的端口,确定活跃/备用槽
|
||||
# 反代入口为 1Panel 站点 jinrixiaoyuan.top(frps 公网链路):
|
||||
# /www/sites/jinrixiaoyuan.top/proxy/root.conf → proxy_pass http://127.0.0.1:8884|8885
|
||||
# 该文件经 conf.d/jinrixiaoyuan.top.conf 的 include /www/sites/.../proxy/*.conf 载入。
|
||||
# ============================================================
|
||||
CURRENT_PORT=$(docker exec 1Panel-openresty-ESmn \
|
||||
grep -oE 'proxy_pass http://[0-9.]+:[0-9]+' \
|
||||
/www/sites/jinrixiaoyuan.top/proxy/root.conf | grep -o '[0-9]\+$')
|
||||
echo "=== 当前反代端口: $CURRENT_PORT ==="
|
||||
|
||||
if [ "$CURRENT_PORT" = "8884" ]; then
|
||||
PORT_ACTIVE=8884
|
||||
PORT_STANDBY=8885
|
||||
elif [ "$CURRENT_PORT" = "8885" ]; then
|
||||
PORT_ACTIVE=8885
|
||||
PORT_STANDBY=8884
|
||||
if [ "$CURRENT_PORT" = "$SLOT_BLUE" ]; then
|
||||
PORT_ACTIVE=$SLOT_BLUE
|
||||
PORT_STANDBY=$SLOT_GREEN
|
||||
elif [ "$CURRENT_PORT" = "$SLOT_GREEN" ]; then
|
||||
PORT_ACTIVE=$SLOT_GREEN
|
||||
PORT_STANDBY=$SLOT_BLUE
|
||||
else
|
||||
echo "ERROR: 未知反代端口 $CURRENT_PORT,中止部署(期望 8884/8885)"
|
||||
echo "ERROR: 未知反代端口 $CURRENT_PORT,中止部署(期望 $SLOT_BLUE/$SLOT_GREEN)"
|
||||
exit 1
|
||||
fi
|
||||
echo " 活跃: $PORT_ACTIVE | 备用: $PORT_STANDBY"
|
||||
|
||||
# 清理之前可能残留的临时容器
|
||||
docker rm -f cpdaily-xiaozhushou-new 2>/dev/null || true
|
||||
echo " 活跃槽: $PORT_ACTIVE | 备用槽: $PORT_STANDBY"
|
||||
echo " 目标: 更新备用槽 :$PORT_STANDBY 到 $IMAGE_TAG 后,切换反代指向它(活跃槽保留,可回滚)"
|
||||
|
||||
# ============================================================
|
||||
# 第二步:在备用端口启动新容器
|
||||
# 第一步附:一次性「收养」旧命名容器
|
||||
# 迁移前的容器名为 cpdaily-xiaozhushou(无端口后缀),本脚本改用
|
||||
# cpdaily-xiaozhushou-<端口>。若不改名,下次部署 rm -f 找不到它 → 端口
|
||||
# 被占用导致 docker run 冲突、部署失败。此处按它实际映射的端口改名(幂等)。
|
||||
# 同时清理历史遗留的 cpdaily-blue 陈尸容器。
|
||||
# ============================================================
|
||||
echo "=== 蓝绿部署: 启动新容器 on :$PORT_STANDBY (image: $IMAGE_TAG) ==="
|
||||
LEGACY_NAME=cpdaily-xiaozhushou
|
||||
if docker inspect "$LEGACY_NAME" >/dev/null 2>&1; then
|
||||
LEGACY_PORT=$(docker port "$LEGACY_NAME" 8093 2>/dev/null | head -1 | grep -oE '[0-9]+$' || true)
|
||||
if [ -n "$LEGACY_PORT" ]; then
|
||||
if docker inspect "cpdaily-xiaozhushou-$LEGACY_PORT" >/dev/null 2>&1; then
|
||||
echo " 发现旧命名容器,但 cpdaily-xiaozhushou-$LEGACY_PORT 已存在,移除旧容器"
|
||||
docker rm -f "$LEGACY_NAME" || true
|
||||
else
|
||||
echo " 收养旧命名容器: $LEGACY_NAME → cpdaily-xiaozhushou-$LEGACY_PORT"
|
||||
docker rename "$LEGACY_NAME" "cpdaily-xiaozhushou-$LEGACY_PORT"
|
||||
fi
|
||||
else
|
||||
echo " 旧命名容器未映射 8093 端口,跳过收养"
|
||||
fi
|
||||
fi
|
||||
docker rm -f cpdaily-blue 2>/dev/null || true
|
||||
|
||||
docker run -d --name cpdaily-xiaozhushou-new \
|
||||
# ============================================================
|
||||
# 第二步:更新「备用槽」容器(此槽当前无流量,停它不会影响线上)
|
||||
# ============================================================
|
||||
echo "=== 停止并移除备用槽旧容器(:$PORT_STANDBY)==="
|
||||
docker rm -f cpdaily-xiaozhushou-$PORT_STANDBY 2>/dev/null || true
|
||||
|
||||
echo "=== 在备用槽启动新容器 :$PORT_STANDBY (image: $IMAGE_TAG) ==="
|
||||
docker run -d --name cpdaily-xiaozhushou-$PORT_STANDBY \
|
||||
--restart unless-stopped \
|
||||
--network likedream-net \
|
||||
-e TZ=Asia/Shanghai \
|
||||
@@ -68,73 +98,92 @@ steps:
|
||||
cpdaily-xiaozhushou:$IMAGE_TAG
|
||||
|
||||
# ============================================================
|
||||
# 第三步:健康检查
|
||||
# 第三步:健康检查(新容器必须就绪,否则直接失败且不动反代)
|
||||
# 用 docker exec 在容器内自测,避免依赖宿主机到容器的网络路径。
|
||||
# ============================================================
|
||||
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
|
||||
for i in $(seq 1 30); do
|
||||
if docker exec cpdaily-xiaozhushou-$PORT_STANDBY \
|
||||
wget -q --timeout=3 -O /dev/null http://127.0.0.1:8093/health 2>/dev/null; then
|
||||
HEALTHY=true
|
||||
break
|
||||
fi
|
||||
echo " 等待新容器就绪... ($i/15)"
|
||||
echo " 等待新容器就绪... ($i/30)"
|
||||
sleep 2
|
||||
done
|
||||
|
||||
if [ "$HEALTHY" = false ]; then
|
||||
echo "ERROR: 新容器未通过健康检查,回滚"
|
||||
docker stop cpdaily-xiaozhushou-new
|
||||
docker rm cpdaily-xiaozhushou-new
|
||||
echo "ERROR: 新容器未通过健康检查,中止部署(线上仍由活跃槽 :$PORT_ACTIVE 提供服务,未受影响)"
|
||||
docker rm -f cpdaily-xiaozhushou-$PORT_STANDBY 2>/dev/null || true
|
||||
exit 1
|
||||
fi
|
||||
echo " 新容器已就绪 ✓"
|
||||
|
||||
# ============================================================
|
||||
# 第四步:切换 1Panel 反代到备用端口
|
||||
# 第四步:切换 1Panel 反代到备用槽(唯一一次代理切换)
|
||||
# 切换前先备份配置,nginx -t 通过后再 reload;reload 后等待旧 worker 退出,
|
||||
# 确保旧连接不再按旧配置转发。
|
||||
# ============================================================
|
||||
echo "=== 切换 1Panel 反代 :$PORT_ACTIVE → :$PORT_STANDBY ==="
|
||||
PROXY_CONF=/www/sites/jinrixiaoyuan.top/proxy/root.conf
|
||||
docker exec 1Panel-openresty-ESmn \
|
||||
sed -i "s|:$PORT_ACTIVE|:$PORT_STANDBY|g" \
|
||||
/www/sites/jinrixiaoyuan.top/proxy/root.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 \
|
||||
-e TZ=Asia/Shanghai \
|
||||
-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 ==="
|
||||
cp $PROXY_CONF $PROXY_CONF.bak
|
||||
docker exec 1Panel-openresty-ESmn \
|
||||
sed -i "s|:$PORT_STANDBY|:$PORT_ACTIVE|g" \
|
||||
/www/sites/jinrixiaoyuan.top/proxy/root.conf
|
||||
docker exec 1Panel-openresty-ESmn nginx -s reload
|
||||
echo " Nginx 热加载完成(零中断)✓"
|
||||
sed -i "s|:$PORT_ACTIVE|:$PORT_STANDBY|g" $PROXY_CONF
|
||||
|
||||
# ============================================================
|
||||
# 第八步:清理
|
||||
# ============================================================
|
||||
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
|
||||
if ! docker exec 1Panel-openresty-ESmn nginx -t 2>&1; then
|
||||
echo "ERROR: nginx 配置校验失败,回滚反代配置"
|
||||
docker exec 1Panel-openresty-ESmn cp $PROXY_CONF.bak $PROXY_CONF
|
||||
docker rm -f cpdaily-xiaozhushou-$PORT_STANDBY 2>/dev/null || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 记录 reload 前的 worker pid,reload 后等其全部退出,避免旧 worker 按旧配置继续转发
|
||||
OLD_WORKERS=$(docker exec 1Panel-openresty-ESmn \
|
||||
sh -c 'ps -eo pid,cmd | grep "[n]ginx: worker" | awk "{print \$1}"' | tr '\n' ' ')
|
||||
docker exec 1Panel-openresty-ESmn nginx -s reload
|
||||
echo " Nginx 热加载完成,等待旧 worker 退出..."
|
||||
|
||||
for i in $(seq 1 15); do
|
||||
STILL=0
|
||||
for p in $OLD_WORKERS; do
|
||||
if docker exec 1Panel-openresty-ESmn sh -c "[ -d /proc/$p ]" 2>/dev/null; then
|
||||
STILL=$((STILL + 1))
|
||||
fi
|
||||
done
|
||||
if [ "$STILL" = "0" ]; then
|
||||
echo " 旧 worker 已全部退出 ✓"
|
||||
break
|
||||
fi
|
||||
echo " 等待旧 worker 释放连接... ($i/15, 剩余 $STILL)"
|
||||
sleep 2
|
||||
done
|
||||
echo "=== 部署完成 ✓ ==="
|
||||
echo " 反代已指向 :$PORT_STANDBY ✓"
|
||||
|
||||
# ============================================================
|
||||
# 第五步:验证线上确实由新槽服务
|
||||
# ============================================================
|
||||
if ! docker exec 1Panel-openresty-ESmn \
|
||||
sh -c "wget -q --timeout=5 -O /dev/null http://127.0.0.1:$PORT_STANDBY/health" 2>/dev/null; then
|
||||
echo "WARN: 新槽健康检查异常,但反代已切换,请人工核查"
|
||||
else
|
||||
echo " 线上验证通过(:$PORT_STANDBY 正常服务)✓"
|
||||
fi
|
||||
|
||||
# ============================================================
|
||||
# 第六步:保留活跃槽用于回滚说明;清理多余历史镜像
|
||||
# 注:旧槽容器不删除 —— 它即上一版本,仍需回滚时可直接把反代切回。
|
||||
# 下次部署时旧槽会被当作「备用槽」重建,因此不会无限堆积。
|
||||
# ============================================================
|
||||
echo "=== 当前容器 ==="
|
||||
docker ps --filter "name=cpdaily-xiaozhushou-" --format '{{.Names}} → {{.Ports}} ({{.Status}})'
|
||||
|
||||
echo "=== 清理历史镜像(保留 latest 与最近 2 个版本)==="
|
||||
docker images cpdaily-xiaozhushou --format '{{.Tag}}' \
|
||||
| grep -v '^latest$' | head -n -2 | while read tag; do
|
||||
docker rmi cpdaily-xiaozhushou:$tag 2>/dev/null || true
|
||||
done
|
||||
|
||||
echo "=== 部署完成 ✓ 活跃槽 :$PORT_STANDBY(旧槽 :$PORT_ACTIVE 保留,可回滚)==="
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"cpdaily-new/internal/config"
|
||||
@@ -14,6 +15,7 @@ import (
|
||||
"cpdaily-new/internal/service"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/gofiber/fiber/v2/middleware/compress"
|
||||
"github.com/gofiber/fiber/v2/middleware/filesystem"
|
||||
)
|
||||
|
||||
@@ -55,6 +57,18 @@ func main() {
|
||||
// 中间件
|
||||
app.Use(middleware.CORS())
|
||||
|
||||
// gzip 压缩:nginx 侧 gzip_types 未覆盖 application/json,接口返回的 JSON
|
||||
// (历史上曾出现单次 2.9MB 的任务列表)会明文传输。在此按内容协商压缩,
|
||||
// 随镜像走版本控制、只影响本服务。
|
||||
// 必须排除 SSE 端点(/api/task/execute/*):它用 SetBodyStreamWriter 流式推送,
|
||||
// 压缩会缓冲响应导致事件无法即时到达。
|
||||
app.Use(compress.New(compress.Config{
|
||||
Level: compress.LevelBestSpeed, // 纯 CPU 敏感场景,优先速度
|
||||
Next: func(c *fiber.Ctx) bool {
|
||||
return strings.HasPrefix(c.Path(), "/api/task/execute/")
|
||||
},
|
||||
}))
|
||||
|
||||
// 路由
|
||||
api := app.Group("/api")
|
||||
|
||||
@@ -80,6 +94,7 @@ func main() {
|
||||
task.Get("/form/:id", handler.GetTaskForm)
|
||||
task.Post("/form/:id", handler.SaveTaskForm)
|
||||
task.Put("/form/:id", handler.SaveTaskForm)
|
||||
task.Get("/form-data/:id", handler.GetTaskSavedForm)
|
||||
task.Put("/update/:id", handler.UpdateTask)
|
||||
task.Delete("/delete/:id", handler.DeleteTask)
|
||||
task.Get("/execute/:id", handler.ExecuteTask)
|
||||
@@ -88,6 +103,19 @@ func main() {
|
||||
task.Post("/upload-policy", handler.GetUploadPolicy)
|
||||
task.Post("/preview-url", handler.GetPreviewUrl)
|
||||
|
||||
// 销假(请假销假)
|
||||
leave := api.Group("/leave", middleware.AuthRequired())
|
||||
leave.Get("/pending-list", handler.GetPendingLeaves)
|
||||
leave.Post("/approvers", handler.GetLeaveApprovers)
|
||||
leave.Post("/execute", handler.ExecuteLeave)
|
||||
leave.Get("/logs", handler.ListLeaveLogs)
|
||||
leave.Delete("/logs/:id", handler.DeleteLeaveLog)
|
||||
// 请假申请(同模块)
|
||||
leave.Get("/types", handler.GetLeaveTypes)
|
||||
leave.Post("/apply-approvers", handler.GetLeaveApplyApprovers)
|
||||
leave.Post("/cc-search", handler.GetLeaveNewCcSearch)
|
||||
leave.Post("/apply", handler.ExecuteLeaveApply)
|
||||
|
||||
// CDK 兑换
|
||||
cdk := api.Group("/cdk", middleware.AuthRequired())
|
||||
cdk.Post("/redeem", handler.UserRedeemCDK)
|
||||
|
||||
@@ -31,7 +31,7 @@ length = 4
|
||||
appVersion = "9.8.8"
|
||||
systemVersion = "iOS 17"
|
||||
calVersion = "first_v4"
|
||||
sliderIds = ["1018764144934965", "sdhyxy", "hqdx"]
|
||||
sliderIds = ["1018764144934965", "sdhyxy", "hqdx", "lntu"]
|
||||
captchaBaseUrl = "http://127.0.0.1:8890"
|
||||
captchaApiKey = "your_captcha_api_key"
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@ require (
|
||||
github.com/go-resty/resty/v2 v2.16.5
|
||||
github.com/gofiber/fiber/v2 v2.52.13
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||
github.com/makiuchi-d/gozxing v0.1.1
|
||||
github.com/redis/go-redis/v9 v9.7.0
|
||||
golang.org/x/crypto v0.50.0
|
||||
golang.org/x/image v0.46.0
|
||||
@@ -37,5 +36,4 @@ require (
|
||||
golang.org/x/net v0.52.0 // indirect
|
||||
golang.org/x/sys v0.48.0 // indirect
|
||||
golang.org/x/text v0.42.0 // indirect
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
|
||||
)
|
||||
|
||||
@@ -33,8 +33,6 @@ 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/makiuchi-d/gozxing v0.1.1 h1:xxqijhoedi+/lZlhINteGbywIrewVdVv2wl9r5O9S1I=
|
||||
github.com/makiuchi-d/gozxing v0.1.1/go.mod h1:eRIHbOjX7QWxLIDJoQuMLhuXg9LAuw6znsUtRkNw9DU=
|
||||
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=
|
||||
@@ -132,8 +130,6 @@ 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=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/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/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
|
||||
|
||||
@@ -23,7 +23,7 @@ func Init() {
|
||||
}
|
||||
|
||||
// 自动迁移
|
||||
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 {
|
||||
if err := DB.AutoMigrate(&model.SysUser{}, &model.DeviceBindToken{}, &model.CpTask{}, &model.CpTaskLog{}, &model.CpLeaveLog{}, &model.AgentUser{}, &model.CDKCode{}, &model.SystemConfig{}, &model.Announcement{}, &model.AnnouncementRead{}, &model.Ticket{}, &model.TicketMessage{}, &model.TicketNotification{}, &model.PendingSchool{}); err != nil {
|
||||
log.Fatalf("自动迁移失败: %v", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"cpdaily-new/internal/model"
|
||||
"cpdaily-new/internal/utils"
|
||||
"log/slog"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -103,7 +104,8 @@ func ListUsers(c *fiber.Ctx) error {
|
||||
taskCountMap[tc.UserID] = tc.Count
|
||||
}
|
||||
|
||||
// 累计成功执行次数(cp_task_logs is_success=1)
|
||||
// 累计成功执行次数(cp_task_logs 签到/查寝 + cp_leave_logs 请假/销假,含用户已删除的软删除记录)
|
||||
// 说明:用户在客户端"删除"日志只是前台不可见(软删除),后台统计必须计入,否则执行次数会凭空减少
|
||||
executedMap := make(map[uint]int64)
|
||||
// 今天执行状态(success/fail/none)
|
||||
todayStatusMap := make(map[uint]string)
|
||||
@@ -118,23 +120,46 @@ func ListUsers(c *fiber.Ctx) error {
|
||||
Count int64
|
||||
MaxStatus int
|
||||
}
|
||||
// 签到/查寝成功次数(Unscoped 包含软删除)
|
||||
var logStats []LogStat
|
||||
database.DB.Model(&model.CpTaskLog{}).
|
||||
database.DB.Unscoped().Model(&model.CpTaskLog{}).
|
||||
Select("user_id, COUNT(*) as count, MAX(is_success) as max_status").
|
||||
Where("user_id IN ? AND is_success = 1", userIDs).
|
||||
Group("user_id").
|
||||
Scan(&logStats)
|
||||
for _, ls := range logStats {
|
||||
executedMap[ls.UserID] = ls.Count
|
||||
executedMap[ls.UserID] += ls.Count
|
||||
}
|
||||
// 请假/销假成功次数(Unscoped 包含软删除)
|
||||
var leaveStats []LogStat
|
||||
database.DB.Unscoped().Model(&model.CpLeaveLog{}).
|
||||
Select("user_id, COUNT(*) as count, MAX(is_success) as max_status").
|
||||
Where("user_id IN ? AND is_success = 1", userIDs).
|
||||
Group("user_id").
|
||||
Scan(&leaveStats)
|
||||
for _, ls := range leaveStats {
|
||||
executedMap[ls.UserID] += ls.Count
|
||||
}
|
||||
|
||||
// 今天 00:00 起的日志
|
||||
// 今天 00:00 起的状态(含软删除):签到/查寝 + 请假/销假两类日志统一判定
|
||||
// 任一成功 → success;否则任有失败 → fail;都无 → none
|
||||
todayStart := time.Now().Format("2006-01-02") + " 00:00:00"
|
||||
var todayLogs []model.CpTaskLog
|
||||
database.DB.Select("user_id, is_success").
|
||||
var todayTaskLogs []model.CpTaskLog
|
||||
database.DB.Unscoped().Select("user_id, is_success").
|
||||
Where("user_id IN ? AND created_at >= ?", userIDs, todayStart).
|
||||
Find(&todayLogs)
|
||||
for _, tl := range todayLogs {
|
||||
Find(&todayTaskLogs)
|
||||
var todayLeaveLogs []model.CpLeaveLog
|
||||
database.DB.Unscoped().Select("user_id, is_success").
|
||||
Where("user_id IN ? AND created_at >= ?", userIDs, todayStart).
|
||||
Find(&todayLeaveLogs)
|
||||
for _, tl := range todayTaskLogs {
|
||||
if tl.IsSuccess {
|
||||
todayStatusMap[tl.UserID] = "success"
|
||||
} else if todayStatusMap[tl.UserID] != "success" {
|
||||
todayStatusMap[tl.UserID] = "fail"
|
||||
}
|
||||
}
|
||||
for _, tl := range todayLeaveLogs {
|
||||
if tl.IsSuccess {
|
||||
todayStatusMap[tl.UserID] = "success"
|
||||
} else if todayStatusMap[tl.UserID] != "success" {
|
||||
@@ -215,10 +240,16 @@ func ListUsers(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
// 当天统计:执行总次数 / 成功执行总次数 / 新增用户数 / 登录用户数
|
||||
// 执行类统计 = 签到/查寝 + 请假/销假(均含用户已删除的软删除记录)
|
||||
todayStart := time.Now().Format("2006-01-02") + " 00:00:00"
|
||||
var todayExecuteCount, todaySuccessCount int64
|
||||
database.DB.Model(&model.CpTaskLog{}).Where("created_at >= ?", todayStart).Count(&todayExecuteCount)
|
||||
database.DB.Model(&model.CpTaskLog{}).Where("created_at >= ? AND is_success = 1", todayStart).Count(&todaySuccessCount)
|
||||
database.DB.Unscoped().Model(&model.CpTaskLog{}).Where("created_at >= ?", todayStart).Count(&todayExecuteCount)
|
||||
database.DB.Unscoped().Model(&model.CpTaskLog{}).Where("created_at >= ? AND is_success = 1", todayStart).Count(&todaySuccessCount)
|
||||
var todayLeaveExecute, todayLeaveSuccess int64
|
||||
database.DB.Unscoped().Model(&model.CpLeaveLog{}).Where("created_at >= ?", todayStart).Count(&todayLeaveExecute)
|
||||
database.DB.Unscoped().Model(&model.CpLeaveLog{}).Where("created_at >= ? AND is_success = 1", todayStart).Count(&todayLeaveSuccess)
|
||||
todayExecuteCount += todayLeaveExecute
|
||||
todaySuccessCount += todayLeaveSuccess
|
||||
var todayNewUsers, todayLoginUsers int64
|
||||
database.DB.Model(&model.SysUser{}).Where("created_at >= ?", todayStart).Count(&todayNewUsers)
|
||||
database.DB.Model(&model.SysUser{}).Where("last_login_at >= ?", todayStart).Count(&todayLoginUsers)
|
||||
@@ -229,9 +260,9 @@ func ListUsers(c *fiber.Ctx) error {
|
||||
"page": page,
|
||||
"pageSize": pageSize,
|
||||
"todayStats": fiber.Map{
|
||||
"executeCount": todayExecuteCount,
|
||||
"successCount": todaySuccessCount,
|
||||
"newUserCount": todayNewUsers,
|
||||
"executeCount": todayExecuteCount,
|
||||
"successCount": todaySuccessCount,
|
||||
"newUserCount": todayNewUsers,
|
||||
"loginUserCount": todayLoginUsers,
|
||||
},
|
||||
})
|
||||
@@ -274,26 +305,26 @@ func GetUserDetail(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
return utils.Success(c, fiber.Map{
|
||||
"id": user.ID,
|
||||
"username": user.Username,
|
||||
"schoolId": user.SchoolID,
|
||||
"schoolName": user.SchoolName,
|
||||
"studentId": user.StudentID,
|
||||
"realName": user.RealName,
|
||||
"password": password,
|
||||
"signCount": user.SignCount,
|
||||
"inviteReward": user.InviteReward,
|
||||
"invitedCount": user.InvitedCount,
|
||||
"inviterId": user.InviterID,
|
||||
"inviterName": inviterName,
|
||||
"status": user.Status,
|
||||
"id": user.ID,
|
||||
"username": user.Username,
|
||||
"schoolId": user.SchoolID,
|
||||
"schoolName": user.SchoolName,
|
||||
"studentId": user.StudentID,
|
||||
"realName": user.RealName,
|
||||
"password": password,
|
||||
"signCount": user.SignCount,
|
||||
"inviteReward": user.InviteReward,
|
||||
"invitedCount": user.InvitedCount,
|
||||
"inviterId": user.InviterID,
|
||||
"inviterName": inviterName,
|
||||
"status": user.Status,
|
||||
"disabledReason": user.DisabledReason,
|
||||
"taskCount": taskCount,
|
||||
"mobileCode": user.MobileCode,
|
||||
"mobileType": user.MobileType,
|
||||
"createdAt": user.CreatedAt.Format("2006-01-02 15:04:05"),
|
||||
"lastLoginAt": lastLogin,
|
||||
"lastLoginIp": user.LastLoginIP,
|
||||
"taskCount": taskCount,
|
||||
"mobileCode": user.MobileCode,
|
||||
"mobileType": user.MobileType,
|
||||
"createdAt": user.CreatedAt.Format("2006-01-02 15:04:05"),
|
||||
"lastLoginAt": lastLogin,
|
||||
"lastLoginIp": user.LastLoginIP,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -354,9 +385,35 @@ type AdminTaskLogItem struct {
|
||||
IsSuccess bool `json:"isSuccess"`
|
||||
ErrorMessage string `json:"errorMessage"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
// Deleted 表示该记录已被用户在客户端删除(软删除),仅后台可见
|
||||
Deleted bool `json:"deleted"`
|
||||
// Source 数据来源:1=签到/查寝(cp_task_logs) 2=请假/销假(cp_leave_logs),两表 id 独立自增,前端 key 需组合去重
|
||||
Source int `json:"source"`
|
||||
}
|
||||
|
||||
// adminLogUnionSQL 后台执行日志明细的数据源:cp_task_logs(签到/查寝)+ cp_leave_logs(请假/销假)UNION。
|
||||
// 原始 SQL 绕过 GORM 软删除过滤,故含用户已删除记录(deleted 标注);source 区分来源(1=任务 2=请假销假)。
|
||||
// 占位符顺序:dayStart, dayEnd, dayStart, dayEnd
|
||||
// 注意:两表由不同时期建表,字符串列 collation 不一致(utf8mb4_unicode_ci vs general_ci),
|
||||
// 直接 UNION 会报 1271 Illegal mix of collations,故显式 COLLATE 统一到 utf8mb4_unicode_ci。
|
||||
const adminLogUnionSQL = `
|
||||
SELECT id, user_id, task_type, title, real_title, is_success, error_message, created_at, deleted, source FROM (
|
||||
SELECT id, user_id, task_type, title, real_title, is_success, error_message, created_at,
|
||||
(deleted_at IS NOT NULL) AS deleted, 1 AS source
|
||||
FROM cp_task_logs
|
||||
WHERE created_at >= ? AND created_at < ?
|
||||
UNION ALL
|
||||
SELECT id, user_id,
|
||||
(CASE action WHEN 'apply' THEN 'leave_apply' ELSE 'leave_report' END) COLLATE utf8mb4_unicode_ci AS task_type,
|
||||
title COLLATE utf8mb4_unicode_ci, '' COLLATE utf8mb4_unicode_ci AS real_title, is_success,
|
||||
error_message COLLATE utf8mb4_unicode_ci, created_at,
|
||||
(deleted_at IS NOT NULL) AS deleted, 2 AS source
|
||||
FROM cp_leave_logs
|
||||
WHERE created_at >= ? AND created_at < ?
|
||||
) AS combined`
|
||||
|
||||
// AdminListTaskLogs 任务执行日志列表(仅admin,供"当天执行总次数"卡片点击查看)
|
||||
// 数据源 = 签到/查寝(cp_task_logs)+ 请假/销假(cp_leave_logs),均含用户已删除的软删除记录(标注 deleted);
|
||||
// 支持按日期(默认当天)、执行状态、关键字(用户名/姓名/任务标题)筛选,分页返回
|
||||
func AdminListTaskLogs(c *fiber.Ctx) error {
|
||||
// 日期(yyyy-MM-dd,默认当天)
|
||||
@@ -387,32 +444,90 @@ func AdminListTaskLogs(c *fiber.Ctx) error {
|
||||
pageSize = 20
|
||||
}
|
||||
|
||||
query := database.DB.Table("cp_task_logs").
|
||||
Joins("LEFT JOIN sys_user ON sys_user.id = cp_task_logs.user_id").
|
||||
Where("cp_task_logs.created_at >= ? AND cp_task_logs.created_at < ?", dayStart, dayEnd)
|
||||
type combinedRow struct {
|
||||
ID uint
|
||||
UserID uint
|
||||
TaskType string
|
||||
Title string
|
||||
RealTitle string
|
||||
IsSuccess bool
|
||||
ErrorMessage string
|
||||
CreatedAt time.Time
|
||||
Deleted bool
|
||||
Source int
|
||||
}
|
||||
|
||||
var rows []combinedRow
|
||||
raw := database.DB.Raw(adminLogUnionSQL, dayStart, dayEnd, dayStart, dayEnd)
|
||||
if status == "success" {
|
||||
query = query.Where("cp_task_logs.is_success = 1")
|
||||
raw = database.DB.Raw("SELECT * FROM ("+adminLogUnionSQL+") t WHERE t.is_success = 1", dayStart, dayEnd, dayStart, dayEnd)
|
||||
} else if status == "fail" {
|
||||
query = query.Where("cp_task_logs.is_success = 0")
|
||||
raw = database.DB.Raw("SELECT * FROM ("+adminLogUnionSQL+") t WHERE t.is_success = 0", dayStart, dayEnd, dayStart, dayEnd)
|
||||
}
|
||||
if keyword != "" {
|
||||
like := "%" + keyword + "%"
|
||||
query = query.Where("sys_user.username LIKE ? OR sys_user.real_name LIKE ? OR cp_task_logs.title LIKE ? OR cp_task_logs.real_title LIKE ?",
|
||||
like, like, like, like)
|
||||
raw.Scan(&rows)
|
||||
|
||||
// 用户信息一次性查询
|
||||
userIDs := make([]uint, 0, len(rows))
|
||||
seen := make(map[uint]bool, len(rows))
|
||||
for _, r := range rows {
|
||||
if !seen[r.UserID] {
|
||||
seen[r.UserID] = true
|
||||
userIDs = append(userIDs, r.UserID)
|
||||
}
|
||||
}
|
||||
userMap := make(map[uint]model.SysUser, len(userIDs))
|
||||
if len(userIDs) > 0 {
|
||||
var us []model.SysUser
|
||||
database.DB.Select("id, username, real_name, school_name").Where("id IN ?", userIDs).Find(&us)
|
||||
for _, u := range us {
|
||||
userMap[u.ID] = u
|
||||
}
|
||||
}
|
||||
|
||||
var total int64
|
||||
query.Count(&total)
|
||||
// 关键字过滤(用户名/姓名/标题)在内存中完成(跨表 UNION 后)
|
||||
like := strings.ToLower(keyword)
|
||||
filtered := make([]AdminTaskLogItem, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
u := userMap[r.UserID]
|
||||
if keyword != "" {
|
||||
hay := strings.ToLower(u.Username + u.RealName + r.Title + r.RealTitle)
|
||||
if !strings.Contains(hay, like) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
filtered = append(filtered, AdminTaskLogItem{
|
||||
ID: r.ID,
|
||||
UserID: r.UserID,
|
||||
Username: u.Username,
|
||||
RealName: u.RealName,
|
||||
SchoolName: u.SchoolName,
|
||||
TaskType: r.TaskType,
|
||||
Title: r.Title,
|
||||
RealTitle: r.RealTitle,
|
||||
IsSuccess: r.IsSuccess,
|
||||
ErrorMessage: r.ErrorMessage,
|
||||
CreatedAt: r.CreatedAt,
|
||||
Deleted: r.Deleted,
|
||||
Source: r.Source,
|
||||
})
|
||||
}
|
||||
// 按时间倒序
|
||||
sort.Slice(filtered, func(i, j int) bool {
|
||||
return filtered[i].CreatedAt.After(filtered[j].CreatedAt)
|
||||
})
|
||||
|
||||
var items []AdminTaskLogItem
|
||||
query.Select("cp_task_logs.id, cp_task_logs.user_id, sys_user.username, sys_user.real_name, sys_user.school_name, cp_task_logs.task_type, cp_task_logs.title, cp_task_logs.real_title, cp_task_logs.is_success, cp_task_logs.error_message, cp_task_logs.created_at").
|
||||
Order("cp_task_logs.created_at DESC").
|
||||
Offset((page - 1) * pageSize).
|
||||
Limit(pageSize).
|
||||
Scan(&items)
|
||||
total := int64(len(filtered))
|
||||
start := (page - 1) * pageSize
|
||||
if start > len(filtered) {
|
||||
start = len(filtered)
|
||||
}
|
||||
end := start + pageSize
|
||||
if end > len(filtered) {
|
||||
end = len(filtered)
|
||||
}
|
||||
|
||||
return utils.Success(c, fiber.Map{
|
||||
"items": items,
|
||||
"items": filtered[start:end],
|
||||
"total": total,
|
||||
"page": page,
|
||||
"pageSize": pageSize,
|
||||
|
||||
@@ -0,0 +1,407 @@
|
||||
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"
|
||||
)
|
||||
|
||||
// ---------- 销假(请假销假)----------
|
||||
|
||||
// GetPendingLeaves 获取今日校园待销假列表(实时请求今日校园,附带规定销假位置)
|
||||
func GetPendingLeaves(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 := getSchoolHost(user.SchoolID)
|
||||
|
||||
// 应用套件分流:新套(leave-new)走独立链路
|
||||
if schoolLeaveFlavor(&user, cookieMap) == cpd.LeaveFlavorNew {
|
||||
items, err := fetchLeaveNewPendingList(cookieMap, host, &user)
|
||||
if err != nil {
|
||||
slog.Error("获取待销假列表失败(新套)", "userId", userID, "error", err)
|
||||
if isCookieExpired(err) {
|
||||
return utils.Fail(c, 401, "登录凭证已过期,请重新登录")
|
||||
}
|
||||
return utils.Fail(c, 503, "获取待销假列表失败: "+err.Error())
|
||||
}
|
||||
return utils.Success(c, items)
|
||||
}
|
||||
|
||||
items, err := cpd.FetchLeaveList(cookieMap, host)
|
||||
if err != nil && isCookieExpired(err) {
|
||||
slog.Info("获取待销假列表cookie过期,尝试刷新", "userId", userID)
|
||||
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
|
||||
cookieMap = cpd.ParseCookieJSON(user.Cookie)
|
||||
items, err = cpd.FetchLeaveList(cookieMap, host)
|
||||
} else if errors.Is(refreshErr, ErrPasswordChanged) {
|
||||
err = ErrPasswordChanged
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
slog.Error("获取待销假列表失败", "userId", userID, "error", err)
|
||||
if errors.Is(err, ErrPasswordChanged) {
|
||||
return utils.Fail(c, 401, "登录凭证已过期,请重新登录")
|
||||
}
|
||||
return utils.Fail(c, 503, "获取待销假列表失败: "+err.Error())
|
||||
}
|
||||
|
||||
return utils.Success(c, items)
|
||||
}
|
||||
|
||||
// ExecuteLeaveRequest 销假执行参数
|
||||
type ExecuteLeaveRequest struct {
|
||||
LeaveID string `json:"leaveId"` // 今日校园请假记录 ID
|
||||
Longitude string `json:"longitude"` // 销假经度(必填)
|
||||
Latitude string `json:"latitude"` // 销假纬度(必填)
|
||||
Address string `json:"address"` // 销假地址(无规定位置时必填)
|
||||
ImageURL string `json:"imageUrl"` // 销假图片 URL(可选)
|
||||
ApproverWid string `json:"approverWid"` // 审批人 userWid(reportApplyFlow=1 时为必填)
|
||||
}
|
||||
|
||||
// ExecuteLeave 执行销假(SSE 同步执行:POST 请求 + 流式事件返回进度与结果)。
|
||||
// 事件流:connecting → fetching_detail → submitting → success / task_error
|
||||
func ExecuteLeave(c *fiber.Ctx) error {
|
||||
userID := c.Locals("userId").(uint)
|
||||
|
||||
var req ExecuteLeaveRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return utils.FailParam(c, "请求参数错误")
|
||||
}
|
||||
req.LeaveID = strings.TrimSpace(req.LeaveID)
|
||||
req.Longitude = strings.TrimSpace(req.Longitude)
|
||||
req.Latitude = strings.TrimSpace(req.Latitude)
|
||||
req.Address = strings.TrimSpace(req.Address)
|
||||
req.ApproverWid = strings.TrimSpace(req.ApproverWid)
|
||||
if req.LeaveID == "" || req.Longitude == "" || req.Latitude == "" {
|
||||
return utils.FailParam(c, "销假经纬度不能为空")
|
||||
}
|
||||
|
||||
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, "请先登录")
|
||||
}
|
||||
|
||||
// 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, title string) {
|
||||
logEntry := model.CpLeaveLog{
|
||||
UserID: userID,
|
||||
LeaveID: req.LeaveID,
|
||||
Title: title,
|
||||
SchoolName: user.SchoolName,
|
||||
Action: model.LeaveActionReport,
|
||||
IsSuccess: isSuccess,
|
||||
ErrorMessage: errMsg,
|
||||
}
|
||||
database.DB.Create(&logEntry)
|
||||
}
|
||||
|
||||
// VIP 判定:VIP 且未过期则免扣次数直接执行
|
||||
isVip := user.VipExpireAt != nil && user.VipExpireAt.After(time.Now())
|
||||
|
||||
// 检查可用次数(VIP 免检查)
|
||||
if !isVip && user.SignCount <= 0 {
|
||||
slog.Warn("销假次数不足,拒绝执行", "userId", userID)
|
||||
sendEvent("task_error", map[string]interface{}{"message": "可用次数不足,请购买CDK或通过邀请获得次数"})
|
||||
return
|
||||
}
|
||||
|
||||
// 限流:同一用户(销假为同步执行,无任务维度,按用户维度限流)
|
||||
rateKey := fmt.Sprintf("leave:%d", userID)
|
||||
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, "msg", msg)
|
||||
sendEvent("task_error", map[string]interface{}{"message": msg, "rateLimit": true})
|
||||
return
|
||||
}
|
||||
|
||||
// 1. 连接成功
|
||||
sendEvent("connecting", map[string]interface{}{"status": "connecting", "message": "正在连接今日校园..."})
|
||||
|
||||
cookieMap := cpd.ParseCookieJSON(user.Cookie)
|
||||
host := getSchoolHost(user.SchoolID)
|
||||
|
||||
// 新套(leave-new):独立销假链路(详情校验 → 销假提交)
|
||||
if schoolLeaveFlavor(&user, cookieMap) == cpd.LeaveFlavorNew {
|
||||
sendEvent("fetching_detail", map[string]interface{}{"status": "fetching_detail", "message": "正在获取请假详情..."})
|
||||
title := "销假"
|
||||
if d, derr := cpd.FetchLeaveNewDetail(cookieMap, host, req.LeaveID); derr == nil {
|
||||
title = leaveLogTitle(&cpd.LeaveDetail{LeaveType: d.TopicName}, req.LeaveID)
|
||||
}
|
||||
sendEvent("submitting", map[string]interface{}{"status": "submitting", "message": "正在提交销假..."})
|
||||
ok, errMsg, needReLogin := leaveNewExecuteReport(&user, cookieMap, host,
|
||||
req.LeaveID, req.Address, req.Longitude, req.Latitude, req.ImageURL, req.ApproverWid)
|
||||
if !ok {
|
||||
slog.Error("销假失败(新套)", "userId", userID, "leaveId", req.LeaveID, "error", errMsg)
|
||||
saveLog(false, errMsg, title)
|
||||
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg, "needReLogin": needReLogin})
|
||||
return
|
||||
}
|
||||
saveLog(true, "", title)
|
||||
if !isVip {
|
||||
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": "销假成功!"})
|
||||
} else {
|
||||
sendEvent("success", map[string]interface{}{"status": "success", "message": "销假成功!(VIP 免扣次数)"})
|
||||
}
|
||||
slog.Info("销假成功(新套)", "userId", userID, "leaveId", req.LeaveID, "title", title, "isVip", isVip)
|
||||
return
|
||||
}
|
||||
|
||||
// 2. 获取请假详情
|
||||
sendEvent("fetching_detail", map[string]interface{}{"status": "fetching_detail", "message": "正在获取请假详情..."})
|
||||
detail, err := cpd.FetchLeaveDetail(cookieMap, host, req.LeaveID)
|
||||
if err != nil && isCookieExpired(err) {
|
||||
slog.Info("获取请假详情cookie过期,尝试自动刷新", "userId", userID, "leaveId", req.LeaveID)
|
||||
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
|
||||
cookieMap = cpd.ParseCookieJSON(user.Cookie)
|
||||
detail, err = cpd.FetchLeaveDetail(cookieMap, host, req.LeaveID)
|
||||
} else if errors.Is(refreshErr, ErrPasswordChanged) {
|
||||
err = ErrPasswordChanged
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
errMsg := "获取请假详情失败: " + err.Error()
|
||||
if errors.Is(err, ErrPasswordChanged) {
|
||||
errMsg = "登录凭证已过期,请重新登录"
|
||||
}
|
||||
slog.Error("获取请假详情失败", "userId", userID, "leaveId", req.LeaveID, "error", err)
|
||||
saveLog(false, errMsg, leaveLogTitle(detail, ""))
|
||||
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg, "needReLogin": errors.Is(err, ErrPasswordChanged)})
|
||||
return
|
||||
}
|
||||
title := leaveLogTitle(detail, req.LeaveID)
|
||||
|
||||
// 销假地址:用户选择/填写的优先,否则兜底第一个规定销假位置地址;两者皆空时阻止提交
|
||||
if req.Address == "" && len(detail.ReportPlaces) == 0 {
|
||||
errMsg := "该请假未设置规定销假位置,请填写销假地址"
|
||||
saveLog(false, errMsg, title)
|
||||
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg})
|
||||
return
|
||||
}
|
||||
|
||||
// 审批人:请假类型要求销假审批(reportApplyFlow=1)时必须选择审批人
|
||||
if detail.ReportApplyFlow && req.ApproverWid == "" {
|
||||
errMsg := "该请假类型销假需要选择审批人,请重新选择"
|
||||
saveLog(false, errMsg, title)
|
||||
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg, "needApprover": true})
|
||||
return
|
||||
}
|
||||
|
||||
// 3. 构造表单并提交销假
|
||||
sendEvent("submitting", map[string]interface{}{"status": "submitting", "message": "正在提交销假..."})
|
||||
form := cpd.BuildLeaveReportForm(detail, req.Address, req.ImageURL, req.ApproverWid)
|
||||
err = cpd.SubmitLeaveReport(cookieMap, host, form, req.Longitude, req.Latitude, user.MobileCode, user.StudentID)
|
||||
if err != nil && isCookieExpired(err) {
|
||||
slog.Info("提交销假cookie过期,尝试自动刷新", "userId", userID, "leaveId", req.LeaveID)
|
||||
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
|
||||
cookieMap = cpd.ParseCookieJSON(user.Cookie)
|
||||
err = cpd.SubmitLeaveReport(cookieMap, host, form, req.Longitude, req.Latitude, user.MobileCode, user.StudentID)
|
||||
} else if errors.Is(refreshErr, ErrPasswordChanged) {
|
||||
err = ErrPasswordChanged
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
errMsg := "销假失败: " + err.Error()
|
||||
if errors.Is(err, ErrPasswordChanged) {
|
||||
errMsg = "登录凭证已过期,请重新登录"
|
||||
}
|
||||
slog.Error("销假失败", "userId", userID, "leaveId", req.LeaveID, "error", err)
|
||||
saveLog(false, errMsg, title)
|
||||
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg, "needReLogin": errors.Is(err, ErrPasswordChanged)})
|
||||
return
|
||||
}
|
||||
|
||||
// 4. 成功:写日志 + 扣减可用次数(VIP 有效期内免扣)
|
||||
saveLog(true, "", title)
|
||||
if !isVip {
|
||||
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": "销假成功!",
|
||||
})
|
||||
} else {
|
||||
sendEvent("success", map[string]interface{}{
|
||||
"status": "success",
|
||||
"message": "销假成功!(VIP 免扣次数)",
|
||||
})
|
||||
}
|
||||
|
||||
slog.Info("销假成功", "userId", userID, "leaveId", req.LeaveID, "title", title, "isVip", isVip)
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetLeaveApprovers 获取销假可选审批人(leaveType.reportApplyFlow=1 的请假类型)。
|
||||
// 请求:{ leaveId };服务端自行取详情中的 leaveType/起止时间,前端无需重复传递。
|
||||
func GetLeaveApprovers(c *fiber.Ctx) error {
|
||||
userID := c.Locals("userId").(uint)
|
||||
|
||||
var req struct {
|
||||
LeaveID string `json:"leaveId"`
|
||||
}
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return utils.FailParam(c, "请求参数错误")
|
||||
}
|
||||
req.LeaveID = strings.TrimSpace(req.LeaveID)
|
||||
if req.LeaveID == "" {
|
||||
return utils.FailParam(c, "leaveId 不能为空")
|
||||
}
|
||||
|
||||
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 := getSchoolHost(user.SchoolID)
|
||||
|
||||
// 新套:销假确认人(getReportApprovers by leaveId)
|
||||
if schoolLeaveFlavor(&user, cookieMap) == cpd.LeaveFlavorNew {
|
||||
approvers, err := fetchLeaveNewReportApprovers(cookieMap, host, req.LeaveID, &user)
|
||||
if err != nil && isCookieExpired(err) {
|
||||
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
|
||||
cookieMap = cpd.ParseCookieJSON(user.Cookie)
|
||||
approvers, err = fetchLeaveNewReportApprovers(cookieMap, host, req.LeaveID, &user)
|
||||
} else if errors.Is(refreshErr, ErrPasswordChanged) {
|
||||
err = ErrPasswordChanged
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
slog.Error("获取销假审批人失败(新套)", "userId", userID, "leaveId", req.LeaveID, "error", err)
|
||||
if errors.Is(err, ErrPasswordChanged) {
|
||||
return utils.Fail(c, 401, "登录凭证已过期,请重新登录")
|
||||
}
|
||||
return utils.Fail(c, 503, "获取销假审批人失败: "+err.Error())
|
||||
}
|
||||
return utils.Success(c, approvers)
|
||||
}
|
||||
|
||||
detail, err := cpd.FetchLeaveDetail(cookieMap, host, req.LeaveID)
|
||||
if err != nil && isCookieExpired(err) {
|
||||
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
|
||||
cookieMap = cpd.ParseCookieJSON(user.Cookie)
|
||||
detail, err = cpd.FetchLeaveDetail(cookieMap, host, req.LeaveID)
|
||||
} else if errors.Is(refreshErr, ErrPasswordChanged) {
|
||||
err = ErrPasswordChanged
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
slog.Error("获取请假详情失败(审批人)", "userId", userID, "leaveId", req.LeaveID, "error", err)
|
||||
if errors.Is(err, ErrPasswordChanged) {
|
||||
return utils.Fail(c, 401, "登录凭证已过期,请重新登录")
|
||||
}
|
||||
return utils.Fail(c, 503, "获取请假详情失败: "+err.Error())
|
||||
}
|
||||
if !detail.ReportApplyFlow {
|
||||
return utils.Success(c, []cpd.LeaveApprover{})
|
||||
}
|
||||
|
||||
approvers, err := cpd.FetchReportApprovers(cookieMap, host, detail.LeaveCodeNum, detail.StartTime, detail.EndTime)
|
||||
if err != nil && isCookieExpired(err) {
|
||||
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
|
||||
cookieMap = cpd.ParseCookieJSON(user.Cookie)
|
||||
approvers, err = cpd.FetchReportApprovers(cookieMap, host, detail.LeaveCodeNum, detail.StartTime, detail.EndTime)
|
||||
} else if errors.Is(refreshErr, ErrPasswordChanged) {
|
||||
err = ErrPasswordChanged
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
slog.Error("获取销假审批人失败", "userId", userID, "leaveId", req.LeaveID, "error", err)
|
||||
if errors.Is(err, ErrPasswordChanged) {
|
||||
return utils.Fail(c, 401, "登录凭证已过期,请重新登录")
|
||||
}
|
||||
return utils.Fail(c, 503, "获取销假审批人失败: "+err.Error())
|
||||
}
|
||||
|
||||
return utils.Success(c, approvers)
|
||||
}
|
||||
|
||||
// leaveLogTitle 销假日志标题:请假类型名 + "销假";类型名缺失时兜底用 leaveId 前缀避免标题为空
|
||||
func leaveLogTitle(detail *cpd.LeaveDetail, leaveID string) string {
|
||||
if detail != nil && detail.LeaveType != "" {
|
||||
return detail.LeaveType + "销假"
|
||||
}
|
||||
if leaveID != "" {
|
||||
return "销假(" + leaveID + ")"
|
||||
}
|
||||
return "销假"
|
||||
}
|
||||
|
||||
// ListLeaveLogs 获取销假日志列表
|
||||
func ListLeaveLogs(c *fiber.Ctx) error {
|
||||
userID := c.Locals("userId").(uint)
|
||||
|
||||
var logs []model.CpLeaveLog
|
||||
database.DB.Where("user_id = ?", userID).
|
||||
Order("created_at DESC").
|
||||
Limit(10).
|
||||
Find(&logs)
|
||||
|
||||
return utils.Success(c, logs)
|
||||
}
|
||||
|
||||
// DeleteLeaveLog 删除销假日志
|
||||
func DeleteLeaveLog(c *fiber.Ctx) error {
|
||||
userID := c.Locals("userId").(uint)
|
||||
id := c.Params("id")
|
||||
|
||||
var log model.CpLeaveLog
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,471 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"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"
|
||||
)
|
||||
|
||||
// ---------- 请假申请 ----------
|
||||
|
||||
// loadLeaveUser 读取当前用户(请假链路公共前置)
|
||||
func loadLeaveUser(c *fiber.Ctx) (*model.SysUser, error) {
|
||||
userID := c.Locals("userId").(uint)
|
||||
var user model.SysUser
|
||||
if err := database.DB.First(&user, userID).Error; err != nil {
|
||||
return nil, fmt.Errorf("用户不存在")
|
||||
}
|
||||
if user.Cookie == "" {
|
||||
return nil, fmt.Errorf("请先登录")
|
||||
}
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
// GetLeaveTypes 获取请假类型列表(含全部权威开关,前端据此渲染表单)。
|
||||
// 按学校应用的套件分流:新套(leave-new)翻译为同一契约返回。
|
||||
func GetLeaveTypes(c *fiber.Ctx) error {
|
||||
user, err := loadLeaveUser(c)
|
||||
if err != nil {
|
||||
return utils.FailAuth(c, err.Error())
|
||||
}
|
||||
|
||||
cookieMap := cpd.ParseCookieJSON(user.Cookie)
|
||||
host := getSchoolHost(user.SchoolID)
|
||||
|
||||
flavor := schoolLeaveFlavor(user, cookieMap)
|
||||
|
||||
var types []cpd.LeaveType
|
||||
if flavor == cpd.LeaveFlavorNew {
|
||||
types, err = fetchLeaveNewTypesWithDetail(cookieMap, host, user)
|
||||
} else {
|
||||
types, err = cpd.FetchLeaveTypes(cookieMap, host)
|
||||
}
|
||||
if err != nil && isCookieExpired(err) {
|
||||
if refreshErr := refreshCpdailyCookie(user); refreshErr == nil {
|
||||
cookieMap = cpd.ParseCookieJSON(user.Cookie)
|
||||
if flavor == cpd.LeaveFlavorNew {
|
||||
types, err = fetchLeaveNewTypesWithDetail(cookieMap, host, user)
|
||||
} else {
|
||||
types, err = cpd.FetchLeaveTypes(cookieMap, host)
|
||||
}
|
||||
} else if errors.Is(refreshErr, ErrPasswordChanged) {
|
||||
err = ErrPasswordChanged
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
slog.Error("获取请假类型失败", "userId", user.ID, "flavor", flavor.String(), "error", err)
|
||||
if errors.Is(err, ErrPasswordChanged) {
|
||||
return utils.Fail(c, 401, "登录凭证已过期,请重新登录")
|
||||
}
|
||||
return utils.Fail(c, 503, "获取请假类型失败: "+err.Error())
|
||||
}
|
||||
|
||||
return utils.Success(c, types)
|
||||
}
|
||||
|
||||
// GetLeaveApplyApprovers 获取请假可选审批人(needApproval=1 的类型)。
|
||||
// 请求:{ leaveType, startTime, endTime }(与销假审批人接口区分:此处选择类型发生在提交前)。
|
||||
// 新套额外返回 flowData(提交请假时原样回传),前端不透明缓存即可。
|
||||
func GetLeaveApplyApprovers(c *fiber.Ctx) error {
|
||||
user, err := loadLeaveUser(c)
|
||||
if err != nil {
|
||||
return utils.FailAuth(c, err.Error())
|
||||
}
|
||||
|
||||
var req struct {
|
||||
LeaveType int `json:"leaveType"`
|
||||
StartTime string `json:"startTime"`
|
||||
EndTime string `json:"endTime"`
|
||||
}
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return utils.FailParam(c, "请求参数错误")
|
||||
}
|
||||
if req.StartTime == "" || req.EndTime == "" {
|
||||
return utils.FailParam(c, "开始/结束时间不能为空")
|
||||
}
|
||||
|
||||
cookieMap := cpd.ParseCookieJSON(user.Cookie)
|
||||
host := getSchoolHost(user.SchoolID)
|
||||
|
||||
flavor := schoolLeaveFlavor(user, cookieMap)
|
||||
|
||||
if flavor == cpd.LeaveFlavorNew {
|
||||
res, err := fetchLeaveNewApplyApprovers(cookieMap, host, req.LeaveType, req.StartTime, req.EndTime)
|
||||
if err != nil && isCookieExpired(err) {
|
||||
if refreshErr := refreshCpdailyCookie(user); refreshErr == nil {
|
||||
cookieMap = cpd.ParseCookieJSON(user.Cookie)
|
||||
res, err = fetchLeaveNewApplyApprovers(cookieMap, host, req.LeaveType, req.StartTime, req.EndTime)
|
||||
} else if errors.Is(refreshErr, ErrPasswordChanged) {
|
||||
err = ErrPasswordChanged
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
slog.Error("获取请假审批人失败(新套)", "userId", user.ID, "leaveType", req.LeaveType, "error", err)
|
||||
if errors.Is(err, ErrPasswordChanged) {
|
||||
return utils.Fail(c, 401, "登录凭证已过期,请重新登录")
|
||||
}
|
||||
return utils.Fail(c, 503, "获取请假审批人失败: "+err.Error())
|
||||
}
|
||||
// 保持与旧套一致的数组契约(flowData 由服务端在执行时重新获取,前端无需感知)
|
||||
return utils.Success(c, res.Approvers)
|
||||
}
|
||||
|
||||
approvers, err := cpd.FetchReportApprovers(cookieMap, host, req.LeaveType, req.StartTime, req.EndTime)
|
||||
if err != nil && isCookieExpired(err) {
|
||||
if refreshErr := refreshCpdailyCookie(user); refreshErr == nil {
|
||||
cookieMap = cpd.ParseCookieJSON(user.Cookie)
|
||||
approvers, err = cpd.FetchReportApprovers(cookieMap, host, req.LeaveType, req.StartTime, req.EndTime)
|
||||
} else if errors.Is(refreshErr, ErrPasswordChanged) {
|
||||
err = ErrPasswordChanged
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
slog.Error("获取请假审批人失败", "userId", user.ID, "leaveType", req.LeaveType, "error", err)
|
||||
if errors.Is(err, ErrPasswordChanged) {
|
||||
return utils.Fail(c, 401, "登录凭证已过期,请重新登录")
|
||||
}
|
||||
return utils.Fail(c, 503, "获取请假审批人失败: "+err.Error())
|
||||
}
|
||||
|
||||
return utils.Success(c, approvers)
|
||||
}
|
||||
|
||||
// ExecuteLeaveApplyRequest 请假申请执行参数
|
||||
type ExecuteLeaveApplyRequest struct {
|
||||
LeaveType int `json:"leaveType"` // 类型编码
|
||||
StartTime string `json:"startTime"` // yyyy-MM-dd HH:mm:ss
|
||||
EndTime string `json:"endTime"` // yyyy-MM-dd HH:mm:ss
|
||||
LeaveReason string `json:"leaveReason"` // 请假原因
|
||||
OutStatus bool `json:"outStatus"` // 是否离校
|
||||
UrgencyMobile string `json:"urgencyMobile"` // 紧急联系人(离校必填)
|
||||
Address string `json:"address"` // 目的地(离校必填)
|
||||
DetailAddress string `json:"detailAddress"` // 详细地址(离校必填)
|
||||
Longitude string `json:"longitude"` // 定位经度
|
||||
Latitude string `json:"latitude"` // 定位纬度
|
||||
LocationAddr string `json:"locationAddr"` // 定位地址
|
||||
ImageURLs []string `json:"imageUrls"` // 附件图片 URL 列表(可选,最多4张且不超学校上限)
|
||||
ImageURL string `json:"imageUrl"` // 兼容旧前端:单附件 URL(与 imageUrls 合并去重)
|
||||
ApproverWid string `json:"approverWid"` // 审批人 userWid(候选为空时可不传)
|
||||
ApproverId string `json:"approverId"` // 审批人 userId
|
||||
ApproverName string `json:"approverName"` // 审批人姓名
|
||||
CcUsers []cpd.LeaveApprover `json:"ccUsers"` // 抄送人(非必填,候选取自审批人列表)
|
||||
}
|
||||
|
||||
// mergeApplyImageURLs 合并 imageUrls 与兼容字段 imageUrl,保序去重并剔除空串
|
||||
func mergeApplyImageURLs(urls []string, legacy string) []string {
|
||||
out := make([]string, 0, len(urls)+1)
|
||||
seen := make(map[string]bool, len(urls)+1)
|
||||
for _, u := range append(append([]string{}, urls...), legacy) {
|
||||
u = strings.TrimSpace(u)
|
||||
if u == "" || seen[u] {
|
||||
continue
|
||||
}
|
||||
seen[u] = true
|
||||
out = append(out, u)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ExecuteLeaveApply 执行请假申请(SSE 同步执行:checkOnceLeave → 提交)。
|
||||
// 事件流:connecting → submitting → success / task_error
|
||||
func ExecuteLeaveApply(c *fiber.Ctx) error {
|
||||
userID := c.Locals("userId").(uint)
|
||||
|
||||
var req ExecuteLeaveApplyRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return utils.FailParam(c, "请求参数错误")
|
||||
}
|
||||
req.StartTime = strings.TrimSpace(req.StartTime)
|
||||
req.EndTime = strings.TrimSpace(req.EndTime)
|
||||
req.LeaveReason = strings.TrimSpace(req.LeaveReason)
|
||||
req.Longitude = strings.TrimSpace(req.Longitude)
|
||||
req.Latitude = strings.TrimSpace(req.Latitude)
|
||||
req.LocationAddr = strings.TrimSpace(req.LocationAddr)
|
||||
req.ApproverWid = strings.TrimSpace(req.ApproverWid)
|
||||
// 附件:多图字段 + 旧前端兼容单图字段,去重合并
|
||||
imageURLs := mergeApplyImageURLs(req.ImageURLs, req.ImageURL)
|
||||
if len(imageURLs) > cpd.LeaveMaxAttachCount(0) {
|
||||
return utils.FailParam(c, "附件最多 4 张")
|
||||
}
|
||||
|
||||
if req.StartTime == "" || req.EndTime == "" {
|
||||
return utils.FailParam(c, "开始/结束时间不能为空")
|
||||
}
|
||||
if req.LeaveReason == "" {
|
||||
return utils.FailParam(c, "请假原因不能为空")
|
||||
}
|
||||
if req.Longitude == "" || req.Latitude == "" {
|
||||
return utils.FailParam(c, "定位经纬度不能为空")
|
||||
}
|
||||
if req.LocationAddr == "" {
|
||||
return utils.FailParam(c, "定位地址不能为空")
|
||||
}
|
||||
if req.OutStatus {
|
||||
if strings.TrimSpace(req.UrgencyMobile) == "" {
|
||||
return utils.FailParam(c, "紧急联系人手机号不能为空")
|
||||
}
|
||||
if strings.TrimSpace(req.Address) == "" {
|
||||
return utils.FailParam(c, "目的地不能为空")
|
||||
}
|
||||
if strings.TrimSpace(req.DetailAddress) == "" {
|
||||
return utils.FailParam(c, "详细地址不能为空")
|
||||
}
|
||||
}
|
||||
|
||||
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, "请先登录")
|
||||
}
|
||||
|
||||
// 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")
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
// 请假记录 ID:申请时平台尚未生成,用时间戳占位(日志/展示用)
|
||||
applyLeaveID := "apply_" + strconv.FormatInt(time.Now().Unix(), 10)
|
||||
|
||||
// 类型配置(拿 needApproval/enableAttach/mustOut 等权威开关做服务端校验)
|
||||
title := leaveApplyLogTitle(req.LeaveType, req.StartTime, req.EndTime)
|
||||
saveLog := func(isSuccess bool, errMsg string) {
|
||||
logEntry := model.CpLeaveLog{
|
||||
UserID: userID,
|
||||
LeaveID: applyLeaveID,
|
||||
Title: title,
|
||||
SchoolName: user.SchoolName,
|
||||
Action: model.LeaveActionApply,
|
||||
IsSuccess: isSuccess,
|
||||
ErrorMessage: errMsg,
|
||||
}
|
||||
database.DB.Create(&logEntry)
|
||||
}
|
||||
|
||||
isVip := user.VipExpireAt != nil && user.VipExpireAt.After(time.Now())
|
||||
|
||||
if !isVip && user.SignCount <= 0 {
|
||||
slog.Warn("请假次数不足,拒绝执行", "userId", userID)
|
||||
sendEvent("task_error", map[string]interface{}{"message": "可用次数不足,请购买CDK或通过邀请获得次数"})
|
||||
return
|
||||
}
|
||||
|
||||
// 限流:与销假同规则(同一用户维度)
|
||||
rateKey := fmt.Sprintf("leave-apply:%d", userID)
|
||||
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, "msg", msg)
|
||||
sendEvent("task_error", map[string]interface{}{"message": msg, "rateLimit": true})
|
||||
return
|
||||
}
|
||||
|
||||
sendEvent("connecting", map[string]interface{}{"status": "connecting", "message": "正在连接今日校园..."})
|
||||
|
||||
cookieMap := cpd.ParseCookieJSON(user.Cookie)
|
||||
host := getSchoolHost(user.SchoolID)
|
||||
|
||||
// 新套(leave-new):独立请假链路(类型详情复核 + 审批流 + 明文 JSON 提交)
|
||||
if schoolLeaveFlavor(&user, cookieMap) == cpd.LeaveFlavorNew {
|
||||
sendEvent("submitting", map[string]interface{}{"status": "submitting", "message": "正在提交请假申请..."})
|
||||
ok, errMsg, needReLogin, needApprover := leaveNewExecuteApply(&user, cookieMap, host, req, imageURLs)
|
||||
if !ok {
|
||||
slog.Error("请假申请失败(新套)", "userId", userID, "error", errMsg)
|
||||
saveLog(false, errMsg)
|
||||
sendEvent("task_error", map[string]interface{}{
|
||||
"status": "error",
|
||||
"message": errMsg,
|
||||
"needReLogin": needReLogin,
|
||||
"needApprover": needApprover,
|
||||
})
|
||||
return
|
||||
}
|
||||
saveLog(true, "")
|
||||
if !isVip {
|
||||
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": "请假申请提交成功!"})
|
||||
} else {
|
||||
sendEvent("success", map[string]interface{}{"status": "success", "message": "请假申请提交成功!(VIP 免扣次数)"})
|
||||
}
|
||||
slog.Info("请假申请成功(新套)", "userId", userID, "leaveType", req.LeaveType, "start", req.StartTime, "end", req.EndTime, "isVip", isVip)
|
||||
return
|
||||
}
|
||||
|
||||
// 1. 获取类型配置(服务端复核开关,避免前端被绕过)
|
||||
cfg, err := cpd.FetchLeaveTypeCfg(cookieMap, host, req.LeaveType)
|
||||
if err != nil && isCookieExpired(err) {
|
||||
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
|
||||
cookieMap = cpd.ParseCookieJSON(user.Cookie)
|
||||
cfg, err = cpd.FetchLeaveTypeCfg(cookieMap, host, req.LeaveType)
|
||||
} else if errors.Is(refreshErr, ErrPasswordChanged) {
|
||||
err = ErrPasswordChanged
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
errMsg := "获取请假类型配置失败: " + err.Error()
|
||||
if errors.Is(err, ErrPasswordChanged) {
|
||||
errMsg = "登录凭证已过期,请重新登录"
|
||||
}
|
||||
slog.Error("获取请假类型配置失败", "userId", userID, "leaveType", req.LeaveType, "error", err)
|
||||
saveLog(false, errMsg)
|
||||
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg, "needReLogin": errors.Is(err, ErrPasswordChanged)})
|
||||
return
|
||||
}
|
||||
if cfg.DisableTime == 1 {
|
||||
errMsg := "该请假类型当前不可用,请选择其他类型"
|
||||
saveLog(false, errMsg)
|
||||
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg})
|
||||
return
|
||||
}
|
||||
if cfg.ReasonWordLimit > 0 && len([]rune(req.LeaveReason)) < cfg.ReasonWordLimit {
|
||||
errMsg := fmt.Sprintf("请假原因至少 %d 字", cfg.ReasonWordLimit)
|
||||
saveLog(false, errMsg)
|
||||
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg})
|
||||
return
|
||||
}
|
||||
// 审批人:needApproval=1 且学校配置了候选审批人时必选;
|
||||
// 候选列表为空(学校未配置审批节点人员,如 ysdwl「大四学生请假」)时允许不选,跳过即可
|
||||
if cfg.NeedApproval == 1 && req.ApproverWid == "" {
|
||||
candidates, err := cpd.FetchReportApprovers(cookieMap, host, req.LeaveType, req.StartTime, req.EndTime)
|
||||
if err != nil {
|
||||
errMsg := "获取请假审批人失败: " + err.Error()
|
||||
slog.Warn("校验审批人候选失败", "userId", userID, "error", err)
|
||||
saveLog(false, errMsg)
|
||||
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg})
|
||||
return
|
||||
}
|
||||
if len(candidates) > 0 {
|
||||
errMsg := "该请假类型需要选择审批人"
|
||||
saveLog(false, errMsg)
|
||||
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg, "needApprover": true})
|
||||
return
|
||||
}
|
||||
slog.Info("该类型无候选审批人,允许不选提交", "userId", userID, "leaveType", req.LeaveType)
|
||||
}
|
||||
|
||||
// 附件复核:类型开关 + 数量上限(学校配置为准,产品上限 4 张)
|
||||
// 注意:实测多所旧套学校 enableAttach 恒为 0(该字段在旧套不代表"可传附件"),
|
||||
// 是否允许以 attachmentNumberLimit>0 为准;仅当两者都表明不可用时才拒绝。
|
||||
if len(imageURLs) > 0 {
|
||||
if cfg.EnableAttach != 1 && cfg.AttachmentNumberLimit <= 0 {
|
||||
errMsg := "该请假类型不支持上传附件"
|
||||
saveLog(false, errMsg)
|
||||
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg})
|
||||
return
|
||||
}
|
||||
if maxN := cpd.LeaveMaxAttachCount(cfg.AttachmentNumberLimit); len(imageURLs) > maxN {
|
||||
errMsg := fmt.Sprintf("附件最多 %d 张", maxN)
|
||||
saveLog(false, errMsg)
|
||||
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 可请假检查
|
||||
if err := cpd.CheckOnceLeave(cookieMap, host); err != nil {
|
||||
errMsg := "当前不可请假: " + err.Error()
|
||||
slog.Warn("checkOnceLeave 拒绝", "userId", userID, "error", err)
|
||||
saveLog(false, errMsg)
|
||||
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg})
|
||||
return
|
||||
}
|
||||
|
||||
// 3. 构造表单并提交
|
||||
sendEvent("submitting", map[string]interface{}{"status": "submitting", "message": "正在提交请假申请..."})
|
||||
p := cpd.LeaveApplyParams{
|
||||
LeaveType: req.LeaveType,
|
||||
StartTime: req.StartTime,
|
||||
EndTime: req.EndTime,
|
||||
LeaveReason: req.LeaveReason,
|
||||
OutStatus: req.OutStatus,
|
||||
UrgencyMobile: req.UrgencyMobile,
|
||||
Address: req.Address,
|
||||
DetailAddress: req.DetailAddress,
|
||||
Longitude: req.Longitude,
|
||||
Latitude: req.Latitude,
|
||||
LocationAddr: req.LocationAddr,
|
||||
ImageURLs: imageURLs,
|
||||
ApproverWid: req.ApproverWid,
|
||||
ApproverId: req.ApproverId,
|
||||
ApproverName: req.ApproverName,
|
||||
CcUsers: req.CcUsers,
|
||||
}
|
||||
form := cpd.BuildLeaveApplyForm(p, user.MobileCode, "15", "9.9.6")
|
||||
err = cpd.SubmitLeaveApply(cookieMap, host, form, req.Longitude, req.Latitude, user.MobileCode, user.StudentID)
|
||||
if err != nil && isCookieExpired(err) {
|
||||
slog.Info("提交请假cookie过期,尝试自动刷新", "userId", userID)
|
||||
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
|
||||
cookieMap = cpd.ParseCookieJSON(user.Cookie)
|
||||
err = cpd.SubmitLeaveApply(cookieMap, host, form, req.Longitude, req.Latitude, user.MobileCode, user.StudentID)
|
||||
} else if errors.Is(refreshErr, ErrPasswordChanged) {
|
||||
err = ErrPasswordChanged
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
errMsg := "请假申请失败: " + err.Error()
|
||||
if errors.Is(err, ErrPasswordChanged) {
|
||||
errMsg = "登录凭证已过期,请重新登录"
|
||||
}
|
||||
slog.Error("请假申请失败", "userId", userID, "error", err)
|
||||
saveLog(false, errMsg)
|
||||
sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg, "needReLogin": errors.Is(err, ErrPasswordChanged)})
|
||||
return
|
||||
}
|
||||
|
||||
// 4. 成功:写日志 + 扣减可用次数(VIP 有效期内免扣)
|
||||
saveLog(true, "")
|
||||
if !isVip {
|
||||
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": "请假申请提交成功!",
|
||||
})
|
||||
} else {
|
||||
sendEvent("success", map[string]interface{}{
|
||||
"status": "success",
|
||||
"message": "请假申请提交成功!(VIP 免扣次数)",
|
||||
})
|
||||
}
|
||||
|
||||
slog.Info("请假申请成功", "userId", userID, "leaveType", req.LeaveType, "start", req.StartTime, "end", req.EndTime, "isVip", isVip)
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// leaveApplyLogTitle 请假日志标题:类型名由前端传入 code 时的兜底为「请假」
|
||||
func leaveApplyLogTitle(leaveType int, startTime, endTime string) string {
|
||||
title := fmt.Sprintf("请假申请(类型%d)", leaveType)
|
||||
if startTime != "" && endTime != "" {
|
||||
title = fmt.Sprintf("请假申请 %s ~ %s", startTime, endTime)
|
||||
}
|
||||
return title
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
cpd "cpdaily-new/internal/service/cpdaily"
|
||||
)
|
||||
|
||||
// TestMergeApplyImageURLs 附件合并:多图字段 + 旧前端兼容单图字段,
|
||||
// 保序去重、剔除空串与首尾空白(避免旧缓存包附件被静默丢弃或重复提交)。
|
||||
func TestMergeApplyImageURLs(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
urls []string
|
||||
legacy string
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
name: "仅多图字段",
|
||||
urls: []string{"a", "b"},
|
||||
legacy: "",
|
||||
want: []string{"a", "b"},
|
||||
},
|
||||
{
|
||||
name: "仅旧单图字段",
|
||||
urls: nil,
|
||||
legacy: "a",
|
||||
want: []string{"a"},
|
||||
},
|
||||
{
|
||||
name: "旧字段与多图重复应去重",
|
||||
urls: []string{"a", "b"},
|
||||
legacy: "a",
|
||||
want: []string{"a", "b"},
|
||||
},
|
||||
{
|
||||
name: "旧字段不在多图中应追加",
|
||||
urls: []string{"a"},
|
||||
legacy: "c",
|
||||
want: []string{"a", "c"},
|
||||
},
|
||||
{
|
||||
name: "空串与空白剔除",
|
||||
urls: []string{"", " ", "a"},
|
||||
legacy: " ",
|
||||
want: []string{"a"},
|
||||
},
|
||||
{
|
||||
name: "首尾空白裁剪",
|
||||
urls: []string{" a ", " b"},
|
||||
legacy: "",
|
||||
want: []string{"a", "b"},
|
||||
},
|
||||
{
|
||||
name: "全部为空",
|
||||
urls: []string{""},
|
||||
legacy: "",
|
||||
want: []string{},
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got := mergeApplyImageURLs(c.urls, c.legacy)
|
||||
if len(got) != len(c.want) {
|
||||
t.Fatalf("mergeApplyImageURLs(%v, %q) = %v, want %v", c.urls, c.legacy, got, c.want)
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != c.want[i] {
|
||||
t.Fatalf("mergeApplyImageURLs(%v, %q) = %v, want %v", c.urls, c.legacy, got, c.want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestLeaveNewToLeaveTypesAttachGating 新套类型翻译:材料区不可见时不得带出数量上限,
|
||||
// 否则前端按「上限>0」的条件会误显示上传入口(旧套学校 enableAttach 恒为 0 的同类问题)。
|
||||
func TestLeaveNewToLeaveTypesAttachGating(t *testing.T) {
|
||||
types := []cpd.LeaveNewType{
|
||||
{TypeWid: 1, TypeName: "可见类型"},
|
||||
{TypeWid: 2, TypeName: "不可见类型"},
|
||||
}
|
||||
details := map[int]*cpd.LeaveNewFormDetail{
|
||||
1: {TypeName: "可见类型", IsVisibledEvidenceFile: true, EvidenceFileCountMax: 4},
|
||||
2: {TypeName: "不可见类型", IsVisibledEvidenceFile: false, EvidenceFileCountMax: 10},
|
||||
}
|
||||
out := leaveNewToLeaveTypes(types, func(wid int) *cpd.LeaveNewFormDetail { return details[wid] })
|
||||
if len(out) != 2 {
|
||||
t.Fatalf("期望 2 个类型, got %d", len(out))
|
||||
}
|
||||
if out[0].EnableAttach != 1 || out[0].AttachmentNumberLimit != 4 {
|
||||
t.Errorf("可见类型应带出开关与上限: %+v", out[0])
|
||||
}
|
||||
if out[1].EnableAttach != 0 || out[1].AttachmentNumberLimit != 0 {
|
||||
t.Errorf("不可见类型不应带出上限(否则前端误显示): %+v", out[1])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"cpdaily-new/internal/model"
|
||||
cpd "cpdaily-new/internal/service/cpdaily"
|
||||
)
|
||||
|
||||
// 请假应用套件分流(新旧两套 API 并存):
|
||||
//
|
||||
// 新套 wec-counselor-leave-new-apps(「请销假」应用,部分学校已迁移,旧前缀被 420 拦截)
|
||||
// 旧套 wec-counselor-leave-apps(「请假」应用,默认)
|
||||
//
|
||||
// 分流依据:辅导猫 getAppInfos 返回的本校应用列表(openUrl 指向哪套);
|
||||
// 探测失败时试探新套端点兜底;结果按学校缓存 30 分钟。
|
||||
|
||||
type leaveFlavorCacheEntry struct {
|
||||
flavor cpd.LeaveAppFlavor
|
||||
expire time.Time
|
||||
}
|
||||
|
||||
var leaveFlavorCache sync.Map // schoolID -> leaveFlavorCacheEntry
|
||||
|
||||
const leaveFlavorCacheTTL = 30 * time.Minute
|
||||
|
||||
// schoolLeaveFlavor 获取(并缓存)学校请假应用套件类型
|
||||
func schoolLeaveFlavor(user *model.SysUser, cookieMap map[string]string) cpd.LeaveAppFlavor {
|
||||
if v, ok := leaveFlavorCache.Load(user.SchoolID); ok {
|
||||
if e, ok2 := v.(leaveFlavorCacheEntry); ok2 && time.Now().Before(e.expire) {
|
||||
return e.flavor
|
||||
}
|
||||
}
|
||||
|
||||
host := getSchoolHost(user.SchoolID)
|
||||
flavor, openURL := cpd.FetchLeaveAppFlavor(cookieMap, host)
|
||||
if flavor == cpd.LeaveFlavorUnknown {
|
||||
// getAppInfos 失败/无请假应用:试探新套端点兜底
|
||||
if cpd.PingLeaveNew(cookieMap, host) {
|
||||
flavor = cpd.LeaveFlavorNew
|
||||
} else {
|
||||
// 默认按旧套处理(cookie 过期等场景下旧套错误的语义更准确)
|
||||
flavor = cpd.LeaveFlavorOld
|
||||
}
|
||||
}
|
||||
slog.Info("请假应用套件分流", "schoolId", user.SchoolID, "flavor", flavor.String(), "openUrl", openURL)
|
||||
leaveFlavorCache.Store(user.SchoolID, leaveFlavorCacheEntry{flavor: flavor, expire: time.Now().Add(leaveFlavorCacheTTL)})
|
||||
return flavor
|
||||
}
|
||||
|
||||
// invalidateLeaveFlavor 清缓存(应用迁移后可强制重探)
|
||||
func invalidateLeaveFlavor(schoolID string) {
|
||||
leaveFlavorCache.Delete(schoolID)
|
||||
}
|
||||
|
||||
// ---------- 新套 → 现有前端契约的翻译 ----------
|
||||
|
||||
// leaveNewToLeaveTypes 新套类型列表 → 旧契约 LeaveType(前端零改动消费)
|
||||
func leaveNewToLeaveTypes(list []cpd.LeaveNewType, detailOf func(typeWid int) *cpd.LeaveNewFormDetail) []cpd.LeaveType {
|
||||
out := make([]cpd.LeaveType, 0, len(list))
|
||||
for _, t := range list {
|
||||
item := cpd.LeaveType{
|
||||
Code: t.TypeWid,
|
||||
Name: t.TypeName,
|
||||
}
|
||||
if d := detailOf(t.TypeWid); d != nil {
|
||||
if d.TypeName != "" {
|
||||
item.Name = d.TypeName
|
||||
}
|
||||
// mustOut=1 表示离校开关固定打开且不可改(新套 isLeaveOut=1)
|
||||
if d.IsLeaveOut == 1 {
|
||||
item.MustOut = 1
|
||||
}
|
||||
// 是否需要审批人由 apply-approvers 实际候选判断;
|
||||
// 类型列表阶段以审批流存在性做提示(有审批流则显示审批人区)
|
||||
item.NeedApproval = 1
|
||||
if d.IsVisibledEvidenceFile {
|
||||
item.EnableAttach = 1
|
||||
item.AttachmentNumberLimit = d.EvidenceFileCountMax
|
||||
} else {
|
||||
// 材料区不可见时不得带出上限,否则前端按「上限>0」会误显示上传入口
|
||||
item.AttachmentNumberLimit = 0
|
||||
}
|
||||
item.ReasonWordLimit = d.LeaveReasonWordsMini
|
||||
item.DisableTime = 0
|
||||
item.LimitDay = 0
|
||||
} else {
|
||||
// 详情获取失败:保守默认(不阻塞列表)
|
||||
item.NeedApproval = 1
|
||||
item.EnableAttach = 1
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// leaveNewPlacesToLeaveReportPlaces 新套销假位置 → 旧契约 LeaveReportPlace
|
||||
func leaveNewPlacesToLeaveReportPlaces(list []cpd.LeaveNewReportPlace) []cpd.LeaveReportPlace {
|
||||
out := make([]cpd.LeaveReportPlace, 0, len(list))
|
||||
for _, p := range list {
|
||||
out = append(out, cpd.LeaveReportPlace{
|
||||
Wid: p.Wid,
|
||||
Address: p.Address,
|
||||
Longitude: p.Longitude,
|
||||
Latitude: p.Latitude,
|
||||
Radius: p.Radius,
|
||||
CounName: p.CounName,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// leaveNewApproversToLeaveApprovers 新套审批人 → 旧契约 LeaveApprover(前端展示用)
|
||||
func leaveNewApproversToLeaveApprovers(list []cpd.LeaveNewApprover) []cpd.LeaveApprover {
|
||||
out := make([]cpd.LeaveApprover, 0, len(list))
|
||||
for _, a := range list {
|
||||
out = append(out, cpd.LeaveApprover{
|
||||
UserWid: a.UserWid,
|
||||
UserId: a.UserId,
|
||||
UserName: a.UserName,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
"cpdaily-new/internal/model"
|
||||
cpd "cpdaily-new/internal/service/cpdaily"
|
||||
)
|
||||
|
||||
// ---------- 新套执行链路(请假/销假) ----------
|
||||
|
||||
// leaveNewResolveApproverAndFlow 服务端自取审批流:按 userWid 匹配完整审批人对象 + flowData。
|
||||
// 返回 (approver, flowData, candidates, err):
|
||||
// - approver 为 nil 表示未提供 userWid 或未匹配(调用方决定是否放行)
|
||||
// - candidates 为全部候选(用于「无候选可放行」判定)
|
||||
func leaveNewResolveApproverAndFlow(cookieMap map[string]string, host string, typeWid int, startTime, endTime, approverWid string) (*cpd.LeaveNewApprover, map[string]interface{}, []cpd.LeaveNewApprover, error) {
|
||||
flow, err := cpd.FetchLeaveNewApproveFlow(cookieMap, host, typeWid, startTime, endTime, "")
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
if approverWid == "" {
|
||||
return nil, flow.FlowData, flow.ApproveUsers, nil
|
||||
}
|
||||
for i := range flow.ApproveUsers {
|
||||
if flow.ApproveUsers[i].UserWid == approverWid {
|
||||
return &flow.ApproveUsers[i], flow.FlowData, flow.ApproveUsers, nil
|
||||
}
|
||||
}
|
||||
return nil, flow.FlowData, flow.ApproveUsers, nil
|
||||
}
|
||||
|
||||
// leaveNewResolveCcUsers 抄送人 userWid 列表 → 完整对象(新套 getCcSearchList 全量匹配)
|
||||
func leaveNewResolveCcUsers(cookieMap map[string]string, host string, widList []string) []cpd.LeaveNewApprover {
|
||||
if len(widList) == 0 {
|
||||
return nil
|
||||
}
|
||||
all, err := cpd.FetchLeaveNewCcSearch(cookieMap, host, "")
|
||||
if err != nil {
|
||||
slog.Warn("获取抄送人候选失败,抄送人将被忽略", "error", err)
|
||||
return nil
|
||||
}
|
||||
want := make(map[string]bool, len(widList))
|
||||
for _, w := range widList {
|
||||
if w != "" {
|
||||
want[w] = true
|
||||
}
|
||||
}
|
||||
out := make([]cpd.LeaveNewApprover, 0, len(widList))
|
||||
seen := make(map[string]bool, len(widList))
|
||||
for _, a := range all {
|
||||
if want[a.UserWid] && !seen[a.UserWid] {
|
||||
seen[a.UserWid] = true
|
||||
out = append(out, a)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// leaveNewTypeIsLeaveOut 新套类型详情 → 离校语义:
|
||||
// isLeaveOut=1 强制离校;isLeaveOut=0 强制不离校;其余由用户选择
|
||||
func leaveNewTypeIsLeaveOut(d *cpd.LeaveNewFormDetail) int {
|
||||
if d == nil {
|
||||
return -1
|
||||
}
|
||||
return d.IsLeaveOut
|
||||
}
|
||||
|
||||
// leaveNewValidateApplyForm 新套请假表单的服务端复核(标题/原因字数/证明材料)
|
||||
func leaveNewValidateApplyForm(d *cpd.LeaveNewFormDetail, req ExecuteLeaveApplyRequest, imageURLs []string) string {
|
||||
if d == nil {
|
||||
return ""
|
||||
}
|
||||
if d.IsRequiredLeaveReason && strings.TrimSpace(req.LeaveReason) == "" {
|
||||
return "请填写请假原因"
|
||||
}
|
||||
if d.LeaveReasonWordsMini > 0 && len([]rune(req.LeaveReason)) < d.LeaveReasonWordsMini {
|
||||
return fmt.Sprintf("请假原因不能少于 %d 字", d.LeaveReasonWordsMini)
|
||||
}
|
||||
// 证明材料(附件):多图复核 —— 可见性开关 / 必填 / 张数下限 / 学校上限(产品上限 4)
|
||||
if len(imageURLs) > 0 {
|
||||
if !d.IsVisibledEvidenceFile {
|
||||
return "该请假类型不支持上传附件"
|
||||
}
|
||||
if maxN := cpd.LeaveMaxAttachCount(d.EvidenceFileCountMax); len(imageURLs) > maxN {
|
||||
return fmt.Sprintf("附件最多 %d 张", maxN)
|
||||
}
|
||||
}
|
||||
if d.IsRequiredEvidence {
|
||||
if len(imageURLs) == 0 {
|
||||
return "请上传证明材料"
|
||||
}
|
||||
if d.EvidenceFileCountMini > 0 && len(imageURLs) < d.EvidenceFileCountMini {
|
||||
return fmt.Sprintf("证明材料至少 %d 张", d.EvidenceFileCountMini)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ---------- 新套销假执行 ----------
|
||||
|
||||
// leaveNewExecuteReport 新套销假执行(供 handler SSE 调用)。
|
||||
// 返回 (是否成功, 用户可见错误信息, 需要重新登录)
|
||||
func leaveNewExecuteReport(user *model.SysUser, cookieMap map[string]string, host string,
|
||||
leaveID, address, longitude, latitude, imageURL, approverWid string) (bool, string, bool) {
|
||||
|
||||
// 1. 详情 + 状态复核(是否处于可销假状态)
|
||||
detail, err := cpd.FetchLeaveNewDetail(cookieMap, host, leaveID)
|
||||
if err != nil {
|
||||
if isCookieExpired(err) {
|
||||
if refreshErr := refreshCpdailyCookie(user); refreshErr == nil {
|
||||
cookieMap = cpd.ParseCookieJSON(user.Cookie)
|
||||
detail, err = cpd.FetchLeaveNewDetail(cookieMap, host, leaveID)
|
||||
} else if errors.Is(refreshErr, ErrPasswordChanged) {
|
||||
return false, "登录凭证已过期,请重新登录", true
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return false, "获取请假详情失败: " + err.Error(), false
|
||||
}
|
||||
}
|
||||
if !detail.CanReport {
|
||||
return false, fmt.Sprintf("当前请假单不可销假(状态:%s)", detail.StatusText), false
|
||||
}
|
||||
|
||||
// 2. 确认人(reportMode=2 老师审批销假时需要)
|
||||
var approver *cpd.LeaveNewApprover
|
||||
if detail.ReportMode == "2" && approverWid != "" {
|
||||
list, err := cpd.FetchLeaveNewReportApprovers(cookieMap, host, leaveID)
|
||||
if err == nil {
|
||||
for i := range list {
|
||||
if list[i].UserWid == approverWid {
|
||||
approver = &list[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 构造并提交
|
||||
form := cpd.BuildLeaveNewReportForm(cpd.LeaveNewReportParams{
|
||||
LeaveID: leaveID,
|
||||
Address: address,
|
||||
Longitude: longitude,
|
||||
Latitude: latitude,
|
||||
ImageURL: imageURL,
|
||||
Approver: approver,
|
||||
UserID: user.StudentID,
|
||||
DeviceID: user.MobileCode,
|
||||
Model: "cpdaily",
|
||||
})
|
||||
err = cpd.SubmitLeaveNewReport(cookieMap, host, form)
|
||||
if err != nil && isCookieExpired(err) {
|
||||
if refreshErr := refreshCpdailyCookie(user); refreshErr == nil {
|
||||
cookieMap = cpd.ParseCookieJSON(user.Cookie)
|
||||
err = cpd.SubmitLeaveNewReport(cookieMap, host, form)
|
||||
} else if errors.Is(refreshErr, ErrPasswordChanged) {
|
||||
return false, "登录凭证已过期,请重新登录", true
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return false, "销假失败: " + err.Error(), false
|
||||
}
|
||||
return true, "", false
|
||||
}
|
||||
|
||||
// ---------- 新套请假执行 ----------
|
||||
|
||||
// leaveNewExecuteApply 新套请假执行(供 handler SSE 调用)。
|
||||
// 返回 (是否成功, 用户可见错误信息, 需要重新登录, 需要选择审批人)
|
||||
func leaveNewExecuteApply(user *model.SysUser, cookieMap map[string]string, host string, req ExecuteLeaveApplyRequest, imageURLs []string) (bool, string, bool, bool) {
|
||||
// 1. 类型详情复核
|
||||
detail, err := cpd.FetchLeaveNewTypeDetail(cookieMap, host, req.LeaveType)
|
||||
if err != nil {
|
||||
if isCookieExpired(err) {
|
||||
if refreshErr := refreshCpdailyCookie(user); refreshErr == nil {
|
||||
cookieMap = cpd.ParseCookieJSON(user.Cookie)
|
||||
detail, err = cpd.FetchLeaveNewTypeDetail(cookieMap, host, req.LeaveType)
|
||||
} else if errors.Is(refreshErr, ErrPasswordChanged) {
|
||||
return false, "登录凭证已过期,请重新登录", true, false
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return false, "获取请假类型配置失败: " + err.Error(), false, false
|
||||
}
|
||||
}
|
||||
if msg := leaveNewValidateApplyForm(detail, req, imageURLs); msg != "" {
|
||||
return false, msg, false, false
|
||||
}
|
||||
|
||||
// 2. 审批流:取 flowData + 匹配完整审批人
|
||||
approver, flowData, candidates, err := leaveNewResolveApproverAndFlow(cookieMap, host, req.LeaveType, req.StartTime, req.EndTime, req.ApproverWid)
|
||||
if err != nil {
|
||||
if isCookieExpired(err) {
|
||||
if refreshErr := refreshCpdailyCookie(user); refreshErr == nil {
|
||||
cookieMap = cpd.ParseCookieJSON(user.Cookie)
|
||||
approver, flowData, candidates, err = leaveNewResolveApproverAndFlow(cookieMap, host, req.LeaveType, req.StartTime, req.EndTime, req.ApproverWid)
|
||||
} else if errors.Is(refreshErr, ErrPasswordChanged) {
|
||||
return false, "登录凭证已过期,请重新登录", true, false
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return false, "获取请假审批人失败: " + err.Error(), false, false
|
||||
}
|
||||
}
|
||||
if req.ApproverWid == "" && len(candidates) > 0 {
|
||||
return false, "该请假类型需要选择审批人", false, true
|
||||
}
|
||||
|
||||
// 3. 抄送人解析(非必填)
|
||||
ccWids := make([]string, 0, len(req.CcUsers))
|
||||
for _, cc := range req.CcUsers {
|
||||
ccWids = append(ccWids, cc.UserWid)
|
||||
}
|
||||
ccUsers := leaveNewResolveCcUsers(cookieMap, host, ccWids)
|
||||
|
||||
// 4. 离校语义:类型强制时以类型为准
|
||||
isLeaveOut := 0
|
||||
if req.OutStatus {
|
||||
isLeaveOut = 1
|
||||
}
|
||||
if fixed := leaveNewTypeIsLeaveOut(detail); fixed == 1 {
|
||||
isLeaveOut = 1
|
||||
} else if fixed == 0 && detail != nil {
|
||||
isLeaveOut = 0
|
||||
}
|
||||
|
||||
form := cpd.BuildLeaveNewApplyForm(cpd.LeaveNewApplyParams{
|
||||
TypeWid: req.LeaveType,
|
||||
BeginTime: trimSeconds(req.StartTime),
|
||||
EndTime: trimSeconds(req.EndTime),
|
||||
LeaveReason: req.LeaveReason,
|
||||
ImageURLs: imageURLs,
|
||||
LocationAddr: req.LocationAddr,
|
||||
Longitude: req.Longitude,
|
||||
Latitude: req.Latitude,
|
||||
IsLeaveOut: isLeaveOut,
|
||||
Destination: req.Address,
|
||||
Detail: req.DetailAddress,
|
||||
EmergencyName: req.UrgencyMobile,
|
||||
EmergencyMobile: req.UrgencyMobile,
|
||||
Approver: approver,
|
||||
CcUsers: ccUsers,
|
||||
FlowData: flowData,
|
||||
UserID: user.StudentID,
|
||||
DeviceID: user.MobileCode,
|
||||
Model: "cpdaily",
|
||||
})
|
||||
err = cpd.SubmitLeaveNewApply(cookieMap, host, form)
|
||||
if err != nil && isCookieExpired(err) {
|
||||
if refreshErr := refreshCpdailyCookie(user); refreshErr == nil {
|
||||
cookieMap = cpd.ParseCookieJSON(user.Cookie)
|
||||
err = cpd.SubmitLeaveNewApply(cookieMap, host, form)
|
||||
} else if errors.Is(refreshErr, ErrPasswordChanged) {
|
||||
return false, "登录凭证已过期,请重新登录", true, false
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return false, "请假申请失败: " + err.Error(), false, false
|
||||
}
|
||||
return true, "", false, false
|
||||
}
|
||||
|
||||
// trimSeconds "yyyy-MM-dd HH:mm:ss" → "yyyy-MM-dd HH:mm"(新套时间格式)
|
||||
func trimSeconds(t string) string {
|
||||
t = strings.TrimSpace(t)
|
||||
if len(t) == 19 && t[16] == ':' && t[13] == ':' {
|
||||
return t[:16]
|
||||
}
|
||||
return t
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"cpdaily-new/internal/database"
|
||||
"cpdaily-new/internal/model"
|
||||
cpd "cpdaily-new/internal/service/cpdaily"
|
||||
"cpdaily-new/internal/utils"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
// ---------- 新套(wec-counselor-leave-new-apps)handler 适配 ----------
|
||||
//
|
||||
// 设计:新套数据在 handler 层翻译为现有前端契约(LeaveType/LeaveItem/LeaveApprover),
|
||||
// 前端无需感知新旧差异;老套逻辑保持原样,仅按 flavor 分流。
|
||||
|
||||
// refreshAfterCookieExpired 通用「cookie 过期→刷新→重试一次」包装
|
||||
func refreshAfterCookieExpired(user *model.SysUser, err error, retry func() error) error {
|
||||
if err == nil || !isCookieExpired(err) {
|
||||
return err
|
||||
}
|
||||
slog.Info("cookie过期,尝试刷新", "userId", user.ID)
|
||||
if refreshErr := refreshCpdailyCookie(user); refreshErr != nil {
|
||||
if errors.Is(refreshErr, ErrPasswordChanged) {
|
||||
return ErrPasswordChanged
|
||||
}
|
||||
return err
|
||||
}
|
||||
return retry()
|
||||
}
|
||||
|
||||
// ---------- 销假:待销假列表(新套) ----------
|
||||
|
||||
// fetchLeaveNewPendingList 新套待销假列表:拉全部记录 → 过滤可销假 → 补位置与开关
|
||||
func fetchLeaveNewPendingList(cookieMap map[string]string, host string, user *model.SysUser) ([]cpd.LeaveItem, error) {
|
||||
records, err := cpd.FetchLeaveNewList(cookieMap, host, 1, 50, "1")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 规定销假位置全局一份(reportList 与记录无关)
|
||||
places := []cpd.LeaveReportPlace{}
|
||||
if p, err := cpd.FetchLeaveNewReportPlaces(cookieMap, host); err == nil {
|
||||
places = leaveNewPlacesToLeaveReportPlaces(p)
|
||||
} else {
|
||||
slog.Warn("获取新套规定销假位置失败", "userId", user.ID, "error", err)
|
||||
}
|
||||
|
||||
list := make([]cpd.LeaveItem, 0, len(records))
|
||||
for _, r := range records {
|
||||
// 列表接口不返回 reportMode/reportUploadMode/isLeaveOut,需补拉详情判断;
|
||||
// 仅对可能可销假的状态(3 休假中 / 4 销假逾期)补详情,减少请求
|
||||
if r.Status != "3" && r.Status != "4" {
|
||||
continue
|
||||
}
|
||||
detail, derr := cpd.FetchLeaveNewDetail(cookieMap, host, r.LeaveID)
|
||||
if derr != nil {
|
||||
slog.Warn("获取新套请假详情失败,跳过该记录", "leaveId", r.LeaveID, "error", derr)
|
||||
continue
|
||||
}
|
||||
if !detail.CanReport {
|
||||
continue
|
||||
}
|
||||
// 销假需要确认人:reportMode=2(老师审批销假)
|
||||
needApprover := detail.ReportMode == "2"
|
||||
enableAttach := detail.ReportUploadMode == "2" || (detail.ReportUploadMode == "1" && detail.IsLeaveOut == "1")
|
||||
list = append(list, cpd.LeaveItem{
|
||||
ID: detail.LeaveID,
|
||||
LeaveType: leaveNewTypeNameOfTopic(detail.TopicName, detail.TypeWid),
|
||||
Reason: detail.LeaveReason,
|
||||
CreateTime: r.ApplyTime,
|
||||
ActEndTime: detail.EndTime,
|
||||
Status: detail.Status,
|
||||
StatusText: detail.StatusText,
|
||||
ReportPlaces: places,
|
||||
ReportApplyFlow: needApprover,
|
||||
EnableAttach: enableAttach,
|
||||
})
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
|
||||
// leaveNewTypeNameOfTopic 从 topicName(如「李闯闯的事假」)提取类型名兜底
|
||||
func leaveNewTypeNameOfTopic(topicName, typeIcon string) string {
|
||||
if topicName == "" {
|
||||
return "请假"
|
||||
}
|
||||
return topicName
|
||||
}
|
||||
|
||||
// ---------- 请假类型(新套) ----------
|
||||
|
||||
// fetchLeaveNewTypesWithDetail 新套类型列表,逐类型补详情(权威开关)
|
||||
func fetchLeaveNewTypesWithDetail(cookieMap map[string]string, host string, user *model.SysUser) ([]cpd.LeaveType, error) {
|
||||
types, err := cpd.FetchLeaveNewTypes(cookieMap, host)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
details := make(map[int]*cpd.LeaveNewFormDetail, len(types))
|
||||
for _, t := range types {
|
||||
d, err := cpd.FetchLeaveNewTypeDetail(cookieMap, host, t.TypeWid)
|
||||
if err != nil {
|
||||
slog.Warn("获取新套类型详情失败,使用基础信息", "typeWid", t.TypeWid, "error", err)
|
||||
continue
|
||||
}
|
||||
details[t.TypeWid] = d
|
||||
}
|
||||
return leaveNewToLeaveTypes(types, func(wid int) *cpd.LeaveNewFormDetail {
|
||||
return details[wid]
|
||||
}), nil
|
||||
}
|
||||
|
||||
// ---------- 销假审批人(新套) ----------
|
||||
|
||||
// fetchLeaveNewReportApprovers 新套销假确认人(getReportApprovers by leaveId)
|
||||
func fetchLeaveNewReportApprovers(cookieMap map[string]string, host, leaveID string, user *model.SysUser) ([]cpd.LeaveApprover, error) {
|
||||
list, err := cpd.FetchLeaveNewReportApprovers(cookieMap, host, leaveID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return leaveNewApproversToLeaveApprovers(list), nil
|
||||
}
|
||||
|
||||
// ---------- 请假审批人(新套) ----------
|
||||
|
||||
// leaveNewApplyApproversResult 新套请假审批人结果(含 flowData 透传)
|
||||
type leaveNewApplyApproversResult struct {
|
||||
Approvers []cpd.LeaveApprover `json:"approvers"`
|
||||
NeedApproval bool `json:"needApproval"`
|
||||
InFlowScope bool `json:"inFlowScope"`
|
||||
FlowData map[string]interface{} `json:"flowData"`
|
||||
}
|
||||
|
||||
// fetchLeaveNewApplyApprovers 新套请假审批人 + 审批流数据
|
||||
func fetchLeaveNewApplyApprovers(cookieMap map[string]string, host string, typeWid int, startTime, endTime string) (*leaveNewApplyApproversResult, error) {
|
||||
flow, err := cpd.FetchLeaveNewApproveFlow(cookieMap, host, typeWid, startTime, endTime, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &leaveNewApplyApproversResult{
|
||||
Approvers: leaveNewApproversToLeaveApprovers(flow.ApproveUsers),
|
||||
NeedApproval: flow.IsNeedApproval,
|
||||
InFlowScope: flow.IsInFlowScope,
|
||||
FlowData: flow.FlowData,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetLeaveNewCcSearch 抄送人搜索(新套;searchKey 为空返回全部)
|
||||
func GetLeaveNewCcSearch(c *fiber.Ctx) error {
|
||||
user, err := loadLeaveUser(c)
|
||||
if err != nil {
|
||||
return utils.FailAuth(c, err.Error())
|
||||
}
|
||||
|
||||
var req struct {
|
||||
SearchKey string `json:"searchKey"`
|
||||
}
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return utils.FailParam(c, "请求参数错误")
|
||||
}
|
||||
|
||||
cookieMap := cpd.ParseCookieJSON(user.Cookie)
|
||||
host := getSchoolHost(user.SchoolID)
|
||||
|
||||
list, err := cpd.FetchLeaveNewCcSearch(cookieMap, host, req.SearchKey)
|
||||
if err != nil {
|
||||
_ = refreshAfterCookieExpired(user, err, func() error {
|
||||
cookieMap = cpd.ParseCookieJSON(user.Cookie)
|
||||
var e error
|
||||
list, e = cpd.FetchLeaveNewCcSearch(cookieMap, host, req.SearchKey)
|
||||
return e
|
||||
})
|
||||
}
|
||||
if err != nil {
|
||||
slog.Error("获取抄送人失败", "userId", user.ID, "error", err)
|
||||
if errors.Is(err, ErrPasswordChanged) {
|
||||
return utils.Fail(c, 401, "登录凭证已过期,请重新登录")
|
||||
}
|
||||
return utils.Fail(c, 503, "获取抄送人失败: "+err.Error())
|
||||
}
|
||||
|
||||
return utils.Success(c, leaveNewApproversToLeaveApprovers(list))
|
||||
}
|
||||
|
||||
// buildLeaveNewApproverByWid 从审批人列表找 userWid 对应完整对象(提交表单用)
|
||||
func buildLeaveNewApproverByWid(list []cpd.LeaveNewApprover, userWid string) *cpd.LeaveNewApprover {
|
||||
if userWid == "" {
|
||||
return nil
|
||||
}
|
||||
for i := range list {
|
||||
if list[i].UserWid == userWid {
|
||||
return &list[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// leaveNewCheckCookieFresh 简单的 cookie 新鲜度检查(新套首次调用失败即触发刷新重试)
|
||||
func leaveNewRetryOnExpired(user *model.SysUser, err error, retry func() error) error {
|
||||
return refreshAfterCookieExpired(user, err, retry)
|
||||
}
|
||||
|
||||
// leaveNewFriendlyErr 新套错误统一包装(带 401 语义)
|
||||
func leaveNewFriendlyErr(c *fiber.Ctx, action string, err error) error {
|
||||
if errors.Is(err, ErrPasswordChanged) {
|
||||
return utils.Fail(c, 401, "登录凭证已过期,请重新登录")
|
||||
}
|
||||
return utils.Fail(c, 503, fmt.Sprintf("%s: %v", action, err))
|
||||
}
|
||||
|
||||
// ---------- 数据库日志辅助 ----------
|
||||
|
||||
// saveLeaveLogEntry 请假/销假日志落库(两套共用)
|
||||
func saveLeaveLogEntry(userID uint, leaveID, title, schoolName, action string, isSuccess bool, errMsg string) {
|
||||
database.DB.Create(&model.CpLeaveLog{
|
||||
UserID: userID,
|
||||
LeaveID: leaveID,
|
||||
Title: title,
|
||||
SchoolName: schoolName,
|
||||
Action: action,
|
||||
IsSuccess: isSuccess,
|
||||
ErrorMessage: errMsg,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
package handler
|
||||
|
||||
// 事务级软删除探针:验证「用户删除日志 → 仅软删除」的四处语义。
|
||||
// 默认跳过;仅在显式设置 PROBE_DB=1 时连接真实数据库执行(需要 backed/config.toml)。
|
||||
// 全过程在单个事务内进行并最终回滚,不写入任何持久数据(AutoMigrate 除外,与 server 启动行为一致)。
|
||||
//
|
||||
// go test ./internal/handler -run TestSoftDeleteProbe -v # 需 PROBE_DB=1
|
||||
//
|
||||
// 覆盖点:
|
||||
// 1. 前台列表(GORM 默认作用域)不展示已删除记录
|
||||
// 2. 任务完成状态判定(Unscoped 查当天成功日志)不受删除影响
|
||||
// 3. 后台统计(已执行累计 / 今日状态 / 今日卡片)计入已删除记录
|
||||
// 4. 后台明细 UNION SQL(adminLogUnionSQL,与 handler 同一常量)含已删除记录并正确标注 deleted/source
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"cpdaily-new/internal/config"
|
||||
"cpdaily-new/internal/database"
|
||||
"cpdaily-new/internal/model"
|
||||
)
|
||||
|
||||
func TestSoftDeleteProbe(t *testing.T) {
|
||||
if os.Getenv("PROBE_DB") != "1" {
|
||||
t.Skip("跳过:需要 PROBE_DB=1 且可连接数据库")
|
||||
}
|
||||
if err := config.Load("../../config.toml"); err != nil {
|
||||
t.Fatalf("加载配置失败: %v", err)
|
||||
}
|
||||
database.Init()
|
||||
|
||||
tx := database.DB.Begin()
|
||||
if tx.Error != nil {
|
||||
t.Fatalf("开启事务失败: %v", tx.Error)
|
||||
}
|
||||
defer tx.Rollback() // 无论成败一律回滚,探针不留痕
|
||||
|
||||
// ---------- 构造数据(全部在事务内,ID 由自增分配) ----------
|
||||
user := model.SysUser{
|
||||
Username: fmt.Sprintf("sdprobe_%d", time.Now().UnixNano()),
|
||||
Password: "probe",
|
||||
SchoolID: "probe",
|
||||
StudentID: "probe",
|
||||
InviteCode: fmt.Sprintf("p%09d", time.Now().UnixNano()%1000000000), // 避免唯一索引冲突
|
||||
}
|
||||
if err := tx.Create(&user).Error; err != nil {
|
||||
t.Fatalf("创建探针用户失败: %v", err)
|
||||
}
|
||||
uid := user.ID
|
||||
|
||||
taskLog := model.CpTaskLog{
|
||||
CpTaskID: 1, UserID: uid, TaskType: "sign",
|
||||
Title: "探针签到", RealTitle: "探针签到", IsSuccess: true,
|
||||
}
|
||||
leaveLog := model.CpLeaveLog{
|
||||
UserID: uid, LeaveID: "probe_leave", Title: "探针请假",
|
||||
SchoolName: "探针学校", Action: model.LeaveActionApply, IsSuccess: true,
|
||||
}
|
||||
if err := tx.Create(&taskLog).Error; err != nil {
|
||||
t.Fatalf("创建任务日志失败: %v", err)
|
||||
}
|
||||
if err := tx.Create(&leaveLog).Error; err != nil {
|
||||
t.Fatalf("创建请假日志失败: %v", err)
|
||||
}
|
||||
|
||||
// 用户端「删除」= GORM 软删除(handler 中即 database.DB.Delete(&log))
|
||||
if err := tx.Delete(&taskLog).Error; err != nil {
|
||||
t.Fatalf("软删除任务日志失败: %v", err)
|
||||
}
|
||||
if err := tx.Delete(&leaveLog).Error; err != nil {
|
||||
t.Fatalf("软删除请假日志失败: %v", err)
|
||||
}
|
||||
|
||||
// 回读确认 deleted_at 已置位(而非物理删除)
|
||||
var rawCount int64
|
||||
if err := tx.Unscoped().Model(&model.CpTaskLog{}).Where("id = ? AND deleted_at IS NOT NULL", taskLog.ID).Count(&rawCount).Error; err != nil || rawCount != 1 {
|
||||
t.Fatalf("任务日志未软删除: count=%d err=%v", rawCount, err)
|
||||
}
|
||||
if err := tx.Unscoped().Model(&model.CpLeaveLog{}).Where("id = ? AND deleted_at IS NOT NULL", leaveLog.ID).Count(&rawCount).Error; err != nil || rawCount != 1 {
|
||||
t.Fatalf("请假日志未软删除: count=%d err=%v", rawCount, err)
|
||||
}
|
||||
|
||||
// ---------- 1. 前台列表不可见(GORM 默认作用域过滤 deleted_at) ----------
|
||||
var frontTask []model.CpTaskLog
|
||||
tx.Where("user_id = ?", uid).Order("created_at DESC").Limit(10).Find(&frontTask)
|
||||
if len(frontTask) != 0 {
|
||||
t.Errorf("[1] 前台任务日志应不可见已删除记录, got %d 条", len(frontTask))
|
||||
}
|
||||
var frontLeave []model.CpLeaveLog
|
||||
tx.Where("user_id = ?", uid).Order("created_at DESC").Limit(10).Find(&frontLeave)
|
||||
if len(frontLeave) != 0 {
|
||||
t.Errorf("[1] 前台请假日志应不可见已删除记录, got %d 条", len(frontLeave))
|
||||
}
|
||||
|
||||
// ---------- 2. 任务完成状态不受删除影响(task.go ListTasks 的跨天重置查询) ----------
|
||||
now := time.Now()
|
||||
startOfToday := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
||||
var todaySuccessIDs []uint
|
||||
tx.Unscoped().Model(&model.CpTaskLog{}).
|
||||
Where("user_id = ? AND is_success = 1 AND created_at >= ?", uid, startOfToday).
|
||||
Distinct("cp_task_id").Pluck("cp_task_id", &todaySuccessIDs)
|
||||
if len(todaySuccessIDs) != 1 || todaySuccessIDs[0] != 1 {
|
||||
t.Errorf("[2] 删除当天成功日志后任务完成状态被误重置: IDs=%v", todaySuccessIDs)
|
||||
}
|
||||
|
||||
// ---------- 3. 后台统计计入已删除 ----------
|
||||
var taskSuccess, leaveSuccess int64
|
||||
tx.Unscoped().Model(&model.CpTaskLog{}).Where("user_id = ? AND is_success = 1", uid).Count(&taskSuccess)
|
||||
tx.Unscoped().Model(&model.CpLeaveLog{}).Where("user_id = ? AND is_success = 1", uid).Count(&leaveSuccess)
|
||||
if taskSuccess != 1 || leaveSuccess != 1 {
|
||||
t.Errorf("[3] 后台已执行累计应计入已删除: task=%d leave=%d", taskSuccess, leaveSuccess)
|
||||
}
|
||||
var todayTask, todayLeave int64
|
||||
tx.Unscoped().Model(&model.CpTaskLog{}).Where("user_id = ? AND created_at >= ?", uid, startOfToday).Count(&todayTask)
|
||||
tx.Unscoped().Model(&model.CpLeaveLog{}).Where("user_id = ? AND created_at >= ?", uid, startOfToday).Count(&todayLeave)
|
||||
if todayTask != 1 || todayLeave != 1 {
|
||||
t.Errorf("[3] 今日统计卡片应计入已删除: task=%d leave=%d", todayTask, todayLeave)
|
||||
}
|
||||
|
||||
// ---------- 4. 后台明细 UNION(同一 adminLogUnionSQL 常量) ----------
|
||||
dayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
||||
dayEnd := dayStart.AddDate(0, 0, 1)
|
||||
type combinedRow struct {
|
||||
ID uint
|
||||
UserID uint
|
||||
TaskType string
|
||||
Deleted bool
|
||||
Source int
|
||||
IsSuccess bool
|
||||
}
|
||||
var rows []combinedRow
|
||||
if err := tx.Raw(adminLogUnionSQL, dayStart, dayEnd, dayStart, dayEnd).Scan(&rows).Error; err != nil {
|
||||
t.Fatalf("[4] UNION 查询失败: %v", err)
|
||||
}
|
||||
var mine []combinedRow
|
||||
for _, r := range rows {
|
||||
if r.UserID == uid {
|
||||
mine = append(mine, r)
|
||||
}
|
||||
}
|
||||
if len(mine) != 2 {
|
||||
t.Fatalf("[4] UNION 应返回 2 条(任务+请假,均含已删除), got %d", len(mine))
|
||||
}
|
||||
sourceSeen := map[int]bool{}
|
||||
taskTypeSeen := map[string]bool{}
|
||||
for _, r := range mine {
|
||||
if !r.Deleted {
|
||||
t.Errorf("[4] 已删除记录在明细中未标注 deleted: %+v", r)
|
||||
}
|
||||
sourceSeen[r.Source] = true
|
||||
taskTypeSeen[r.TaskType] = true
|
||||
}
|
||||
if !sourceSeen[1] || !sourceSeen[2] {
|
||||
t.Errorf("[4] source 标注缺失: %v", sourceSeen)
|
||||
}
|
||||
if !taskTypeSeen["sign"] || !taskTypeSeen["leave_apply"] {
|
||||
t.Errorf("[4] task_type 映射缺失: %v", taskTypeSeen)
|
||||
}
|
||||
|
||||
// 用户端状态筛选(success=1)走 UNION 包裹分支,确认占位符与包裹 SQL 兼容
|
||||
var successRows []combinedRow
|
||||
tx.Raw("SELECT * FROM ("+adminLogUnionSQL+") t WHERE t.is_success = 1", dayStart, dayEnd, dayStart, dayEnd).Scan(&successRows)
|
||||
found := 0
|
||||
for _, r := range successRows {
|
||||
if r.UserID == uid {
|
||||
found++
|
||||
}
|
||||
}
|
||||
if found != 2 {
|
||||
t.Errorf("[4] success 过滤分支应保留 2 条已删除成功记录, got %d", found)
|
||||
}
|
||||
|
||||
t.Log("软删除探针全部通过:前台隐藏 / 状态不受影响 / 后台计入 / 明细标注")
|
||||
// defer 回滚,探针不落库
|
||||
}
|
||||
@@ -242,10 +242,20 @@ func SaveTaskForm(c *fiber.Ctx) error {
|
||||
return utils.Success(c, nil)
|
||||
}
|
||||
|
||||
// TaskWithMatch 任务列表附带匹配状态
|
||||
type TaskWithMatch struct {
|
||||
model.CpTask
|
||||
MatchStatus string `json:"matchStatus"` // matched_unsigned, matched_signed, no_match, check_failed
|
||||
// TaskListItem 任务列表的轻量响应项。
|
||||
// 关键:**不包含 formData**。formData 可能是 longtext(历史数据里存过 base64 图片,
|
||||
// 单条曾达 2.9MB),把它放进列表接口会让首页每次多传数 MB。
|
||||
// 列表渲染只需要下面这些字段;表单内容另走 /task/form-data/:id 获取。
|
||||
type TaskListItem struct {
|
||||
ID uint `json:"id"`
|
||||
TaskType string `json:"taskType"`
|
||||
Title string `json:"title"`
|
||||
RealTitle string `json:"realTitle"`
|
||||
TaskStatus string `json:"taskStatus"`
|
||||
ErrorMessage string `json:"errorMessage"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
MatchStatus string `json:"matchStatus"`
|
||||
MissingFields []string `json:"missingFields"` // 必填但为空的字段标题(服务端预计算,前端无需解析 formData)
|
||||
}
|
||||
|
||||
// globMatch 简单通配符匹配(仅支持 *)
|
||||
@@ -295,7 +305,9 @@ func ListTasks(c *fiber.Ctx) error {
|
||||
now := time.Now()
|
||||
startOfToday := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
||||
var todaySuccessIDs []uint
|
||||
database.DB.Model(&model.CpTaskLog{}).
|
||||
// Unscoped:用户在客户端"删除"日志只是软删除,不代表当天没执行过;
|
||||
// 若漏加会因当天成功日志被删而把任务重置回 pending,导致重复打卡
|
||||
database.DB.Unscoped().Model(&model.CpTaskLog{}).
|
||||
Where("user_id = ? AND is_success = 1 AND created_at >= ?", userID, startOfToday).
|
||||
Distinct("cp_task_id").
|
||||
Pluck("cp_task_id", &todaySuccessIDs)
|
||||
@@ -347,10 +359,20 @@ func ListTasks(c *fiber.Ctx) error {
|
||||
}
|
||||
}
|
||||
|
||||
// 构造返回结果
|
||||
result := make([]TaskWithMatch, len(tasks))
|
||||
// 构造返回结果(轻量 DTO:不含 formData)
|
||||
result := make([]TaskListItem, len(tasks))
|
||||
for i, task := range tasks {
|
||||
item := TaskWithMatch{CpTask: task, MatchStatus: "check_failed"}
|
||||
item := TaskListItem{
|
||||
ID: task.ID,
|
||||
TaskType: task.TaskType,
|
||||
Title: task.Title,
|
||||
RealTitle: task.RealTitle,
|
||||
TaskStatus: task.TaskStatus,
|
||||
ErrorMessage: task.ErrorMessage,
|
||||
CreatedAt: task.CreatedAt,
|
||||
MatchStatus: "check_failed",
|
||||
MissingFields: missingRequiredFormFields(task.FormData),
|
||||
}
|
||||
if fetchOk {
|
||||
item.MatchStatus = "no_match"
|
||||
// 用 title 做 glob 匹配(支持 * 通配符)
|
||||
@@ -374,6 +396,26 @@ func ListTasks(c *fiber.Ctx) error {
|
||||
return utils.Success(c, result)
|
||||
}
|
||||
|
||||
// GetTaskSavedForm 返回该任务**已保存**的表单内容(formData),供编辑弹窗预填。
|
||||
// 与 GetTaskForm 的区别:后者是从今日校园重新拉取表单模板(会丢失用户已填值)。
|
||||
// 单独成接口,是为了让列表接口(/task/list)不必携带大字段 formData。
|
||||
func GetTaskSavedForm(c *fiber.Ctx) error {
|
||||
userID := c.Locals("userId").(uint)
|
||||
id := c.Params("id")
|
||||
|
||||
var task model.CpTask
|
||||
if err := database.DB.Select("id", "form_data").
|
||||
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, "查询失败")
|
||||
}
|
||||
|
||||
return utils.Success(c, fiber.Map{"formData": task.FormData})
|
||||
}
|
||||
|
||||
|
||||
// DeleteTask 删除任务
|
||||
func DeleteTask(c *fiber.Ctx) error {
|
||||
userID := c.Locals("userId").(uint)
|
||||
@@ -716,6 +758,27 @@ func GetUploadPolicy(c *fiber.Ctx) error {
|
||||
cookieMap := cpd.ParseCookieJSON(user.Cookie)
|
||||
host := getSchoolHost(user.SchoolID)
|
||||
|
||||
// 请假类图片:新套学校走 leave-new OBS 协议(COS 风格),旧套保持原路径
|
||||
if req.TaskType == string(cpd.TaskTypeLeave) && schoolLeaveFlavor(&user, cookieMap) == cpd.LeaveFlavorNew {
|
||||
policy, err := cpd.GetLeaveNewUploadPolicy(cookieMap, host)
|
||||
if err != nil && isCookieExpired(err) {
|
||||
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
|
||||
cookieMap = cpd.ParseCookieJSON(user.Cookie)
|
||||
policy, err = cpd.GetLeaveNewUploadPolicy(cookieMap, host)
|
||||
} 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)
|
||||
}
|
||||
|
||||
policy, err := cpd.GetUploadPolicy(cookieMap, host, cpd.TaskType(req.TaskType))
|
||||
if err != nil && isCookieExpired(err) {
|
||||
slog.Info("获取上传策略cookie过期,尝试刷新", "userId", userID)
|
||||
@@ -760,6 +823,27 @@ func GetPreviewUrl(c *fiber.Ctx) error {
|
||||
cookieMap := cpd.ParseCookieJSON(user.Cookie)
|
||||
host := getSchoolHost(user.SchoolID)
|
||||
|
||||
// 请假类图片预览:新套学校走 leave-new OBS 协议(参数 obsKey)
|
||||
if req.TaskType == string(cpd.TaskTypeLeave) && schoolLeaveFlavor(&user, cookieMap) == cpd.LeaveFlavorNew {
|
||||
previewUrl, err := cpd.GetLeaveNewPreviewURL(cookieMap, host, req.FileKey)
|
||||
if err != nil && isCookieExpired(err) {
|
||||
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
|
||||
cookieMap = cpd.ParseCookieJSON(user.Cookie)
|
||||
previewUrl, err = cpd.GetLeaveNewPreviewURL(cookieMap, host, 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})
|
||||
}
|
||||
|
||||
previewUrl, err := cpd.GetPreviewUrl(cookieMap, host, cpd.TaskType(req.TaskType), req.FileKey)
|
||||
if err != nil && isCookieExpired(err) {
|
||||
slog.Info("获取预览URL cookie过期,尝试刷新", "userId", userID)
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// CpLeaveLog 请假/销假执行日志
|
||||
// 说明:DeletedAt 软删除字段——用户在客户端"删除"日志时仅标记 deleted_at,
|
||||
// 前台日志列表不再展示,但后台统计仍计入。
|
||||
type CpLeaveLog struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
UserID uint `gorm:"not null;index" json:"userId"`
|
||||
LeaveID string `gorm:"type:varchar(64);not null" json:"leaveId"` // 今日校园请假记录 ID(请假申请为 apply_<时间戳>)
|
||||
Title string `gorm:"type:varchar(255);not null" json:"title"` // 日志标题(请假类型名 + 动作)
|
||||
SchoolName string `gorm:"type:varchar(255)" json:"schoolName"` // 学校名称
|
||||
Action string `gorm:"type:varchar(16);not null;default:report" json:"action"` // report=销假 / apply=请假
|
||||
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"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"deletedAt"`
|
||||
}
|
||||
|
||||
func (CpLeaveLog) TableName() string {
|
||||
return "cp_leave_logs"
|
||||
}
|
||||
|
||||
// 日志动作类型:请假申请 / 销假
|
||||
const (
|
||||
LeaveActionApply = "apply" // 请假申请
|
||||
LeaveActionReport = "report" // 销假
|
||||
)
|
||||
@@ -1,19 +1,26 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// CpTaskLog 任务执行日志
|
||||
// 说明:DeletedAt 软删除字段——用户在客户端"删除"日志时仅标记 deleted_at,
|
||||
// 前台日志列表不再展示,但后台统计(已执行次数/今日状态/执行总次数)仍计入。
|
||||
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"`
|
||||
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"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"deletedAt"`
|
||||
}
|
||||
|
||||
func (CpTaskLog) TableName() string {
|
||||
|
||||
@@ -30,6 +30,9 @@ func getImageUrlPrefix(taskType TaskType) string {
|
||||
switch taskType {
|
||||
case TaskTypeSleepCheck:
|
||||
return "/wec-counselor-attendance-apps/student/attendance"
|
||||
case TaskTypeLeave:
|
||||
// 销假图片上传走请假模块
|
||||
return "/wec-counselor-leave-apps/leave"
|
||||
case TaskTypeSign:
|
||||
return "/wec-counselor-sign-apps/stu"
|
||||
default:
|
||||
@@ -142,6 +145,9 @@ func GetPreviewUrl(cookieMap map[string]string, host string, taskType TaskType,
|
||||
switch taskType {
|
||||
case TaskTypeSleepCheck:
|
||||
apiPath = "/previewAttachment"
|
||||
case TaskTypeLeave:
|
||||
// 销假图片预览走请假模块 OBS 接口
|
||||
apiPath = "/obs/previewAttachment"
|
||||
case TaskTypeSign:
|
||||
apiPath = "/sign/previewAttachment"
|
||||
default:
|
||||
|
||||
@@ -0,0 +1,447 @@
|
||||
package cpdaily
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 销假(请假销假)功能。
|
||||
// 对应今日校园 wec-counselor-leave-apps 模块:
|
||||
//
|
||||
// 待销假列表:POST {host}/wec-counselor-leave-apps/leave/stu/listOther
|
||||
// 请假详情: POST {host}/wec-counselor-leave-apps/leave/stu/detailOther
|
||||
// 审批人列表:POST {host}/wec-counselor-leave-apps/leave/stu/obtainApproveFlow
|
||||
// 提交销假: POST {host}/wec-counselor-leave-apps/leave/report
|
||||
const leaveAPIBase = "/wec-counselor-leave-apps/leave"
|
||||
|
||||
// LeaveReportPlace 学校规定的销假位置(详情 reportPlaces 元素)。
|
||||
// 销假提交的位置应在其中一个位置的 radius(米)范围内;
|
||||
// 老版本取 leaveExtends[0](续假位置)或 detail.applyLocation(请假提交时定位),
|
||||
// 均非学校规定的销假位置,已按真实接口修正为 reportPlaces。
|
||||
type LeaveReportPlace struct {
|
||||
Wid string `json:"wid"`
|
||||
Address string `json:"address"`
|
||||
Longitude string `json:"longitude"`
|
||||
Latitude string `json:"latitude"`
|
||||
Radius string `json:"radius"` // 允许范围半径(米)
|
||||
CounName string `json:"counName"` // 位置设置人
|
||||
}
|
||||
|
||||
// LeaveApprover 销假可选审批人(leaveType.reportApplyFlow=1 时需要)
|
||||
type LeaveApprover struct {
|
||||
UserWid string `json:"userWid"`
|
||||
UserId string `json:"userId"`
|
||||
UserName string `json:"userName"`
|
||||
}
|
||||
|
||||
// LeaveItem 待销假记录(列表展示 + 销假表单所需开关/规定位置)
|
||||
type LeaveItem struct {
|
||||
ID string `json:"id"` // 请假记录 ID
|
||||
LeaveType string `json:"leaveType"` // 请假类型名称(如 事假/病假)
|
||||
Reason string `json:"reason"` // 请假原因
|
||||
CreateTime string `json:"createTime"` // 请假提交时间
|
||||
ActEndTime string `json:"actEndTime"` // 预计结束时间
|
||||
Status string `json:"status"` // 系统请假状态码(4=已完成,已过滤)
|
||||
// 状态文案(新套有明确文案映射;旧套为空,前端回退本地映射)
|
||||
StatusText string `json:"statusText,omitempty"`
|
||||
// 学校规定的销假位置(详情 reportPlaces)。有值时前端展示可选列表并预填经纬度;
|
||||
// 无值时提示用户手动填写
|
||||
ReportPlaces []LeaveReportPlace `json:"reportPlaces"`
|
||||
// 销假是否需要选择审批人(leaveType.reportApplyFlow=1)
|
||||
ReportApplyFlow bool `json:"reportApplyFlow"`
|
||||
// 销假是否允许上传图片(leaveType.enableAttach=1)
|
||||
EnableAttach bool `json:"enableAttach"`
|
||||
}
|
||||
|
||||
// LeaveDetail 请假详情(提交销假所需字段)
|
||||
type LeaveDetail struct {
|
||||
ReportID string // datas.detail.id,提交表单的 id 字段
|
||||
LeaveType string // datas.leaveType.name
|
||||
LeaveCode string // datas.leaveType.code,提交表单 leaveType 字段(接口返回为数字,统一转纯文本)
|
||||
LeaveCodeNum int // datas.leaveType.code 数字形式(obtainApproveFlow 请求用)
|
||||
StartTime string // datas.detail.startDate(审批流请求用,提交前补秒)
|
||||
EndTime string // datas.detail.endDate
|
||||
ReportApplyFlow bool // 销假需要选择审批人
|
||||
EnableAttach bool // 销假允许上传图片
|
||||
ReportPlaces []LeaveReportPlace
|
||||
}
|
||||
|
||||
// leaveAnyStr 将 interface{} 值转为纯文本字符串(数字类型避免科学计数法)
|
||||
func leaveAnyStr(v interface{}) string {
|
||||
if v == nil {
|
||||
return ""
|
||||
}
|
||||
switch t := v.(type) {
|
||||
case string:
|
||||
return t
|
||||
case float64:
|
||||
return strconv.FormatFloat(t, 'f', -1, 64)
|
||||
case json.Number:
|
||||
return t.String()
|
||||
case bool:
|
||||
return strconv.FormatBool(t)
|
||||
default:
|
||||
return fmt.Sprintf("%v", v)
|
||||
}
|
||||
}
|
||||
|
||||
// leaveAnyBool 将 interface{} 值转为布尔(兼容数字/字符串/布尔三种返回风格)
|
||||
func leaveAnyBool(v interface{}) bool {
|
||||
switch t := v.(type) {
|
||||
case bool:
|
||||
return t
|
||||
case float64:
|
||||
return t != 0
|
||||
case json.Number:
|
||||
n, _ := t.Int64()
|
||||
return n != 0
|
||||
case string:
|
||||
return t == "1" || strings.EqualFold(t, "true")
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// leaveAnyInt 将 interface{} 值转为整数(兼容数字/字符串两种返回风格)
|
||||
func leaveAnyInt(v interface{}) int {
|
||||
switch t := v.(type) {
|
||||
case float64:
|
||||
return int(t)
|
||||
case json.Number:
|
||||
n, _ := t.Int64()
|
||||
return int(n)
|
||||
case string:
|
||||
n, _ := strconv.Atoi(strings.TrimSpace(t))
|
||||
return n
|
||||
case bool:
|
||||
if t {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// leaveMapStr 从 map 中取字符串值
|
||||
func leaveMapStr(m map[string]interface{}, key string) string {
|
||||
if m == nil {
|
||||
return ""
|
||||
}
|
||||
return leaveAnyStr(m[key])
|
||||
}
|
||||
|
||||
// leaveTimeWithSeconds 将 "2025-10-24 20:45" 补秒为审批流接口要求的 "2025-10-24 20:45:00"
|
||||
func leaveTimeWithSeconds(t string) string {
|
||||
t = strings.TrimSpace(t)
|
||||
if len(t) == 16 { // yyyy-MM-dd HH:mm
|
||||
return t + ":00"
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
// leavePost 通用 POST 请求(带移动端 UA 与 Cookie),返回响应 body;
|
||||
// extension 非空时额外携带 Cpdaily-Extension 签名头(销假提交用)
|
||||
func leavePost(cookieMap map[string]string, url string, body interface{}, extension string) ([]byte, error) {
|
||||
client := newTaskClient()
|
||||
req := 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", cookiesToStr(cookieMap))
|
||||
if extension != "" {
|
||||
req = req.SetHeader("Cpdaily-Extension", extension)
|
||||
}
|
||||
resp, err := req.SetBody(body).Post(url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.RawBody().Close()
|
||||
return io.ReadAll(resp.RawBody())
|
||||
}
|
||||
|
||||
// FetchLeaveList 获取今日校园待销假列表。
|
||||
// 过滤已完成(status=4)的记录;每条记录附带详情中的规定销假位置(reportPlaces)
|
||||
// 与开关(reportApplyFlow/enableAttach)。
|
||||
// 详情获取失败不阻塞列表展示(仅视为无规定位置、无开关)。
|
||||
func FetchLeaveList(cookieMap map[string]string, host string) ([]LeaveItem, error) {
|
||||
body, err := leavePost(cookieMap, host+leaveAPIBase+"/stu/listOther", map[string]interface{}{
|
||||
"pageNumber": 1,
|
||||
"pageSize": 20,
|
||||
"needApproval": 1,
|
||||
}, "")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取待销假列表失败: %w", err)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Code json.Number `json:"code"`
|
||||
ErrCode int `json:"errCode"`
|
||||
Message string `json:"message"`
|
||||
Datas struct {
|
||||
Rows []map[string]interface{} `json:"rows"`
|
||||
} `json:"datas"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("解析待销假列表失败: %w (body: %.200s)", err, string(body))
|
||||
}
|
||||
if result.ErrCode != 0 {
|
||||
return nil, fmt.Errorf("获取待销假列表错误(%d): %s", result.ErrCode, result.Message)
|
||||
}
|
||||
code, _ := result.Code.Int64()
|
||||
if code != 0 {
|
||||
msg := result.Message
|
||||
if msg == "" {
|
||||
msg = "未知错误"
|
||||
}
|
||||
return nil, fmt.Errorf("%s", msg)
|
||||
}
|
||||
|
||||
list := make([]LeaveItem, 0, len(result.Datas.Rows))
|
||||
for _, row := range result.Datas.Rows {
|
||||
status := leaveMapStr(row, "status")
|
||||
if status == "4" { // 已完成,无需销假
|
||||
continue
|
||||
}
|
||||
id := leaveMapStr(row, "id")
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
item := LeaveItem{
|
||||
ID: id,
|
||||
LeaveType: leaveMapStr(row, "leaveType"),
|
||||
Reason: leaveMapStr(row, "leaveReason"),
|
||||
CreateTime: leaveMapStr(row, "createTime"),
|
||||
ActEndTime: leaveMapStr(row, "actEndTime"),
|
||||
Status: status,
|
||||
}
|
||||
// 补充规定销假位置与开关(来自详情 reportPlaces / leaveType)
|
||||
if d, err := FetchLeaveDetail(cookieMap, host, id); err == nil {
|
||||
item.ReportPlaces = d.ReportPlaces
|
||||
item.ReportApplyFlow = d.ReportApplyFlow
|
||||
item.EnableAttach = d.EnableAttach
|
||||
} else {
|
||||
slog.Warn("获取请假详情失败,规定销假位置与开关视为空", "leaveId", id, "error", err)
|
||||
}
|
||||
list = append(list, item)
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
|
||||
// FetchLeaveDetail 获取请假详情(提交销假所需字段 + 规定销假位置与开关)
|
||||
func FetchLeaveDetail(cookieMap map[string]string, host, leaveID string) (*LeaveDetail, error) {
|
||||
body, err := leavePost(cookieMap, host+leaveAPIBase+"/stu/detailOther", map[string]interface{}{
|
||||
"id": leaveID,
|
||||
"needApproval": 1,
|
||||
"uaIsCpadaily": "true",
|
||||
}, "")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取请假详情失败: %w", err)
|
||||
}
|
||||
|
||||
// 注意:leaveType.code 真实返回为数字(如 3/12),必须用 interface{} 承接
|
||||
var result struct {
|
||||
Code json.Number `json:"code"`
|
||||
ErrCode int `json:"errCode"`
|
||||
Message string `json:"message"`
|
||||
Datas struct {
|
||||
Detail struct {
|
||||
ID string `json:"id"`
|
||||
StartDate string `json:"startDate"`
|
||||
EndDate string `json:"endDate"`
|
||||
} `json:"detail"`
|
||||
LeaveType struct {
|
||||
Code interface{} `json:"code"`
|
||||
Name string `json:"name"`
|
||||
ReportApplyFlow interface{} `json:"reportApplyFlow"`
|
||||
EnableAttach interface{} `json:"enableAttach"`
|
||||
} `json:"leaveType"`
|
||||
ReportPlaces []struct {
|
||||
Wid interface{} `json:"wid"`
|
||||
Address string `json:"address"`
|
||||
Longitude interface{} `json:"longitude"`
|
||||
Latitude interface{} `json:"latitude"`
|
||||
Radius interface{} `json:"radius"`
|
||||
CounName string `json:"counName"`
|
||||
} `json:"reportPlaces"`
|
||||
} `json:"datas"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("解析请假详情失败: %w (body: %.200s)", err, string(body))
|
||||
}
|
||||
if result.ErrCode != 0 {
|
||||
return nil, fmt.Errorf("获取请假详情错误(%d): %s", result.ErrCode, result.Message)
|
||||
}
|
||||
code, _ := result.Code.Int64()
|
||||
if code != 0 {
|
||||
msg := result.Message
|
||||
if msg == "" {
|
||||
msg = "未知错误"
|
||||
}
|
||||
return nil, fmt.Errorf("%s", msg)
|
||||
}
|
||||
if result.Datas.Detail.ID == "" {
|
||||
return nil, fmt.Errorf("没有获取到请假详情信息")
|
||||
}
|
||||
|
||||
codeStr := leaveAnyStr(result.Datas.LeaveType.Code)
|
||||
codeNum, _ := strconv.Atoi(codeStr)
|
||||
d := &LeaveDetail{
|
||||
ReportID: result.Datas.Detail.ID,
|
||||
LeaveType: result.Datas.LeaveType.Name,
|
||||
LeaveCode: codeStr,
|
||||
LeaveCodeNum: codeNum,
|
||||
StartTime: result.Datas.Detail.StartDate,
|
||||
EndTime: result.Datas.Detail.EndDate,
|
||||
ReportApplyFlow: leaveAnyBool(result.Datas.LeaveType.ReportApplyFlow),
|
||||
EnableAttach: leaveAnyBool(result.Datas.LeaveType.EnableAttach),
|
||||
}
|
||||
for _, p := range result.Datas.ReportPlaces {
|
||||
d.ReportPlaces = append(d.ReportPlaces, LeaveReportPlace{
|
||||
Wid: leaveAnyStr(p.Wid),
|
||||
Address: p.Address,
|
||||
Longitude: leaveAnyStr(p.Longitude),
|
||||
Latitude: leaveAnyStr(p.Latitude),
|
||||
Radius: leaveAnyStr(p.Radius),
|
||||
CounName: p.CounName,
|
||||
})
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
// FetchReportApprovers 获取销假可选审批人(leaveType.reportApplyFlow=1 的请假类型使用)。
|
||||
// 复用请假申请的审批流接口;时间必须精确到秒(yyyy-MM-dd HH:mm:ss)。
|
||||
// 优先取 datas.approvers(首节点可选审批人),为空时兜底展开 approveNodes 中的全部审批人。
|
||||
func FetchReportApprovers(cookieMap map[string]string, host string, leaveCodeNum int, startTime, endTime string) ([]LeaveApprover, error) {
|
||||
body, err := leavePost(cookieMap, host+leaveAPIBase+"/stu/obtainApproveFlow", map[string]interface{}{
|
||||
"leaveType": leaveCodeNum,
|
||||
"startTime": leaveTimeWithSeconds(startTime),
|
||||
"endTime": leaveTimeWithSeconds(endTime),
|
||||
}, "")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取销假审批人失败: %w", err)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Code json.Number `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Datas struct {
|
||||
Approvers []LeaveApprover `json:"approvers"`
|
||||
ApproveNodes []struct {
|
||||
ApproveNode []struct {
|
||||
NodeName string `json:"nodeName"`
|
||||
Approvers []LeaveApprover `json:"approvers"`
|
||||
} `json:"approveNode"`
|
||||
} `json:"approveNodes"`
|
||||
} `json:"datas"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("解析销假审批人失败: %w (body: %.200s)", err, string(body))
|
||||
}
|
||||
code, _ := result.Code.Int64()
|
||||
if code != 0 {
|
||||
msg := result.Message
|
||||
if msg == "" {
|
||||
msg = "未知错误"
|
||||
}
|
||||
return nil, fmt.Errorf("%s", msg)
|
||||
}
|
||||
|
||||
list := make([]LeaveApprover, 0, len(result.Datas.Approvers))
|
||||
seen := make(map[string]bool)
|
||||
for _, a := range result.Datas.Approvers {
|
||||
if a.UserWid == "" || seen[a.UserWid] {
|
||||
continue
|
||||
}
|
||||
seen[a.UserWid] = true
|
||||
list = append(list, a)
|
||||
}
|
||||
if len(list) == 0 {
|
||||
for _, n := range result.Datas.ApproveNodes {
|
||||
for _, node := range n.ApproveNode {
|
||||
for _, a := range node.Approvers {
|
||||
if a.UserWid == "" || seen[a.UserWid] {
|
||||
continue
|
||||
}
|
||||
seen[a.UserWid] = true
|
||||
list = append(list, a)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
|
||||
// BuildLeaveReportForm 构造销假提交表单(bodyString 内容)。
|
||||
// reportLocation 为用户选择/填写的销假地址,为空时兜底第一个规定销假位置地址;
|
||||
// approverWid 为所选审批人(reportApplyFlow=1 时必填,其余传空)。
|
||||
func BuildLeaveReportForm(d *LeaveDetail, address, imageUrl, approverWid string) map[string]interface{} {
|
||||
if address == "" && len(d.ReportPlaces) > 0 {
|
||||
address = d.ReportPlaces[0].Address
|
||||
}
|
||||
form := map[string]interface{}{
|
||||
"id": d.ReportID,
|
||||
"needApproval": 1,
|
||||
"leaveType": d.LeaveCode,
|
||||
"reportLocation": address,
|
||||
"approverWid": approverWid,
|
||||
"reportApplyExplain": nil,
|
||||
"uaIsCpadaily": true,
|
||||
}
|
||||
if imageUrl != "" {
|
||||
form["reportAttach"] = []map[string]interface{}{
|
||||
{"type": 1, "url": imageUrl},
|
||||
}
|
||||
} else {
|
||||
form["reportAttach"] = []interface{}{}
|
||||
}
|
||||
return form
|
||||
}
|
||||
|
||||
// SubmitLeaveReport 提交销假(bodyString 加密 + Cpdaily-Extension 签名,与签到提交同构)
|
||||
func SubmitLeaveReport(cookieMap map[string]string, host string, form map[string]interface{}, lon, lat, deviceId, username string) error {
|
||||
bodyString := GetBodyString(form)
|
||||
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",
|
||||
}
|
||||
submitData["userId"] = username
|
||||
submitData["sign"] = GetSubmitSign(submitData)
|
||||
|
||||
body, err := leavePost(cookieMap, host+leaveAPIBase+"/report", submitData, extensionString)
|
||||
if err != nil {
|
||||
return fmt.Errorf("提交销假请求失败: %w", err)
|
||||
}
|
||||
|
||||
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()
|
||||
slog.Info("leave/report 响应", "code", result.Code.String(), "message", result.Message, "body", string(body))
|
||||
if code != 0 {
|
||||
msg := result.Message
|
||||
if msg == "" {
|
||||
msg = "未知错误"
|
||||
}
|
||||
return fmt.Errorf("%s", msg)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
package cpdaily
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// 请假申请功能(与销假同属 wec-counselor-leave-apps 模块):
|
||||
//
|
||||
// 请假类型列表:POST {host}/wec-counselor-leave-apps/leave/queryLeaveTypes
|
||||
// 类型详细配置:POST {host}/wec-counselor-leave-apps/leave/stu/queryLeaveTypeCfg
|
||||
// 可请假检查: POST {host}/wec-counselor-leave-apps/leave/stu/checkOnceLeave
|
||||
// 审批人列表: POST {host}/wec-counselor-leave-apps/leave/stu/obtainApproveFlow
|
||||
// 提交请假: POST {host}/wec-counselor-leave-apps/leave/apply
|
||||
//
|
||||
// 注意:真实接口中 queryLeaveTypes 只返回基础信息(needApproval/enableAttach 等
|
||||
// 均为 null),权威开关全部来自 queryLeaveTypeCfg,故列表接口会把两者合并后返回。
|
||||
|
||||
// LeaveType 请假类型(queryLeaveTypes + queryLeaveTypeCfg 合并后的完整信息)
|
||||
type LeaveType struct {
|
||||
Code int `json:"code"` // 类型编码(提交与查询都用它)
|
||||
Name string `json:"name"` // 类型名称
|
||||
MustOut int `json:"mustOut"` // 1=必须离校(离校开关强制打开且不可改)
|
||||
NeedApproval int `json:"needApproval"` // 1=需要选择审批人
|
||||
EnableAttach int `json:"enableAttach"` // 1=允许上传图片附件
|
||||
ReasonWordLimit int `json:"reasonWordLimit"` // 请假原因最少字数
|
||||
AttachmentNumberLimit int `json:"attachmentNumberLimit"` // 附件数量上限
|
||||
ChooseAttendance int `json:"chooseAttendance"` // 1=需要打卡相关核验(透传)
|
||||
DisableTime int `json:"disableTime"` // 1=当前类型不可用
|
||||
LimitDay int `json:"limitDay"` // 单次最长可请假天数(0=不限)
|
||||
}
|
||||
|
||||
// CheckOnceLeave 检查是否允许请假(学校可能配置同一时间仅一条请假单等限制)
|
||||
func CheckOnceLeave(cookieMap map[string]string, host string) error {
|
||||
body, err := leavePost(cookieMap, host+leaveAPIBase+"/stu/checkOnceLeave", map[string]interface{}{}, "")
|
||||
if err != nil {
|
||||
return fmt.Errorf("检查请假资格失败: %w", err)
|
||||
}
|
||||
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: %.200s)", err, string(body))
|
||||
}
|
||||
code, _ := result.Code.Int64()
|
||||
if code != 0 {
|
||||
msg := result.Message
|
||||
if msg == "" {
|
||||
msg = "当前不可请假"
|
||||
}
|
||||
return fmt.Errorf("%s", msg)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// FetchLeaveTypes 获取请假类型列表(已合并 queryLeaveTypeCfg 的权威开关)。
|
||||
// 单个类型的 cfg 查询失败不阻塞整体列表(该类型保留基础信息,开关取保守默认值)。
|
||||
func FetchLeaveTypes(cookieMap map[string]string, host string) ([]LeaveType, error) {
|
||||
body, err := leavePost(cookieMap, host+leaveAPIBase+"/queryLeaveTypes", map[string]interface{}{}, "")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取请假类型失败: %w", err)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Code json.Number `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Datas struct {
|
||||
LeaveType []struct {
|
||||
Code interface{} `json:"code"`
|
||||
Name string `json:"name"`
|
||||
MustOut interface{} `json:"mustOut"`
|
||||
ReasonWordLimit interface{} `json:"reasonWordLimit"`
|
||||
AttachmentNumberLimit interface{} `json:"attachmentNumberLimit"`
|
||||
} `json:"leaveType"`
|
||||
} `json:"datas"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("解析请假类型失败: %w (body: %.200s)", err, string(body))
|
||||
}
|
||||
code, _ := result.Code.Int64()
|
||||
if code != 0 {
|
||||
msg := result.Message
|
||||
if msg == "" {
|
||||
msg = "未知错误"
|
||||
}
|
||||
return nil, fmt.Errorf("%s", msg)
|
||||
}
|
||||
|
||||
list := make([]LeaveType, 0, len(result.Datas.LeaveType))
|
||||
for _, lt := range result.Datas.LeaveType {
|
||||
codeNum, _ := strconv.Atoi(leaveAnyStr(lt.Code))
|
||||
item := LeaveType{
|
||||
Code: codeNum,
|
||||
Name: lt.Name,
|
||||
MustOut: leaveAnyInt(lt.MustOut),
|
||||
ReasonWordLimit: leaveAnyInt(lt.ReasonWordLimit),
|
||||
AttachmentNumberLimit: leaveAnyInt(lt.AttachmentNumberLimit),
|
||||
}
|
||||
if cfg, err := FetchLeaveTypeCfg(cookieMap, host, codeNum); err == nil {
|
||||
item.MustOut = cfg.MustOut
|
||||
item.NeedApproval = cfg.NeedApproval
|
||||
item.EnableAttach = cfg.EnableAttach
|
||||
item.ReasonWordLimit = cfg.ReasonWordLimit
|
||||
item.AttachmentNumberLimit = cfg.AttachmentNumberLimit
|
||||
item.ChooseAttendance = cfg.ChooseAttendance
|
||||
item.DisableTime = cfg.DisableTime
|
||||
item.LimitDay = cfg.LimitDay
|
||||
if cfg.Name != "" {
|
||||
item.Name = cfg.Name
|
||||
}
|
||||
} else {
|
||||
slog.Warn("获取请假类型配置失败,使用基础信息", "code", codeNum, "error", err)
|
||||
}
|
||||
list = append(list, item)
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
|
||||
// FetchLeaveTypeCfg 获取请假类型详细配置(needApproval/enableAttach 等权威开关)
|
||||
func FetchLeaveTypeCfg(cookieMap map[string]string, host string, code int) (*LeaveType, error) {
|
||||
body, err := leavePost(cookieMap, host+leaveAPIBase+"/stu/queryLeaveTypeCfg", map[string]interface{}{
|
||||
"code": code,
|
||||
}, "")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取请假类型配置失败: %w", err)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Code json.Number `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Datas struct {
|
||||
Code interface{} `json:"code"`
|
||||
Name string `json:"name"`
|
||||
MustOut interface{} `json:"mustOut"`
|
||||
NeedApproval interface{} `json:"needApproval"`
|
||||
EnableAttach interface{} `json:"enableAttach"`
|
||||
ReasonWordLimit interface{} `json:"reasonWordLimit"`
|
||||
AttachmentNumberLimit interface{} `json:"attachmentNumberLimit"`
|
||||
ChooseAttendance interface{} `json:"chooseAttendance"`
|
||||
DisableTime interface{} `json:"disableTime"`
|
||||
LimitDay interface{} `json:"limitDay"`
|
||||
} `json:"datas"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("解析请假类型配置失败: %w (body: %.200s)", err, string(body))
|
||||
}
|
||||
codeVal, _ := result.Code.Int64()
|
||||
if codeVal != 0 {
|
||||
msg := result.Message
|
||||
if msg == "" {
|
||||
msg = "未知错误"
|
||||
}
|
||||
return nil, fmt.Errorf("%s", msg)
|
||||
}
|
||||
|
||||
return &LeaveType{
|
||||
Code: leaveAnyInt(result.Datas.Code),
|
||||
Name: result.Datas.Name,
|
||||
MustOut: leaveAnyInt(result.Datas.MustOut),
|
||||
NeedApproval: leaveAnyInt(result.Datas.NeedApproval),
|
||||
EnableAttach: leaveAnyInt(result.Datas.EnableAttach),
|
||||
ReasonWordLimit: leaveAnyInt(result.Datas.ReasonWordLimit),
|
||||
AttachmentNumberLimit: leaveAnyInt(result.Datas.AttachmentNumberLimit),
|
||||
ChooseAttendance: leaveAnyInt(result.Datas.ChooseAttendance),
|
||||
DisableTime: leaveAnyInt(result.Datas.DisableTime),
|
||||
LimitDay: leaveAnyInt(result.Datas.LimitDay),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// LeaveApplyParams 请假申请提交参数
|
||||
type LeaveApplyParams struct {
|
||||
LeaveType int // 类型编码
|
||||
StartTime string // yyyy-MM-dd HH:mm:ss
|
||||
EndTime string // yyyy-MM-dd HH:mm:ss
|
||||
LeaveReason string // 请假原因
|
||||
OutStatus bool // 是否离校
|
||||
UrgencyMobile string // 紧急联系人手机号(离校必填)
|
||||
Address string // 目的地(离校必填,如 xx省/xx市/xx区)
|
||||
DetailAddress string // 详细地址(离校必填)
|
||||
Longitude string // 定位经度
|
||||
Latitude string // 定位纬度
|
||||
LocationAddr string // 定位地址
|
||||
ImageURLs []string // 附件图片 URL 列表(可选,最多 4 张且不超学校上限)
|
||||
ApproverWid string // 审批人 userWid(needApproval=1 且学校配置了候选审批人时必填;候选为空可不选)
|
||||
ApproverId string // 审批人 userId
|
||||
ApproverName string // 审批人姓名
|
||||
CcUsers []LeaveApprover // 抄送人(非必填;候选取自审批人列表)
|
||||
}
|
||||
|
||||
// LeaveMaxAttachCount 附件数量上限:学校配置为准,但产品上限 4 张。
|
||||
// limit<=0(学校未配置)按 4;limit>4 收敛为 4;否则取学校值。
|
||||
func LeaveMaxAttachCount(schoolLimit int) int {
|
||||
if schoolLimit <= 0 || schoolLimit > 4 {
|
||||
return 4
|
||||
}
|
||||
return schoolLimit
|
||||
}
|
||||
|
||||
// BuildLeaveCcInfo 构造抄送人信息(ccInfo)。
|
||||
// 官方结构:{totalSize, teachers:[], students:[]},人员含 personType(0=老师 1=学生)。
|
||||
// 审批人接口不返回 personType,按 userId 是否纯数字推断(学号纯数字,教师工号常含字母)。
|
||||
func BuildLeaveCcInfo(users []LeaveApprover) map[string]interface{} {
|
||||
teachers := make([]interface{}, 0, len(users))
|
||||
students := make([]interface{}, 0, len(users))
|
||||
for _, u := range users {
|
||||
if u.UserWid == "" {
|
||||
continue
|
||||
}
|
||||
personType := 1 // 默认学生
|
||||
if u.UserId == "" {
|
||||
personType = 0
|
||||
} else {
|
||||
for _, ch := range u.UserId {
|
||||
if ch < '0' || ch > '9' {
|
||||
personType = 0 // 含非数字字符 → 教师
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
person := map[string]interface{}{
|
||||
"userWid": u.UserWid,
|
||||
"userId": u.UserId,
|
||||
"userName": u.UserName,
|
||||
"name2Id": u.UserName + "/" + u.UserId,
|
||||
"personType": personType,
|
||||
"dept": nil,
|
||||
"sex": nil,
|
||||
"teacherName": u.UserName,
|
||||
}
|
||||
if personType == 0 {
|
||||
teachers = append(teachers, person)
|
||||
} else {
|
||||
students = append(students, person)
|
||||
}
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"totalSize": len(teachers) + len(students),
|
||||
"teachers": teachers,
|
||||
"students": students,
|
||||
}
|
||||
}
|
||||
|
||||
// BuildLeaveApplyForm 构造请假申请表单(/leave/apply 的明文 JSON body,
|
||||
// 与销假的 bodyString 加密不同:该接口只携带 Cpdaily-Extension 头)。
|
||||
func BuildLeaveApplyForm(p LeaveApplyParams, deviceId, systemVersion, appVersion string) map[string]interface{} {
|
||||
urgencyMobile := p.UrgencyMobile
|
||||
address := p.Address
|
||||
detailAddress := p.DetailAddress
|
||||
destination := ""
|
||||
if p.OutStatus {
|
||||
destination = address
|
||||
if detailAddress != "" {
|
||||
if destination != "" {
|
||||
destination += "/"
|
||||
}
|
||||
destination += detailAddress
|
||||
}
|
||||
} else {
|
||||
urgencyMobile = ""
|
||||
address = ""
|
||||
detailAddress = ""
|
||||
}
|
||||
|
||||
form := map[string]interface{}{
|
||||
"leaveType": p.LeaveType,
|
||||
"startTime": p.StartTime,
|
||||
"endTime": p.EndTime,
|
||||
"urgencyMobile": urgencyMobile,
|
||||
"leaveReason": p.LeaveReason,
|
||||
"isAttendance": nil,
|
||||
"outStatus": p.OutStatus,
|
||||
"address": address,
|
||||
"detailAddress": detailAddress,
|
||||
"destination": destination,
|
||||
"holidayAutoDefineVos": []interface{}{},
|
||||
"appVersion": appVersion,
|
||||
"deviceId": deviceId,
|
||||
"ccInfo": BuildLeaveCcInfo(p.CcUsers),
|
||||
"cheatType": nil,
|
||||
"source": 1,
|
||||
"location": map[string]interface{}{
|
||||
"deviceId": deviceId,
|
||||
"systemName": "Android",
|
||||
"systemVersion": systemVersion,
|
||||
"model": "cpdaily",
|
||||
"address": p.LocationAddr,
|
||||
"latitude": p.Latitude,
|
||||
"longitude": p.Longitude,
|
||||
},
|
||||
"locationType": 0,
|
||||
"disclaimers": "本人承诺填写的信息真实有效,并对本次提交请假申请的信息真实性负责。",
|
||||
"uaIsCpadaily": "true",
|
||||
"leaveApplyExplain": nil,
|
||||
}
|
||||
if p.ApproverWid != "" {
|
||||
form["approverWid"] = p.ApproverWid
|
||||
form["approverId"] = p.ApproverId
|
||||
form["approverName"] = p.ApproverName
|
||||
}
|
||||
if len(p.ImageURLs) > 0 {
|
||||
attachs := make([]map[string]interface{}, 0, len(p.ImageURLs))
|
||||
for _, u := range p.ImageURLs {
|
||||
if u == "" {
|
||||
continue
|
||||
}
|
||||
attachs = append(attachs, map[string]interface{}{"type": 1, "url": u})
|
||||
}
|
||||
if len(attachs) > 0 {
|
||||
form["applyAttach"] = attachs
|
||||
} else {
|
||||
form["applyAttach"] = []interface{}{}
|
||||
}
|
||||
} else {
|
||||
form["applyAttach"] = []interface{}{}
|
||||
}
|
||||
return form
|
||||
}
|
||||
|
||||
// SubmitLeaveApply 提交请假申请(明文 JSON body + Cpdaily-Extension 头)
|
||||
func SubmitLeaveApply(cookieMap map[string]string, host string, form map[string]interface{}, lon, lat, deviceId, username string) error {
|
||||
extensionString := GetExtensionString(lat, lon, deviceId, username)
|
||||
|
||||
body, err := leavePost(cookieMap, host+leaveAPIBase+"/apply", form, extensionString)
|
||||
if err != nil {
|
||||
return fmt.Errorf("提交请假请求失败: %w", err)
|
||||
}
|
||||
|
||||
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()
|
||||
slog.Info("leave/apply 响应", "code", result.Code.String(), "message", result.Message)
|
||||
if code != 0 {
|
||||
msg := result.Message
|
||||
if msg == "" {
|
||||
msg = "未知错误"
|
||||
}
|
||||
return fmt.Errorf("%s", msg)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
package cpdaily
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// mock 请假申请接口:queryLeaveTypes 返回基础信息(开关全 null,复刻真实返回),
|
||||
// queryLeaveTypeCfg 返回权威开关,checkOnceLeave 返回成功。
|
||||
func newLeaveApplyMockServer(t *testing.T) *httptest.Server {
|
||||
t.Helper()
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch r.URL.Path {
|
||||
case "/wec-counselor-leave-apps/leave/queryLeaveTypes":
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"code": "0",
|
||||
"datas": map[string]interface{}{
|
||||
"leaveType": []map[string]interface{}{
|
||||
{
|
||||
"id": "4443", "code": 3, "name": "外出申请(省内)", "mustOut": 1,
|
||||
"needApproval": nil, "enableAttach": nil, "reasonWordLimit": 10, "attachmentNumberLimit": 4,
|
||||
},
|
||||
{
|
||||
"id": "1225", "code": 1, "name": "事假", "mustOut": 0,
|
||||
"needApproval": nil, "enableAttach": nil, "reasonWordLimit": 10, "attachmentNumberLimit": 4,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
case "/wec-counselor-leave-apps/leave/stu/queryLeaveTypeCfg":
|
||||
var body map[string]interface{}
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
code := leaveAnyInt(body["code"])
|
||||
// code=3:需要审批人 + 允许附件;code=1:均不需要(复刻实测差异)
|
||||
needApproval, enableAttach := 0, 0
|
||||
if code == 3 {
|
||||
needApproval, enableAttach = 1, 1
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"code": "0",
|
||||
"datas": map[string]interface{}{
|
||||
"wid": "4443", "code": code, "name": "外出申请(省内)",
|
||||
"mustOut": 1, "needApproval": needApproval, "enableAttach": enableAttach,
|
||||
"reasonWordLimit": 10, "attachmentNumberLimit": 4, "chooseAttendance": 0,
|
||||
"disableTime": 0, "limitDay": 90,
|
||||
},
|
||||
})
|
||||
case "/wec-counselor-leave-apps/leave/stu/checkOnceLeave":
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{"code": "0", "message": "SUCCESS", "datas": nil})
|
||||
case "/wec-counselor-leave-apps/leave/apply":
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{"code": "0", "message": "SUCCESS"})
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
func TestFetchLeaveTypesMergesTypeCfg(t *testing.T) {
|
||||
srv := newLeaveApplyMockServer(t)
|
||||
defer srv.Close()
|
||||
|
||||
list, err := FetchLeaveTypes(map[string]string{}, srv.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("FetchLeaveTypes 返回错误: %v", err)
|
||||
}
|
||||
if len(list) != 2 {
|
||||
t.Fatalf("期望 2 个请假类型,实际 %d 个", len(list))
|
||||
}
|
||||
// code=3:权威开关来自 cfg(needApproval=1 / enableAttach=1)
|
||||
var out LeaveType
|
||||
var personal LeaveType
|
||||
for _, lt := range list {
|
||||
if lt.Code == 3 {
|
||||
out = lt
|
||||
}
|
||||
if lt.Code == 1 {
|
||||
personal = lt
|
||||
}
|
||||
}
|
||||
if out.Name != "外出申请(省内)" || out.NeedApproval != 1 || out.EnableAttach != 1 || out.MustOut != 1 {
|
||||
t.Errorf("类型合并结果错误: %+v", out)
|
||||
}
|
||||
if out.ReasonWordLimit != 10 || out.LimitDay != 90 {
|
||||
t.Errorf("字数/天数限制错误: %+v", out)
|
||||
}
|
||||
if personal.NeedApproval != 0 || personal.EnableAttach != 0 {
|
||||
t.Errorf("事假不应需要审批/附件: %+v", personal)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckOnceLeave(t *testing.T) {
|
||||
srv := newLeaveApplyMockServer(t)
|
||||
defer srv.Close()
|
||||
|
||||
if err := CheckOnceLeave(map[string]string{}, srv.URL); err != nil {
|
||||
t.Errorf("checkOnceLeave 应通过: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildLeaveApplyForm(t *testing.T) {
|
||||
// 离校:紧急联系人/目的地/详细地址均保留,destination 由地址拼接
|
||||
p := LeaveApplyParams{
|
||||
LeaveType: 3,
|
||||
StartTime: "2026-09-15 08:00:00",
|
||||
EndTime: "2026-09-16 20:00:00",
|
||||
LeaveReason: "回家处理家庭事务",
|
||||
OutStatus: true,
|
||||
UrgencyMobile: "13800000000",
|
||||
Address: "福建省/厦门市/思明区",
|
||||
DetailAddress: "某某路 1 号",
|
||||
Longitude: "118.1",
|
||||
Latitude: "24.6",
|
||||
LocationAddr: "福建省厦门市思明区",
|
||||
ImageURLs: []string{"https://obs.example.com/a.jpg", "https://obs.example.com/b.jpg"},
|
||||
ApproverWid: "93458825",
|
||||
ApproverId: "20220109",
|
||||
ApproverName: "罗亭",
|
||||
}
|
||||
form := BuildLeaveApplyForm(p, "device-1", "15", "9.9.6")
|
||||
|
||||
if form["leaveType"] != 3 || form["startTime"] != "2026-09-15 08:00:00" || form["outStatus"] != true {
|
||||
t.Errorf("基础字段错误: %+v", form)
|
||||
}
|
||||
if form["destination"] != "福建省/厦门市/思明区/某某路 1 号" {
|
||||
t.Errorf("destination 拼接错误: %v", form["destination"])
|
||||
}
|
||||
if form["approverWid"] != "93458825" || form["approverId"] != "20220109" || form["approverName"] != "罗亭" {
|
||||
t.Errorf("审批人字段错误: %v/%v/%v", form["approverWid"], form["approverId"], form["approverName"])
|
||||
}
|
||||
attach, ok := form["applyAttach"].([]map[string]interface{})
|
||||
if !ok || len(attach) != 2 || attach[0]["url"] != "https://obs.example.com/a.jpg" || attach[1]["url"] != "https://obs.example.com/b.jpg" {
|
||||
t.Errorf("多附件结构错误: %v", form["applyAttach"])
|
||||
}
|
||||
if attach[0]["type"] != 1 || attach[1]["type"] != 1 {
|
||||
t.Errorf("附件 type 应为 1: %v", attach)
|
||||
}
|
||||
loc, ok := form["location"].(map[string]interface{})
|
||||
if !ok || loc["longitude"] != "118.1" || loc["latitude"] != "24.6" || loc["address"] != "福建省厦门市思明区" {
|
||||
t.Errorf("location 结构错误: %v", form["location"])
|
||||
}
|
||||
|
||||
// 不离校:紧急联系人/地址字段清空、destination 为空、无审批人/无附件
|
||||
p2 := LeaveApplyParams{
|
||||
LeaveType: 1,
|
||||
StartTime: "2026-09-15 08:00:00",
|
||||
EndTime: "2026-09-15 18:00:00",
|
||||
LeaveReason: "身体不适需休息",
|
||||
OutStatus: false,
|
||||
UrgencyMobile: "13800000000", // 故意传入,应被清空
|
||||
Address: "福建省/厦门市",
|
||||
DetailAddress: "某某路",
|
||||
Longitude: "118.1",
|
||||
Latitude: "24.6",
|
||||
LocationAddr: "校内",
|
||||
}
|
||||
form2 := BuildLeaveApplyForm(p2, "device-1", "15", "9.9.6")
|
||||
if form2["urgencyMobile"] != "" || form2["address"] != "" || form2["detailAddress"] != "" {
|
||||
t.Errorf("不离校时地址字段应清空: %v/%v/%v", form2["urgencyMobile"], form2["address"], form2["detailAddress"])
|
||||
}
|
||||
if form2["destination"] != "" {
|
||||
t.Errorf("不离校时 destination 应为空: %v", form2["destination"])
|
||||
}
|
||||
if _, ok := form2["approverWid"]; ok {
|
||||
t.Errorf("未选审批人时不应携带 approverWid")
|
||||
}
|
||||
if attach, ok := form2["applyAttach"].([]interface{}); !ok || len(attach) != 0 {
|
||||
t.Errorf("无附件时 applyAttach 应为空数组: %v", form2["applyAttach"])
|
||||
}
|
||||
// 无抄送人:ccInfo 仍必须存在且为空结构
|
||||
cc, ok := form2["ccInfo"].(map[string]interface{})
|
||||
if !ok || cc["totalSize"] != 0 {
|
||||
t.Errorf("无抄送人时 ccInfo 应为空结构: %v", form2["ccInfo"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildLeaveCcInfo(t *testing.T) {
|
||||
// 学生(纯数字 userId)+ 教师(含字母 userId)分组
|
||||
users := []LeaveApprover{
|
||||
{UserWid: "92274797", UserId: "02727", UserName: "王欣然"}, // 纯数字 → students
|
||||
{UserWid: "915560902", UserId: "kjxy0003", UserName: "杨瑚"}, // 含字母 → teachers
|
||||
}
|
||||
cc := BuildLeaveCcInfo(users)
|
||||
if cc["totalSize"] != 2 {
|
||||
t.Errorf("totalSize 应为 2,实际 %v", cc["totalSize"])
|
||||
}
|
||||
students, _ := cc["students"].([]interface{})
|
||||
teachers, _ := cc["teachers"].([]interface{})
|
||||
if len(students) != 1 || len(teachers) != 1 {
|
||||
t.Fatalf("分组错误: students=%d teachers=%d", len(students), len(teachers))
|
||||
}
|
||||
st, _ := students[0].(map[string]interface{})
|
||||
if st["userWid"] != "92274797" || st["name2Id"] != "王欣然/02727" || st["personType"] != 1 {
|
||||
t.Errorf("学生抄送人字段错误: %v", st)
|
||||
}
|
||||
tc, _ := teachers[0].(map[string]interface{})
|
||||
if tc["userWid"] != "915560902" || tc["personType"] != 0 {
|
||||
t.Errorf("教师抄送人字段错误: %v", tc)
|
||||
}
|
||||
|
||||
// 空列表:结构完整但 totalSize=0
|
||||
empty := BuildLeaveCcInfo(nil)
|
||||
if empty["totalSize"] != 0 {
|
||||
t.Errorf("空抄送人 totalSize 应为 0: %v", empty["totalSize"])
|
||||
}
|
||||
if arr, ok := empty["teachers"].([]interface{}); !ok || len(arr) != 0 {
|
||||
t.Errorf("空抄送人 teachers 应为空数组: %v", empty["teachers"])
|
||||
}
|
||||
|
||||
// 提交表单携带抄送人
|
||||
form := BuildLeaveApplyForm(LeaveApplyParams{
|
||||
LeaveType: 1, StartTime: "2026-09-15 08:00:00", EndTime: "2026-09-15 18:00:00",
|
||||
LeaveReason: "身体不适需休息", Longitude: "118.1", Latitude: "24.6", LocationAddr: "校内",
|
||||
CcUsers: users,
|
||||
}, "device-1", "15", "9.9.6")
|
||||
fcc, _ := form["ccInfo"].(map[string]interface{})
|
||||
if fcc == nil || fcc["totalSize"] != 2 {
|
||||
t.Errorf("表单 ccInfo 应包含 2 名抄送人: %v", form["ccInfo"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubmitLeaveApply(t *testing.T) {
|
||||
srv := newLeaveApplyMockServer(t)
|
||||
defer srv.Close()
|
||||
|
||||
form := BuildLeaveApplyForm(LeaveApplyParams{
|
||||
LeaveType: 1, StartTime: "2026-09-15 08:00:00", EndTime: "2026-09-15 18:00:00",
|
||||
LeaveReason: "身体不适需休息", Longitude: "118.1", Latitude: "24.6", LocationAddr: "校内",
|
||||
}, "device-1", "15", "9.9.6")
|
||||
|
||||
if err := SubmitLeaveApply(map[string]string{}, srv.URL, form, "118.1", "24.6", "device-1", "20251960205"); err != nil {
|
||||
t.Errorf("SubmitLeaveApply 应成功: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLeaveMaxAttachCount 附件数量上限:学校未配置/超 4 张收敛为 4,否则取学校值
|
||||
func TestLeaveMaxAttachCount(t *testing.T) {
|
||||
cases := []struct {
|
||||
schoolLimit int
|
||||
want int
|
||||
}{
|
||||
{0, 4}, // 未配置
|
||||
{-1, 4}, // 异常值
|
||||
{2, 2},
|
||||
{4, 4},
|
||||
{9, 4}, // 超产品上限
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := LeaveMaxAttachCount(c.schoolLimit); got != c.want {
|
||||
t.Errorf("LeaveMaxAttachCount(%d) = %d, want %d", c.schoolLimit, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildLeaveApplyFormEmptyAttachURLs 多图字段中的空串应被剔除,全空时仍输出空数组
|
||||
func TestBuildLeaveApplyFormEmptyAttachURLs(t *testing.T) {
|
||||
form := BuildLeaveApplyForm(LeaveApplyParams{
|
||||
LeaveType: 1, StartTime: "2026-09-15 08:00:00", EndTime: "2026-09-15 18:00:00",
|
||||
LeaveReason: "身体不适需休息", Longitude: "118.1", Latitude: "24.6", LocationAddr: "校内",
|
||||
ImageURLs: []string{"", "https://obs.example.com/only.jpg", ""},
|
||||
}, "device-1", "15", "9.9.6")
|
||||
attach, ok := form["applyAttach"].([]map[string]interface{})
|
||||
if !ok || len(attach) != 1 || attach[0]["url"] != "https://obs.example.com/only.jpg" {
|
||||
t.Errorf("空串应被剔除: %v", form["applyAttach"])
|
||||
}
|
||||
|
||||
form2 := BuildLeaveApplyForm(LeaveApplyParams{
|
||||
LeaveType: 1, StartTime: "2026-09-15 08:00:00", EndTime: "2026-09-15 18:00:00",
|
||||
LeaveReason: "身体不适需休息", Longitude: "118.1", Latitude: "24.6", LocationAddr: "校内",
|
||||
ImageURLs: []string{"", ""},
|
||||
}, "device-1", "15", "9.9.6")
|
||||
if attach2, ok := form2["applyAttach"].([]interface{}); !ok || len(attach2) != 0 {
|
||||
t.Errorf("全空时应为空数组: %v", form2["applyAttach"])
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,165 @@
|
||||
package cpdaily
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestLeaveNewObsKeyOf 验证 obsKey 推导(与官方前端 AA 函数一致)
|
||||
func TestLeaveNewObsKeyOf(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
url string
|
||||
want string
|
||||
}{
|
||||
{"普通路径", "https://counselor-cos-wecres.campusphere.net/counselor/leave/1020385267004784/attachment/abc.jpeg?sign=xxx", "/counselor/leave/1020385267004784/attachment/abc.jpeg"},
|
||||
{"newBucket形态", "https://cdn.example.com/foo/newBucket/leave/abc.png", "newBucket/leave/abc.png"},
|
||||
{"空URL", "", ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if got := LeaveNewObsKeyOf(c.url); got != c.want {
|
||||
t.Errorf("LeaveNewObsKeyOf(%q) = %q, want %q", c.url, got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestLeaveAppFlavorFromApps 验证应用列表 → 套件判定
|
||||
func TestLeaveNewCanReport(t *testing.T) {
|
||||
cases := []struct {
|
||||
status, reportMode string
|
||||
want bool
|
||||
}{
|
||||
{"3", "1", true},
|
||||
{"4", "1", true},
|
||||
{"3", "2", true},
|
||||
{"4", "2", true},
|
||||
{"3", "0", false},
|
||||
{"4", "3", false},
|
||||
{"2", "1", false},
|
||||
{"7", "1", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := leaveNewCanReport(LeaveNewItem{Status: c.status, ReportMode: c.reportMode})
|
||||
if got != c.want {
|
||||
t.Errorf("canReport(status=%s, reportMode=%s) = %v, want %v", c.status, c.reportMode, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestLeaveNewStatusText 验证状态文案映射完整
|
||||
func TestLeaveNewStatusText(t *testing.T) {
|
||||
for _, code := range []string{"1", "2", "3", "4", "5", "6", "7", "8", "9", "10"} {
|
||||
if LeaveNewStatusText[code] == "" {
|
||||
t.Errorf("status %s 缺少文案映射", code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildLeaveNewReportForm 验证销假表单结构(含确认人与附件)
|
||||
func TestBuildLeaveNewReportForm(t *testing.T) {
|
||||
approver := &LeaveNewApprover{UserWid: "92271929", UserId: "2022110006", UserName: "张文烨", RoleCode: "1003", RoleName: "辅导员", DeptName: "智能制造学院"}
|
||||
form := BuildLeaveNewReportForm(LeaveNewReportParams{
|
||||
LeaveID: "e1529da9d4c94341b0b1f7f2278eb2ab",
|
||||
Address: "泰州学院",
|
||||
Longitude: "119.948267",
|
||||
Latitude: "32.474102",
|
||||
Approver: approver,
|
||||
})
|
||||
if form["leaveId"] != "e1529da9d4c94341b0b1f7f2278eb2ab" {
|
||||
t.Errorf("leaveId 缺失: %v", form["leaveId"])
|
||||
}
|
||||
loc, ok := form["location"].(map[string]interface{})
|
||||
if !ok || loc["address"] != "泰州学院" {
|
||||
t.Errorf("location 结构错误: %v", form["location"])
|
||||
}
|
||||
if form["approverWid"] != "92271929" || form["approverRoleCode"] != "1003" {
|
||||
t.Errorf("确认人字段错误: approverWid=%v approverRoleCode=%v", form["approverWid"], form["approverRoleCode"])
|
||||
}
|
||||
if attachs, ok := form["attachs"].([]interface{}); !ok || len(attachs) != 0 {
|
||||
t.Errorf("无附件时 attachs 应为空数组: %v", form["attachs"])
|
||||
}
|
||||
|
||||
form2 := BuildLeaveNewReportForm(LeaveNewReportParams{
|
||||
LeaveID: "x", Address: "a", Longitude: "1", Latitude: "2",
|
||||
ImageURL: "https://cos.example.com/counselor/leave/1/attachment/a.jpeg?sign=1",
|
||||
})
|
||||
attachs2 := form2["attachs"].([]map[string]interface{})
|
||||
if len(attachs2) != 1 || attachs2[0]["obsKey"] != "/counselor/leave/1/attachment/a.jpeg" {
|
||||
t.Errorf("附件结构错误: %v", attachs2)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildLeaveNewApplyForm 验证请假表单结构(离校/不离校两分支 + 抄送人)
|
||||
func TestBuildLeaveNewApplyForm(t *testing.T) {
|
||||
approver := &LeaveNewApprover{UserWid: "92271929", UserId: "2022110006", UserName: "张文烨"}
|
||||
form := BuildLeaveNewApplyForm(LeaveNewApplyParams{
|
||||
TypeWid: 8214,
|
||||
BeginTime: "2026-09-16 08:00",
|
||||
EndTime: "2026-09-16 18:00",
|
||||
LeaveReason: "测试原因",
|
||||
LocationAddr: "泰州学院",
|
||||
Longitude: "119.9",
|
||||
Latitude: "32.4",
|
||||
IsLeaveOut: 1,
|
||||
Destination: "江苏省/南京市/玄武区",
|
||||
Detail: "某地",
|
||||
EmergencyName: "张三",
|
||||
EmergencyMobile: "13800000000",
|
||||
Approver: approver,
|
||||
CcUsers: []LeaveNewApprover{{UserWid: "1", UserId: "s1", UserName: "李四", RoleCode: "1003"}},
|
||||
ImageURLs: []string{
|
||||
"https://cos.example.com/counselor/leave/1/attachment/a.jpeg?sign=1",
|
||||
"https://cos.example.com/counselor/leave/1/attachment/b.png?sign=2",
|
||||
},
|
||||
})
|
||||
if form["typeWid"] != 8214 {
|
||||
t.Errorf("typeWid 错误: %v", form["typeWid"])
|
||||
}
|
||||
attachs := form["attachs"].([]map[string]interface{})
|
||||
if len(attachs) != 2 {
|
||||
t.Fatalf("多附件应为 2 张: %v", attachs)
|
||||
}
|
||||
if attachs[0]["attachUrl"] != "https://cos.example.com/counselor/leave/1/attachment/a.jpeg?sign=1" ||
|
||||
attachs[0]["obsKey"] != "/counselor/leave/1/attachment/a.jpeg" {
|
||||
t.Errorf("第 1 张附件结构错误: %v", attachs[0])
|
||||
}
|
||||
if attachs[1]["attachUrl"] != "https://cos.example.com/counselor/leave/1/attachment/b.png?sign=2" ||
|
||||
attachs[1]["obsKey"] != "/counselor/leave/1/attachment/b.png" {
|
||||
t.Errorf("第 2 张附件结构错误: %v", attachs[1])
|
||||
}
|
||||
if attachs[0]["attachType"] != 1 || attachs[1]["attachType"] != 1 {
|
||||
t.Errorf("attachType 应为 1: %v", attachs)
|
||||
}
|
||||
lo := form["leaveOutInfo"].(map[string]interface{})
|
||||
if lo["isLeaveOut"] != 1 || lo["destination"] != "江苏省/南京市/玄武区" || lo["emergencyConcat"] != "张三" {
|
||||
t.Errorf("leaveOutInfo 错误: %v", lo)
|
||||
}
|
||||
cc := form["ccObjects"].([]map[string]interface{})
|
||||
if len(cc) != 1 || cc[0]["userWid"] != "1" || cc[0]["source"] != "2" {
|
||||
t.Errorf("ccObjects 错误: %v", cc)
|
||||
}
|
||||
|
||||
// 不离校分支:离校字段应清空
|
||||
form2 := BuildLeaveNewApplyForm(LeaveNewApplyParams{TypeWid: 8216, IsLeaveOut: 0, Destination: "不该出现", EmergencyMobile: "138"})
|
||||
lo2 := form2["leaveOutInfo"].(map[string]interface{})
|
||||
if lo2["destination"] != "" || lo2["emergencyMobile"] != "" {
|
||||
t.Errorf("不离校时离校字段应清空: %v", lo2)
|
||||
}
|
||||
if attachs2, ok := form2["attachs"].([]map[string]interface{}); !ok || len(attachs2) != 0 {
|
||||
t.Errorf("无附件时 attachs 应为空数组: %v", form2["attachs"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestLeaveNewResultErr 验证响应码解析(code 为字符串 "0")
|
||||
func TestLeaveNewResultErr(t *testing.T) {
|
||||
if _, err := leaveNewResultErr([]byte(`{"code":"0","message":"SUCCESS","datas":{}}`), "测试"); err != nil {
|
||||
t.Errorf("code=0 不应报错: %v", err)
|
||||
}
|
||||
if _, err := leaveNewResultErr([]byte(`{"code":"-1","message":"参数错误"}`), "测试"); err == nil || err.Error() != "参数错误" {
|
||||
t.Errorf("非 0 应返回 message: %v", err)
|
||||
}
|
||||
if _, err := leaveNewResultErr([]byte(`<html>404</html>`), "测试"); err == nil {
|
||||
t.Error("非 JSON 应报错")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
package cpdaily
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// mock 销假模块接口:listOther 返回两行(一行待销假、一行已完成),
|
||||
// detailOther 返回规定销假位置(reportPlaces)与开关(reportApplyFlow/enableAttach)。
|
||||
// leaveType.code 故意用数字,复刻真实接口(此前声明为 string 导致解析失败)。
|
||||
func newLeaveMockServer(t *testing.T) *httptest.Server {
|
||||
t.Helper()
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch r.URL.Path {
|
||||
case "/wec-counselor-leave-apps/leave/stu/listOther":
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"code": "0",
|
||||
"datas": map[string]interface{}{
|
||||
"rows": []map[string]interface{}{
|
||||
{
|
||||
"id": "301",
|
||||
"leaveType": "事假",
|
||||
"leaveReason": "回家",
|
||||
"createTime": "2026-09-13 10:00",
|
||||
"actEndTime": "2026-09-14 08:00",
|
||||
"status": "8",
|
||||
},
|
||||
{
|
||||
"id": "302",
|
||||
"leaveType": "病假",
|
||||
"status": "4", // 已完成,应被过滤
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
case "/wec-counselor-leave-apps/leave/stu/detailOther":
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"code": "0",
|
||||
"datas": map[string]interface{}{
|
||||
"detail": map[string]interface{}{
|
||||
"id": "301",
|
||||
"startDate": "2026-09-13 10:00",
|
||||
"endDate": "2026-09-14 08:00",
|
||||
},
|
||||
"leaveType": map[string]interface{}{
|
||||
"code": 1, // 数字,复刻真实返回
|
||||
"name": "事假",
|
||||
"reportApplyFlow": 1,
|
||||
"enableAttach": 1,
|
||||
},
|
||||
"reportPlaces": []map[string]interface{}{
|
||||
{
|
||||
"wid": "1184",
|
||||
"address": "云南大学东二院",
|
||||
"longitude": 102.70567818032221,
|
||||
"latitude": "25.059769487255817",
|
||||
"radius": "360",
|
||||
"counName": "ampadmin",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
case "/wec-counselor-leave-apps/leave/stu/obtainApproveFlow":
|
||||
var body map[string]interface{}
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
// 时间必须带秒,否则真实服务端返回"日期时间格式错误"
|
||||
start, _ := body["startTime"].(string)
|
||||
end, _ := body["endTime"].(string)
|
||||
if len(start) != 19 || len(end) != 19 {
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"code": "2210990000", "message": "日期时间格式错误",
|
||||
})
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"code": "0",
|
||||
"datas": map[string]interface{}{
|
||||
"approvers": []map[string]interface{}{
|
||||
{"userWid": "93458825", "userId": "20220109", "userName": "罗亭"},
|
||||
{"userWid": "1246664953", "userId": "12024206007", "userName": "曾伟"},
|
||||
},
|
||||
},
|
||||
})
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
func TestFetchLeaveListFiltersCompletedAndParsesReportPlaces(t *testing.T) {
|
||||
srv := newLeaveMockServer(t)
|
||||
defer srv.Close()
|
||||
|
||||
items, err := FetchLeaveList(map[string]string{"MOD_AUTH_CAS": "test"}, srv.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("FetchLeaveList 返回错误: %v", err)
|
||||
}
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("期望过滤后剩 1 条待销假记录,实际 %d 条: %+v", len(items), items)
|
||||
}
|
||||
item := items[0]
|
||||
if item.ID != "301" || item.LeaveType != "事假" || item.Reason != "回家" {
|
||||
t.Errorf("列表字段解析错误: %+v", item)
|
||||
}
|
||||
if len(item.ReportPlaces) != 1 {
|
||||
t.Fatalf("期望解析出 1 个规定销假位置,实际 %d 个", len(item.ReportPlaces))
|
||||
}
|
||||
p := item.ReportPlaces[0]
|
||||
// 数字型经纬度应转为纯文本(避免科学计数法)
|
||||
if p.Longitude != "102.70567818032221" || p.Latitude != "25.059769487255817" || p.Address != "云南大学东二院" || p.Radius != "360" {
|
||||
t.Errorf("规定销假位置解析错误: %+v", p)
|
||||
}
|
||||
if !item.ReportApplyFlow || !item.EnableAttach {
|
||||
t.Errorf("开关解析错误: reportApplyFlow=%v enableAttach=%v", item.ReportApplyFlow, item.EnableAttach)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchLeaveDetailParsesSubmitFields(t *testing.T) {
|
||||
srv := newLeaveMockServer(t)
|
||||
defer srv.Close()
|
||||
|
||||
d, err := FetchLeaveDetail(map[string]string{}, srv.URL, "301")
|
||||
if err != nil {
|
||||
t.Fatalf("FetchLeaveDetail 返回错误: %v", err)
|
||||
}
|
||||
if d.ReportID != "301" {
|
||||
t.Errorf("ReportID = %q, 期望 301", d.ReportID)
|
||||
}
|
||||
// 数字型 code 应转为纯文本 + 数字双形式
|
||||
if d.LeaveCode != "1" {
|
||||
t.Errorf("LeaveCode = %q, 期望 1", d.LeaveCode)
|
||||
}
|
||||
if d.LeaveCodeNum != 1 {
|
||||
t.Errorf("LeaveCodeNum = %d, 期望 1", d.LeaveCodeNum)
|
||||
}
|
||||
if d.LeaveType != "事假" {
|
||||
t.Errorf("LeaveType = %q, 期望 事假", d.LeaveType)
|
||||
}
|
||||
if !d.ReportApplyFlow || !d.EnableAttach {
|
||||
t.Errorf("开关解析错误: reportApplyFlow=%v enableAttach=%v", d.ReportApplyFlow, d.EnableAttach)
|
||||
}
|
||||
if d.StartTime != "2026-09-13 10:00" || d.EndTime != "2026-09-14 08:00" {
|
||||
t.Errorf("起止时间解析错误: %q ~ %q", d.StartTime, d.EndTime)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchReportApprovers(t *testing.T) {
|
||||
srv := newLeaveMockServer(t)
|
||||
defer srv.Close()
|
||||
|
||||
list, err := FetchReportApprovers(map[string]string{}, srv.URL, 1, "2026-09-13 10:00", "2026-09-14 08:00")
|
||||
if err != nil {
|
||||
t.Fatalf("FetchReportApprovers 返回错误: %v", err)
|
||||
}
|
||||
if len(list) != 2 || list[0].UserName != "罗亭" || list[0].UserWid != "93458825" {
|
||||
t.Errorf("审批人解析错误: %+v", list)
|
||||
}
|
||||
|
||||
// 秒级时间:入参已含秒时原样透传
|
||||
if got := leaveTimeWithSeconds("2026-09-13 10:00:30"); got != "2026-09-13 10:00:30" {
|
||||
t.Errorf("已含秒时间不应改动,实际 %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildLeaveReportForm(t *testing.T) {
|
||||
d := &LeaveDetail{
|
||||
ReportID: "301",
|
||||
LeaveCode: "1",
|
||||
ReportPlaces: []LeaveReportPlace{
|
||||
{Address: "规定位置地址", Longitude: "118.1", Latitude: "24.6"},
|
||||
},
|
||||
}
|
||||
|
||||
// 无用户地址:兜底使用第一个规定销假位置地址;无图片:reportAttach 为空数组
|
||||
form := BuildLeaveReportForm(d, "", "", "")
|
||||
if form["reportLocation"] != "规定位置地址" {
|
||||
t.Errorf("reportLocation 应兜底规定位置地址,实际 %v", form["reportLocation"])
|
||||
}
|
||||
if attach, ok := form["reportAttach"].([]interface{}); !ok || len(attach) != 0 {
|
||||
t.Errorf("无图片时 reportAttach 应为空数组,实际 %v", form["reportAttach"])
|
||||
}
|
||||
if form["approverWid"] != "" {
|
||||
t.Errorf("未选审批人时 approverWid 应为空字符串,实际 %v", form["approverWid"])
|
||||
}
|
||||
if form["id"] != "301" || form["leaveType"] != "1" {
|
||||
t.Errorf("id/leaveType 字段错误: %v / %v", form["id"], form["leaveType"])
|
||||
}
|
||||
|
||||
// 用户地址优先 + 有图片 + 选择了审批人
|
||||
form2 := BuildLeaveReportForm(d, "用户填写地址", "https://obs.example.com/a.jpg", "93458825")
|
||||
if form2["reportLocation"] != "用户填写地址" {
|
||||
t.Errorf("用户填写的地址应优先,实际 %v", form2["reportLocation"])
|
||||
}
|
||||
if form2["approverWid"] != "93458825" {
|
||||
t.Errorf("应携带所选审批人,实际 %v", form2["approverWid"])
|
||||
}
|
||||
attach, ok := form2["reportAttach"].([]map[string]interface{})
|
||||
if !ok || len(attach) != 1 || attach[0]["type"] != 1 || attach[0]["url"] != "https://obs.example.com/a.jpg" {
|
||||
t.Errorf("有图片时 reportAttach 结构错误: %v", form2["reportAttach"])
|
||||
}
|
||||
}
|
||||
@@ -106,13 +106,25 @@ func casLogin(ctx context.Context, school *SchoolDetailInfo, username, password
|
||||
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
|
||||
if isSliderCaptchaSchool(school.ID) {
|
||||
// 滑块验证码学校(wisedu 定制 CAS 滑块,如华侨大学):
|
||||
// openSliderCaptcha 取图 → 打码平台识别 → 等激活窗口 → sign 协议提交 verifySliderCaptcha
|
||||
//(提交打码值 v、v+1、v-1,失败换图重试,详见 slider.go),
|
||||
// 通过后 captcha 字段留空提交
|
||||
casBase := fmt.Sprintf("%s://%s/authserver", loginResponseUrl.Scheme, loginResponseUrl.Host)
|
||||
if err := resolveWiseduSliderCaptcha(loginCtx, casBase, loginResponseUrl.String(), sc.Client); err != nil {
|
||||
return nil, nil, fmt.Errorf("滑块验证失败: %w", err)
|
||||
}
|
||||
slog.Info("CAS滑块验证通过")
|
||||
} else {
|
||||
captchaCode := resolveOcrCaptcha(ctx, loginResponseUrl, loginType, sc.Client)
|
||||
slog.Info("CAS验证码OCR", "code", captchaCode)
|
||||
switch loginType {
|
||||
case 0:
|
||||
allInputs["captchaResponse"] = captchaCode
|
||||
default:
|
||||
allInputs["captcha"] = captchaCode
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -200,8 +212,10 @@ func parseLoginForm(page []byte, username, password string) map[string]string {
|
||||
return inputs
|
||||
}
|
||||
|
||||
// 优先选择密码登录表单(pwdFromId),其次是其他已知CAS表单ID
|
||||
selectors := []string{"#pwdFromId", "#phoneFromId", "#casLoginForm", "#loginFromId", "#fm1", "form"}
|
||||
// 优先选择密码登录表单(pwdFromId/pwdLoginDiv),其次是其他已知CAS表单ID。
|
||||
// pwdLoginDiv 为 wisedu 定制 CAS 的账密区域(如华侨大学登录页含 3 个 id 均为
|
||||
// loginFromId 的表单 fido/动态码/账密,账密字段位于 #pwdLoginDiv 内)
|
||||
selectors := []string{"#pwdFromId", "#phoneFromId", "#casLoginForm", "#pwdLoginDiv", "#loginFromId", "#fm1", "form"}
|
||||
var formSel *goquery.Selection
|
||||
for _, sel := range selectors {
|
||||
formSel = doc.Find(sel)
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
package cpdaily
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ========== 辽宁工程技术大学(lntu)单独适配 ==========
|
||||
|
||||
// LntuSchoolId 辽宁工程技术大学 tenant id
|
||||
const LntuSchoolId = "lntu"
|
||||
|
||||
// lntuCasLoginEntry 计算辽宁工程技术大学 CAS 登录入口:
|
||||
// 网关域 fdm.lntu.edu.cn 的 /iap(school.IdsUrl)会 302 到学校 CAS
|
||||
// (authserver.lntu.edu.cn/authserver/login),service 参数携带网关回调
|
||||
// /iap/loginSuccess?sessionToken=...;走完 CAS 账密后回调该地址,
|
||||
// cookie 才能正确落在 fdm.lntu.edu.cn 域(wec-* 任务接口所在域)。
|
||||
func lntuCasLoginEntry(school *SchoolDetailInfo) string {
|
||||
if school == nil {
|
||||
return ""
|
||||
}
|
||||
if school.IdsUrl != "" && strings.Contains(school.IdsUrl, "/iap") {
|
||||
return school.IdsUrl
|
||||
}
|
||||
if school.WecloudUrl != "" {
|
||||
return strings.TrimSuffix(school.WecloudUrl, "/") + "/iap"
|
||||
}
|
||||
return school.AmpUrl
|
||||
}
|
||||
|
||||
// lntuCasLogin 辽宁工程技术大学强制走 CAS:
|
||||
// 平台接口返回 joinType=CLOUD(IsIap()=true),但网关 /iap/doLogin 不存在(HTTP 405),
|
||||
// IAP 账密提交必然失败并报 FAIL_UPNOTMATCH("用户名或密码不匹配",误导用户);
|
||||
// 实际链路为 IAP 入口(fdm.lntu.edu.cn/iap)302 → 学校 CAS 账密 → 回调
|
||||
// /iap/loginSuccess 建立网关会话。因此强制走 CAS 流程(同许昌学院 xcu 模式)。
|
||||
func lntuCasLogin(ctx context.Context, school *SchoolDetailInfo, username, password string) ([]*http.Cookie, *MFAContext, error) {
|
||||
lntu := *school
|
||||
if entry := lntuCasLoginEntry(school); entry != "" {
|
||||
lntu.AmpUrl = entry
|
||||
}
|
||||
return casLogin(ctx, &lntu, username, password)
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package cpdaily
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestLntuCasLoginEntry 覆盖辽宁工程技术大学 CAS 登录入口计算逻辑
|
||||
func TestLntuCasLoginEntry(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
school *SchoolDetailInfo
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "正常idsUrl(/iap 网关入口)",
|
||||
school: &SchoolDetailInfo{IdsUrl: "https://fdm.lntu.edu.cn/iap"},
|
||||
want: "https://fdm.lntu.edu.cn/iap",
|
||||
},
|
||||
{
|
||||
name: "idsUrl缺失回退wecloud+/iap",
|
||||
school: &SchoolDetailInfo{WecloudUrl: "https://fdm.lntu.edu.cn"},
|
||||
want: "https://fdm.lntu.edu.cn/iap",
|
||||
},
|
||||
{
|
||||
name: "wecloud带尾斜杠",
|
||||
school: &SchoolDetailInfo{WecloudUrl: "https://fdm.lntu.edu.cn/"},
|
||||
want: "https://fdm.lntu.edu.cn/iap",
|
||||
},
|
||||
{
|
||||
name: "idsUrl非iap路径回退wecloud",
|
||||
school: &SchoolDetailInfo{IdsUrl: "https://ids.lntu.edu.cn/authserver", WecloudUrl: "https://fdm.lntu.edu.cn"},
|
||||
want: "https://fdm.lntu.edu.cn/iap",
|
||||
},
|
||||
{
|
||||
name: "均缺失回退ampUrl",
|
||||
school: &SchoolDetailInfo{AmpUrl: "https://fdm.lntu.edu.cn/wec-portal-mobile/client"},
|
||||
want: "https://fdm.lntu.edu.cn/wec-portal-mobile/client",
|
||||
},
|
||||
{
|
||||
name: "均为空",
|
||||
school: &SchoolDetailInfo{},
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "nil学校",
|
||||
school: nil,
|
||||
want: "",
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if got := lntuCasLoginEntry(c.school); got != c.want {
|
||||
t.Errorf("lntuCasLoginEntry() = %q, want %q", got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestLntuCasLoginEntryOverridesAmpUrl 确认 lntuCasLogin 用网关入口覆盖 ampUrl 后走 CAS 流程
|
||||
func TestLntuCasLoginEntryOverridesAmpUrl(t *testing.T) {
|
||||
school := &SchoolDetailInfo{
|
||||
ID: LntuSchoolId,
|
||||
Name: "辽宁工程技术大学",
|
||||
JoinType: "CLOUD", // 平台标记为 CLOUD,但 IAP 不可用,需强制 CAS
|
||||
IdsUrl: "https://fdm.lntu.edu.cn/iap",
|
||||
WecloudUrl: "https://fdm.lntu.edu.cn",
|
||||
AmpUrl: "https://fdm.lntu.edu.cn/wec-portal-mobile/client",
|
||||
}
|
||||
// 模拟 lntuCasLogin 内部的入口覆盖(不发起网络请求)
|
||||
clone := *school
|
||||
clone.AmpUrl = lntuCasLoginEntry(school)
|
||||
if got := getCasValidLoginUrl(&clone); got != "https://fdm.lntu.edu.cn/iap" {
|
||||
t.Errorf("getCasValidLoginUrl() = %q, want 网关 /iap 入口", got)
|
||||
}
|
||||
// 原对象不被修改
|
||||
if school.AmpUrl != "https://fdm.lntu.edu.cn/wec-portal-mobile/client" {
|
||||
t.Errorf("原 school 对象 ampUrl 被意外修改: %q", school.AmpUrl)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLntuIsSliderCaptchaSchool 确认 lntu 归入滑块验证码学校需通过配置声明
|
||||
// (captchaSwitch=2 + createSliderCaptcha → /common/toSliderCaptcha.htl,与 hqdx 同协议)
|
||||
func TestLntuSliderProtocolSameAsHqu(t *testing.T) {
|
||||
// 协议一致性由配置驱动:lntu 与 hqdx 共用 resolveWiseduSliderCaptcha
|
||||
//(openSliderCaptcha.htl 取图 → 打码 → verifySliderCaptcha.htl sign 提交)。
|
||||
// 此处仅断言常量存在且不与其他学校 ID 冲突。
|
||||
if LntuSchoolId == "" || LntuSchoolId != "lntu" {
|
||||
t.Errorf("LntuSchoolId = %q, want lntu", LntuSchoolId)
|
||||
}
|
||||
if HquSchoolId == LntuSchoolId {
|
||||
t.Errorf("lntu 与 hqdx 学校 ID 冲突")
|
||||
}
|
||||
}
|
||||
@@ -1,32 +1,26 @@
|
||||
package cpdaily
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"image"
|
||||
_ "image/gif"
|
||||
_ "image/jpeg"
|
||||
_ "image/png"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-resty/resty/v2"
|
||||
"github.com/makiuchi-d/gozxing"
|
||||
"github.com/makiuchi-d/gozxing/qrcode"
|
||||
"golang.org/x/image/draw"
|
||||
_ "golang.org/x/image/webp"
|
||||
)
|
||||
|
||||
// qrUuidPattern 匹配二维码内容中的 uuid 参数(如 ...?uuid=1583a6...&isNeedExtra=0)
|
||||
var qrUuidPattern = regexp.MustCompile(`uuid=([0-9a-zA-Z_-]+)`)
|
||||
|
||||
// bareUuidPattern 匹配纯 uuid(32 位十六进制字符串)
|
||||
// bareUuidPattern 匹配纯 uuid(32 位十六进制字符串,如 1583a66272854585833eb13b56fc1937)
|
||||
var bareUuidPattern = regexp.MustCompile(`^[0-9a-fA-F]{32}$`)
|
||||
|
||||
// bareTokenPattern 匹配"整串即 uuid 的裸值",用于存库后回读的场景。
|
||||
// 链接里的 uuid 允许 [0-9a-zA-Z_-]+(见 qrUuidPattern),并非都是 32 位 hex,
|
||||
// 因此仅靠 bareUuidPattern 无法反解非 hex 的 uuid,会导致签到失败。
|
||||
// 长度限制 8~64 且不含空白/URL 符号,避免把普通文本误判为 uuid。
|
||||
var bareTokenPattern = regexp.MustCompile(`^[0-9a-zA-Z_-]{8,64}$`)
|
||||
|
||||
// ExtractQrUuid 从二维码文本内容中提取签到二维码 uuid。
|
||||
// 支持完整链接(取 uuid= 参数值)或纯 uuid 字符串。
|
||||
// 支持三种形式:完整链接(取 uuid= 参数值)、32 位 hex 裸 uuid、其它形式的裸 uuid token。
|
||||
//
|
||||
// 二维码图片的识别在前端完成(jsQR),后端只接收解析后的 uuid 文本,故此处不涉及图片解码。
|
||||
func ExtractQrUuid(text string) string {
|
||||
text = strings.TrimSpace(text)
|
||||
if text == "" {
|
||||
@@ -38,105 +32,8 @@ func ExtractQrUuid(text string) string {
|
||||
if bareUuidPattern.MatchString(text) {
|
||||
return strings.ToLower(text)
|
||||
}
|
||||
if bareTokenPattern.MatchString(text) {
|
||||
return text
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// DecodeQRFromImage 解码图片数据中的二维码,返回其中文本内容。
|
||||
// 先直接解码,失败后放大图片重试(应对小尺寸/低分辨率截图)。
|
||||
func DecodeQRFromImage(data []byte) (string, error) {
|
||||
img, _, err := image.Decode(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("识别图片格式失败: %w", err)
|
||||
}
|
||||
|
||||
text, err := decodeQRImage(img)
|
||||
if err == nil {
|
||||
return text, nil
|
||||
}
|
||||
// 放大 3 倍重试
|
||||
if text, err2 := decodeQRImage(scaleImage(img, 3)); err2 == nil {
|
||||
return text, nil
|
||||
}
|
||||
return "", fmt.Errorf("未能识别图片中的二维码: %w", err)
|
||||
}
|
||||
|
||||
func decodeQRImage(img image.Image) (string, error) {
|
||||
bmp, err := gozxing.NewBinaryBitmapFromImage(img)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
hints := map[gozxing.DecodeHintType]interface{}{
|
||||
gozxing.DecodeHintType_TRY_HARDER: true,
|
||||
}
|
||||
result, err := qrcode.NewQRCodeReader().Decode(bmp, hints)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return result.GetText(), nil
|
||||
}
|
||||
|
||||
// scaleImage 将图片按整数倍放大(用于提高小图二维码的识别率)
|
||||
func scaleImage(img image.Image, factor int) image.Image {
|
||||
b := img.Bounds()
|
||||
dst := image.NewRGBA(image.Rect(0, 0, b.Dx()*factor, b.Dy()*factor))
|
||||
draw.CatmullRom.Scale(dst, dst.Bounds(), img, b, draw.Over, nil)
|
||||
return dst
|
||||
}
|
||||
|
||||
// ResolveQrUuidFromContent 从用户提供的二维码内容中解析出签到 uuid。
|
||||
// 支持三种形式:
|
||||
// 1. 文本直填(二维码链接或纯 uuid)
|
||||
// 2. base64 图片(dataURI,前端上传的本地预览图)
|
||||
// 3. http(s) 图片链接(下载后解码)
|
||||
func ResolveQrUuidFromContent(content string) (string, error) {
|
||||
content = strings.TrimSpace(content)
|
||||
if content == "" {
|
||||
return "", fmt.Errorf("尚未上传签到二维码")
|
||||
}
|
||||
|
||||
// 1) 内容本身已是文本(链接或 uuid),直接提取
|
||||
if uuid := ExtractQrUuid(content); uuid != "" {
|
||||
return uuid, nil
|
||||
}
|
||||
|
||||
// 2) base64 图片(dataURI)
|
||||
if IsDataURIImage(content) {
|
||||
idx := strings.Index(content, ",")
|
||||
if idx < 0 {
|
||||
return "", fmt.Errorf("二维码图片数据格式不正确")
|
||||
}
|
||||
imgBytes, err := base64.StdEncoding.DecodeString(content[idx+1:])
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("解码二维码图片失败: %w", err)
|
||||
}
|
||||
return decodeQrTextToUuid(imgBytes)
|
||||
}
|
||||
|
||||
// 3) http(s) 图片链接:下载后解码
|
||||
if strings.HasPrefix(content, "http://") || strings.HasPrefix(content, "https://") {
|
||||
client := resty.New().SetTimeout(30 * time.Second)
|
||||
resp, err := client.R().
|
||||
SetHeader("User-Agent", "Mozilla/5.0 (Linux; Android 15) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.81 Mobile Safari/537.36").
|
||||
Get(content)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("下载二维码图片失败: %w", err)
|
||||
}
|
||||
if resp.StatusCode() > 299 {
|
||||
return "", fmt.Errorf("下载二维码图片失败(HTTP %d)", resp.StatusCode())
|
||||
}
|
||||
return decodeQrTextToUuid(resp.Body())
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("无法识别的二维码内容,请上传二维码图片或粘贴二维码链接")
|
||||
}
|
||||
|
||||
func decodeQrTextToUuid(imgBytes []byte) (string, error) {
|
||||
text, err := DecodeQRFromImage(imgBytes)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if uuid := ExtractQrUuid(text); uuid != "" {
|
||||
return uuid, nil
|
||||
}
|
||||
return "", fmt.Errorf("二维码内容中未找到 uuid: %s", text)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
package cpdaily
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -15,6 +13,13 @@ func TestExtractQrUuid(t *testing.T) {
|
||||
{" xxxxx?uuid=abc123XYZ&y=1 ", "abc123XYZ"},
|
||||
{"no uuid here", ""},
|
||||
{"", ""},
|
||||
// 裸 uuid 回读(formData 只存 uuid 后的解析路径)
|
||||
{"abc123XYZ", "abc123XYZ"}, // 非 32-hex 的裸 uuid
|
||||
{"1583a66272854585833eb13b56fc1937", "1583a66272854585833eb13b56fc1937"}, // 32-hex
|
||||
{"uuid_with-dash", "uuid_with-dash"}, // 含 _ 和 -
|
||||
{"ab", ""}, // 太短,不认为是 uuid
|
||||
{"has space x", ""}, // 含空白,不认为是 uuid
|
||||
{"https://x.edu.cn/a/b", ""}, // URL 含符号,不走裸 token 分支
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := ExtractQrUuid(c.in); got != c.want {
|
||||
@@ -22,39 +27,3 @@ func TestExtractQrUuid(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveQrUuidFromLinkText(t *testing.T) {
|
||||
uuid, err := ResolveQrUuidFromContent("https://ynu.campusphere.net/wec-counselor-sign-apps/stu/qrsign/index.html?uuid=1583a66272854585833eb13b56fc1937&isNeedExtra=0")
|
||||
if err != nil || uuid != testQrUuid {
|
||||
t.Fatalf("link text resolve = (%q, %v), want (%q, nil)", uuid, err, testQrUuid)
|
||||
}
|
||||
if _, err := ResolveQrUuidFromContent(""); err == nil {
|
||||
t.Fatal("empty content should return error")
|
||||
}
|
||||
if _, err := ResolveQrUuidFromContent("some random text"); err == nil {
|
||||
t.Fatal("unrecognized content should return error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeQrImageAndResolve(t *testing.T) {
|
||||
imgBytes, err := os.ReadFile("testdata/test_qr.png")
|
||||
if err != nil {
|
||||
t.Fatalf("read test qr image: %v", err)
|
||||
}
|
||||
|
||||
// 1) 直接解码图片中的二维码
|
||||
text, err := DecodeQRFromImage(imgBytes)
|
||||
if err != nil {
|
||||
t.Fatalf("decode qr image: %v", err)
|
||||
}
|
||||
if got := ExtractQrUuid(text); got != testQrUuid {
|
||||
t.Fatalf("decoded text %q -> uuid %q, want %q", text, got, testQrUuid)
|
||||
}
|
||||
|
||||
// 2) dataURI 形式(前端本地上传的图片)
|
||||
dataURI := "data:image/png;base64," + base64.StdEncoding.EncodeToString(imgBytes)
|
||||
uuid, err := ResolveQrUuidFromContent(dataURI)
|
||||
if err != nil || uuid != testQrUuid {
|
||||
t.Fatalf("dataURI resolve = (%q, %v), want (%q, nil)", uuid, err, testQrUuid)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -176,6 +176,17 @@ func CpdailyLogin(ctx context.Context, school *SchoolDetailInfo, username, passw
|
||||
if school.ID == XcuSchoolId {
|
||||
return xcuCasLogin(ctx, school, username, password)
|
||||
}
|
||||
// 辽宁工程技术大学:平台配置 joinType=CLOUD(IsIap=true),但网关 /iap/doLogin 不存在(405),
|
||||
// IAP 提交报 FAIL_UPNOTMATCH 误导为"密码错误";实际链路为 /iap 302 → 学校 CAS(authserver.lntu.edu.cn)
|
||||
// → 回调 /iap/loginSuccess 建立网关会话,同 xcu 模式单独强制走 CAS(详见 login_lntu.go)
|
||||
if school.ID == LntuSchoolId {
|
||||
return lntuCasLogin(ctx, school, username, password)
|
||||
}
|
||||
// 华侨大学:写死强制走通用 CAS 流程——CAS 账密登录需 wisedu 定制滑块验证码
|
||||
//(casLogin 内按 sliderIds 配置分流,见 slider.go),IAP 账密接口不兼容(FAIL_UPNOTMATCH)
|
||||
if school.ID == HquSchoolId {
|
||||
return casLogin(ctx, school, username, password)
|
||||
}
|
||||
if school.IsIap() || iapSchoolIds[school.ID] {
|
||||
if !hasUsableLoginUrl(school) {
|
||||
slog.Warn("IAP学校缺少登录地址", "school", school.Name, "schoolId", school.ID)
|
||||
|
||||
@@ -0,0 +1,341 @@
|
||||
package cpdaily
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"cpdaily-new/internal/config"
|
||||
|
||||
"github.com/go-resty/resty/v2"
|
||||
)
|
||||
|
||||
// HquSchoolId 华侨大学 tenant id(CAS 登录需 wisedu 滑块验证码,IAP 账密接口不兼容)
|
||||
const HquSchoolId = "hqdx"
|
||||
|
||||
// isSliderCaptchaSchool 判断学校是否为滑块验证码学校(wisedu 定制 CAS 滑块)。
|
||||
// 学校列表来自配置 [cpdaily] sliderIds(与老版一致:1018764144934965/sdhyxy/hqdx)。
|
||||
func isSliderCaptchaSchool(schoolID string) bool {
|
||||
for _, id := range config.Global.Cpdaily.SliderIds {
|
||||
if id == schoolID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ========== 滑块验证码识别(通用方案) ==========
|
||||
//
|
||||
// 与图片验证码 OCR(captchaOCR)对等的通用识别入口:sliderGapOCR。
|
||||
// 打码平台 /slider 接口(与 /classifier 等共用同一个 X-Api-Key):
|
||||
//
|
||||
// POST {captchaBaseUrl}/slider JSON: {"big_image": "<b64>", "small_image": "<b64>"}
|
||||
// 响应: {"code":200,"msg":"success","data":152}
|
||||
// data = 滑块需横向移动的像素距离(wisedu 口径,与服务端 verifySliderCaptcha 期望值同尺度)
|
||||
|
||||
// sliderGapOCR 识别滑块缺口横向位置(通用入口)。
|
||||
// bigImageB64/smallImageB64 为背景大图与滑块小图的 base64(不带 data: 前缀)。
|
||||
// 返回滑块需横向移动的像素距离。
|
||||
func sliderGapOCR(bigImageB64, smallImageB64 string) (int, error) {
|
||||
cfg := config.Global.Cpdaily
|
||||
resp, err := resty.New().
|
||||
SetTimeout(15*time.Second).
|
||||
R().
|
||||
SetHeader("X-Api-Key", cfg.CaptchaApiKey).
|
||||
SetHeader("Content-Type", "application/json").
|
||||
SetBody(map[string]string{
|
||||
"big_image": bigImageB64,
|
||||
"small_image": smallImageB64,
|
||||
}).
|
||||
Post(strings.TrimSuffix(cfg.CaptchaBaseUrl, "/") + "/slider")
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("滑块识别请求失败: %w", err)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Code int `json:"code"`
|
||||
Data int `json:"data"`
|
||||
Msg string `json:"msg"`
|
||||
}
|
||||
bodyStr := resp.String()
|
||||
if err := json.Unmarshal([]byte(bodyStr), &result); err != nil {
|
||||
return 0, fmt.Errorf("滑块识别响应解析失败: %w, body: %s", err, bodyStr)
|
||||
}
|
||||
if result.Code != 200 {
|
||||
return 0, fmt.Errorf("滑块识别失败: code=%d msg=%s", result.Code, result.Msg)
|
||||
}
|
||||
if result.Data <= 0 {
|
||||
return 0, fmt.Errorf("滑块识别结果无效: %d", result.Data)
|
||||
}
|
||||
return result.Data, nil
|
||||
}
|
||||
|
||||
// ========== wisedu CAS 滑块验证码(如华侨大学 id.hqu.edu.cn) ==========
|
||||
//
|
||||
// 前端协议(longbow sliderCaptcha v2.0 + encrypt.js,2026-02 逆向并实测调通):
|
||||
//
|
||||
// 1. GET {cas}/common/openSliderCaptcha.htl
|
||||
// → {"bigImage":"<b64>","smallImage":"<b64>","tagWidth":..,"yHeight":..}
|
||||
// 2. safeSecure = window.atob(smallImage) 末 16 字节(latin1→UTF-8 字符串,trim 后作 AES key)
|
||||
// 3. 拖动结束后 POST {cas}/common/verifySliderCaptcha.htl,表单仅一个字段 sign:
|
||||
// sign = encryptPassword(JSON.stringify({canvasLength,moveLength,tracks}), safeSecure)
|
||||
// encryptPassword(data,key) = AES-128-CBC-PKCS7(key=Utf8(key.trim()),
|
||||
// iv=randomString(16),明文=randomString(64)+data),输出标准 base64
|
||||
// 4. 响应 {"errorCode":1,"errorMsg":"success"} = 通过;{"errorCode":0} = 失败
|
||||
//
|
||||
// 服务端校验特性(实测修正,推翻此前「无容差 + ±12 扫描」结论):
|
||||
// - open 后存在约 1.3~1.7s 的激活延迟:延迟内提交任何值(含真值)都返回失败;
|
||||
// - 激活后服务端接受以真值为中心的宽区间(实测上缘 +8、下缘 -6~-8,相对打码值),
|
||||
// 打码平台返回值即为区间中心附近(误差 0±1),故等待激活后提交打码值本身即可通过;
|
||||
// - 失败不消费图片,可在同一张图上反复提交;仅验证成功才作废该图;
|
||||
// - 早期观察到的「打码偏差 -8~+9」为快速扫描跨过激活窗口的落点伪影(非真实识别误差)。
|
||||
|
||||
// wiseduSliderVerifyWidth 前端滑块容器宽度(canvasLength 固定上报值)
|
||||
const wiseduSliderVerifyWidth = 280
|
||||
|
||||
// wiseduAesChars encrypt.js 随机字符集(用于 64 位随机前缀与 16 位随机 IV)
|
||||
const wiseduAesChars = "ABCDEFGHJKMNPQRSTWXYZabcdefhijkmnprstwxyz2345678"
|
||||
|
||||
// wiseduSliderMaxRounds 换图重试上限(每张图内先提交窄候选序列)
|
||||
const wiseduSliderMaxRounds = 5
|
||||
|
||||
// wiseduSliderActivateWait 服务端激活延迟等待时长:从 openSliderCaptcha 响应完成起算。
|
||||
// 实测激活点约在 open 后 1.3~1.7s(期间提交任何值都被拒),2.5s 留足抖动裕度。
|
||||
// 测试可替换为零。
|
||||
var wiseduSliderActivateWait = 2500 * time.Millisecond
|
||||
|
||||
// wiseduSliderSubmitGap 同图候选值之间的提交间隔,进一步吸收激活窗口抖动。
|
||||
// 测试可替换为零。
|
||||
var wiseduSliderSubmitGap = 350 * time.Millisecond
|
||||
|
||||
// wiseduSliderCandidates 同图候选值偏移序列:以打码值本身为中心(贴合接受区间中心),
|
||||
// ±1 仅作识别误差/激活抖动的兜底(激活后接受 ±8 宽区间,通常首个候选即命中)。
|
||||
var wiseduSliderCandidates = []int{0, 1, -1}
|
||||
|
||||
// wiseduSliderRetryDelay 换图重试等待时长(测试可替换为零延迟)
|
||||
var wiseduSliderRetryDelay = func(round int) time.Duration {
|
||||
return time.Duration(500+round*300) * time.Millisecond
|
||||
}
|
||||
|
||||
// wiseduSliderTrack 前端 tracks 元素(a=横向位移、b=纵向偏移、c=累计时间ms)
|
||||
type wiseduSliderTrack struct {
|
||||
A int `json:"a"`
|
||||
B int `json:"b"`
|
||||
C int `json:"c"`
|
||||
}
|
||||
|
||||
// wiseduSliderTrackPayload verifySliderCaptcha 加密前的 JSON 载荷
|
||||
type wiseduSliderTrackPayload struct {
|
||||
CanvasLength int `json:"canvasLength"`
|
||||
MoveLength int `json:"moveLength"`
|
||||
Tracks []wiseduSliderTrack `json:"tracks"`
|
||||
}
|
||||
|
||||
// resolveWiseduSliderCaptcha 完成 wisedu CAS 滑块验证(自动换图重试)。
|
||||
// casBase 为 CAS 根路径(如 https://id.hqu.edu.cn/authserver);
|
||||
// referer 为当前登录页 URL(模拟浏览器请求头);
|
||||
// client 须与登录页共用同一会话(cookie jar)。
|
||||
func resolveWiseduSliderCaptcha(ctx context.Context, casBase, referer string, client *resty.Client) error {
|
||||
return resolveWiseduSliderCaptchaFn(ctx, casBase, referer, client, sliderGapOCR)
|
||||
}
|
||||
|
||||
// resolveWiseduSliderCaptchaFn 允许注入缺口识别函数(测试用)
|
||||
func resolveWiseduSliderCaptchaFn(ctx context.Context, casBase, referer string, client *resty.Client,
|
||||
gapFn func(bigImageB64, smallImageB64 string) (int, error)) error {
|
||||
base := strings.TrimSuffix(casBase, "/")
|
||||
var lastErr error
|
||||
for round := 0; round < wiseduSliderMaxRounds; round++ {
|
||||
if round > 0 {
|
||||
time.Sleep(wiseduSliderRetryDelay(round))
|
||||
}
|
||||
lastErr = wiseduSliderOnce(ctx, base, referer, client, gapFn, round)
|
||||
if lastErr == nil {
|
||||
return nil
|
||||
}
|
||||
slog.Info("wisedu滑块本轮失败,换图重试", "round", round+1, "err", lastErr.Error())
|
||||
}
|
||||
return lastErr
|
||||
}
|
||||
|
||||
// wiseduSliderOnce 单轮滑块验证:取图 → 打码 → 等激活窗口 → 提交窄候选序列(v、v+1、v-1)
|
||||
func wiseduSliderOnce(ctx context.Context, base, referer string, client *resty.Client,
|
||||
gapFn func(string, string) (int, error), round int) error {
|
||||
// 1. 获取滑块图片(同会话换图)
|
||||
resp, err := client.R().SetContext(ctx).
|
||||
SetHeader("X-Requested-With", "XMLHttpRequest").
|
||||
SetHeader("Referer", referer).
|
||||
Get(base + "/common/openSliderCaptcha.htl")
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取滑块图片失败: %w", err)
|
||||
}
|
||||
openedAt := time.Now()
|
||||
var slider struct {
|
||||
BigImage string `json:"bigImage"`
|
||||
SmallImage string `json:"smallImage"`
|
||||
}
|
||||
body := readBody(resp)
|
||||
if err := json.Unmarshal(body, &slider); err != nil || slider.BigImage == "" || slider.SmallImage == "" {
|
||||
return fmt.Errorf("解析滑块图片响应失败: %v, body: %s", err, string(body[:min(len(body), 200)]))
|
||||
}
|
||||
|
||||
// 2. safeSecure(小图末 16 字节)与打码识别
|
||||
safeSecure, err := sliderSafeSecure(slider.SmallImage)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
gapX, err := gapFn(slider.BigImage, slider.SmallImage)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 3. 等待服务端激活窗口:激活前提交任何值(含真值)都会被拒
|
||||
if wait := wiseduSliderActivateWait - time.Since(openedAt); wait > 0 {
|
||||
slog.Info("wisedu滑块等待激活窗口", "round", round+1, "wait", wait.String())
|
||||
time.Sleep(wait)
|
||||
}
|
||||
|
||||
// 4. 提交窄候选序列:打码值本身(接受区间中心),v±1 兜底激活抖动/识别误差
|
||||
for i, off := range wiseduSliderCandidates {
|
||||
if i > 0 && wiseduSliderSubmitGap > 0 {
|
||||
time.Sleep(wiseduSliderSubmitGap)
|
||||
}
|
||||
moveLength := gapX + off
|
||||
if moveLength <= 0 {
|
||||
continue
|
||||
}
|
||||
payload, err := json.Marshal(wiseduSliderTrackPayload{
|
||||
CanvasLength: wiseduSliderVerifyWidth,
|
||||
MoveLength: moveLength,
|
||||
Tracks: wiseduSliderTracks(moveLength),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sign, err := encryptSliderSign(string(payload), safeSecure)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
vResp, err := client.R().SetContext(ctx).
|
||||
SetHeader("X-Requested-With", "XMLHttpRequest").
|
||||
SetHeader("Referer", referer).
|
||||
SetFormData(map[string]string{"sign": sign}).
|
||||
Post(base + "/common/verifySliderCaptcha.htl")
|
||||
if err != nil {
|
||||
return fmt.Errorf("提交滑块校验失败: %w", err)
|
||||
}
|
||||
var vResult struct {
|
||||
ErrorCode int `json:"errorCode"`
|
||||
ErrorMsg string `json:"errorMsg"`
|
||||
}
|
||||
vBody := readBody(vResp)
|
||||
_ = json.Unmarshal(vBody, &vResult)
|
||||
slog.Info("wisedu滑块校验", "round", round+1, "gapX", gapX, "offset", off,
|
||||
"moveLength", moveLength, "errorCode", vResult.ErrorCode)
|
||||
if vResult.ErrorCode == 1 {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("本张图候选值全部未通过(打码值 %d)", gapX)
|
||||
}
|
||||
|
||||
// sliderSafeSecure 取小图 base64 解码后的末 16 字节作为 safeSecure。
|
||||
// 前端 ids-sliderCaptcha.js 中 safeSecure.value 由 atob(smallImage) 的末 16 个
|
||||
// charCode 逐字节拼成;encrypt.js 使用时先 trim 再作 AES key(Utf8 编码)。
|
||||
func sliderSafeSecure(smallImageB64 string) (string, error) {
|
||||
raw, err := base64.StdEncoding.DecodeString(smallImageB64)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("小图 base64 解码失败: %w", err)
|
||||
}
|
||||
if len(raw) < 16 {
|
||||
return "", fmt.Errorf("小图数据不足 16 字节: %d", len(raw))
|
||||
}
|
||||
var sb strings.Builder
|
||||
for _, c := range raw[len(raw)-16:] {
|
||||
sb.WriteRune(rune(c))
|
||||
}
|
||||
// 复刻 JS 正则 /(^\s+)|(\s+$)/g 的 trim(字节码点 ≤255,空白字符集有限)
|
||||
return strings.Trim(sb.String(), "\t\n\v\f\r \u00a0"), nil
|
||||
}
|
||||
|
||||
// encryptSliderSign 复刻 encrypt.js encryptPassword(data, key):
|
||||
// AES-128-CBC-PKCS7,key=Utf8(key.trim()),iv=randomString(16),
|
||||
// 明文=randomString(64)+data,输出标准 base64(仅密文,IV 不外传,与 CryptoJS 一致)。
|
||||
//
|
||||
// IV 随机且不随请求发送是安全的:CBC 解密时 IV 只影响第一块(16 字节)明文,
|
||||
// 而首块落在 64 位随机前缀内部;服务端剥离前缀后从固定偏移解析 JSON,不受影响
|
||||
// (已用真实 CryptoJS 输出 + openssl/node 解密交叉验证)。
|
||||
func encryptSliderSign(data, safeSecure string) (string, error) {
|
||||
key := []byte(safeSecure)
|
||||
switch len(key) {
|
||||
case 16, 24, 32:
|
||||
default:
|
||||
return "", fmt.Errorf("safeSecure 长度非法(%d 字节,须为 16/24/32)", len(key))
|
||||
}
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
padded := pkcs7Pad([]byte(randomAesChars(64)+data), block.BlockSize())
|
||||
iv := []byte(randomAesChars(aes.BlockSize))
|
||||
ciphertext := make([]byte, len(padded))
|
||||
cipher.NewCBCEncrypter(block, iv).CryptBlocks(ciphertext, padded)
|
||||
return base64.StdEncoding.EncodeToString(ciphertext), nil
|
||||
}
|
||||
|
||||
// wiseduSliderTracks 生成拟人化拖动轨迹,模拟 longbow.js 采集的 {a,b,c} 采样点:
|
||||
// 首个点为 {0,0,0},随后按比例推进并带轻微抖动,最后一点精确对齐 moveLength
|
||||
// (对应前端 mouseup 时的最终位移)。
|
||||
func wiseduSliderTracks(move int) []wiseduSliderTrack {
|
||||
steps := 8 + randSmallN(5) // 8~12 个中间采样点
|
||||
tracks := make([]wiseduSliderTrack, 0, steps+2)
|
||||
tracks = append(tracks, wiseduSliderTrack{A: 0, B: 0, C: 0})
|
||||
a, c := 0, 0
|
||||
for i := 1; i <= steps; i++ {
|
||||
target := move * i / steps
|
||||
if target <= a {
|
||||
target = a + 1
|
||||
}
|
||||
if target > move {
|
||||
target = move
|
||||
}
|
||||
a = target + randSmallN(3) - 1 // ±1 抖动
|
||||
if a > move {
|
||||
a = move
|
||||
}
|
||||
if a < 0 {
|
||||
a = 0
|
||||
}
|
||||
c += 20 + randSmallN(45)
|
||||
tracks = append(tracks, wiseduSliderTrack{A: a, B: randSmallN(3) - 1, C: c})
|
||||
}
|
||||
c += 30 + randSmallN(40)
|
||||
tracks = append(tracks, wiseduSliderTrack{A: move, B: 0, C: c})
|
||||
return tracks
|
||||
}
|
||||
|
||||
// randomAesChars 生成 n 个 wiseduAesChars 字符集的随机字符(对应 encrypt.js randomString)
|
||||
func randomAesChars(n int) string {
|
||||
buf := make([]byte, n)
|
||||
_, _ = rand.Read(buf)
|
||||
for i, c := range buf {
|
||||
buf[i] = wiseduAesChars[int(c)%len(wiseduAesChars)]
|
||||
}
|
||||
return string(buf)
|
||||
}
|
||||
|
||||
// randSmallN 返回 [0,n) 的伪随机数(仅用于轨迹/时间的拟人化抖动,无安全要求)
|
||||
func randSmallN(n int) int {
|
||||
if n <= 0 {
|
||||
return 0
|
||||
}
|
||||
b := make([]byte, 1)
|
||||
_, _ = rand.Read(b)
|
||||
return int(b[0]) % n
|
||||
}
|
||||
@@ -0,0 +1,497 @@
|
||||
package cpdaily
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"cpdaily-new/internal/config"
|
||||
|
||||
"github.com/go-resty/resty/v2"
|
||||
)
|
||||
|
||||
// ========== isSliderCaptchaSchool ==========
|
||||
|
||||
func TestIsSliderCaptchaSchool(t *testing.T) {
|
||||
old := config.Global.Cpdaily.SliderIds
|
||||
defer func() { config.Global.Cpdaily.SliderIds = old }()
|
||||
config.Global.Cpdaily.SliderIds = []string{"1018764144934965", "sdhyxy", "hqdx"}
|
||||
|
||||
for _, id := range []string{"hqdx", "sdhyxy", "1018764144934965"} {
|
||||
if !isSliderCaptchaSchool(id) {
|
||||
t.Errorf("%s 应为滑块学校", id)
|
||||
}
|
||||
}
|
||||
for _, id := range []string{"xcu", "nut", "hfnu", ""} {
|
||||
if isSliderCaptchaSchool(id) {
|
||||
t.Errorf("%s 不应为滑块学校", id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ========== safeSecure / sign 加密(encrypt.js 协议) ==========
|
||||
|
||||
// mockSliderSafe 模拟小图末 16 字节(hqu 实测为 ASCII 串)
|
||||
const mockSliderSafe = "Ta1PFklnamXPYqZX"
|
||||
|
||||
// mockSmallImageB64 构造以 mockSliderSafe 结尾的 base64 小图
|
||||
func mockSmallImageB64() string {
|
||||
raw := append([]byte{0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x01}, []byte(mockSliderSafe)...)
|
||||
return base64.StdEncoding.EncodeToString(raw)
|
||||
}
|
||||
|
||||
func TestSliderSafeSecure(t *testing.T) {
|
||||
got, err := sliderSafeSecure(mockSmallImageB64())
|
||||
if err != nil {
|
||||
t.Fatalf("sliderSafeSecure: %v", err)
|
||||
}
|
||||
if got != mockSliderSafe {
|
||||
t.Fatalf("safeSecure=%q, want %q", got, mockSliderSafe)
|
||||
}
|
||||
|
||||
// 首尾空白应被 trim(复刻 encrypt.js 的 key.replace 正则)
|
||||
trimmed := base64.StdEncoding.EncodeToString([]byte("\tTa1PFklnamXPYqZ\n"))
|
||||
if got, err := sliderSafeSecure(trimmed); err != nil || got != "Ta1PFklnamXPYqZ" {
|
||||
t.Fatalf("trim 后 safeSecure=%q err=%v", got, err)
|
||||
}
|
||||
|
||||
// 不足 16 字节应报错
|
||||
short := base64.StdEncoding.EncodeToString([]byte("short"))
|
||||
if _, err := sliderSafeSecure(short); err == nil {
|
||||
t.Fatal("小图不足 16 字节应报错")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncryptSliderSign(t *testing.T) {
|
||||
const key = "0123456789abcdef"
|
||||
plain := `{"canvasLength":280,"moveLength":99,"tracks":[{"a":0,"b":0,"c":0}]}`
|
||||
|
||||
sign, err := encryptSliderSign(plain, key)
|
||||
if err != nil {
|
||||
t.Fatalf("encryptSliderSign: %v", err)
|
||||
}
|
||||
raw, err := base64.StdEncoding.DecodeString(sign)
|
||||
if err != nil || len(raw)%aes.BlockSize != 0 {
|
||||
t.Fatalf("sign base64/长度异常: err=%v len=%d", err, len(raw))
|
||||
}
|
||||
|
||||
// 用零 IV 解密(IV 只影响首块 16 字节,落于前缀内,不影响前缀后半段与载荷)
|
||||
block, _ := aes.NewCipher([]byte(key))
|
||||
pt := make([]byte, len(raw))
|
||||
cipher.NewCBCDecrypter(block, make([]byte, aes.BlockSize)).CryptBlocks(pt, raw)
|
||||
pt = pkcs7Unpad(pt)
|
||||
|
||||
if len(pt) != 64+len(plain) {
|
||||
t.Fatalf("解密明文长度=%d, want %d", len(pt), 64+len(plain))
|
||||
}
|
||||
if string(pt[64:]) != plain {
|
||||
t.Fatalf("解密明文=%q, want %q", string(pt[64:]), plain)
|
||||
}
|
||||
// 首块因 IV 未知为乱码,第 16~63 字节应为合法前缀字符(48 个)
|
||||
for _, c := range string(pt[16:64]) {
|
||||
if !strings.ContainsRune(wiseduAesChars, c) {
|
||||
t.Fatalf("随机前缀包含非法字符 %q", c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 真实 CryptoJS(/tmp/hqu_encrypt.js encryptPassword)产出的 sign 夹具:
|
||||
// key=Ta1PFklnamXPYqZX,明文载荷为 canvasLength=280/moveLength=65/2 个 tracks 采样点。
|
||||
// 用于锁定与前端 CryptoJS 的互通性(前缀长度、填充、base64、载荷可解析)。
|
||||
const realCryptoJSSignFixture = "+vjAgkLevuKAVqM/x6dCJJwlR5xLcrJ0yW6tqTmeu8MXCCBJvY4rttiA4ouUBHYdCP2zjYzbGgKDyTvTQdmvPnOV6DlSiQvCIhtaQcBksVFjYsMi2i7hCaKu3sfA9SszYZVs/8AvSqPPcvr+MlvarV7fSPaJdtPpbI1lYSU30MiFHBTl5V8cZMdlA+Zj290xoQlg3niG2V3f0sLzEQ4Wrg=="
|
||||
|
||||
func TestDecryptRealCryptoJSFixture(t *testing.T) {
|
||||
pt, err := decryptMockSign(realCryptoJSSignFixture)
|
||||
if err != nil {
|
||||
t.Fatalf("解密真实 CryptoJS sign 失败: %v", err)
|
||||
}
|
||||
p := mockDecryptSign(t, realCryptoJSSignFixture)
|
||||
if p.CanvasLength != 280 || p.MoveLength != 65 {
|
||||
t.Fatalf("载荷字段错误: %+v", p)
|
||||
}
|
||||
if len(p.Tracks) != 2 || p.Tracks[1].A != 65 {
|
||||
t.Fatalf("tracks 错误: %+v", p.Tracks)
|
||||
}
|
||||
// 第 16~63 字节为合法前缀字符(48 个)
|
||||
for _, c := range string(pt[16:64]) {
|
||||
if !strings.ContainsRune(wiseduAesChars, c) {
|
||||
t.Fatalf("随机前缀包含非法字符 %q", c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWiseduSliderTracks(t *testing.T) {
|
||||
tracks := wiseduSliderTracks(65)
|
||||
if len(tracks) < 2 {
|
||||
t.Fatalf("tracks 采样点过少: %d", len(tracks))
|
||||
}
|
||||
if first := tracks[0]; first.A != 0 || first.B != 0 || first.C != 0 {
|
||||
t.Fatalf("首个采样点应为 {0,0,0}: %+v", first)
|
||||
}
|
||||
if last := tracks[len(tracks)-1]; last.A != 65 {
|
||||
t.Fatalf("末点 a=%d, want 65(对齐 moveLength)", last.A)
|
||||
}
|
||||
prevC := 0
|
||||
for i, p := range tracks {
|
||||
if p.C < prevC {
|
||||
t.Fatalf("第 %d 点时间回退: %+v", i, p)
|
||||
}
|
||||
if p.A < 0 || p.A > 65 {
|
||||
t.Fatalf("第 %d 点位移越界: %+v", i, p)
|
||||
}
|
||||
prevC = p.C
|
||||
}
|
||||
}
|
||||
|
||||
// ========== wisedu CAS 滑块流程(httptest mock,sign 协议) ==========
|
||||
|
||||
// disableSliderDelays 将换图重试延迟、激活等待与候选间隔全部置零(加速测试)
|
||||
func disableSliderDelays(t *testing.T) {
|
||||
t.Helper()
|
||||
oldDelay := wiseduSliderRetryDelay
|
||||
oldWait := wiseduSliderActivateWait
|
||||
oldGap := wiseduSliderSubmitGap
|
||||
wiseduSliderRetryDelay = func(int) time.Duration { return 0 }
|
||||
wiseduSliderActivateWait = 0
|
||||
wiseduSliderSubmitGap = 0
|
||||
t.Cleanup(func() {
|
||||
wiseduSliderRetryDelay = oldDelay
|
||||
wiseduSliderActivateWait = oldWait
|
||||
wiseduSliderSubmitGap = oldGap
|
||||
})
|
||||
}
|
||||
|
||||
// mockDecryptSign 用 mockSliderSafe 解密并解析 sign 载荷(模拟服务端行为)。
|
||||
// 加密端 IV 随机且不外传:CBC 中 IV 只影响解密后第一块(16 字节),而该 16 字节
|
||||
// 落在 64 位随机前缀内部,服务端剥掉前缀后解析 JSON 不受任何影响,故此处 IV 取
|
||||
// 零向量即可,解密结果第 16 字节起与前端一致(已用真实 CryptoJS 输出交叉验证)。
|
||||
func mockDecryptSign(t *testing.T, sign string) wiseduSliderTrackPayload {
|
||||
t.Helper()
|
||||
pt, err := decryptMockSign(sign)
|
||||
if err != nil {
|
||||
t.Fatalf("解密 sign 失败: %v", err)
|
||||
}
|
||||
var p wiseduSliderTrackPayload
|
||||
if err := json.Unmarshal(pt[64:], &p); err != nil {
|
||||
t.Fatalf("解密载荷 JSON 解析失败: %v, prefix64=%q body=%s", err, string(pt[:64]), string(pt[64:]))
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// decryptMockSign 用 mockSliderSafe 解密 sign(零 IV),返回去除 PKCS7 填充后的明文
|
||||
func decryptMockSign(sign string) ([]byte, error) {
|
||||
raw, err := base64.StdEncoding.DecodeString(sign)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(raw) < 32 || len(raw)%aes.BlockSize != 0 {
|
||||
return nil, fmt.Errorf("sign 长度异常: %d", len(raw))
|
||||
}
|
||||
block, err := aes.NewCipher([]byte(mockSliderSafe))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pt := make([]byte, len(raw))
|
||||
cipher.NewCBCDecrypter(block, make([]byte, aes.BlockSize)).CryptBlocks(pt, raw)
|
||||
pt = pkcs7Unpad(pt)
|
||||
if len(pt) < 64 {
|
||||
return nil, fmt.Errorf("解密后明文过短: %d", len(pt))
|
||||
}
|
||||
return pt, nil
|
||||
}
|
||||
|
||||
// mockSliderServer 起一个 wisedu CAS 滑块 mock:
|
||||
// verify 接受 accept 集合中的 moveLength(errorCode=1),其余一律 errorCode=0
|
||||
func mockSliderServer(t *testing.T, accept map[int]bool, wantGapX int,
|
||||
openCalls, verifyCalls *int) *httptest.Server {
|
||||
t.Helper()
|
||||
small := mockSmallImageB64()
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/authserver/common/openSliderCaptcha.htl", func(w http.ResponseWriter, r *http.Request) {
|
||||
*openCalls++
|
||||
if r.Header.Get("X-Requested-With") != "XMLHttpRequest" {
|
||||
t.Errorf("open 缺少 X-Requested-With 头")
|
||||
}
|
||||
if r.Header.Get("Referer") == "" {
|
||||
t.Errorf("open 缺少 Referer 头")
|
||||
}
|
||||
writeJSON(w, map[string]string{"bigImage": "ZmFrZWJpZw==", "smallImage": small})
|
||||
})
|
||||
mux.HandleFunc("/authserver/common/verifySliderCaptcha.htl", func(w http.ResponseWriter, r *http.Request) {
|
||||
*verifyCalls++
|
||||
if r.Header.Get("X-Requested-With") != "XMLHttpRequest" {
|
||||
t.Errorf("verify 缺少 X-Requested-With 头")
|
||||
}
|
||||
_ = r.ParseForm()
|
||||
p := mockDecryptSign(t, r.PostFormValue("sign"))
|
||||
if p.CanvasLength != 280 {
|
||||
t.Errorf("canvasLength=%d, want 280", p.CanvasLength)
|
||||
}
|
||||
if len(p.Tracks) < 2 {
|
||||
t.Errorf("tracks 采样点过少: %d", len(p.Tracks))
|
||||
}
|
||||
if last := p.Tracks[len(p.Tracks)-1]; last.A != p.MoveLength {
|
||||
t.Errorf("tracks 末点 a=%d 应等于 moveLength=%d", last.A, p.MoveLength)
|
||||
}
|
||||
if accept[p.MoveLength] {
|
||||
writeJSON(w, map[string]int{"errorCode": 1})
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]int{"errorCode": 0})
|
||||
})
|
||||
ts := httptest.NewServer(mux)
|
||||
t.Cleanup(ts.Close)
|
||||
return ts
|
||||
}
|
||||
|
||||
// 打码值直接命中:1 次 open + 1 次 verify
|
||||
func TestResolveWiseduSliderCaptchaMock(t *testing.T) {
|
||||
disableSliderDelays(t)
|
||||
var openCalls, verifyCalls int
|
||||
ts := mockSliderServer(t, map[int]bool{65: true}, 65, &openCalls, &verifyCalls)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
err := resolveWiseduSliderCaptchaFn(ctx, ts.URL+"/authserver", ts.URL+"/login", newTestRestyClient(),
|
||||
func(string, string) (int, error) { return 65, nil })
|
||||
if err != nil {
|
||||
t.Fatalf("滑块流程失败: %v", err)
|
||||
}
|
||||
if openCalls != 1 || verifyCalls != 1 {
|
||||
t.Fatalf("open=%d verify=%d, want 1/1", openCalls, verifyCalls)
|
||||
}
|
||||
}
|
||||
|
||||
// 打码值略偏(真值=打码值+1):同一张图第 2 个候选即命中,不应换图
|
||||
func TestResolveWiseduSliderCaptchaCandidateHit(t *testing.T) {
|
||||
disableSliderDelays(t)
|
||||
var openCalls, verifyCalls int
|
||||
ts := mockSliderServer(t, map[int]bool{66: true}, 65, &openCalls, &verifyCalls)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
err := resolveWiseduSliderCaptchaFn(ctx, ts.URL+"/authserver", ts.URL+"/login", newTestRestyClient(),
|
||||
func(string, string) (int, error) { return 65, nil })
|
||||
if err != nil {
|
||||
t.Fatalf("滑块流程失败: %v", err)
|
||||
}
|
||||
// 失败不换图(±1 由同图候选覆盖),成功即止
|
||||
if openCalls != 1 {
|
||||
t.Fatalf("open=%d, want 1(同图候选不应换图)", openCalls)
|
||||
}
|
||||
if verifyCalls != 2 {
|
||||
t.Fatalf("verify=%d, want 2(65 失败后 66 命中)", verifyCalls)
|
||||
}
|
||||
}
|
||||
|
||||
// 真值=打码值-1:第 3 个候选(65,66,64 中 64)命中
|
||||
func TestResolveWiseduSliderCaptchaCandidateHitBelow(t *testing.T) {
|
||||
disableSliderDelays(t)
|
||||
var openCalls, verifyCalls int
|
||||
ts := mockSliderServer(t, map[int]bool{64: true}, 65, &openCalls, &verifyCalls)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
err := resolveWiseduSliderCaptchaFn(ctx, ts.URL+"/authserver", ts.URL+"/login", newTestRestyClient(),
|
||||
func(string, string) (int, error) { return 65, nil })
|
||||
if err != nil {
|
||||
t.Fatalf("滑块流程失败: %v", err)
|
||||
}
|
||||
if openCalls != 1 || verifyCalls != 3 {
|
||||
t.Fatalf("open=%d verify=%d, want 1/3(65,66 失败后 64 命中)", openCalls, verifyCalls)
|
||||
}
|
||||
}
|
||||
|
||||
// 5 张图全失败:每图 3 个候选值(0,+1,-1),共 15 次 verify 后报错
|
||||
func TestResolveWiseduSliderCaptchaExhausted(t *testing.T) {
|
||||
disableSliderDelays(t)
|
||||
var openCalls, verifyCalls int
|
||||
ts := mockSliderServer(t, map[int]bool{}, 65, &openCalls, &verifyCalls)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
defer cancel()
|
||||
err := resolveWiseduSliderCaptchaFn(ctx, ts.URL+"/authserver", ts.URL+"/login", newTestRestyClient(),
|
||||
func(string, string) (int, error) { return 65, nil })
|
||||
if err == nil {
|
||||
t.Fatal("持续失败应返回错误")
|
||||
}
|
||||
if openCalls != 5 {
|
||||
t.Fatalf("open=%d, want 5", openCalls)
|
||||
}
|
||||
if verifyCalls != 3*5 {
|
||||
t.Fatalf("verify=%d, want %d", verifyCalls, 3*5)
|
||||
}
|
||||
}
|
||||
|
||||
// 取图失败(非 JSON 响应)应报错并换图重试
|
||||
func TestResolveWiseduSliderCaptchaBadResponse(t *testing.T) {
|
||||
disableSliderDelays(t)
|
||||
var openCalls, verifyCalls int
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/authserver/common/openSliderCaptcha.htl", func(w http.ResponseWriter, r *http.Request) {
|
||||
openCalls++
|
||||
_, _ = w.Write([]byte("<html>error</html>"))
|
||||
})
|
||||
mux.HandleFunc("/authserver/common/verifySliderCaptcha.htl", func(w http.ResponseWriter, r *http.Request) {
|
||||
verifyCalls++
|
||||
t.Error("取图失败时不应调用 verify")
|
||||
})
|
||||
ts := httptest.NewServer(mux)
|
||||
defer ts.Close()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
defer cancel()
|
||||
if err := resolveWiseduSliderCaptchaFn(ctx, ts.URL+"/authserver", ts.URL+"/login", newTestRestyClient(),
|
||||
func(string, string) (int, error) { return 65, nil }); err == nil {
|
||||
t.Fatal("应返回错误")
|
||||
}
|
||||
if openCalls != 5 {
|
||||
t.Fatalf("open=%d, want 5", openCalls)
|
||||
}
|
||||
if verifyCalls != 0 {
|
||||
t.Fatalf("verify=%d, want 0", verifyCalls)
|
||||
}
|
||||
}
|
||||
|
||||
// 服务端激活延迟:mock 在 open 后 300ms 内拒绝一切值(复刻实测 1.3~1.7s 激活窗口),
|
||||
// 等待逻辑(此处缩短为 400ms)应保证首个候选即命中:open=1、verify=1。
|
||||
// 若等待缺失,首个候选会落在拒绝窗口内而多提交一轮,verify 数与 open 数都会变化。
|
||||
func TestResolveWiseduSliderCaptchaActivationWait(t *testing.T) {
|
||||
oldWait := wiseduSliderActivateWait
|
||||
oldGap := wiseduSliderSubmitGap
|
||||
oldDelay := wiseduSliderRetryDelay
|
||||
wiseduSliderActivateWait = 400 * time.Millisecond
|
||||
wiseduSliderSubmitGap = 0
|
||||
wiseduSliderRetryDelay = func(int) time.Duration { return 0 }
|
||||
t.Cleanup(func() {
|
||||
wiseduSliderActivateWait = oldWait
|
||||
wiseduSliderSubmitGap = oldGap
|
||||
wiseduSliderRetryDelay = oldDelay
|
||||
})
|
||||
|
||||
const rejectWithin = 300 * time.Millisecond
|
||||
var openCalls, verifyCalls int
|
||||
var openAt time.Time
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/authserver/common/openSliderCaptcha.htl", func(w http.ResponseWriter, r *http.Request) {
|
||||
openCalls++
|
||||
openAt = time.Now()
|
||||
writeJSON(w, map[string]string{"bigImage": "ZmFrZWJpZw==", "smallImage": mockSmallImageB64()})
|
||||
})
|
||||
mux.HandleFunc("/authserver/common/verifySliderCaptcha.htl", func(w http.ResponseWriter, r *http.Request) {
|
||||
verifyCalls++
|
||||
_ = r.ParseForm()
|
||||
p := mockDecryptSign(t, r.PostFormValue("sign"))
|
||||
if time.Since(openAt) >= rejectWithin && p.MoveLength == 65 {
|
||||
writeJSON(w, map[string]int{"errorCode": 1})
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]int{"errorCode": 0})
|
||||
})
|
||||
ts := httptest.NewServer(mux)
|
||||
defer ts.Close()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
err := resolveWiseduSliderCaptchaFn(ctx, ts.URL+"/authserver", ts.URL+"/login", newTestRestyClient(),
|
||||
func(string, string) (int, error) { return 65, nil })
|
||||
if err != nil {
|
||||
t.Fatalf("滑块流程失败: %v", err)
|
||||
}
|
||||
if openCalls != 1 || verifyCalls != 1 {
|
||||
t.Fatalf("open=%d verify=%d, want 1/1(激活等待后首个候选应命中)", openCalls, verifyCalls)
|
||||
}
|
||||
}
|
||||
|
||||
// ========== parseLoginForm 表单选择(#pwdLoginDiv 优先级) ==========
|
||||
|
||||
// 模拟华侨大学登录页:3 个 form(fido/动态码/账密),账密字段位于 #pwdLoginDiv
|
||||
func TestParseLoginFormPwdLoginDiv(t *testing.T) {
|
||||
inputs := parseLoginForm([]byte(hquLoginPageHTML), "2523132014", "testpwd123")
|
||||
|
||||
if inputs["cllt"] != "userNameLogin" {
|
||||
t.Fatalf("应选中 #pwdLoginDiv 账密表单, cllt=%s", inputs["cllt"])
|
||||
}
|
||||
if inputs["username"] != "2523132014" {
|
||||
t.Fatalf("username 错误: %s", inputs["username"])
|
||||
}
|
||||
if inputs["execution"] != "e1s1" {
|
||||
t.Fatalf("execution 错误: %s", inputs["execution"])
|
||||
}
|
||||
// 密码应被 AES 加密(含 pwdEncryptSalt)
|
||||
if inputs["password"] == "" || inputs["password"] == "testpwd123" {
|
||||
t.Fatalf("password 应被加密: %q", inputs["password"])
|
||||
}
|
||||
// userPassword(明文框)应被删除
|
||||
if _, ok := inputs["userPassword"]; ok {
|
||||
t.Fatal("userPassword 字段应被删除")
|
||||
}
|
||||
// captcha 留空(滑块通过后提交)
|
||||
if v, ok := inputs["captcha"]; !ok || v != "" {
|
||||
t.Fatalf("captcha 应保留空值: %q %v", v, ok)
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 辅助 ==========
|
||||
|
||||
func writeJSON(w http.ResponseWriter, v interface{}) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
func newTestRestyClient() *resty.Client {
|
||||
// 与 sessionClient 保持一致:DoNotParseResponse 模式(readBody 依赖 RawBody)
|
||||
jar, _ := cookiejar.New(nil)
|
||||
return resty.New().
|
||||
SetTimeout(5*time.Second).
|
||||
SetDoNotParseResponse(true).
|
||||
SetCookieJar(jar).
|
||||
SetHeader("User-Agent", "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)")
|
||||
}
|
||||
|
||||
// 模拟华侨大学登录页 HTML(账密字段位于 #pwdLoginDiv,含 pwdEncryptSalt)
|
||||
const hquLoginPageHTML = `<!DOCTYPE html>
|
||||
<html><body>
|
||||
<form method="post" id="loginFromId_fido" class="loginFromClass_fido" action="/authserver/login">
|
||||
<input type="hidden" name="_eventId" value="submit"/>
|
||||
<input type="text" name="username" value=""/>
|
||||
<input type="hidden" name="responseJson" value=""/>
|
||||
<input type="hidden" id="cllt" name="cllt" value="userNameLoginFido"/>
|
||||
<input type="hidden" name="dllt" value="generalLogin"/>
|
||||
<input type="hidden" id="lt" name="lt" value=""/>
|
||||
<input type="hidden" name="execution" value="e1s1"/>
|
||||
</form>
|
||||
<form method="post" id="loginFromId" class="loginFromClass" action="/authserver/login">
|
||||
<input type="text" name="username" value=""/>
|
||||
<input type="text" id="captcha" name="captcha" placeholder="请输入验证码"/>
|
||||
<input type="text" name="dynamicCode" value=""/>
|
||||
<input type="hidden" name="_eventId" value="submit"/>
|
||||
<input type="hidden" id="lt" name="lt" value=""/>
|
||||
<input type="hidden" id="cllt" name="cllt" value="dynamicLogin"/>
|
||||
<input type="hidden" name="dllt" value="generalLogin"/>
|
||||
<input type="hidden" name="execution" value="e1s1"/>
|
||||
</form>
|
||||
<form method="post" id="loginFromId" class="loginFromClass" action="/authserver/login">
|
||||
<div id="pwdLoginDiv">
|
||||
<input type="text" name="username" value=""/>
|
||||
<input type="password" name="userPassword" value=""/>
|
||||
<input type="hidden" name="password" value=""/>
|
||||
<input type="text" id="captcha" name="captcha" placeholder="请输入验证码"/>
|
||||
<input type="hidden" name="_eventId" value="submit"/>
|
||||
<input type="hidden" id="lt" name="lt" value=""/>
|
||||
<input type="hidden" id="cllt" name="cllt" value="userNameLogin"/>
|
||||
<input type="hidden" name="dllt" value="generalLogin"/>
|
||||
<input type="hidden" name="execution" value="e1s1"/>
|
||||
</div>
|
||||
</form>
|
||||
<input type="hidden" id="pwdEncryptSalt" value="sQJL9rnqFD65soWK"/>
|
||||
</body></html>`
|
||||
@@ -26,6 +26,7 @@ type TaskType string
|
||||
const (
|
||||
TaskTypeSign TaskType = "sign"
|
||||
TaskTypeSleepCheck TaskType = "sleep_check"
|
||||
TaskTypeLeave TaskType = "leave" // 销假(请假销假)图片上传使用
|
||||
)
|
||||
|
||||
// 各任务类型的 API 基础路径
|
||||
@@ -497,47 +498,59 @@ func ParseTaskForm(detail *TaskFormDetail, historyRows map[string]interface{}) [
|
||||
histSignPhotoUrl := ""
|
||||
histIsMalposition := ""
|
||||
|
||||
// 优先从 signPlaceSelected 提取(学校配置的签到范围点,cpdaily 返回 JSON 字符串或数组两种格式)
|
||||
// 有历史表单时是历史已选地点,无历史表单时是学校设置的默认签到范围,用于兜底填充
|
||||
if sps, ok := histRows["signPlaceSelected"]; ok {
|
||||
var places []map[string]interface{}
|
||||
switch v := sps.(type) {
|
||||
case string:
|
||||
if v != "" && v != "[]" {
|
||||
if err := json.Unmarshal([]byte(v), &places); err != nil {
|
||||
places = nil
|
||||
}
|
||||
}
|
||||
case []interface{}:
|
||||
for _, p := range v {
|
||||
if m, ok := p.(map[string]interface{}); ok {
|
||||
places = append(places, m)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(places) > 0 {
|
||||
// 取第一个签到范围点(主校区);无历史记录时作为默认坐标兜底
|
||||
if lng := mapValueStr(places[0], "longitude"); lng != "" {
|
||||
histLon = lng
|
||||
}
|
||||
if lat := mapValueStr(places[0], "latitude"); lat != "" {
|
||||
histLat = lat
|
||||
}
|
||||
if addr := mapValueStr(places[0], "address"); addr != "" {
|
||||
histAddress = addr
|
||||
}
|
||||
}
|
||||
// 签到范围点(校验坐标是否在打卡范围内):优先当前任务详情,其次历史详情
|
||||
rangePlaces := parseSignPlaces(rows)
|
||||
if len(rangePlaces) == 0 {
|
||||
rangePlaces = parseSignPlaces(histRows)
|
||||
}
|
||||
// 降级到 histRows 中的 longitude/latitude
|
||||
|
||||
// 优先使用历史记录中的真实签到位置(坐标 + 地址),
|
||||
// 但坐标必须仍落在打卡范围内——超出范围的坐标提交会被服务端以「位置不在签到范围内」拒绝,
|
||||
// 此时丢弃历史坐标,改由下方回退到学校配置的签到范围点。
|
||||
histLon = mapValueStr(histRows, "longitude")
|
||||
histLat = mapValueStr(histRows, "latitude")
|
||||
if histLon != "" && histLat != "" && coordsWithinSignRange(histLat, histLon, rangePlaces) {
|
||||
histAddress = mapValueStr(histRows, "signAddress")
|
||||
if histAddress == "" {
|
||||
histAddress = mapValueStr(histRows, "position")
|
||||
}
|
||||
} else {
|
||||
// 历史坐标缺失或超出范围:不采用历史坐标(地址一并丢弃,保持与坐标同源)
|
||||
histLon, histLat = "", ""
|
||||
}
|
||||
|
||||
// 回退:学校配置的签到范围点(signPlaceSelected[0],主校区),无历史记录时作为默认坐标
|
||||
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)
|
||||
if sps, ok := histRows["signPlaceSelected"]; ok {
|
||||
var places []map[string]interface{}
|
||||
switch v := sps.(type) {
|
||||
case string:
|
||||
if v != "" && v != "[]" {
|
||||
if err := json.Unmarshal([]byte(v), &places); err != nil {
|
||||
places = nil
|
||||
}
|
||||
}
|
||||
case []interface{}:
|
||||
for _, p := range v {
|
||||
if m, ok := p.(map[string]interface{}); ok {
|
||||
places = append(places, m)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(places) > 0 {
|
||||
if lng := mapValueStr(places[0], "longitude"); lng != "" {
|
||||
histLon = lng
|
||||
}
|
||||
if lat := mapValueStr(places[0], "latitude"); lat != "" {
|
||||
histLat = lat
|
||||
}
|
||||
if addr := mapValueStr(places[0], "address"); addr != "" {
|
||||
histAddress = addr
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 如果还是空的,再降级到当前表单中的 longitude/latitude(任务默认坐标)
|
||||
// 再降级到当前表单中的 longitude/latitude(任务默认坐标)
|
||||
if histLon == "" || histLat == "" {
|
||||
if lon, ok := rows["longitude"]; ok && lon != nil {
|
||||
histLon = fmt.Sprintf("%v", lon)
|
||||
@@ -546,7 +559,7 @@ func ParseTaskForm(detail *TaskFormDetail, historyRows map[string]interface{}) [
|
||||
histLat = fmt.Sprintf("%v", lat)
|
||||
}
|
||||
}
|
||||
// 地址降级链:signPlaceSelected[].address → histRows.position → histRows.signAddress → rows.position → rows.signAddress
|
||||
// 地址兜底链:histRows.position → histRows.signAddress → rows.position → rows.signAddress
|
||||
if histAddress == "" {
|
||||
if pos, ok := histRows["position"]; ok && pos != nil {
|
||||
histAddress = fmt.Sprintf("%v", pos)
|
||||
@@ -724,27 +737,124 @@ func ParseTaskForm(detail *TaskFormDetail, historyRows map[string]interface{}) [
|
||||
return list
|
||||
}
|
||||
|
||||
// extractScanPosition 提取扫码签到的经纬度与地址,供表单预填与提交使用。
|
||||
// 取值优先级与普通签到一致:signPlaceSelected[0](学校配置/历史已选范围点)
|
||||
// → 历史表单 / 详情的 longitude|latitude → 详情的 position|signAddress(地址)。
|
||||
// 扫码签到若不带定位,服务端会因经度/纬度为空串而拒绝提交(报 "empty String")。
|
||||
func extractScanPosition(detail *TaskFormDetail, histRows map[string]interface{}) (lon, lat, address string) {
|
||||
rows := detail.Rows
|
||||
|
||||
if histRows != nil {
|
||||
if sps, ok := histRows["signPlaceSelected"]; ok {
|
||||
var places []map[string]interface{}
|
||||
switch v := sps.(type) {
|
||||
case string:
|
||||
if v != "" && v != "[]" {
|
||||
if err := json.Unmarshal([]byte(v), &places); err != nil {
|
||||
places = nil
|
||||
}
|
||||
}
|
||||
case []interface{}:
|
||||
for _, p := range v {
|
||||
if m, ok := p.(map[string]interface{}); ok {
|
||||
places = append(places, m)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(places) > 0 {
|
||||
lon = mapValueStr(places[0], "longitude")
|
||||
lat = mapValueStr(places[0], "latitude")
|
||||
address = mapValueStr(places[0], "address")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 降级:历史表单里的 longitude/latitude
|
||||
if histRows != nil {
|
||||
if lon == "" {
|
||||
lon = mapValueStr(histRows, "longitude")
|
||||
}
|
||||
if lat == "" {
|
||||
lat = mapValueStr(histRows, "latitude")
|
||||
}
|
||||
}
|
||||
// 再降级:当前任务详情里的 longitude/latitude(学校默认坐标)
|
||||
if lon == "" {
|
||||
lon = mapValueStr(rows, "longitude")
|
||||
}
|
||||
if lat == "" {
|
||||
lat = mapValueStr(rows, "latitude")
|
||||
}
|
||||
|
||||
// 地址降级链
|
||||
if address == "" && histRows != nil {
|
||||
address = mapValueStr(histRows, "position")
|
||||
}
|
||||
if address == "" && histRows != nil {
|
||||
address = mapValueStr(histRows, "signAddress")
|
||||
}
|
||||
if address == "" {
|
||||
address = mapValueStr(rows, "position")
|
||||
}
|
||||
if address == "" {
|
||||
address = mapValueStr(rows, "signAddress")
|
||||
}
|
||||
return lon, lat, address
|
||||
}
|
||||
|
||||
// parseScanTaskForm 生成扫码签到任务(signMode=2)的表单项:
|
||||
// 签到二维码(必填,支持上传图片或粘贴链接)+ 可选签到照片 + 可选额外字段
|
||||
// 经纬度/地址 + 可选签到照片 + 可选额外字段 + 签到二维码(放最后)。
|
||||
// 字段顺序与普通签到保持一致(定位在前),二维码作为扫码任务特有项置于末尾。
|
||||
func parseScanTaskForm(detail *TaskFormDetail, histRows map[string]interface{}) []cpdmodel.CpTaskFormItem {
|
||||
rows := detail.Rows
|
||||
var list []cpdmodel.CpTaskFormItem
|
||||
|
||||
// 签到二维码
|
||||
// 二维码ID(uuid):历史值若为可解析的链接/文本,统一归一化为 uuid 存储,
|
||||
// 避免把整条链接(乃至 base64 图片)写进 formData。
|
||||
histQr := ""
|
||||
if histRows != nil {
|
||||
if v, ok := histRows["qrCode"]; ok && v != nil {
|
||||
histQr = fmt.Sprintf("%v", v)
|
||||
raw := fmt.Sprintf("%v", v)
|
||||
if uuid := ExtractQrUuid(raw); uuid != "" {
|
||||
histQr = uuid
|
||||
} else if !IsDataURIImage(raw) {
|
||||
histQr = raw
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 经纬度与地址:扫码签到接口同样要求提交定位(缺省会因空串被服务端拒绝,
|
||||
// 报 "empty String"),且用户可在表单中选择签到范围点,故与普通签到保持一致的字段。
|
||||
scanLon, scanLat, scanAddress := extractScanPosition(detail, histRows)
|
||||
list = append(list, cpdmodel.CpTaskFormItem{
|
||||
Title: "签到二维码",
|
||||
ColName: "qrCode",
|
||||
Type: cpdmodel.ItemTypeImage,
|
||||
Title: "经度",
|
||||
ColName: "lon",
|
||||
Type: cpdmodel.ItemTypeInput,
|
||||
Required: true,
|
||||
Content: histQr,
|
||||
Content: scanLon,
|
||||
Default: scanLon,
|
||||
Show: true,
|
||||
Tip: "上传现场签到二维码图片(或直接粘贴二维码链接/内容),执行时自动解析出 uuid 提交",
|
||||
Tip: "签到地点经度坐标",
|
||||
})
|
||||
list = append(list, cpdmodel.CpTaskFormItem{
|
||||
Title: "纬度",
|
||||
ColName: "lat",
|
||||
Type: cpdmodel.ItemTypeInput,
|
||||
Required: true,
|
||||
Content: scanLat,
|
||||
Default: scanLat,
|
||||
Show: true,
|
||||
Tip: "签到地点纬度坐标",
|
||||
})
|
||||
list = append(list, cpdmodel.CpTaskFormItem{
|
||||
Title: "签到具体地址",
|
||||
ColName: "address",
|
||||
Type: cpdmodel.ItemTypeInput,
|
||||
Required: true,
|
||||
Content: scanAddress,
|
||||
Default: scanAddress,
|
||||
Show: true,
|
||||
Tip: "签到完毕后查看时最底部那个最详细的地址信息",
|
||||
})
|
||||
|
||||
// 签到照片(任务要求时保留)
|
||||
@@ -815,6 +925,17 @@ func parseScanTaskForm(detail *TaskFormDetail, histRows map[string]interface{})
|
||||
}
|
||||
}
|
||||
|
||||
// 二维码ID(uuid)放最后:扫码任务特有项,置于定位/照片/额外字段之后
|
||||
list = append(list, cpdmodel.CpTaskFormItem{
|
||||
Title: "二维码ID",
|
||||
ColName: "uuid",
|
||||
Type: cpdmodel.ItemTypeImage,
|
||||
Required: true,
|
||||
Content: histQr,
|
||||
Show: true,
|
||||
Tip: "上传现场签到二维码图片,自动解析并保存二维码ID(uuid)",
|
||||
})
|
||||
|
||||
return list
|
||||
}
|
||||
|
||||
@@ -967,24 +1088,66 @@ func fillExtraFields(rows map[string]interface{}, formItems []cpdmodel.CpTaskFor
|
||||
}
|
||||
|
||||
// fillScanSubmitForm 生成扫码签到(signMode=2)的提交表单:
|
||||
// 解析签到二维码(图片/链接)得到 uuid,用 qrUuid 替代 signInstanceWid(两者不并存),
|
||||
// 不携带经纬度等定位字段。
|
||||
// 取二维码ID(uuid)字段,提取 uuid 后用 qrUuid 替代 signInstanceWid(两者不并存)。
|
||||
// 定位字段与普通签到一致地提交:服务端对扫码签到的提交体同样要求经度/纬度,
|
||||
// 缺失会因空串转数字失败而返回 "empty String",故此处带上 lon/lat/position。
|
||||
// 字段名兼容:新数据为 uuid,历史数据为 qrCode。
|
||||
// 二维码图片的识别在前端完成,这里只处理已解析出的 uuid 文本。
|
||||
func fillScanSubmitForm(rows map[string]interface{}, formItems []cpdmodel.CpTaskFormItem) (map[string]interface{}, error) {
|
||||
qrContent := ""
|
||||
for _, v := range formItems {
|
||||
if v.ColName == "qrCode" {
|
||||
if v.ColName == "uuid" || v.ColName == "qrCode" {
|
||||
qrContent = strings.TrimSpace(v.Content)
|
||||
break
|
||||
}
|
||||
}
|
||||
uuid, err := ResolveQrUuidFromContent(qrContent)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("二维码签到任务缺少有效签到二维码: %w", err)
|
||||
uuid := ExtractQrUuid(qrContent)
|
||||
if uuid == "" {
|
||||
return nil, fmt.Errorf("二维码签到任务缺少有效的二维码ID(uuid),请重新上传二维码解析")
|
||||
}
|
||||
|
||||
// 经纬度与地址(与普通签到一致):缺失会被服务端判定为参数为空
|
||||
var scanLon, scanLat, scanAddress string
|
||||
for _, v := range formItems {
|
||||
switch v.ColName {
|
||||
case "lon":
|
||||
scanLon = strings.TrimSpace(v.Content)
|
||||
case "lat":
|
||||
scanLat = strings.TrimSpace(v.Content)
|
||||
case "address":
|
||||
scanAddress = strings.TrimSpace(v.Content)
|
||||
case "latAndLon":
|
||||
parts := strings.Split(v.Content, ",")
|
||||
if len(parts) == 2 {
|
||||
if scanLon == "" {
|
||||
scanLon = strings.TrimSpace(parts[0])
|
||||
}
|
||||
if scanLat == "" {
|
||||
scanLat = strings.TrimSpace(parts[1])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 表单未提供定位时,回退到任务详情中的学校默认坐标
|
||||
if scanLon == "" {
|
||||
scanLon = mapValueStr(rows, "longitude")
|
||||
}
|
||||
if scanLat == "" {
|
||||
scanLat = mapValueStr(rows, "latitude")
|
||||
}
|
||||
if scanAddress == "" {
|
||||
scanAddress = mapValueStr(rows, "position")
|
||||
}
|
||||
if scanAddress == "" {
|
||||
scanAddress = mapValueStr(rows, "signAddress")
|
||||
}
|
||||
|
||||
result := map[string]interface{}{
|
||||
"qrUuid": uuid,
|
||||
"uaIsCpadaily": true,
|
||||
"longitude": scanLon,
|
||||
"latitude": scanLat,
|
||||
"position": scanAddress,
|
||||
}
|
||||
|
||||
// 签到照片(任务要求时保留,与普通签到一致)
|
||||
@@ -1078,6 +1241,25 @@ func parseSignPlaces(rows map[string]interface{}) []SignPlace {
|
||||
return places
|
||||
}
|
||||
|
||||
// coordsWithinSignRange 判断坐标是否落在任一签到范围点内。
|
||||
// places 为空(学校未配置范围点)时返回 true——无范围信息则不做限制,沿用历史坐标。
|
||||
func coordsWithinSignRange(latStr, lonStr string, places []SignPlace) bool {
|
||||
if len(places) == 0 {
|
||||
return true
|
||||
}
|
||||
lat, err1 := strconv.ParseFloat(strings.TrimSpace(latStr), 64)
|
||||
lon, err2 := strconv.ParseFloat(strings.TrimSpace(lonStr), 64)
|
||||
if err1 != nil || err2 != nil {
|
||||
return false
|
||||
}
|
||||
for _, p := range places {
|
||||
if haversineMeters(lat, lon, p.LatitudeF, p.LongitudeF) <= p.RadiusMeters {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// haversineMeters 计算两个经纬度坐标之间的球面距离(米)
|
||||
func haversineMeters(lat1, lon1, lat2, lon2 float64) float64 {
|
||||
const earthRadius = 6371000.0
|
||||
|
||||
@@ -6,14 +6,17 @@ import (
|
||||
cpdmodel "cpdaily-new/internal/model"
|
||||
)
|
||||
|
||||
// 扫码任务:提交表单必须用 qrUuid 替代 signInstanceWid,且不携带经纬度
|
||||
// 扫码任务:提交表单必须用 qrUuid 替代 signInstanceWid,并携带经纬度/地址
|
||||
func TestFillScanSubmitForm(t *testing.T) {
|
||||
rows := map[string]interface{}{
|
||||
"signMode": float64(2),
|
||||
"signInstanceWid": "397161",
|
||||
"longitude": "102.7",
|
||||
"latitude": "25.0",
|
||||
"position": "昆明文理学院",
|
||||
}
|
||||
formItems := []cpdmodel.CpTaskFormItem{
|
||||
{Title: "签到二维码", ColName: "qrCode", Type: cpdmodel.ItemTypeImage,
|
||||
{Title: "二维码ID", ColName: "uuid", Type: cpdmodel.ItemTypeImage,
|
||||
Content: "https://ynu.campusphere.net/wec-counselor-sign-apps/stu/qrsign/index.html?uuid=1583a66272854585833eb13b56fc1937&isNeedExtra=0"},
|
||||
}
|
||||
form, err := FillSubmitForm(rows, formItems, "device-1")
|
||||
@@ -26,11 +29,12 @@ func TestFillScanSubmitForm(t *testing.T) {
|
||||
if _, ok := form["signInstanceWid"]; ok {
|
||||
t.Fatal("scan submit form must NOT contain signInstanceWid")
|
||||
}
|
||||
if _, ok := form["latitude"]; ok {
|
||||
t.Fatal("scan submit form must NOT contain latitude")
|
||||
// 定位字段必须携带(缺失会因空串被服务端拒绝,报 "empty String")
|
||||
if form["longitude"] != "102.7" || form["latitude"] != "25.0" {
|
||||
t.Fatalf("scan form must carry lon/lat, got lon=%v lat=%v", form["longitude"], form["latitude"])
|
||||
}
|
||||
if _, ok := form["longitude"]; ok {
|
||||
t.Fatal("scan submit form must NOT contain longitude")
|
||||
if form["position"] != "昆明文理学院" {
|
||||
t.Fatalf("scan form must carry position, got %v", form["position"])
|
||||
}
|
||||
if form["uaIsCpadaily"] != true {
|
||||
t.Fatal("uaIsCpadaily missing")
|
||||
@@ -38,7 +42,29 @@ func TestFillScanSubmitForm(t *testing.T) {
|
||||
|
||||
// 缺少二维码 → 明确报错(不再静默生成会假成功的表单)
|
||||
if _, err := FillSubmitForm(rows, nil, "device-1"); err == nil {
|
||||
t.Fatal("missing qrCode should return error")
|
||||
t.Fatal("missing uuid should return error")
|
||||
}
|
||||
|
||||
// 只存 uuid 的裸值(新流程):必须能正确解析并提交(含非 32-hex 的 uuid)
|
||||
for _, bare := range []string{"1583a66272854585833eb13b56fc1937", "abc123XYZ"} {
|
||||
items := []cpdmodel.CpTaskFormItem{
|
||||
{Title: "二维码ID", ColName: "uuid", Type: cpdmodel.ItemTypeImage, Content: bare},
|
||||
}
|
||||
f, err := FillSubmitForm(rows, items, "device-1")
|
||||
if err != nil {
|
||||
t.Fatalf("bare uuid %q should resolve, got error: %v", bare, err)
|
||||
}
|
||||
if f["qrUuid"] != bare {
|
||||
t.Fatalf("bare uuid %q → qrUuid = %v", bare, f["qrUuid"])
|
||||
}
|
||||
}
|
||||
|
||||
// 历史数据(colName 仍为 qrCode)继续兼容
|
||||
oldItems := []cpdmodel.CpTaskFormItem{
|
||||
{Title: "签到二维码", ColName: "qrCode", Type: cpdmodel.ItemTypeImage, Content: "1583a66272854585833eb13b56fc1937"},
|
||||
}
|
||||
if f, err := FillSubmitForm(rows, oldItems, "device-1"); err != nil || f["qrUuid"] != "1583a66272854585833eb13b56fc1937" {
|
||||
t.Fatalf("legacy qrCode field should still work: form=%v err=%v", f, err)
|
||||
}
|
||||
|
||||
// 普通任务(无 signMode)不受影响:走原逻辑,且不含 qrUuid
|
||||
@@ -60,16 +86,49 @@ func TestFillScanSubmitForm(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 扫码任务表单项解析:ParseTaskForm 应生成「签到二维码」图片项,不含经纬度项
|
||||
// 扫码任务表单项解析:ParseTaskForm 应生成经纬度/地址项 + 「二维码ID」图片项(二维码在最后)
|
||||
func TestParseScanTaskForm(t *testing.T) {
|
||||
detail := &TaskFormDetail{
|
||||
Rows: map[string]interface{}{"signMode": float64(2)},
|
||||
}
|
||||
items := ParseTaskForm(detail, nil)
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("scan form items = %d, want 1: %+v", len(items), items)
|
||||
// 经度 + 纬度 + 地址 + 二维码ID
|
||||
if len(items) != 4 {
|
||||
t.Fatalf("scan form items = %d, want 4: %+v", len(items), items)
|
||||
}
|
||||
if items[0].ColName != "qrCode" || items[0].Type != cpdmodel.ItemTypeImage || !items[0].Required {
|
||||
t.Fatalf("unexpected qrCode item: %+v", items[0])
|
||||
// 二维码ID 位于最后
|
||||
last := items[len(items)-1]
|
||||
if last.ColName != "uuid" || last.Type != cpdmodel.ItemTypeImage || !last.Required {
|
||||
t.Fatalf("unexpected uuid item (should be last): %+v", last)
|
||||
}
|
||||
wantPos := map[string]bool{"lon": false, "lat": false, "address": false}
|
||||
for _, it := range items[:len(items)-1] {
|
||||
if _, ok := wantPos[it.ColName]; ok {
|
||||
wantPos[it.ColName] = true
|
||||
}
|
||||
}
|
||||
for k, ok := range wantPos {
|
||||
if !ok {
|
||||
t.Fatalf("scan form missing position item %q: %+v", k, items)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 历史行数据里的二维码链接应被归一化为 uuid 存储(不把整条链接写入 formData)
|
||||
func TestParseScanTaskFormNormalizesUuid(t *testing.T) {
|
||||
detail := &TaskFormDetail{Rows: map[string]interface{}{"signMode": float64(2)}}
|
||||
histRows := map[string]interface{}{
|
||||
"qrCode": "https://ynu.campusphere.net/wec-counselor-sign-apps/stu/qrsign/index.html?uuid=1583a66272854585833eb13b56fc1937&isNeedExtra=0",
|
||||
}
|
||||
items := ParseTaskForm(detail, histRows)
|
||||
if len(items) < 1 {
|
||||
t.Fatalf("unexpected items: %+v", items)
|
||||
}
|
||||
uuidItem := items[len(items)-1]
|
||||
if uuidItem.ColName != "uuid" {
|
||||
t.Fatalf("last item should be uuid, got %+v", uuidItem)
|
||||
}
|
||||
if uuidItem.Content != "1583a66272854585833eb13b56fc1937" {
|
||||
t.Fatalf("history link should normalize to uuid, got %q", uuidItem.Content)
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.4 KiB |
@@ -6,6 +6,28 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#f5f5f5" />
|
||||
<title>小助手</title>
|
||||
<script>
|
||||
// 老内核(Chrome 78~92,即微信 XWEB / 国产浏览器)缺失的 ES2022 运行时 API 补齐。
|
||||
// 背景:reka-ui(其弹窗组件)与 @vueuse/shared 内部直接调用 Object.hasOwn
|
||||
// (Chrome 93+),而 esbuild/vite 的 target 只降语法、不降运行时 API,
|
||||
// 构建产物在 Chrome 78 上会在弹窗挂载时抛 TypeError。
|
||||
// 该内联脚本先于 module 产物执行(module 默认 defer),故此处按需补齐即可;
|
||||
// 现代内核因特性检测成立而跳过,零开销。写法保持 ES5,避免老内核解析失败。
|
||||
;(function () {
|
||||
if (typeof Object.hasOwn !== 'function') {
|
||||
Object.defineProperty(Object, 'hasOwn', {
|
||||
value: function (obj, prop) {
|
||||
if (obj === null || obj === undefined) {
|
||||
throw new TypeError('Cannot convert undefined or null to object')
|
||||
}
|
||||
return Object.prototype.hasOwnProperty.call(Object(obj), prop)
|
||||
},
|
||||
configurable: true,
|
||||
writable: true,
|
||||
})
|
||||
}
|
||||
})()
|
||||
</script>
|
||||
<script>
|
||||
// 暗色主题防闪烁:与 composables/useTheme.ts 保持同一时间规则
|
||||
// (18:00–次日 06:00 暗色),在首屏渲染前预设 html.dark 与状态栏色
|
||||
|
||||
Generated
+495
-50
@@ -11,8 +11,10 @@
|
||||
"axios": "^1.13.6",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"jsqr": "^1.4.0",
|
||||
"lucide-vue-next": "^1.0.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"reka-ui": "^2.10.4",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"vue": "^3.5.30",
|
||||
"vue-router": "^5.0.4"
|
||||
@@ -22,6 +24,7 @@
|
||||
"@types/node": "^26.0.1",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@vitejs/plugin-vue": "^6.0.5",
|
||||
"lightningcss": "^1.33.0",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"typescript": "~5.9.3",
|
||||
"vite": "^8.0.1",
|
||||
@@ -159,6 +162,79 @@
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/core": {
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz",
|
||||
"integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==",
|
||||
"dependencies": {
|
||||
"@floating-ui/utils": "^0.2.12"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/dom": {
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz",
|
||||
"integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==",
|
||||
"dependencies": {
|
||||
"@floating-ui/core": "^1.8.0",
|
||||
"@floating-ui/utils": "^0.2.12"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/utils": {
|
||||
"version": "0.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz",
|
||||
"integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww=="
|
||||
},
|
||||
"node_modules/@floating-ui/vue": {
|
||||
"version": "1.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/vue/-/vue-1.1.11.tgz",
|
||||
"integrity": "sha512-HzHKCNVxnGS35r9fCHBc3+uCnjw9IWIlCPL683cGgM9Kgj2BiAl8x1mS7vtvP6F9S/e/q4O6MApwSHj8hNLGfw==",
|
||||
"dependencies": {
|
||||
"@floating-ui/dom": "^1.7.6",
|
||||
"@floating-ui/utils": "^0.2.11",
|
||||
"vue-demi": ">=0.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/vue/node_modules/vue-demi": {
|
||||
"version": "0.14.10",
|
||||
"resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz",
|
||||
"integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==",
|
||||
"hasInstallScript": true,
|
||||
"bin": {
|
||||
"vue-demi-fix": "bin/vue-demi-fix.js",
|
||||
"vue-demi-switch": "bin/vue-demi-switch.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@vue/composition-api": "^1.0.0-rc.1",
|
||||
"vue": "^3.0.0-0 || ^2.6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@vue/composition-api": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@internationalized/date": {
|
||||
"version": "3.12.4",
|
||||
"resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.12.4.tgz",
|
||||
"integrity": "sha512-M1dEn4c1U1HsSlaVR8upZtSqvXrTkHDfv18H01uCSJyjVLDxnBR38v/fMxecmlwXKR4i9HeZcmgQAPE6A+aGJQ==",
|
||||
"dependencies": {
|
||||
"@swc/helpers": "^0.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@internationalized/number": {
|
||||
"version": "3.6.8",
|
||||
"resolved": "https://registry.npmjs.org/@internationalized/number/-/number-3.6.8.tgz",
|
||||
"integrity": "sha512-8UmMFia46DUt+k97zKd9fKWXcWHR+k8ae3eYzILETuT2KbIvLyOfac7zesw+sJdRAAZ7Q9pM1Mk22aXp2LD0Ig==",
|
||||
"dependencies": {
|
||||
"@swc/helpers": "^0.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/gen-mapping": {
|
||||
"version": "0.3.13",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||
@@ -474,6 +550,14 @@
|
||||
"integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
|
||||
"devOptional": true
|
||||
},
|
||||
"node_modules/@swc/helpers": {
|
||||
"version": "0.5.23",
|
||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz",
|
||||
"integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==",
|
||||
"dependencies": {
|
||||
"tslib": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/node": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.2.tgz",
|
||||
@@ -489,6 +573,255 @@
|
||||
"tailwindcss": "4.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/node/node_modules/lightningcss": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
||||
"integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"detect-libc": "^2.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"lightningcss-android-arm64": "1.32.0",
|
||||
"lightningcss-darwin-arm64": "1.32.0",
|
||||
"lightningcss-darwin-x64": "1.32.0",
|
||||
"lightningcss-freebsd-x64": "1.32.0",
|
||||
"lightningcss-linux-arm-gnueabihf": "1.32.0",
|
||||
"lightningcss-linux-arm64-gnu": "1.32.0",
|
||||
"lightningcss-linux-arm64-musl": "1.32.0",
|
||||
"lightningcss-linux-x64-gnu": "1.32.0",
|
||||
"lightningcss-linux-x64-musl": "1.32.0",
|
||||
"lightningcss-win32-arm64-msvc": "1.32.0",
|
||||
"lightningcss-win32-x64-msvc": "1.32.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/node/node_modules/lightningcss-android-arm64": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
|
||||
"integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/node/node_modules/lightningcss-darwin-arm64": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
|
||||
"integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/node/node_modules/lightningcss-darwin-x64": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
|
||||
"integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/node/node_modules/lightningcss-freebsd-x64": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
|
||||
"integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/node/node_modules/lightningcss-linux-arm-gnueabihf": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
|
||||
"integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/node/node_modules/lightningcss-linux-arm64-gnu": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
|
||||
"integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/node/node_modules/lightningcss-linux-arm64-musl": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz",
|
||||
"integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/node/node_modules/lightningcss-linux-x64-gnu": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz",
|
||||
"integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/node/node_modules/lightningcss-linux-x64-musl": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz",
|
||||
"integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/node/node_modules/lightningcss-win32-arm64-msvc": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz",
|
||||
"integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/node/node_modules/lightningcss-win32-x64-msvc": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz",
|
||||
"integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.2.tgz",
|
||||
@@ -731,6 +1064,30 @@
|
||||
"vite": "^5.2.0 || ^6 || ^7 || ^8"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/virtual-core": {
|
||||
"version": "3.17.11",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.17.11.tgz",
|
||||
"integrity": "sha512-+ILjvtHup6Y2hzQ6YzwMgX1Q+oQpxEGOXCEsCNaPoIP0VxMbizIBTmYTDtkerkIQS8/CbP1BRuyt8V/8BCsy1g==",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/vue-virtual": {
|
||||
"version": "3.13.39",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/vue-virtual/-/vue-virtual-3.13.39.tgz",
|
||||
"integrity": "sha512-AG8sI+6nfrDvN6ixuHJL9LaJTf3//0V5VITZYW3UOaIuSVexnEzsbMu4LH/JhMfRTxwhUXPnLqkl1DlzkZbRtg==",
|
||||
"dependencies": {
|
||||
"@tanstack/virtual-core": "3.17.11"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": "^2.7.0 || ^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tybys/wasm-util": {
|
||||
"version": "0.10.3",
|
||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
|
||||
@@ -764,6 +1121,11 @@
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/web-bluetooth": {
|
||||
"version": "0.0.21",
|
||||
"resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz",
|
||||
"integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA=="
|
||||
},
|
||||
"node_modules/@vitejs/plugin-vue": {
|
||||
"version": "6.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.7.tgz",
|
||||
@@ -962,6 +1324,41 @@
|
||||
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.39.tgz",
|
||||
"integrity": "sha512-l1rrBtBfTnmxvtsvdQDXltUUy8S1Y+ZaqdfUzmAnJkTd8Z8rv5v/ytW+TKiqEOWyHPoqtPlNFSs0lhRmYVSHVA=="
|
||||
},
|
||||
"node_modules/@vueuse/core": {
|
||||
"version": "14.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@vueuse/core/-/core-14.4.0.tgz",
|
||||
"integrity": "sha512-X4WHz1HlCzCBoYXesUkifzzWBAcZgXG8Fi5iNPQg/epdzOB3gu8Fawj3hvuwYR1nGcXGnvxwYYcUC/71++svtQ==",
|
||||
"dependencies": {
|
||||
"@types/web-bluetooth": "^0.0.21",
|
||||
"@vueuse/metadata": "14.4.0",
|
||||
"@vueuse/shared": "14.4.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": "^3.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vueuse/metadata": {
|
||||
"version": "14.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-14.4.0.tgz",
|
||||
"integrity": "sha512-swx/255R6JyHZFJhx845iz5CRWDZdCfvkZOpACWc5+c5WHcG24mv8gUT1WIdFQaHt6dq79rvILd9QnCWiyVm9g==",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
}
|
||||
},
|
||||
"node_modules/@vueuse/shared": {
|
||||
"version": "14.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-14.4.0.tgz",
|
||||
"integrity": "sha512-JRgY90Sz8DDtPMsaDflvPMp9xYk69JZAmbuDvAquUVXKr2gEjqtzGNTTthLfckH0BzBqvnu31gb4a8TGLRe79g==",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": "^3.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/acorn": {
|
||||
"version": "8.17.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz",
|
||||
@@ -1012,6 +1409,17 @@
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/aria-hidden": {
|
||||
"version": "1.2.6",
|
||||
"resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz",
|
||||
"integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==",
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/ast-kit": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/ast-kit/-/ast-kit-2.2.0.tgz",
|
||||
@@ -1191,6 +1599,11 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/defu": {
|
||||
"version": "6.1.7",
|
||||
"resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz",
|
||||
"integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ=="
|
||||
},
|
||||
"node_modules/delayed-stream": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||
@@ -1542,10 +1955,15 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/jsqr": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/jsqr/-/jsqr-1.4.0.tgz",
|
||||
"integrity": "sha512-dxLob7q65Xg2DvstYkRpkYtmKm2sPJ9oFhrhmudT1dZvNFFTlroai3AWSpLey/w5vMcLBXRgOJsbXpdN9HzU/A=="
|
||||
},
|
||||
"node_modules/lightningcss": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
||||
"integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
|
||||
"integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==",
|
||||
"devOptional": true,
|
||||
"dependencies": {
|
||||
"detect-libc": "^2.0.3"
|
||||
@@ -1558,23 +1976,23 @@
|
||||
"url": "https://opencollective.com/parcel"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"lightningcss-android-arm64": "1.32.0",
|
||||
"lightningcss-darwin-arm64": "1.32.0",
|
||||
"lightningcss-darwin-x64": "1.32.0",
|
||||
"lightningcss-freebsd-x64": "1.32.0",
|
||||
"lightningcss-linux-arm-gnueabihf": "1.32.0",
|
||||
"lightningcss-linux-arm64-gnu": "1.32.0",
|
||||
"lightningcss-linux-arm64-musl": "1.32.0",
|
||||
"lightningcss-linux-x64-gnu": "1.32.0",
|
||||
"lightningcss-linux-x64-musl": "1.32.0",
|
||||
"lightningcss-win32-arm64-msvc": "1.32.0",
|
||||
"lightningcss-win32-x64-msvc": "1.32.0"
|
||||
"lightningcss-android-arm64": "1.33.0",
|
||||
"lightningcss-darwin-arm64": "1.33.0",
|
||||
"lightningcss-darwin-x64": "1.33.0",
|
||||
"lightningcss-freebsd-x64": "1.33.0",
|
||||
"lightningcss-linux-arm-gnueabihf": "1.33.0",
|
||||
"lightningcss-linux-arm64-gnu": "1.33.0",
|
||||
"lightningcss-linux-arm64-musl": "1.33.0",
|
||||
"lightningcss-linux-x64-gnu": "1.33.0",
|
||||
"lightningcss-linux-x64-musl": "1.33.0",
|
||||
"lightningcss-win32-arm64-msvc": "1.33.0",
|
||||
"lightningcss-win32-x64-msvc": "1.33.0"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-android-arm64": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
|
||||
"integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz",
|
||||
"integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1592,9 +2010,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-darwin-arm64": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
|
||||
"integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz",
|
||||
"integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1612,9 +2030,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-darwin-x64": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
|
||||
"integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz",
|
||||
"integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1632,9 +2050,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-freebsd-x64": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
|
||||
"integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz",
|
||||
"integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1652,9 +2070,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-arm-gnueabihf": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
|
||||
"integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz",
|
||||
"integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -1672,9 +2090,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-arm64-gnu": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
|
||||
"integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz",
|
||||
"integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1692,9 +2110,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-arm64-musl": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz",
|
||||
"integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==",
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz",
|
||||
"integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1712,9 +2130,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-x64-gnu": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz",
|
||||
"integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz",
|
||||
"integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1732,9 +2150,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-x64-musl": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz",
|
||||
"integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==",
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz",
|
||||
"integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1752,9 +2170,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-win32-arm64-msvc": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz",
|
||||
"integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz",
|
||||
"integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1772,9 +2190,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-win32-x64-msvc": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz",
|
||||
"integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==",
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz",
|
||||
"integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1929,6 +2347,11 @@
|
||||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/ohash": {
|
||||
"version": "2.0.12",
|
||||
"resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.12.tgz",
|
||||
"integrity": "sha512-65S/5gk9YSsaRjcyf7Nfa6h/d3E8/1gslpXfI4W7Dxn/oap8IKRuNT5VXkLQ1YFKIEg4apRY4Pj6aiwFzrDdmw=="
|
||||
},
|
||||
"node_modules/p-limit": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
|
||||
@@ -2098,6 +2521,30 @@
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/reka-ui": {
|
||||
"version": "2.10.4",
|
||||
"resolved": "https://registry.npmjs.org/reka-ui/-/reka-ui-2.10.4.tgz",
|
||||
"integrity": "sha512-kbS5GAbkHkYj0EVKAg5ZPEPndhBjeUFSa1Aq5m5ftQwyHnB1v5tw8X6fcwfKH/ry3StPXblMWM1DW82MVS71cw==",
|
||||
"dependencies": {
|
||||
"@floating-ui/dom": "^1.6.13",
|
||||
"@floating-ui/vue": "^1.1.6",
|
||||
"@internationalized/date": "^3.5.0",
|
||||
"@internationalized/number": "^3.5.0",
|
||||
"@tanstack/vue-virtual": "^3.12.0",
|
||||
"@vueuse/core": "^14.1.0",
|
||||
"@vueuse/shared": "^14.1.0",
|
||||
"aria-hidden": "^1.2.4",
|
||||
"defu": "^6.1.5",
|
||||
"ohash": "^2.0.11"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/zernonia"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": ">= 3.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/require-directory": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||
@@ -2232,9 +2679,7 @@
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
|
||||
@@ -5,15 +5,17 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"build": "vite build && node scripts/postbuild-css.mjs",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.13.6",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"jsqr": "^1.4.0",
|
||||
"lucide-vue-next": "^1.0.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"reka-ui": "^2.10.4",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"vue": "^3.5.30",
|
||||
"vue-router": "^5.0.4"
|
||||
@@ -23,6 +25,7 @@
|
||||
"@types/node": "^26.0.1",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@vitejs/plugin-vue": "^6.0.5",
|
||||
"lightningcss": "^1.33.0",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"typescript": "~5.9.3",
|
||||
"vite": "^8.0.1",
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
// 老内核兼容后处理(构建期执行):
|
||||
//
|
||||
// 背景:Tailwind v4 的产物大量使用较新的 CSS 能力,老内核(国产浏览器/微信内置
|
||||
// 内核,实测线上存在 Chrome 78/85/91/97)会丢弃不认识的部分,表现为“只剩基础
|
||||
// 元素、没有样式”。逐项处理如下:
|
||||
//
|
||||
// ① CSS 级联层 @layer(Chrome 99+):老内核不认识该 at-rule,会把整块工具类
|
||||
// 直接丢弃 —— 这是“无样式”的主因,必须剥离;
|
||||
// ② oklch() 颜色(Chrome 111+):降级为十六进制色值(lightningcss);
|
||||
// ③ :where() 选择器(Chrome 88+):其特异性为 0 的设计依赖较新引擎,
|
||||
// 老内核整条规则丢弃;解包为等价选择器(保持原有特异性位置);
|
||||
// ④ JS 语法(可选链等)由 vite build.target 统一降级,不在此处处理。
|
||||
//
|
||||
// 处理顺序:先解包 :where(lightningcss 不处理它),再整体按目标内核降级,
|
||||
// 最后剥离 @layer 并做完整性校验。
|
||||
import { readdir, readFile, writeFile } from 'node:fs/promises'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { transform } from 'lightningcss'
|
||||
|
||||
const assetsDir = resolve(process.cwd(), 'dist/assets')
|
||||
|
||||
// lightningcss 目标版本:Chrome 78(微信 XWEB 常见下限,线上真实存在)
|
||||
const TARGETS = {
|
||||
chrome: 78 << 16,
|
||||
safari: 12 << 16,
|
||||
ios_saf: 12 << 16,
|
||||
android: 78 << 16,
|
||||
}
|
||||
|
||||
/** 解包 :where(...) → 内部选择器(老内核不支持 :where,会整条丢弃规则) */
|
||||
function unwrapWhere(css) {
|
||||
let out = ''
|
||||
let i = 0
|
||||
while (i < css.length) {
|
||||
const m = css.indexOf(':where(', i)
|
||||
if (m < 0) {
|
||||
out += css.slice(i)
|
||||
break
|
||||
}
|
||||
// 避免误伤字符串/注释中的 :where(本项目 CSS 产物中无此类内容,保守处理仍做括号配对)
|
||||
out += css.slice(i, m)
|
||||
let depth = 0
|
||||
let end = -1
|
||||
for (let k = m + 6; k < css.length; k++) {
|
||||
if (css[k] === '(') depth++
|
||||
else if (css[k] === ')') {
|
||||
depth--
|
||||
if (depth === 0) {
|
||||
end = k
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if (end < 0) {
|
||||
out += css.slice(m)
|
||||
break
|
||||
}
|
||||
out += css.slice(m + 7, end) // 去掉 ":where(" 与末端 ")"
|
||||
i = end + 1
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** 剥离 @layer 包裹:保留块内规则与顺序(层叠仍由源顺序 + 选择器优先级决定) */
|
||||
function unwrapCascadeLayers(css) {
|
||||
let out = css
|
||||
const re = /@layer\s+[A-Za-z0-9_,\s-]+?\{/g
|
||||
|
||||
// 反复处理,兼容理论上的嵌套 @layer
|
||||
for (let pass = 0; pass < 8; pass++) {
|
||||
const heads = []
|
||||
let m
|
||||
re.lastIndex = 0
|
||||
while ((m = re.exec(out)) !== null) {
|
||||
heads.push({ head: m.index, bodyStart: re.lastIndex - 1 })
|
||||
}
|
||||
if (heads.length === 0) break
|
||||
|
||||
for (let i = heads.length - 1; i >= 0; i--) {
|
||||
const { head, bodyStart } = heads[i]
|
||||
let depth = 0
|
||||
let end = -1
|
||||
for (let k = bodyStart; k < out.length; k++) {
|
||||
if (out[k] === '{') depth++
|
||||
else if (out[k] === '}') {
|
||||
depth--
|
||||
if (depth === 0) {
|
||||
end = k
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if (end < 0) continue
|
||||
out = out.slice(0, head) + out.slice(bodyStart + 1, end) + out.slice(end + 1)
|
||||
}
|
||||
}
|
||||
|
||||
// 移除裸 @layer 声明语句(如 `@layer components;`)
|
||||
out = out.replace(/@layer\s+[A-Za-z0-9_,\s-]+;/g, '')
|
||||
return out
|
||||
}
|
||||
|
||||
/** 校验:括号闭合(剥离过程中出现异常时立刻失败,避免把坏 CSS 发上线) */
|
||||
function assertBalanced(css, file) {
|
||||
let depth = 0
|
||||
for (const ch of css) {
|
||||
if (ch === '{') depth++
|
||||
else if (ch === '}') {
|
||||
depth--
|
||||
if (depth < 0) throw new Error(`[postbuild-css] ${file} 括号不闭合(出现多余的 })`)
|
||||
}
|
||||
}
|
||||
if (depth !== 0) throw new Error(`[postbuild-css] ${file} 括号不闭合(缺少 ${depth} 个 })`)
|
||||
}
|
||||
|
||||
const files = (await readdir(assetsDir)).filter((f) => f.endsWith('.css'))
|
||||
if (files.length === 0) {
|
||||
throw new Error('[postbuild-css] dist/assets 下未找到 CSS 产物,构建流程异常')
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
const path = join(assetsDir, file)
|
||||
const src = await readFile(path)
|
||||
|
||||
// ① 解包 :where()(lightningcss 不会处理它,需先做)
|
||||
let prepared = Buffer.from(unwrapWhere(src.toString('utf8')))
|
||||
const whereCount = (src.toString('utf8').match(/:where\(/g) || []).length
|
||||
|
||||
// ② 按目标内核降级(oklch 等新颜色函数 → 老内核可识别的色值)
|
||||
const { code } = transform({
|
||||
filename: file,
|
||||
code: prepared,
|
||||
minify: true,
|
||||
targets: TARGETS,
|
||||
})
|
||||
let out = code.toString()
|
||||
|
||||
// ③ 剥离 @layer 包裹
|
||||
out = unwrapCascadeLayers(out)
|
||||
|
||||
// ④ 兜底校验:任何一项残留都说明处理逻辑失效,直接失败避免上线残缺样式
|
||||
if (/@layer/.test(out)) throw new Error(`[postbuild-css] ${file} 仍残留 @layer`)
|
||||
if (/oklch\(/.test(out)) throw new Error(`[postbuild-css] ${file} 仍残留 oklch(`)
|
||||
if (/:where\(/.test(out)) throw new Error(`[postbuild-css] ${file} 仍残留 :where(`)
|
||||
assertBalanced(out, file)
|
||||
|
||||
await writeFile(path, out)
|
||||
console.log(
|
||||
`[postbuild-css] ${file}: ${src.length} → ${out.length} bytes` +
|
||||
`(解包 ${whereCount} 处 :where + 降级颜色 + 剥离 @layer)`
|
||||
)
|
||||
}
|
||||
@@ -2,6 +2,8 @@
|
||||
import { watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useMessage } from '@/composables/useMessage'
|
||||
import UpdateChecker from '@/components/UpdateChecker.vue'
|
||||
import ConfirmHost from '@/components/ConfirmHost.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const { toasts, removeToast } = useMessage()
|
||||
@@ -26,6 +28,12 @@ watch(
|
||||
<template>
|
||||
<router-view />
|
||||
|
||||
<!-- 全局新版本检测(无 UI,发现更新时弹窗提示刷新) -->
|
||||
<UpdateChecker />
|
||||
|
||||
<!-- 全局确认/提示弹窗宿主(confirmDialog / alertDialog) -->
|
||||
<ConfirmHost />
|
||||
|
||||
<!-- Toast 容器 -->
|
||||
<Teleport to="body">
|
||||
<div class="fixed top-4 right-4 z-[100] flex flex-col gap-2 max-w-sm">
|
||||
|
||||
@@ -381,12 +381,14 @@ export interface AdminTaskLogItem {
|
||||
username: string
|
||||
realName: string
|
||||
schoolName: string
|
||||
taskType: string // sign / sleep_check
|
||||
taskType: string // sign / sleep_check / leave_apply / leave_report
|
||||
title: string
|
||||
realTitle: string
|
||||
isSuccess: boolean
|
||||
errorMessage: string
|
||||
createdAt: string
|
||||
deleted: boolean // 用户已在客户端删除(软删除),仅后台可见
|
||||
source: number // 1=签到/查寝 2=请假/销假(两表 id 独立自增,列表 key 需组合)
|
||||
}
|
||||
|
||||
export interface AdminTaskLogResult {
|
||||
|
||||
@@ -200,12 +200,15 @@ export interface TaskInfo {
|
||||
realTitle: string
|
||||
signWid: string
|
||||
signInstanceWid: string
|
||||
formData: string
|
||||
/** 列表接口不再返回(可能达 MB 级);需要时走 getTaskSavedForm */
|
||||
formData?: string
|
||||
taskStatus: string
|
||||
errorMessage: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
matchStatus: string
|
||||
/** 服务端预计算的「必填但为空」字段标题,替代前端解析 formData */
|
||||
missingFields?: string[]
|
||||
}
|
||||
|
||||
export interface TaskTitleItem {
|
||||
@@ -241,11 +244,16 @@ export function createTask(params: { taskType: string; title: string; realTitle:
|
||||
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 getTaskSavedForm(id: number) {
|
||||
return api.get<{ data: { formData: string } }>(`/task/form-data/${id}`)
|
||||
}
|
||||
|
||||
/** 保存任务表单 */
|
||||
export function saveTaskForm(id: number, formData: FormItem[]) {
|
||||
return api.post(`/task/form/${id}`, { formData })
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
import api from './index'
|
||||
|
||||
// ======== 类型定义 ========
|
||||
|
||||
/** 学校规定的销假位置(详情 reportPlaces) */
|
||||
export interface LeaveReportPlace {
|
||||
wid: string
|
||||
address: string
|
||||
longitude: string
|
||||
latitude: string
|
||||
/** 允许范围半径(米):销假定位需在该位置半径内 */
|
||||
radius: string
|
||||
/** 位置设置人 */
|
||||
counName: string
|
||||
}
|
||||
|
||||
/** 待销假记录(含规定销假位置与销假开关) */
|
||||
export interface LeaveItem {
|
||||
id: string
|
||||
leaveType: string
|
||||
reason: string
|
||||
createTime: string
|
||||
actEndTime: string
|
||||
status: string
|
||||
/** 状态文案(服务端权威映射,新套学校返回;旧套为空时前端本地映射兜底) */
|
||||
statusText?: string
|
||||
/** 学校规定的销假位置列表;为空时需用户手动输入 */
|
||||
reportPlaces: LeaveReportPlace[]
|
||||
/** 该请假类型销假是否需要选择审批人(leaveType.reportApplyFlow=1) */
|
||||
reportApplyFlow: boolean
|
||||
/** 该请假类型销假是否允许上传图片(leaveType.enableAttach=1) */
|
||||
enableAttach: boolean
|
||||
}
|
||||
|
||||
/** 销假可选审批人 */
|
||||
export interface LeaveApprover {
|
||||
userWid: string
|
||||
userId: string
|
||||
userName: string
|
||||
}
|
||||
|
||||
/** 销假执行参数 */
|
||||
export interface ExecuteLeaveParams {
|
||||
leaveId: string
|
||||
longitude: string
|
||||
latitude: string
|
||||
address: string
|
||||
imageUrl?: string
|
||||
/** reportApplyFlow=true 时必填 */
|
||||
approverWid?: string
|
||||
}
|
||||
|
||||
/** 销假日志 */
|
||||
export interface LeaveLog {
|
||||
id: number
|
||||
userId: number
|
||||
leaveId: string
|
||||
title: string
|
||||
schoolName: string
|
||||
/** report=销假 / apply=请假 */
|
||||
action: string
|
||||
isSuccess: boolean
|
||||
errorMessage: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
/** 请假类型(已合并 queryLeaveTypeCfg 的权威开关) */
|
||||
export interface LeaveType {
|
||||
code: number
|
||||
name: string
|
||||
/** 1=必须离校 */
|
||||
mustOut: number
|
||||
/** 1=需要选择审批人 */
|
||||
needApproval: number
|
||||
/** 1=允许上传图片附件 */
|
||||
enableAttach: number
|
||||
/** 请假原因最少字数 */
|
||||
reasonWordLimit: number
|
||||
/** 附件数量上限 */
|
||||
attachmentNumberLimit: number
|
||||
/** 1=当前类型不可用 */
|
||||
disableTime: number
|
||||
/** 单次最长可请假天数(0=不限) */
|
||||
limitDay: number
|
||||
}
|
||||
|
||||
/** 请假执行参数 */
|
||||
export interface ExecuteLeaveApplyParams {
|
||||
leaveType: number
|
||||
startTime: string
|
||||
endTime: string
|
||||
leaveReason: string
|
||||
outStatus: boolean
|
||||
urgencyMobile?: string
|
||||
address?: string
|
||||
detailAddress?: string
|
||||
longitude: string
|
||||
latitude: string
|
||||
locationAddr: string
|
||||
/** 附件图片 URL 列表(最多 4 张且不超学校上限;后端兼容旧 imageUrl 单图字段) */
|
||||
imageUrls?: string[]
|
||||
approverWid?: string
|
||||
approverId?: string
|
||||
approverName?: string
|
||||
/** 抄送人(非必填;候选取自审批人列表) */
|
||||
ccUsers?: LeaveApprover[]
|
||||
}
|
||||
|
||||
// ======== API ========
|
||||
|
||||
/** 获取今日校园待销假列表(实时请求,附带规定销假位置与开关) */
|
||||
export function getPendingLeaves() {
|
||||
return api.get<{ data: LeaveItem[] }>('/leave/pending-list')
|
||||
}
|
||||
|
||||
/** 获取销假可选审批人(reportApplyFlow=true 的请假类型使用) */
|
||||
export function getLeaveApprovers(leaveId: string) {
|
||||
return api.post<{ data: LeaveApprover[] }>('/leave/approvers', { leaveId })
|
||||
}
|
||||
|
||||
/** 获取请假类型列表(含权威开关:needApproval/enableAttach/reasonWordLimit 等) */
|
||||
export function getLeaveTypes() {
|
||||
return api.get<{ data: LeaveType[] }>('/leave/types')
|
||||
}
|
||||
|
||||
/** 获取请假可选审批人(needApproval=1 的类型,需先选类型与起止时间) */
|
||||
export function getApplyApprovers(params: { leaveType: number; startTime: string; endTime: string }) {
|
||||
return api.post<{ data: LeaveApprover[] }>('/leave/apply-approvers', params)
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行请假申请(POST + SSE 流式事件,与销假执行体验一致)。
|
||||
* 事件流:connecting → submitting → success / task_error
|
||||
*/
|
||||
export function executeLeaveApplySSE(params: ExecuteLeaveApplyParams, callbacks: {
|
||||
onConnecting?: () => void
|
||||
onSubmitting?: () => void
|
||||
onSuccess?: (data: any) => void
|
||||
onError?: (message: string, data?: any) => void
|
||||
}): AbortController {
|
||||
return leaveSSE('/leave/apply', params, callbacks)
|
||||
}
|
||||
|
||||
/** 获取销假日志列表 */
|
||||
export function getLeaveLogs() {
|
||||
return api.get<{ data: LeaveLog[] }>('/leave/logs')
|
||||
}
|
||||
|
||||
/** 删除销假日志 */
|
||||
export function deleteLeaveLog(id: number) {
|
||||
return api.delete(`/leave/logs/${id}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行销假(POST + SSE 流式事件,与打卡执行体验一致)。
|
||||
* 事件流:connecting → fetching_detail → submitting → success / task_error
|
||||
* 返回 AbortController,可用于中断(如关闭弹窗时)。
|
||||
*/
|
||||
export function executeLeaveSSE(params: ExecuteLeaveParams, callbacks: {
|
||||
onConnecting?: () => void
|
||||
onFetchingDetail?: () => void
|
||||
onSubmitting?: () => void
|
||||
onSuccess?: (data: any) => void
|
||||
onError?: (message: string, data?: any) => void
|
||||
}): AbortController {
|
||||
return leaveSSE('/leave/execute', params, callbacks)
|
||||
}
|
||||
|
||||
/**
|
||||
* 请假/销假共用的 SSE 执行封装。
|
||||
* 事件流:connecting → (fetching_detail) → submitting → success / task_error
|
||||
*/
|
||||
function leaveSSE(path: string, params: object, callbacks: {
|
||||
onConnecting?: () => void
|
||||
onFetchingDetail?: () => void
|
||||
onSubmitting?: () => void
|
||||
onSuccess?: (data: any) => void
|
||||
onError?: (message: string, data?: any) => void
|
||||
}): AbortController {
|
||||
const controller = new AbortController()
|
||||
|
||||
;(async () => {
|
||||
let finished = false
|
||||
const finish = (fn?: (...args: any[]) => void, ...args: any[]) => {
|
||||
if (finished) return
|
||||
finished = true
|
||||
fn?.(...args)
|
||||
}
|
||||
try {
|
||||
const baseUrl = import.meta.env.VITE_API_BASE_URL || '/api'
|
||||
const token = localStorage.getItem('access_token') || ''
|
||||
const res = await fetch(`${baseUrl}${path}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': 'Bearer ' + token,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(params),
|
||||
signal: controller.signal,
|
||||
})
|
||||
|
||||
// HTTP 错误:服务端以 JSON 返回(参数校验/鉴权等,非 SSE 阶段)
|
||||
if (!res.ok || !res.body) {
|
||||
let msg = '连接失败,请检查网络'
|
||||
try {
|
||||
const j = await res.json()
|
||||
if (j?.message) msg = j.message
|
||||
} catch { /* 保留默认提示 */ }
|
||||
finish(callbacks.onError, msg, { message: msg })
|
||||
return
|
||||
}
|
||||
|
||||
const reader = res.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buf = ''
|
||||
for (;;) {
|
||||
const { value, done } = await reader.read()
|
||||
if (done) break
|
||||
buf += decoder.decode(value, { stream: true })
|
||||
|
||||
// 按空行切分 SSE 事件块(event: xxx\ndata: xxx\n\n)
|
||||
let sep: number
|
||||
while ((sep = buf.indexOf('\n\n')) >= 0) {
|
||||
const chunk = buf.slice(0, sep)
|
||||
buf = buf.slice(sep + 2)
|
||||
|
||||
let event = 'message'
|
||||
const dataLines: string[] = []
|
||||
for (const line of chunk.split('\n')) {
|
||||
if (line.startsWith('event:')) {
|
||||
event = line.slice(6).trim()
|
||||
} else if (line.startsWith('data:')) {
|
||||
dataLines.push(line.slice(5).trim())
|
||||
}
|
||||
}
|
||||
let data: any = {}
|
||||
const dataRaw = dataLines.join('\n')
|
||||
try { data = dataRaw ? JSON.parse(dataRaw) : {} } catch { /* 容忍非 JSON data */ }
|
||||
|
||||
switch (event) {
|
||||
case 'connecting':
|
||||
callbacks.onConnecting?.()
|
||||
break
|
||||
case 'fetching_detail':
|
||||
callbacks.onFetchingDetail?.()
|
||||
break
|
||||
case 'submitting':
|
||||
callbacks.onSubmitting?.()
|
||||
break
|
||||
case 'success':
|
||||
finish(callbacks.onSuccess, data)
|
||||
break
|
||||
case 'task_error':
|
||||
finish(callbacks.onError, data?.message || '执行失败', data)
|
||||
break
|
||||
}
|
||||
if (finished) break
|
||||
}
|
||||
if (finished) break
|
||||
}
|
||||
|
||||
// 流结束但未收到任何结果事件:视为异常中断
|
||||
finish(callbacks.onError, '连接中断,请重试')
|
||||
} catch (e: any) {
|
||||
if (e?.name === 'AbortError') return // 主动取消,不回调
|
||||
finish(callbacks.onError, e?.message || '连接失败,请检查网络')
|
||||
}
|
||||
})()
|
||||
|
||||
return controller
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
<script setup lang="ts">
|
||||
// ConfirmHost —— 全局确认/提示弹窗宿主,挂在 App.vue,全站仅此一个实例。
|
||||
//
|
||||
// 与 useConfirm 的单例状态配合:任何组件 await confirmDialog()/alertDialog() 都会
|
||||
// 反映到这里渲染的 AppDialog 上。视觉对齐 HomeView「确认再次打卡 / 签到次数不足」那类
|
||||
// 居中弹窗(圆形图标底 + 标题 + 灰色说明 + 双按钮)。
|
||||
import AppDialog from '@/components/ui/dialog/AppDialog.vue'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { AlertTriangle, CircleAlert } from 'lucide-vue-next'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
|
||||
const { state, respond } = useConfirm()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppDialog
|
||||
:open="state?.open || false"
|
||||
variant="center"
|
||||
z-class="z-[300]"
|
||||
panel-class="w-[85%] sm:max-w-sm"
|
||||
:fade="true"
|
||||
:title="state?.title || '提示'"
|
||||
@update:open="(v: boolean) => { if (!v) respond(false) }"
|
||||
>
|
||||
<div v-if="state" class="p-6 text-center">
|
||||
<div
|
||||
:class="[
|
||||
'w-14 h-14 rounded-full flex items-center justify-center mx-auto mb-4',
|
||||
state.danger ? 'bg-red-100' : 'bg-blue-100',
|
||||
]"
|
||||
>
|
||||
<AlertTriangle v-if="state.danger" class="w-7 h-7 text-red-500" />
|
||||
<CircleAlert v-else class="w-7 h-7 text-blue-500" />
|
||||
</div>
|
||||
<h3 class="text-base font-semibold text-gray-900 mb-2">{{ state.title }}</h3>
|
||||
<p
|
||||
v-if="state.content"
|
||||
class="text-sm text-gray-500 leading-relaxed whitespace-pre-wrap"
|
||||
>
|
||||
{{ state.content }}
|
||||
</p>
|
||||
<div class="mt-5 flex gap-2">
|
||||
<Button
|
||||
v-if="!state.alertOnly"
|
||||
variant="outline"
|
||||
class="flex-1 h-10 text-sm font-medium"
|
||||
@click="respond(false)"
|
||||
>
|
||||
{{ state.cancelText || '取消' }}
|
||||
</Button>
|
||||
<Button
|
||||
:variant="state.danger ? 'destructive' : 'default'"
|
||||
class="flex-1 h-10 text-sm font-medium"
|
||||
@click="respond(true)"
|
||||
>
|
||||
{{ state.confirmText || '确定' }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</AppDialog>
|
||||
</template>
|
||||
@@ -68,7 +68,7 @@ const steps: GuideStep[] = [
|
||||
tab: 'home',
|
||||
title: '新建每日签到任务',
|
||||
targets: ['[data-guide="home-create-task"]'],
|
||||
descByKind: () => '点击蓝色的「+ 新的签到任务」按钮,选择任务名称(如 晚签到),然后填好对应签到信息并保存,任务就创建好了。',
|
||||
descByKind: () => '点击蓝色的「+ 新的打卡任务」按钮,选择任务名称(如 晚签到),然后填好对应签到信息并保存,任务就创建好了。',
|
||||
},
|
||||
{
|
||||
tab: 'home',
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
<script setup lang="ts">
|
||||
// 版本更新检测:定时拉取首页 index.html,比对 JS/CSS 资源文件名。
|
||||
// 后端每次发布都会重新打包,文件名带内容 hash;资源名变化即说明有新版本,
|
||||
// 弹窗提示用户刷新(确认后立即 reload,拿到新 index.html 与新资源)。
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
import AppDialog from '@/components/ui/dialog/AppDialog.vue'
|
||||
|
||||
const CHECK_INTERVAL = 5 * 60 * 1000 // 5 分钟
|
||||
const FIRST_DELAY = 30 * 1000 // 启动 30 秒后首次检查(避免与首屏请求抢占)
|
||||
const DISMISS_KEY = 'xz_update_dismissed_version'
|
||||
|
||||
const showUpdate = ref(false)
|
||||
// 当前页面加载时使用的资源名集合(相对路径)
|
||||
const currentAssets = new Set<string>()
|
||||
let timer: ReturnType<typeof setTimeout> | null = null
|
||||
let stopped = false
|
||||
let prompting = false
|
||||
let pendingVersion = ''
|
||||
|
||||
/** 从 HTML 文本中提取 /assets/ 下的 js/css 资源名 */
|
||||
function extractAssets(html: string): Set<string> {
|
||||
const out = new Set<string>()
|
||||
const re = /\/assets\/[A-Za-z0-9_.-]+\.(?:js|css)/g
|
||||
let m: RegExpExecArray | null
|
||||
while ((m = re.exec(html)) !== null) {
|
||||
out.add(m[0])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** 读取当前页面已加载的资源名(由 script/link 标签推导,等同当前构建版本) */
|
||||
function collectCurrentAssets(): Set<string> {
|
||||
const out = new Set<string>()
|
||||
document.querySelectorAll<HTMLScriptElement>('script[src]').forEach((el) => {
|
||||
const m = el.src.match(/\/assets\/[A-Za-z0-9_.-]+\.(?:js|css)/)
|
||||
if (m) out.add(m[0])
|
||||
})
|
||||
document.querySelectorAll<HTMLLinkElement>('link[href]').forEach((el) => {
|
||||
const m = el.href.match(/\/assets\/[A-Za-z0-9_.-]+\.(?:js|css)/)
|
||||
if (m) out.add(m[0])
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
async function checkUpdate() {
|
||||
if (stopped || prompting) return
|
||||
try {
|
||||
// 版本探测必须绕过 HTTP 缓存(后端 index.html 有 1 小时 MaxAge),故带时间戳参数
|
||||
const res = await fetch(`/?_v=${Date.now()}`, {
|
||||
cache: 'no-store',
|
||||
headers: { 'Cache-Control': 'no-cache', Pragma: 'no-cache' },
|
||||
})
|
||||
if (!res.ok) return
|
||||
const html = await res.text()
|
||||
const latest = extractAssets(html)
|
||||
if (latest.size === 0 || currentAssets.size === 0) return
|
||||
|
||||
// 任一新资源名未在当前页面出现 → 有新版本
|
||||
const hasNew = [...latest].some((a) => !currentAssets.has(a))
|
||||
if (!hasNew) return
|
||||
|
||||
const newVersion = [...latest].join(',')
|
||||
if (sessionStorage.getItem(DISMISS_KEY) === newVersion) return // 本会话已提示过
|
||||
|
||||
pendingVersion = newVersion
|
||||
prompting = true
|
||||
showUpdate.value = true
|
||||
} catch {
|
||||
// 网络异常静默跳过,等待下一轮
|
||||
} finally {
|
||||
prompting = false
|
||||
}
|
||||
}
|
||||
|
||||
function schedule(delay: number) {
|
||||
if (stopped) return
|
||||
timer = setTimeout(async () => {
|
||||
await checkUpdate()
|
||||
schedule(CHECK_INTERVAL)
|
||||
}, delay)
|
||||
}
|
||||
|
||||
/** 用户确认刷新:立即重载(拿新版本资源) */
|
||||
function reloadNow() {
|
||||
showUpdate.value = false
|
||||
location.reload()
|
||||
}
|
||||
|
||||
/** 用户选择稍后:记录本次版本,本会话内不再重复提示 */
|
||||
function dismiss() {
|
||||
showUpdate.value = false
|
||||
if (pendingVersion) sessionStorage.setItem(DISMISS_KEY, pendingVersion)
|
||||
}
|
||||
|
||||
/** 弹窗关闭统一入口(按钮 / 点遮罩 / Esc 都走这里),关闭即视为「稍后」 */
|
||||
function onDialogOpenChange(value: boolean) {
|
||||
if (value) {
|
||||
showUpdate.value = true
|
||||
return
|
||||
}
|
||||
dismiss()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
currentAssets.clear()
|
||||
collectCurrentAssets().forEach((a) => currentAssets.add(a))
|
||||
schedule(FIRST_DELAY)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
stopped = true
|
||||
if (timer) clearTimeout(timer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 新版本提示弹窗:标题 + 说明 + 稍后/立即刷新 -->
|
||||
<AppDialog
|
||||
:open="showUpdate"
|
||||
variant="center"
|
||||
z-class="z-[200]"
|
||||
panel-class="w-[86%] max-w-[340px] p-5"
|
||||
title="发现新版本"
|
||||
description="网站已更新,刷新后即可使用最新功能,是否立即刷新?"
|
||||
@update:open="onDialogOpenChange"
|
||||
>
|
||||
<h3 class="text-base font-semibold text-gray-900 mb-2">发现新版本</h3>
|
||||
<p class="text-sm text-gray-500 leading-relaxed mb-5">
|
||||
网站已更新,刷新后即可使用最新功能,是否立即刷新?
|
||||
</p>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
class="flex-1 py-2.5 rounded-lg border border-gray-200 text-sm text-gray-600 active:bg-gray-50"
|
||||
@click="dismiss"
|
||||
>
|
||||
稍后
|
||||
</button>
|
||||
<button
|
||||
class="flex-1 py-2.5 rounded-lg bg-blue-600 text-white text-sm font-medium active:bg-blue-700"
|
||||
@click="reloadNow"
|
||||
>
|
||||
立即刷新
|
||||
</button>
|
||||
</div>
|
||||
</AppDialog>
|
||||
</template>
|
||||
@@ -0,0 +1,187 @@
|
||||
<script setup lang="ts">
|
||||
// AppDialog —— 全站统一弹窗封装(reka-ui Dialog 底座)。
|
||||
//
|
||||
// 目的:用 Dialog 原语换取焦点捕获、Esc 关闭、body 滚动锁定、aria 语义与 Portal 渲染,
|
||||
// 同时保持与现有手写弹窗一致的视觉(白色卡片 + 圆角 + 遮罩)。
|
||||
//
|
||||
// 结构说明(与 reka 的约定):
|
||||
// - DialogContent(DismissableLayer)本身不带定位样式,只作为焦点/事件容器;
|
||||
// - 真正的「铺满全屏的定位层」是它内部的 div:pointer-events:none 让点击穿透到底下的
|
||||
// DialogOverlay,从而「点遮罩关闭」才能成立(点在自己身上不算 outside,不会关闭);
|
||||
// - 面板单独 pointer-events:auto,恢复可交互。
|
||||
// 这与项目里 MobileLayout 手写弹窗的 pointer-events 处理方式一致。
|
||||
//
|
||||
// 面板基础样式(w-full / bg-white / flex flex-col / overflow-hidden / max-h-[85vh] / shadow-xl)
|
||||
// 与原手写弹窗一致;**原弹窗没有阴影时**必须在 panelClass 里显式传 shadow-none
|
||||
// (经 tailwind-merge 覆盖,实测 cn('shadow-xl','shadow-none') → 'shadow-none'),
|
||||
// 否则会凭空多出阴影(LeaveView 等页面的面板原本无阴影)。
|
||||
//
|
||||
// 形态(variant):
|
||||
// - sheet(默认):移动端贴底、桌面端居中(≈原 items-end sm:items-center),
|
||||
// 面板默认 rounded-t-2xl sm:rounded-2xl,调用方在 panelClass 里给 sm:max-w-sm;
|
||||
// - center:始终居中(≈原 items-center),调用方在 panelClass 里给宽度与内边距。
|
||||
//
|
||||
// 典型用法:
|
||||
// <AppDialog v-model:open="show" variant="center" z-class="z-[200]"
|
||||
// panel-class="w-[86%] max-w-[340px] p-5" title="发现新版本" :description="desc">
|
||||
// <!-- 面板内容(视觉标题由业务自己渲染,组件内的 Title/Description 仅供读屏) -->
|
||||
// </AppDialog>
|
||||
//
|
||||
// 关闭控制:dismissable=false 时拦截遮罩点击与 Esc(对应「执行中不允许关闭」);
|
||||
// 关闭动作统一经 update:open 冒泡,调用方按需守卫。
|
||||
import {
|
||||
DialogRoot,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from 'reka-ui'
|
||||
import type { FocusOutsideEvent, PointerDownOutsideEvent } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
open: boolean
|
||||
/** sheet=移动端贴底/桌面居中;center=始终居中 */
|
||||
variant?: 'sheet' | 'center'
|
||||
/** 层级类(遮罩与定位层同层,如 z-[60]) */
|
||||
zClass?: string
|
||||
/** 遮罩浓度类(默认 bg-black/40,部分弹窗用 /50) */
|
||||
overlayClass?: string
|
||||
/**
|
||||
* 淡入淡出(默认关)。
|
||||
* 注意:reka-ui 的 Presence 只识别 CSS animation(读 computed animation-name),
|
||||
* **不识别 Vue <Transition> 的 class 过渡**,且 DialogContent 根是 fragment
|
||||
* 无法被 <Transition> 包裹。故原手写弹窗的 `<Transition name="fade">`(0.2s 透明度)
|
||||
* 需由本组件的 CSS animation 承接(见文件末尾 style)。
|
||||
*/
|
||||
fade?: boolean
|
||||
/** 定位层附加类(如 px-6 给居中弹窗留边距) */
|
||||
wrapperClass?: string
|
||||
/** 面板附加类(宽度/内边距/圆角等,经 tailwind-merge 合并) */
|
||||
panelClass?: string
|
||||
/** false 时禁止点遮罩与 Esc 关闭(执行中场景) */
|
||||
dismissable?: boolean
|
||||
/** 读屏标题;视觉标题由业务在默认插槽自渲染 */
|
||||
title?: string
|
||||
/** 读屏描述(缺省回退为标题,避免 aria-describedby 指向不存在的节点) */
|
||||
description?: string
|
||||
}>(),
|
||||
{
|
||||
variant: 'sheet',
|
||||
zClass: 'z-50',
|
||||
overlayClass: 'bg-black/40',
|
||||
wrapperClass: '',
|
||||
panelClass: '',
|
||||
dismissable: true,
|
||||
fade: false,
|
||||
title: '提示',
|
||||
description: '',
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:open', value: boolean): void
|
||||
}>()
|
||||
|
||||
function onOpenChange(value: boolean) {
|
||||
emit('update:open', value)
|
||||
}
|
||||
|
||||
// reka 以 CustomEvent 派发,preventDefault 即阻止 dismiss(见 DismissableLayer 实现)
|
||||
function onInteractOutside(e: PointerDownOutsideEvent | FocusOutsideEvent) {
|
||||
if (!props.dismissable) e.preventDefault()
|
||||
}
|
||||
|
||||
function onEscapeKeyDown(e: KeyboardEvent) {
|
||||
if (!props.dismissable) e.preventDefault()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogRoot :open="props.open" @update:open="onOpenChange">
|
||||
<DialogPortal>
|
||||
<!-- 遮罩:reka 会自动加 pointer-events:auto 并响应「点遮罩关闭」 -->
|
||||
<DialogOverlay
|
||||
:class="cn('fixed inset-0', props.overlayClass, props.zClass, props.fade && 'app-dialog-fade')"
|
||||
/>
|
||||
|
||||
<!-- 事件/焦点容器:本身不承载布局(零尺寸),只作为焦点/事件容器。
|
||||
fade 类必须挂在这里:它会 forward 到 role="dialog" 那个根节点,也就是
|
||||
Presence 追踪的节点;Presence 靠监听该节点自身的 animationend 判断离场
|
||||
结束,动画挂到子节点上则读不到,会直接卸载(瞬间消失)。
|
||||
fade 时额外加 relative + 同层 z:淡入淡出的 opacity<1 会让本节点形成
|
||||
z-index:0 的层叠上下文,届时内部 fixed 面板被困其中,而 overlay(正 z)
|
||||
会在其后绘制、把面板盖住 0.2s 再突然跳回;加上定位与 zClass 后本节点
|
||||
与 overlay 同级且 DOM 在后 → 始终在上。不加 fade 时保持原样(零改动)。 -->
|
||||
<DialogContent
|
||||
:class="cn('outline-none', props.fade && ['relative', props.zClass, 'app-dialog-fade'])"
|
||||
@interact-outside="onInteractOutside"
|
||||
@escape-key-down="onEscapeKeyDown"
|
||||
>
|
||||
<!-- 定位层:铺满全屏但点击穿透(点空白处落到遮罩上才能关闭) -->
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'fixed inset-0 flex justify-center',
|
||||
props.variant === 'sheet' ? 'items-end sm:items-center' : 'items-center',
|
||||
props.wrapperClass,
|
||||
props.zClass,
|
||||
)
|
||||
"
|
||||
style="pointer-events: none"
|
||||
>
|
||||
<!-- 面板:视觉与手写弹窗一致;pointer-events 恢复以便交互 -->
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'relative w-full bg-white flex flex-col overflow-hidden shadow-xl max-h-[85vh]',
|
||||
props.variant === 'sheet' ? 'rounded-t-2xl sm:rounded-2xl' : 'rounded-2xl',
|
||||
props.panelClass,
|
||||
)
|
||||
"
|
||||
style="pointer-events: auto"
|
||||
>
|
||||
<!-- 读屏标题/描述(视觉标题由业务自渲染,这里不重复显示) -->
|
||||
<DialogTitle class="sr-only">{{ props.title }}</DialogTitle>
|
||||
<DialogDescription class="sr-only">{{ props.description || props.title }}</DialogDescription>
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</DialogPortal>
|
||||
</DialogRoot>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
/* 淡入淡出(fade=true 时启用)。
|
||||
*
|
||||
* 为什么不用 <Transition>:reka-ui 的 Presence 通过读取 computed style 的
|
||||
* animation-name 判断「是否正在动画」,只认 CSS animation;Vue 的 <Transition>
|
||||
* 是 class 过渡,Presence 感知不到,会导致离场动画被直接跳过(瞬间消失)。
|
||||
* 因此这里用 CSS animation + reka 输出的 data-state="open|closed" 实现进出场。
|
||||
* 非 scoped:内容经 DialogPortal 传送到 body,且需同时作用于 overlay 与 content。 */
|
||||
.app-dialog-fade[data-state='open'] {
|
||||
animation: app-dialog-fade-in 0.2s ease;
|
||||
}
|
||||
.app-dialog-fade[data-state='closed'] {
|
||||
animation: app-dialog-fade-out 0.2s ease;
|
||||
}
|
||||
@keyframes app-dialog-fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@keyframes app-dialog-fade-out {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,76 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
// 全局确认/提示弹窗服务。
|
||||
//
|
||||
// 背景:项目此前用浏览器原生 confirm()/alert()(v1.2.55「弹窗统一化」只迁移了手写
|
||||
// role="dialog" 元素,原生调用不在其扫描范围内,故遗留至今)。这里提供与 useMessage
|
||||
// 同风格的模块级单例服务:任意组件调用 confirmDialog/alertDialog 即弹出统一的 AppDialog。
|
||||
//
|
||||
// 用法:
|
||||
// const ok = await confirmDialog({ title: '删除任务', content: '确定要删除此任务吗?', danger: true })
|
||||
// if (!ok) return
|
||||
// await alertDialog({ title: '保存失败', content: msg })
|
||||
|
||||
export interface ConfirmOptions {
|
||||
/** 标题(同时作为读屏标题) */
|
||||
title: string
|
||||
/** 说明文字(可选,支持换行) */
|
||||
content?: string
|
||||
/** 确认按钮文案,默认「确定」 */
|
||||
confirmText?: string
|
||||
/** 取消按钮文案,默认「取消」 */
|
||||
cancelText?: string
|
||||
/** true 时用警示样式(红色图标 + destructive 确认按钮),用于删除等破坏性操作 */
|
||||
danger?: boolean
|
||||
/** true 时只显示确认按钮(提示框形态,对应原 alert) */
|
||||
alertOnly?: boolean
|
||||
}
|
||||
|
||||
interface ConfirmState extends ConfirmOptions {
|
||||
open: boolean
|
||||
}
|
||||
|
||||
const state = ref<ConfirmState | null>(null)
|
||||
let resolver: ((ok: boolean) => void) | null = null
|
||||
|
||||
/**
|
||||
* 弹出确认框,返回用户是否确认(取消/遮罩/Esc 均为 false)。
|
||||
* 同一时刻只保留一个确认框:若上一个尚未结清,先以 false 结清,避免 Promise 悬挂。
|
||||
*/
|
||||
export function confirmDialog(options: ConfirmOptions): Promise<boolean> {
|
||||
if (resolver) {
|
||||
const prev = resolver
|
||||
resolver = null
|
||||
prev(false)
|
||||
}
|
||||
state.value = { ...options, open: true }
|
||||
return new Promise<boolean>((resolve) => {
|
||||
resolver = resolve
|
||||
})
|
||||
}
|
||||
|
||||
/** 弹出纯提示框(单按钮,对应原 alert),用户关闭后 resolve(true) */
|
||||
export function alertDialog(
|
||||
options: Omit<ConfirmOptions, 'cancelText' | 'alertOnly'>,
|
||||
): Promise<boolean> {
|
||||
return confirmDialog({
|
||||
...options,
|
||||
alertOnly: true,
|
||||
confirmText: options.confirmText || '我知道了',
|
||||
})
|
||||
}
|
||||
|
||||
export function useConfirm() {
|
||||
function respond(ok: boolean) {
|
||||
state.value = state.value ? { ...state.value, open: false } : null
|
||||
const r = resolver
|
||||
resolver = null
|
||||
r?.(ok)
|
||||
// 等离场动画结束再清空内容,避免关闭过程中文字/图标突然消失
|
||||
setTimeout(() => {
|
||||
if (state.value && !state.value.open) state.value = null
|
||||
}, 200)
|
||||
}
|
||||
|
||||
return { state, respond }
|
||||
}
|
||||
@@ -30,6 +30,7 @@ export function useMessage() {
|
||||
success: (msg: string) => addToast('success', msg),
|
||||
error: (msg: string) => addToast('error', msg),
|
||||
info: (msg: string) => addToast('info', msg),
|
||||
warning: (msg: string) => addToast('info', msg),
|
||||
removeToast,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted, onUnmounted, provide } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { CalendarCheck2, CalendarX2, User, Bell, X, CheckCircle, ChevronDown, MessageSquare, Sparkles } from 'lucide-vue-next'
|
||||
import { CalendarCheck2, CalendarX2, User, Bell, X, CheckCircle, ChevronDown, MessageSquare, Sparkles, BookOpen } from 'lucide-vue-next'
|
||||
import { useMessage } from '@/composables/useMessage'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import AppDialog from '@/components/ui/dialog/AppDialog.vue'
|
||||
import GuideOverlay from '@/components/GuideOverlay.vue'
|
||||
import * as authApi from '@/api/auth'
|
||||
import * as announceApi from '@/api/announcement'
|
||||
@@ -20,7 +21,8 @@ const avatarFailed = ref(false)
|
||||
|
||||
const tabs = [
|
||||
{ name: 'home', label: '打卡', icon: CalendarCheck2 },
|
||||
{ name: 'leave', label: '销假', icon: CalendarX2 },
|
||||
{ name: 'leave', label: '请/销假', icon: CalendarX2 },
|
||||
{ name: 'course', label: '网课', icon: BookOpen },
|
||||
{ name: 'profile', label: '个人中心', icon: User },
|
||||
]
|
||||
|
||||
@@ -252,8 +254,8 @@ onUnmounted(() => {
|
||||
|
||||
<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="activeTab === 'home' || activeTab === 'leave'" 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" />
|
||||
@@ -263,7 +265,7 @@ onUnmounted(() => {
|
||||
<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>
|
||||
@@ -297,193 +299,187 @@ onUnmounted(() => {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ======== 公告面板遮罩 ======== -->
|
||||
<transition name="fade">
|
||||
<div v-if="showAnnPanel" class="fixed inset-0 z-50 bg-black/50" @click="showAnnPanel = false" />
|
||||
</transition>
|
||||
<!-- ======== 公告面板(遮罩 + 底部面板) ======== -->
|
||||
<AppDialog
|
||||
:open="showAnnPanel"
|
||||
overlay-class="bg-black/50"
|
||||
panel-class="sm:max-w-sm max-h-[80vh] shadow-2xl"
|
||||
:fade="true"
|
||||
title="通知"
|
||||
@update:open="(v: boolean) => { if (!v) showAnnPanel = false }"
|
||||
>
|
||||
<!-- 拖拽指示条 -->
|
||||
<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>
|
||||
|
||||
<!-- ======== 公告底部面板 ======== -->
|
||||
<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 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="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 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>
|
||||
<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>
|
||||
|
||||
<!-- 分隔线 -->
|
||||
<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 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>
|
||||
</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 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="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 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>
|
||||
</transition>
|
||||
</AppDialog>
|
||||
|
||||
<!-- ======== 公告详情 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>
|
||||
<AppDialog
|
||||
:open="showDetail && !!detailItem"
|
||||
variant="center"
|
||||
z-class="z-[60]"
|
||||
overlay-class="bg-black/50"
|
||||
wrapper-class="px-6"
|
||||
panel-class="max-w-sm shadow-2xl"
|
||||
:fade="true"
|
||||
:title="detailItem?.title || '公告详情'"
|
||||
@update:open="(v: boolean) => { if (!v) showDetail = false }"
|
||||
>
|
||||
<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>
|
||||
</transition>
|
||||
</Teleport>
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<span class="text-[10px] text-gray-400">{{ detailItem ? 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>
|
||||
</AppDialog>
|
||||
|
||||
<!-- ======== 强制弹出公告 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>
|
||||
<AppDialog
|
||||
:open="!!currentForcePopup"
|
||||
variant="center"
|
||||
z-class="z-[60]"
|
||||
overlay-class="bg-black/50"
|
||||
wrapper-class="px-6"
|
||||
panel-class="max-w-sm shadow-2xl"
|
||||
:fade="true"
|
||||
:title="currentForcePopup?.title || '公告'"
|
||||
@update:open="(v: boolean) => { if (!v) handleForcePopupDismiss() }"
|
||||
>
|
||||
<!-- 顶部装饰条 -->
|
||||
<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>
|
||||
</transition>
|
||||
</Teleport>
|
||||
<Button class="w-full h-11 text-sm font-medium rounded-xl" @click="handleForcePopupDismiss">
|
||||
我知道了
|
||||
</Button>
|
||||
</div>
|
||||
</AppDialog>
|
||||
|
||||
<!-- ======== 新手引导入口(全局:所有布局内页面显示;引导展示期间隐藏) ======== -->
|
||||
<button
|
||||
@@ -507,24 +503,6 @@ onUnmounted(() => {
|
||||
</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%);
|
||||
}
|
||||
|
||||
/* 引导入口气泡:上下浮动跳动,吸引注意但不刺眼 */
|
||||
@keyframes guide-bob {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
|
||||
@@ -3,6 +3,7 @@ import LoginView from '@/views/login/LoginView.vue'
|
||||
import HomeView from '@/views/home/HomeView.vue'
|
||||
import ProfileView from '@/views/profile/ProfileView.vue'
|
||||
import LeaveView from '@/views/leave/LeaveView.vue'
|
||||
import CourseView from '@/views/course/CourseView.vue'
|
||||
import BindDeviceView from '@/views/bind/BindDeviceView.vue'
|
||||
import MobileLayout from '@/layouts/MobileLayout.vue'
|
||||
import BrowserGuideView from '@/views/guide/BrowserGuideView.vue'
|
||||
@@ -151,6 +152,11 @@ const router = createRouter({
|
||||
name: 'leave',
|
||||
component: LeaveView,
|
||||
},
|
||||
{
|
||||
path: 'course',
|
||||
name: 'course',
|
||||
component: CourseView,
|
||||
},
|
||||
{
|
||||
path: 'profile',
|
||||
name: 'profile',
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { } from '@/components/ui/card'
|
||||
import AppDialog from '@/components/ui/dialog/AppDialog.vue'
|
||||
import { useMessage } from '@/composables/useMessage'
|
||||
import * as adminApi from '@/api/admin'
|
||||
|
||||
@@ -294,149 +294,157 @@ async function handleToggleStatus(agent: adminApi.AgentUser) {
|
||||
</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>
|
||||
<AppDialog
|
||||
:open="showCreateModal"
|
||||
variant="center"
|
||||
panel-class="rounded-xl max-w-md mx-4 p-6"
|
||||
title="创建下级代理"
|
||||
@update:open="(v: boolean) => { if (!v) showCreateModal = false }"
|
||||
>
|
||||
<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 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>
|
||||
</Teleport>
|
||||
|
||||
<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>
|
||||
</AppDialog>
|
||||
|
||||
<!-- 充值弹窗 -->
|
||||
<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>
|
||||
<AppDialog
|
||||
:open="showRechargeModal"
|
||||
variant="center"
|
||||
panel-class="rounded-xl max-w-sm mx-4 p-6"
|
||||
title="充值"
|
||||
@update:open="(v: boolean) => { if (!v) showRechargeModal = false }"
|
||||
>
|
||||
<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>
|
||||
</Teleport>
|
||||
|
||||
<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>
|
||||
</AppDialog>
|
||||
|
||||
<!-- 修改费率弹窗 -->
|
||||
<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>
|
||||
<AppDialog
|
||||
:open="showRateModal"
|
||||
variant="center"
|
||||
panel-class="rounded-xl max-w-sm mx-4 p-6"
|
||||
title="修改费率"
|
||||
@update:open="(v: boolean) => { if (!v) showRateModal = false }"
|
||||
>
|
||||
<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>
|
||||
</Teleport>
|
||||
|
||||
<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>
|
||||
</AppDialog>
|
||||
|
||||
<!-- 修改密码弹窗 (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>
|
||||
<AppDialog
|
||||
:open="showPasswordModal"
|
||||
variant="center"
|
||||
panel-class="rounded-xl max-w-sm mx-4 p-6"
|
||||
title="修改密码"
|
||||
@update:open="(v: boolean) => { if (!v) showPasswordModal = false }"
|
||||
>
|
||||
<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>
|
||||
<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>
|
||||
</Teleport>
|
||||
|
||||
<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>
|
||||
</AppDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -4,7 +4,9 @@ import { Megaphone, Plus, Pencil, Trash2, X, Bell, Users, User, Bot, Search } fr
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import AppDialog from '@/components/ui/dialog/AppDialog.vue'
|
||||
import { useMessage } from '@/composables/useMessage'
|
||||
import { confirmDialog } from '@/composables/useConfirm'
|
||||
import * as adminApi from '@/api/admin'
|
||||
|
||||
const message = useMessage()
|
||||
@@ -133,7 +135,13 @@ async function handleDelete(id: number) {
|
||||
message.info('系统自动生成的公告不能删除')
|
||||
return
|
||||
}
|
||||
if (!confirm('确定要删除此公告吗?')) return
|
||||
const ok = await confirmDialog({
|
||||
title: '删除公告',
|
||||
content: '确定要删除此公告吗?',
|
||||
confirmText: '删除',
|
||||
danger: true,
|
||||
})
|
||||
if (!ok) return
|
||||
try {
|
||||
await adminApi.deleteAnnouncement(id)
|
||||
message.success('已删除')
|
||||
@@ -329,129 +337,127 @@ function scopeLabel(item: AnnouncementEx) {
|
||||
</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">{{ editingId ? '编辑公告' : '新建公告' }}</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>
|
||||
<AppDialog
|
||||
:open="showCreateModal"
|
||||
variant="center"
|
||||
panel-class="w-[85%] sm:max-w-md"
|
||||
:title="editingId ? '编辑公告' : '新建公告'"
|
||||
@update:open="(v: boolean) => { showCreateModal = v }"
|
||||
>
|
||||
<div class="flex items-center justify-between px-5 pt-5 pb-3">
|
||||
<h3 class="text-base font-semibold text-gray-900">{{ editingId ? '编辑公告' : '新建公告' }}</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 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>
|
||||
<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>
|
||||
<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="formTargetMode === 'all' ? 'bg-blue-500 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'"
|
||||
@click="formTargetMode = 'all'"
|
||||
>
|
||||
<Users class="w-3.5 h-3.5 inline mr-1" />
|
||||
全部
|
||||
</button>
|
||||
<button
|
||||
class="flex-1 h-10 rounded-lg text-sm font-medium transition-colors"
|
||||
:class="formTargetMode === 'specific' ? 'bg-blue-500 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'"
|
||||
@click="formTargetMode = 'specific'"
|
||||
>
|
||||
<User class="w-3.5 h-3.5 inline mr-1" />
|
||||
指定用户
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 指定用户ID输入 -->
|
||||
<div v-if="formTargetMode === 'specific'">
|
||||
<label class="block text-xs font-medium text-gray-500 mb-1.5">用户ID</label>
|
||||
<Input
|
||||
v-model="formTargetUserId"
|
||||
type="number"
|
||||
placeholder="输入用户ID"
|
||||
class="h-10 text-sm"
|
||||
/>
|
||||
<p class="text-[10px] text-gray-400 mt-1">可以在用户管理页面查看用户ID</p>
|
||||
</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 ? '保存中...' : (editingId ? '保存修改' : '发布公告') }}
|
||||
</Button>
|
||||
<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>
|
||||
<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="formTargetMode === 'all' ? 'bg-blue-500 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'"
|
||||
@click="formTargetMode = 'all'"
|
||||
>
|
||||
<Users class="w-3.5 h-3.5 inline mr-1" />
|
||||
全部
|
||||
</button>
|
||||
<button
|
||||
class="flex-1 h-10 rounded-lg text-sm font-medium transition-colors"
|
||||
:class="formTargetMode === 'specific' ? 'bg-blue-500 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'"
|
||||
@click="formTargetMode = 'specific'"
|
||||
>
|
||||
<User class="w-3.5 h-3.5 inline mr-1" />
|
||||
指定用户
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 指定用户ID输入 -->
|
||||
<div v-if="formTargetMode === 'specific'">
|
||||
<label class="block text-xs font-medium text-gray-500 mb-1.5">用户ID</label>
|
||||
<Input
|
||||
v-model="formTargetUserId"
|
||||
type="number"
|
||||
placeholder="输入用户ID"
|
||||
class="h-10 text-sm"
|
||||
/>
|
||||
<p class="text-[10px] text-gray-400 mt-1">可以在用户管理页面查看用户ID</p>
|
||||
</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 ? '保存中...' : (editingId ? '保存修改' : '发布公告') }}
|
||||
</Button>
|
||||
</div>
|
||||
</Teleport>
|
||||
</AppDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import AppDialog from '@/components/ui/dialog/AppDialog.vue'
|
||||
import { useMessage } from '@/composables/useMessage'
|
||||
import { copyToClipboard } from '@/utils/clipboard'
|
||||
import * as adminApi from '@/api/admin'
|
||||
@@ -182,8 +183,8 @@ async function copyCode(code: string, index: number) {
|
||||
|
||||
async function copyResultCode(code: string, index: number) {
|
||||
if (await copyToClipboard(code)) {
|
||||
resultCopiedIdx.value = index
|
||||
setTimeout(() => { resultCopiedIdx.value = null }, 2000)
|
||||
copiedResultIdx.value = index
|
||||
setTimeout(() => { copiedResultIdx.value = null }, 2000)
|
||||
message.success('已复制')
|
||||
} else {
|
||||
message.error('复制失败,请长按 CDK 码手动复制')
|
||||
@@ -434,181 +435,183 @@ function statusClass(s: string) {
|
||||
</div>
|
||||
|
||||
<!-- 生成CDK弹窗(form / result 两视图) -->
|
||||
<Teleport to="body">
|
||||
<div v-if="showGenerateModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/40" @click.self="modalView === 'form' ? (showGenerateModal = false) : null">
|
||||
<div
|
||||
class="bg-white rounded-xl shadow-xl w-full mx-4 p-6 flex flex-col"
|
||||
:class="modalView === 'result' ? 'max-w-lg max-h-[80vh]' : 'max-w-sm'"
|
||||
>
|
||||
<!-- 配置视图 -->
|
||||
<template v-if="modalView === 'form'">
|
||||
<div class="flex items-center justify-between mb-4 flex-shrink-0">
|
||||
<h2 class="text-lg font-semibold text-gray-900">生成CDK</h2>
|
||||
<button class="text-gray-400 hover:text-gray-600" @click="closeGenerateModal">
|
||||
<X class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<AppDialog
|
||||
:open="showGenerateModal"
|
||||
variant="center"
|
||||
:dismissable="modalView === 'form'"
|
||||
:panel-class="modalView === 'result' ? 'rounded-xl mx-4 p-6 max-w-lg max-h-[80vh]' : 'rounded-xl mx-4 p-6 max-w-sm'"
|
||||
title="生成CDK"
|
||||
@update:open="(v: boolean) => { if (!v && modalView === 'form') showGenerateModal = false }"
|
||||
>
|
||||
<!-- 配置视图 -->
|
||||
<template v-if="modalView === 'form'">
|
||||
<div class="flex items-center justify-between mb-4 flex-shrink-0">
|
||||
<h2 class="text-lg font-semibold text-gray-900">生成CDK</h2>
|
||||
<button class="text-gray-400 hover:text-gray-600" @click="closeGenerateModal">
|
||||
<X class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<Label class="mb-2 block text-sm text-gray-600">CDK 类型</Label>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="h-10 rounded-md border text-sm font-medium transition-colors"
|
||||
:class="generateType === 'count' ? 'bg-emerald-600 text-white border-emerald-600' : 'bg-white text-gray-700 border-gray-300 hover:border-emerald-400'"
|
||||
@click="generateType = 'count'"
|
||||
>次数 CDK</button>
|
||||
<button
|
||||
type="button"
|
||||
class="h-10 rounded-md border text-sm font-medium transition-colors"
|
||||
:class="generateType === 'vip' ? 'bg-amber-500 text-white border-amber-500' : 'bg-white text-gray-700 border-gray-300 hover:border-amber-400'"
|
||||
@click="generateType = 'vip'"
|
||||
>⭐ VIP CDK</button>
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
<template v-if="generateType === 'count'">
|
||||
<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>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div>
|
||||
<Label class="mb-1 block text-sm text-gray-600">VIP 卡类型</Label>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="h-10 rounded-md border text-sm font-medium transition-colors"
|
||||
:class="generateVipDays === 7 ? 'bg-amber-500 text-white border-amber-500' : 'bg-white text-gray-700 border-gray-300 hover:border-amber-400'"
|
||||
@click="generateVipDays = 7"
|
||||
>周卡 · 7天</button>
|
||||
<button
|
||||
type="button"
|
||||
class="h-10 rounded-md border text-sm font-medium transition-colors"
|
||||
:class="generateVipDays === 30 ? 'bg-amber-500 text-white border-amber-500' : 'bg-white text-gray-700 border-gray-300 hover:border-amber-400'"
|
||||
@click="generateVipDays = 30"
|
||||
>月卡 · 30天</button>
|
||||
</div>
|
||||
<p class="text-xs text-gray-400 mt-1">兑换后从当前到期时间顺延,到期即享免扣次数执行任务</p>
|
||||
</div>
|
||||
</template>
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<Label class="mb-2 block text-sm text-gray-600">CDK 类型</Label>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="h-10 rounded-md border text-sm font-medium transition-colors"
|
||||
:class="generateType === 'count' ? 'bg-emerald-600 text-white border-emerald-600' : 'bg-white text-gray-700 border-gray-300 hover:border-emerald-400'"
|
||||
@click="generateType = 'count'"
|
||||
>次数 CDK</button>
|
||||
<button
|
||||
type="button"
|
||||
class="h-10 rounded-md border text-sm font-medium transition-colors"
|
||||
:class="generateType === 'vip' ? 'bg-amber-500 text-white border-amber-500' : 'bg-white text-gray-700 border-gray-300 hover:border-amber-400'"
|
||||
@click="generateType = 'vip'"
|
||||
>⭐ VIP CDK</button>
|
||||
</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>
|
||||
<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>
|
||||
<template v-if="generateType === 'count'">
|
||||
<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>
|
||||
</template>
|
||||
|
||||
<!-- 结果视图:展示全部CDK -->
|
||||
<template v-else>
|
||||
<div class="flex items-center justify-between mb-3 flex-shrink-0">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-gray-900 flex items-center gap-2">
|
||||
<CheckCircle2 class="w-5 h-5 text-green-500" />
|
||||
生成成功
|
||||
</h2>
|
||||
<p class="text-xs text-gray-400 mt-0.5">
|
||||
共 {{ resultCdks.length }} 个CDK ·
|
||||
<template v-if="generateType === 'vip'">{{ generateVipDays === 7 ? 'VIP 周卡 7 天' : 'VIP 月卡 30 天' }} · </template>
|
||||
<template v-else>每张 {{ generateMaxRuns }} 次 · </template>
|
||||
扣费
|
||||
<span class="font-mono text-gray-600">{{ resultCost.toFixed(2) }} 元</span>
|
||||
</p>
|
||||
</div>
|
||||
<button class="text-gray-400 hover:text-gray-600" @click="closeGenerateModal">
|
||||
<X class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- CDK 列表(可滚动) -->
|
||||
<div class="flex-1 overflow-y-auto border border-gray-100 rounded-lg bg-gray-50/60 min-h-[120px] max-h-[40vh]">
|
||||
<div
|
||||
v-for="cdk in resultCdks"
|
||||
:key="cdk.id"
|
||||
class="flex items-center justify-between px-3 py-2 border-b border-gray-100 last:border-0 bg-white"
|
||||
>
|
||||
<code class="font-mono text-xs text-gray-800">{{ cdk.code }}</code>
|
||||
<div>
|
||||
<Label class="mb-1 block text-sm text-gray-600">VIP 卡类型</Label>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
class="flex-shrink-0 inline-flex items-center gap-1 px-2 py-1 rounded-md text-[11px] transition-colors"
|
||||
:class="copiedResultIdx === resultCdks.indexOf(cdk) ? 'text-green-600 bg-green-50' : 'text-blue-500 hover:bg-blue-50'"
|
||||
@click="copyResultCode(cdk.code, resultCdks.indexOf(cdk))"
|
||||
>
|
||||
<Copy v-if="copiedResultIdx !== resultCdks.indexOf(cdk)" class="w-3 h-3" />
|
||||
<CheckCheck v-else class="w-3 h-3" />
|
||||
复制
|
||||
</button>
|
||||
type="button"
|
||||
class="h-10 rounded-md border text-sm font-medium transition-colors"
|
||||
:class="generateVipDays === 7 ? 'bg-amber-500 text-white border-amber-500' : 'bg-white text-gray-700 border-gray-300 hover:border-amber-400'"
|
||||
@click="generateVipDays = 7"
|
||||
>周卡 · 7天</button>
|
||||
<button
|
||||
type="button"
|
||||
class="h-10 rounded-md border text-sm font-medium transition-colors"
|
||||
:class="generateVipDays === 30 ? 'bg-amber-500 text-white border-amber-500' : 'bg-white text-gray-700 border-gray-300 hover:border-amber-400'"
|
||||
@click="generateVipDays = 30"
|
||||
>月卡 · 30天</button>
|
||||
</div>
|
||||
<p class="text-xs text-gray-400 mt-1">兑换后从当前到期时间顺延,到期即享免扣次数执行任务</p>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 mt-4 flex-shrink-0">
|
||||
<Button variant="outline" class="h-10 px-4" @click="openGenerateModal">
|
||||
<Plus class="w-4 h-4 mr-1" />
|
||||
继续生成
|
||||
</Button>
|
||||
<Button class="flex-1 h-10" @click="copyAllCodes">
|
||||
<CheckCheck v-if="copiedAll" class="w-4 h-4 mr-1" />
|
||||
<Copy v-else class="w-4 h-4 mr-1" />
|
||||
{{ copiedAll ? '已复制全部' : '复制全部CDK' }}
|
||||
</Button>
|
||||
</div>
|
||||
<p class="text-center text-[10px] text-gray-300 mt-2">
|
||||
提示:CDK 按行分隔,可直接粘贴到表格/文档批量分发
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<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>
|
||||
</template>
|
||||
|
||||
<!-- 结果视图:展示全部CDK -->
|
||||
<template v-else>
|
||||
<div class="flex items-center justify-between mb-3 flex-shrink-0">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-gray-900 flex items-center gap-2">
|
||||
<CheckCircle2 class="w-5 h-5 text-green-500" />
|
||||
生成成功
|
||||
</h2>
|
||||
<p class="text-xs text-gray-400 mt-0.5">
|
||||
共 {{ resultCdks.length }} 个CDK ·
|
||||
<template v-if="generateType === 'vip'">{{ generateVipDays === 7 ? 'VIP 周卡 7 天' : 'VIP 月卡 30 天' }} · </template>
|
||||
<template v-else>每张 {{ generateMaxRuns }} 次 · </template>
|
||||
扣费
|
||||
<span class="font-mono text-gray-600">{{ resultCost.toFixed(2) }} 元</span>
|
||||
</p>
|
||||
</div>
|
||||
<button class="text-gray-400 hover:text-gray-600" @click="closeGenerateModal">
|
||||
<X class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- CDK 列表(可滚动) -->
|
||||
<div class="flex-1 overflow-y-auto border border-gray-100 rounded-lg bg-gray-50/60 min-h-[120px] max-h-[40vh]">
|
||||
<div
|
||||
v-for="cdk in resultCdks"
|
||||
:key="cdk.id"
|
||||
class="flex items-center justify-between px-3 py-2 border-b border-gray-100 last:border-0 bg-white"
|
||||
>
|
||||
<code class="font-mono text-xs text-gray-800">{{ cdk.code }}</code>
|
||||
<button
|
||||
class="flex-shrink-0 inline-flex items-center gap-1 px-2 py-1 rounded-md text-[11px] transition-colors"
|
||||
:class="copiedResultIdx === resultCdks.indexOf(cdk) ? 'text-green-600 bg-green-50' : 'text-blue-500 hover:bg-blue-50'"
|
||||
@click="copyResultCode(cdk.code, resultCdks.indexOf(cdk))"
|
||||
>
|
||||
<Copy v-if="copiedResultIdx !== resultCdks.indexOf(cdk)" class="w-3 h-3" />
|
||||
<CheckCheck v-else class="w-3 h-3" />
|
||||
复制
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 mt-4 flex-shrink-0">
|
||||
<Button variant="outline" class="h-10 px-4" @click="openGenerateModal">
|
||||
<Plus class="w-4 h-4 mr-1" />
|
||||
继续生成
|
||||
</Button>
|
||||
<Button class="flex-1 h-10" @click="copyAllCodes">
|
||||
<CheckCheck v-if="copiedAll" class="w-4 h-4 mr-1" />
|
||||
<Copy v-else class="w-4 h-4 mr-1" />
|
||||
{{ copiedAll ? '已复制全部' : '复制全部CDK' }}
|
||||
</Button>
|
||||
</div>
|
||||
<p class="text-center text-[10px] text-gray-300 mt-2">
|
||||
提示:CDK 按行分隔,可直接粘贴到表格/文档批量分发
|
||||
</p>
|
||||
</template>
|
||||
</AppDialog>
|
||||
|
||||
<!-- 禁用/恢复确认弹窗 -->
|
||||
<Teleport to="body">
|
||||
<div v-if="showStatusModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/40" @click.self="showStatusModal = false">
|
||||
<div class="bg-white rounded-xl shadow-xl w-full max-w-sm mx-4 p-6">
|
||||
<div class="flex items-center gap-2.5 mb-3">
|
||||
<div
|
||||
class="w-9 h-9 rounded-xl flex items-center justify-center"
|
||||
:class="statusAction === 'disable' ? 'bg-red-100' : 'bg-green-100'"
|
||||
>
|
||||
<Ban v-if="statusAction === 'disable'" class="w-4 h-4 text-red-500" />
|
||||
<RotateCcw v-else class="w-4 h-4 text-green-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-base font-semibold text-gray-900">{{ statusText }}</h2>
|
||||
<p class="text-xs text-gray-400 mt-0.5">{{ statusTarget?.code }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-sm text-gray-500 leading-relaxed text-justify">
|
||||
<template v-if="statusAction === 'disable'">
|
||||
禁用后该CDK将<strong class="text-red-500">无法被用户兑换</strong>,如需使用可随时恢复。
|
||||
</template>
|
||||
<template v-else>
|
||||
恢复后该CDK将重新变为<strong class="text-green-600">未使用</strong>状态,可被用户兑换。
|
||||
</template>
|
||||
</p>
|
||||
<div class="flex gap-2 mt-5">
|
||||
<Button variant="outline" class="flex-1 h-10" @click="showStatusModal = false">取消</Button>
|
||||
<Button
|
||||
class="flex-1 h-10"
|
||||
:class="statusAction === 'disable' ? 'bg-red-500 hover:bg-red-600' : 'bg-green-500 hover:bg-green-600'"
|
||||
:disabled="statusLoading"
|
||||
@click="confirmStatusChange"
|
||||
>
|
||||
<LoaderCircle v-if="statusLoading" class="w-4 h-4 animate-spin mr-1" />
|
||||
{{ statusAction === 'disable' ? '确认禁用' : '确认恢复' }}
|
||||
</Button>
|
||||
</div>
|
||||
<AppDialog
|
||||
:open="showStatusModal"
|
||||
variant="center"
|
||||
panel-class="rounded-xl max-w-sm mx-4 p-6"
|
||||
:title="statusText"
|
||||
@update:open="(v: boolean) => { if (!v) showStatusModal = false }"
|
||||
>
|
||||
<div class="flex items-center gap-2.5 mb-3">
|
||||
<div
|
||||
class="w-9 h-9 rounded-xl flex items-center justify-center"
|
||||
:class="statusAction === 'disable' ? 'bg-red-100' : 'bg-green-100'"
|
||||
>
|
||||
<Ban v-if="statusAction === 'disable'" class="w-4 h-4 text-red-500" />
|
||||
<RotateCcw v-else class="w-4 h-4 text-green-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-base font-semibold text-gray-900">{{ statusText }}</h2>
|
||||
<p class="text-xs text-gray-400 mt-0.5">{{ statusTarget?.code }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
<p class="text-sm text-gray-500 leading-relaxed text-justify">
|
||||
<template v-if="statusAction === 'disable'">
|
||||
禁用后该CDK将<strong class="text-red-500">无法被用户兑换</strong>,如需使用可随时恢复。
|
||||
</template>
|
||||
<template v-else>
|
||||
恢复后该CDK将重新变为<strong class="text-green-600">未使用</strong>状态,可被用户兑换。
|
||||
</template>
|
||||
</p>
|
||||
<div class="flex gap-2 mt-5">
|
||||
<Button variant="outline" class="flex-1 h-10" @click="showStatusModal = false">取消</Button>
|
||||
<Button
|
||||
class="flex-1 h-10"
|
||||
:class="statusAction === 'disable' ? 'bg-red-500 hover:bg-red-600' : 'bg-green-500 hover:bg-green-600'"
|
||||
:disabled="statusLoading"
|
||||
@click="confirmStatusChange"
|
||||
>
|
||||
<LoaderCircle v-if="statusLoading" class="w-4 h-4 animate-spin mr-1" />
|
||||
{{ statusAction === 'disable' ? '确认禁用' : '确认恢复' }}
|
||||
</Button>
|
||||
</div>
|
||||
</AppDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
} from 'lucide-vue-next'
|
||||
import { useMessage } from '@/composables/useMessage'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import AppDialog from '@/components/ui/dialog/AppDialog.vue'
|
||||
import type { AgentInfo } from '@/api/admin'
|
||||
import * as announceApi from '@/api/announcement'
|
||||
import type { AnnouncementItem } from '@/api/announcement'
|
||||
@@ -328,55 +329,60 @@ function handleLogout() {
|
||||
</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>
|
||||
<AppDialog
|
||||
:open="showDetail && !!detailItem"
|
||||
variant="center"
|
||||
z-class="z-[60]"
|
||||
wrapper-class="px-4"
|
||||
overlay-class="bg-black/50"
|
||||
panel-class="w-[85%] sm:max-w-sm shadow-2xl"
|
||||
:fade="true"
|
||||
title="公告详情"
|
||||
@update:open="(v: boolean) => { if (!v) showDetail = false }"
|
||||
>
|
||||
<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>
|
||||
</transition>
|
||||
</Teleport>
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<span class="text-[10px] text-gray-400">{{ detailItem ? 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>
|
||||
</AppDialog>
|
||||
|
||||
<!-- 强制弹出公告 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>
|
||||
<AppDialog
|
||||
:open="!!currentForcePopup"
|
||||
variant="center"
|
||||
z-class="z-[60]"
|
||||
wrapper-class="px-4"
|
||||
panel-class="w-[85%] sm:max-w-sm"
|
||||
:fade="true"
|
||||
title="强制公告"
|
||||
@update:open="(v: boolean) => { if (!v) dismissForcePopup() }"
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
</transition>
|
||||
</Teleport>
|
||||
<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>
|
||||
</AppDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ref, onMounted } from 'vue'
|
||||
import { Shield, RefreshCw, CheckCircle, XCircle, Search, Eye, EyeOff, Copy } from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import AppDialog from '@/components/ui/dialog/AppDialog.vue'
|
||||
import { useMessage } from '@/composables/useMessage'
|
||||
import { copyToClipboard } from '@/utils/clipboard'
|
||||
import api from '@/api/index'
|
||||
@@ -383,42 +384,42 @@ async function copyText(text: string) {
|
||||
</div>
|
||||
|
||||
<!-- 拒绝理由弹窗 -->
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="rejectTarget"
|
||||
class="fixed inset-0 z-50 bg-black/40 flex items-center justify-center px-4"
|
||||
@click.self="closeRejectModal"
|
||||
>
|
||||
<div class="bg-white rounded-2xl w-full max-w-md p-6 shadow-xl">
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<div class="w-9 h-9 rounded-full bg-red-50 flex items-center justify-center">
|
||||
<XCircle class="w-5 h-5 text-red-500" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-base font-bold text-gray-900">拒绝适配申请</h3>
|
||||
<p class="text-xs text-gray-400">{{ rejectTarget.schoolName }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<label class="block text-sm text-gray-600 mb-1.5">拒绝理由 <span class="text-red-500">*</span></label>
|
||||
<textarea
|
||||
v-model="rejectReason"
|
||||
rows="4"
|
||||
maxlength="500"
|
||||
placeholder="请说明拒绝理由(必填),理由将通过邮件通知该学校的联系邮箱"
|
||||
class="w-full rounded-lg border border-gray-200 px-3 py-2.5 text-sm text-gray-900 placeholder:text-gray-300 focus:outline-none focus:ring-2 focus:ring-red-100 focus:border-red-300 resize-none"
|
||||
></textarea>
|
||||
<p class="text-[11px] text-gray-400 mt-1">发送后将邮件通知该条记录的联系邮箱</p>
|
||||
<div class="flex justify-end gap-2 mt-5">
|
||||
<Button variant="outline" size="sm" class="h-9 text-xs" :disabled="rejectSubmitting" @click="closeRejectModal">
|
||||
取消
|
||||
</Button>
|
||||
<Button size="sm" class="h-9 text-xs bg-red-500 hover:bg-red-600 text-white" :disabled="rejectSubmitting" @click="confirmReject">
|
||||
<XCircle class="w-3.5 h-3.5 mr-1" />
|
||||
{{ rejectSubmitting ? '提交中...' : '确认拒绝' }}
|
||||
</Button>
|
||||
</div>
|
||||
<AppDialog
|
||||
:open="!!rejectTarget"
|
||||
variant="center"
|
||||
wrapper-class="px-4"
|
||||
panel-class="max-w-md p-6"
|
||||
:dismissable="!rejectSubmitting"
|
||||
title="拒绝适配申请"
|
||||
@update:open="(v: boolean) => { if (!v) closeRejectModal() }"
|
||||
>
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<div class="w-9 h-9 rounded-full bg-red-50 flex items-center justify-center">
|
||||
<XCircle class="w-5 h-5 text-red-500" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-base font-bold text-gray-900">拒绝适配申请</h3>
|
||||
<p class="text-xs text-gray-400">{{ rejectTarget?.schoolName }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
<label class="block text-sm text-gray-600 mb-1.5">拒绝理由 <span class="text-red-500">*</span></label>
|
||||
<textarea
|
||||
v-model="rejectReason"
|
||||
rows="4"
|
||||
maxlength="500"
|
||||
placeholder="请说明拒绝理由(必填),理由将通过邮件通知该学校的联系邮箱"
|
||||
class="w-full rounded-lg border border-gray-200 px-3 py-2.5 text-sm text-gray-900 placeholder:text-gray-300 focus:outline-none focus:ring-2 focus:ring-red-100 focus:border-red-300 resize-none"
|
||||
></textarea>
|
||||
<p class="text-[11px] text-gray-400 mt-1">发送后将邮件通知该条记录的联系邮箱</p>
|
||||
<div class="flex justify-end gap-2 mt-5">
|
||||
<Button variant="outline" size="sm" class="h-9 text-xs" :disabled="rejectSubmitting" @click="closeRejectModal">
|
||||
取消
|
||||
</Button>
|
||||
<Button size="sm" class="h-9 text-xs bg-red-500 hover:bg-red-600 text-white" :disabled="rejectSubmitting" @click="confirmReject">
|
||||
<XCircle class="w-3.5 h-3.5 mr-1" />
|
||||
{{ rejectSubmitting ? '提交中...' : '确认拒绝' }}
|
||||
</Button>
|
||||
</div>
|
||||
</AppDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -30,7 +30,7 @@ import {
|
||||
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 AppDialog from '@/components/ui/dialog/AppDialog.vue'
|
||||
import { useMessage } from '@/composables/useMessage'
|
||||
import * as adminApi from '@/api/admin'
|
||||
|
||||
@@ -125,6 +125,8 @@ interface AdminTaskLogItem {
|
||||
isSuccess: boolean
|
||||
errorMessage: string
|
||||
createdAt: string
|
||||
deleted: boolean
|
||||
source: number
|
||||
}
|
||||
|
||||
const showLogsModal = ref(false)
|
||||
@@ -229,6 +231,8 @@ function isTodayLogin(lastLoginAt: string) {
|
||||
function taskTypeLabel(t: string) {
|
||||
if (t === 'sign') return '签到'
|
||||
if (t === 'sleep_check') return '查寝'
|
||||
if (t === 'leave_apply') return '请假'
|
||||
if (t === 'leave_report') return '销假'
|
||||
return t
|
||||
}
|
||||
|
||||
@@ -609,332 +613,331 @@ function todayStatusLabel(s: string) {
|
||||
</div>
|
||||
|
||||
<!-- 详情弹窗 -->
|
||||
<Teleport to="body">
|
||||
<Transition name="fade">
|
||||
<div v-if="showDetail" class="fixed inset-0 z-50 flex items-center justify-center px-4">
|
||||
<div class="absolute inset-0 bg-black/40" @click="showDetail = false" />
|
||||
<div class="relative w-[90%] sm:max-w-lg bg-white rounded-2xl shadow-xl overflow-hidden max-h-[90vh] flex flex-col">
|
||||
<div class="h-1.5 bg-gradient-to-r from-green-500 to-green-400" />
|
||||
<div class="flex items-center justify-between px-5 pt-5 pb-3">
|
||||
<div class="flex items-center gap-2.5">
|
||||
<div class="w-9 h-9 rounded-xl bg-green-100 flex items-center justify-center">
|
||||
<Users class="w-5 h-5 text-green-500" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-base font-semibold text-gray-900">用户详情</h3>
|
||||
<p class="text-xs text-gray-400">{{ detailUser?.username }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button class="w-7 h-7 rounded-full bg-gray-100 flex items-center justify-center text-gray-400 hover:text-gray-600" @click="showDetail = false">
|
||||
<X class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="detailLoading" class="flex items-center justify-center py-16">
|
||||
<LoaderCircle class="w-6 h-6 animate-spin text-green-500" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="detailUser" class="px-5 pb-5 overflow-y-auto space-y-3">
|
||||
<!-- 状态 -->
|
||||
<div class="flex items-center gap-2 px-3 py-2 rounded-lg"
|
||||
:class="detailUser.status === 'disabled' ? 'bg-red-50' : 'bg-green-50'">
|
||||
<AlertTriangle v-if="detailUser.status === 'disabled'" class="w-4 h-4 text-red-500" />
|
||||
<CheckCircle v-else class="w-4 h-4 text-green-500" />
|
||||
<span class="text-sm font-medium" :class="detailUser.status === 'disabled' ? 'text-red-700' : 'text-green-700'">
|
||||
{{ detailUser.status === 'disabled' ? '已禁用' : '正常' }}
|
||||
</span>
|
||||
<span v-if="detailUser.disabledReason" class="text-xs text-red-500 ml-2">原因:{{ detailUser.disabledReason }}</span>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div class="bg-gray-50 rounded-xl p-3.5">
|
||||
<div class="flex items-center gap-2 text-xs text-gray-400 mb-1">
|
||||
<School class="w-3.5 h-3.5" /> 学校
|
||||
</div>
|
||||
<p class="text-sm font-medium text-gray-900">{{ detailUser.schoolName || '--' }}</p>
|
||||
</div>
|
||||
<div class="bg-gray-50 rounded-xl p-3.5">
|
||||
<div class="flex items-center gap-2 text-xs text-gray-400 mb-1">
|
||||
<IdCard class="w-3.5 h-3.5" /> 学工号
|
||||
</div>
|
||||
<p class="text-sm font-medium text-gray-900 font-mono">{{ detailUser.studentId || '--' }}</p>
|
||||
</div>
|
||||
<div class="bg-gray-50 rounded-xl p-3.5">
|
||||
<div class="flex items-center gap-2 text-xs text-gray-400 mb-1">
|
||||
<Lock class="w-3.5 h-3.5" /> 密码
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<p class="text-sm font-medium text-gray-900 font-mono truncate">
|
||||
{{ showPasswordMap[detailUser.id] ? detailUser.password : '••••••••' }}
|
||||
</p>
|
||||
<button class="flex-shrink-0 text-gray-400 hover:text-gray-600" @click="togglePassword(detailUser.id)">
|
||||
<EyeOff v-if="!showPasswordMap[detailUser.id]" class="w-3.5 h-3.5" />
|
||||
<Eye v-else class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-gray-50 rounded-xl p-3.5">
|
||||
<div class="flex items-center gap-2 text-xs text-gray-400 mb-1">
|
||||
<ClipboardCheck class="w-3.5 h-3.5" /> 签到次数
|
||||
</div>
|
||||
<p class="text-sm font-medium text-gray-900">{{ detailUser.signCount }} 次</p>
|
||||
</div>
|
||||
<div class="bg-gray-50 rounded-xl p-3.5">
|
||||
<div class="flex items-center gap-2 text-xs text-gray-400 mb-1">
|
||||
<Gift class="w-3.5 h-3.5" /> 邀请积分
|
||||
</div>
|
||||
<p class="text-sm font-medium text-gray-900">{{ detailUser.inviteReward }}</p>
|
||||
</div>
|
||||
<div class="bg-gray-50 rounded-xl p-3.5">
|
||||
<div class="flex items-center gap-2 text-xs text-gray-400 mb-1">
|
||||
<UserPlus class="w-3.5 h-3.5" /> 邀请人数
|
||||
</div>
|
||||
<p class="text-sm font-medium text-gray-900">{{ detailUser.invitedCount }} 人</p>
|
||||
</div>
|
||||
<div class="bg-gray-50 rounded-xl p-3.5">
|
||||
<div class="flex items-center gap-2 text-xs text-gray-400 mb-1">
|
||||
<UserPlus class="w-3.5 h-3.5" /> 邀请人
|
||||
</div>
|
||||
<p class="text-sm font-medium text-gray-900 truncate">{{ detailUser.inviterName || '--' }}</p>
|
||||
</div>
|
||||
<div class="bg-gray-50 rounded-xl p-3.5">
|
||||
<div class="flex items-center gap-2 text-xs text-gray-400 mb-1">
|
||||
<ClipboardCheck class="w-3.5 h-3.5" /> 任务数
|
||||
</div>
|
||||
<p class="text-sm font-medium text-gray-900">{{ detailUser.taskCount }} 个</p>
|
||||
</div>
|
||||
<div class="bg-gray-50 rounded-xl p-3.5">
|
||||
<div class="flex items-center gap-2 text-xs text-gray-400 mb-1">
|
||||
<Smartphone class="w-3.5 h-3.5" /> 设备码
|
||||
</div>
|
||||
<p class="text-sm font-medium text-gray-900 font-mono text-xs truncate">{{ detailUser.mobileCode || '未绑定' }}</p>
|
||||
</div>
|
||||
<div class="bg-gray-50 rounded-xl p-3.5">
|
||||
<div class="flex items-center gap-2 text-xs text-gray-400 mb-1">
|
||||
<Calendar class="w-3.5 h-3.5" /> 注册时间
|
||||
</div>
|
||||
<p class="text-sm font-medium text-gray-900">{{ detailUser.createdAt }}</p>
|
||||
</div>
|
||||
<div class="bg-gray-50 rounded-xl p-3.5">
|
||||
<div class="flex items-center gap-2 text-xs text-gray-400 mb-1">
|
||||
<Clock class="w-3.5 h-3.5" /> 最后登录
|
||||
</div>
|
||||
<p class="text-sm font-medium text-gray-900">{{ detailUser.lastLoginAt || '--' }}</p>
|
||||
</div>
|
||||
<div class="bg-gray-50 rounded-xl p-3.5">
|
||||
<div class="flex items-center gap-2 text-xs text-gray-400 mb-1">
|
||||
<Globe class="w-3.5 h-3.5" /> 最后登录IP
|
||||
</div>
|
||||
<p class="text-sm font-medium text-gray-900 font-mono">{{ detailUser.lastLoginIp || '--' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<AppDialog
|
||||
:open="showDetail"
|
||||
variant="center"
|
||||
wrapper-class="px-4"
|
||||
panel-class="w-[90%] sm:max-w-lg max-h-[90vh]"
|
||||
:fade="true"
|
||||
title="用户详情"
|
||||
@update:open="(v: boolean) => { showDetail = v }"
|
||||
>
|
||||
<div class="h-1.5 bg-gradient-to-r from-green-500 to-green-400" />
|
||||
<div class="flex items-center justify-between px-5 pt-5 pb-3">
|
||||
<div class="flex items-center gap-2.5">
|
||||
<div class="w-9 h-9 rounded-xl bg-green-100 flex items-center justify-center">
|
||||
<Users class="w-5 h-5 text-green-500" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-base font-semibold text-gray-900">用户详情</h3>
|
||||
<p class="text-xs text-gray-400">{{ detailUser?.username }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
<button class="w-7 h-7 rounded-full bg-gray-100 flex items-center justify-center text-gray-400 hover:text-gray-600" @click="showDetail = false">
|
||||
<X class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="detailLoading" class="flex items-center justify-center py-16">
|
||||
<LoaderCircle class="w-6 h-6 animate-spin text-green-500" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="detailUser" class="px-5 pb-5 overflow-y-auto space-y-3">
|
||||
<!-- 状态 -->
|
||||
<div class="flex items-center gap-2 px-3 py-2 rounded-lg"
|
||||
:class="detailUser.status === 'disabled' ? 'bg-red-50' : 'bg-green-50'">
|
||||
<AlertTriangle v-if="detailUser.status === 'disabled'" class="w-4 h-4 text-red-500" />
|
||||
<CheckCircle v-else class="w-4 h-4 text-green-500" />
|
||||
<span class="text-sm font-medium" :class="detailUser.status === 'disabled' ? 'text-red-700' : 'text-green-700'">
|
||||
{{ detailUser.status === 'disabled' ? '已禁用' : '正常' }}
|
||||
</span>
|
||||
<span v-if="detailUser.disabledReason" class="text-xs text-red-500 ml-2">原因:{{ detailUser.disabledReason }}</span>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div class="bg-gray-50 rounded-xl p-3.5">
|
||||
<div class="flex items-center gap-2 text-xs text-gray-400 mb-1">
|
||||
<School class="w-3.5 h-3.5" /> 学校
|
||||
</div>
|
||||
<p class="text-sm font-medium text-gray-900">{{ detailUser.schoolName || '--' }}</p>
|
||||
</div>
|
||||
<div class="bg-gray-50 rounded-xl p-3.5">
|
||||
<div class="flex items-center gap-2 text-xs text-gray-400 mb-1">
|
||||
<IdCard class="w-3.5 h-3.5" /> 学工号
|
||||
</div>
|
||||
<p class="text-sm font-medium text-gray-900 font-mono">{{ detailUser.studentId || '--' }}</p>
|
||||
</div>
|
||||
<div class="bg-gray-50 rounded-xl p-3.5">
|
||||
<div class="flex items-center gap-2 text-xs text-gray-400 mb-1">
|
||||
<Lock class="w-3.5 h-3.5" /> 密码
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<p class="text-sm font-medium text-gray-900 font-mono truncate">
|
||||
{{ showPasswordMap[detailUser.id] ? detailUser.password : '••••••••' }}
|
||||
</p>
|
||||
<button class="flex-shrink-0 text-gray-400 hover:text-gray-600" @click="togglePassword(detailUser.id)">
|
||||
<EyeOff v-if="!showPasswordMap[detailUser.id]" class="w-3.5 h-3.5" />
|
||||
<Eye v-else class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-gray-50 rounded-xl p-3.5">
|
||||
<div class="flex items-center gap-2 text-xs text-gray-400 mb-1">
|
||||
<ClipboardCheck class="w-3.5 h-3.5" /> 签到次数
|
||||
</div>
|
||||
<p class="text-sm font-medium text-gray-900">{{ detailUser.signCount }} 次</p>
|
||||
</div>
|
||||
<div class="bg-gray-50 rounded-xl p-3.5">
|
||||
<div class="flex items-center gap-2 text-xs text-gray-400 mb-1">
|
||||
<Gift class="w-3.5 h-3.5" /> 邀请积分
|
||||
</div>
|
||||
<p class="text-sm font-medium text-gray-900">{{ detailUser.inviteReward }}</p>
|
||||
</div>
|
||||
<div class="bg-gray-50 rounded-xl p-3.5">
|
||||
<div class="flex items-center gap-2 text-xs text-gray-400 mb-1">
|
||||
<UserPlus class="w-3.5 h-3.5" /> 邀请人数
|
||||
</div>
|
||||
<p class="text-sm font-medium text-gray-900">{{ detailUser.invitedCount }} 人</p>
|
||||
</div>
|
||||
<div class="bg-gray-50 rounded-xl p-3.5">
|
||||
<div class="flex items-center gap-2 text-xs text-gray-400 mb-1">
|
||||
<UserPlus class="w-3.5 h-3.5" /> 邀请人
|
||||
</div>
|
||||
<p class="text-sm font-medium text-gray-900 truncate">{{ detailUser.inviterName || '--' }}</p>
|
||||
</div>
|
||||
<div class="bg-gray-50 rounded-xl p-3.5">
|
||||
<div class="flex items-center gap-2 text-xs text-gray-400 mb-1">
|
||||
<ClipboardCheck class="w-3.5 h-3.5" /> 任务数
|
||||
</div>
|
||||
<p class="text-sm font-medium text-gray-900">{{ detailUser.taskCount }} 个</p>
|
||||
</div>
|
||||
<div class="bg-gray-50 rounded-xl p-3.5">
|
||||
<div class="flex items-center gap-2 text-xs text-gray-400 mb-1">
|
||||
<Smartphone class="w-3.5 h-3.5" /> 设备码
|
||||
</div>
|
||||
<p class="text-sm font-medium text-gray-900 font-mono text-xs truncate">{{ detailUser.mobileCode || '未绑定' }}</p>
|
||||
</div>
|
||||
<div class="bg-gray-50 rounded-xl p-3.5">
|
||||
<div class="flex items-center gap-2 text-xs text-gray-400 mb-1">
|
||||
<Calendar class="w-3.5 h-3.5" /> 注册时间
|
||||
</div>
|
||||
<p class="text-sm font-medium text-gray-900">{{ detailUser.createdAt }}</p>
|
||||
</div>
|
||||
<div class="bg-gray-50 rounded-xl p-3.5">
|
||||
<div class="flex items-center gap-2 text-xs text-gray-400 mb-1">
|
||||
<Clock class="w-3.5 h-3.5" /> 最后登录
|
||||
</div>
|
||||
<p class="text-sm font-medium text-gray-900">{{ detailUser.lastLoginAt || '--' }}</p>
|
||||
</div>
|
||||
<div class="bg-gray-50 rounded-xl p-3.5">
|
||||
<div class="flex items-center gap-2 text-xs text-gray-400 mb-1">
|
||||
<Globe class="w-3.5 h-3.5" /> 最后登录IP
|
||||
</div>
|
||||
<p class="text-sm font-medium text-gray-900 font-mono">{{ detailUser.lastLoginIp || '--' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppDialog>
|
||||
|
||||
<!-- 禁用弹窗 -->
|
||||
<Teleport to="body">
|
||||
<Transition name="fade">
|
||||
<div v-if="showDisableModal" class="fixed inset-0 z-50 flex items-center justify-center px-4">
|
||||
<div class="absolute inset-0 bg-black/40" @click="showDisableModal = false" />
|
||||
<div class="relative w-[85%] sm:max-w-sm bg-white rounded-2xl shadow-xl overflow-hidden">
|
||||
<div class="h-1.5 bg-gradient-to-r from-red-500 to-orange-400" />
|
||||
<div class="p-6 space-y-4">
|
||||
<div class="flex items-center gap-2.5">
|
||||
<div class="w-9 h-9 rounded-xl bg-red-100 flex items-center justify-center">
|
||||
<Ban class="w-5 h-5 text-red-500" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-base font-semibold text-gray-900">禁用用户</h3>
|
||||
<p class="text-xs text-gray-400">{{ disableTarget?.username }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label class="mb-1.5 block text-xs font-medium text-gray-500">禁用原因</Label>
|
||||
<textarea
|
||||
v-model="disableReason"
|
||||
placeholder="请输入禁用原因,用户登录时将看到此信息"
|
||||
class="w-full h-20 px-3 py-2 text-sm border border-gray-200 rounded-lg resize-none focus:outline-none focus:ring-2 focus:ring-red-500/20 focus:border-red-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
class="flex-1 h-10 text-sm"
|
||||
@click="showDisableModal = false"
|
||||
>取消</Button>
|
||||
<Button
|
||||
class="flex-1 h-10 text-sm font-medium bg-red-500 hover:bg-red-600"
|
||||
:disabled="disabling || !disableReason.trim()"
|
||||
@click="handleDisable"
|
||||
>
|
||||
{{ disabling ? '处理中...' : '确认禁用' }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<AppDialog
|
||||
:open="showDisableModal"
|
||||
variant="center"
|
||||
wrapper-class="px-4"
|
||||
panel-class="w-[85%] sm:max-w-sm"
|
||||
:fade="true"
|
||||
title="禁用用户"
|
||||
@update:open="(v: boolean) => { showDisableModal = v }"
|
||||
>
|
||||
<div class="h-1.5 bg-gradient-to-r from-red-500 to-orange-400" />
|
||||
<div class="p-6 space-y-4">
|
||||
<div class="flex items-center gap-2.5">
|
||||
<div class="w-9 h-9 rounded-xl bg-red-100 flex items-center justify-center">
|
||||
<Ban class="w-5 h-5 text-red-500" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-base font-semibold text-gray-900">禁用用户</h3>
|
||||
<p class="text-xs text-gray-400">{{ disableTarget?.username }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
|
||||
<div>
|
||||
<Label class="mb-1.5 block text-xs font-medium text-gray-500">禁用原因</Label>
|
||||
<textarea
|
||||
v-model="disableReason"
|
||||
placeholder="请输入禁用原因,用户登录时将看到此信息"
|
||||
class="w-full h-20 px-3 py-2 text-sm border border-gray-200 rounded-lg resize-none focus:outline-none focus:ring-2 focus:ring-red-500/20 focus:border-red-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
class="flex-1 h-10 text-sm"
|
||||
@click="showDisableModal = false"
|
||||
>取消</Button>
|
||||
<Button
|
||||
class="flex-1 h-10 text-sm font-medium bg-red-500 hover:bg-red-600"
|
||||
:disabled="disabling || !disableReason.trim()"
|
||||
@click="handleDisable"
|
||||
>
|
||||
{{ disabling ? '处理中...' : '确认禁用' }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</AppDialog>
|
||||
|
||||
<!-- 当天执行日志弹窗 -->
|
||||
<Teleport to="body">
|
||||
<Transition name="fade">
|
||||
<div v-if="showLogsModal" class="fixed inset-0 z-50 flex items-center justify-center px-4">
|
||||
<div class="absolute inset-0 bg-black/40" @click="showLogsModal = false" />
|
||||
<div class="relative w-full sm:max-w-3xl bg-white rounded-2xl shadow-xl overflow-hidden flex flex-col max-h-[85vh]">
|
||||
<div class="h-1.5 bg-gradient-to-r from-blue-500 to-cyan-400 flex-shrink-0" />
|
||||
<AppDialog
|
||||
:open="showLogsModal"
|
||||
variant="center"
|
||||
wrapper-class="px-4"
|
||||
panel-class="sm:max-w-3xl"
|
||||
:fade="true"
|
||||
title="执行日志明细"
|
||||
@update:open="(v: boolean) => { showLogsModal = v }"
|
||||
>
|
||||
<div class="h-1.5 bg-gradient-to-r from-blue-500 to-cyan-400 flex-shrink-0" />
|
||||
|
||||
<!-- 头部 -->
|
||||
<div class="px-5 pt-4 pb-3 flex items-start justify-between flex-shrink-0">
|
||||
<div>
|
||||
<h3 class="text-base font-semibold text-gray-900">执行日志明细</h3>
|
||||
<p class="text-xs text-gray-400 mt-0.5">{{ logsDate }} · 共 {{ logsTotal }} 条记录</p>
|
||||
</div>
|
||||
<button
|
||||
class="p-1.5 rounded-lg text-gray-400 hover:text-gray-600 hover:bg-gray-100 transition-colors"
|
||||
@click="showLogsModal = false"
|
||||
>
|
||||
<X class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 筛选栏 -->
|
||||
<div class="px-5 pb-3 flex flex-wrap items-center gap-2 flex-shrink-0">
|
||||
<input
|
||||
type="date"
|
||||
:value="logsDate"
|
||||
class="h-9 px-2.5 rounded-lg border border-gray-200 text-sm text-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-400"
|
||||
@change="handleLogsDateChange"
|
||||
/>
|
||||
<div class="flex rounded-lg border border-gray-200 overflow-hidden">
|
||||
<button
|
||||
v-for="opt in logsStatusOptions"
|
||||
:key="opt.key"
|
||||
class="h-9 px-3 text-xs font-medium transition-colors"
|
||||
:class="logsStatus === opt.key ? 'bg-blue-500 text-white' : 'bg-white text-gray-500 hover:bg-gray-50'"
|
||||
@click="handleLogsStatusChange(opt.key)"
|
||||
>
|
||||
{{ opt.label }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="relative flex-1 min-w-[180px]">
|
||||
<Search class="absolute left-3 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-gray-400" />
|
||||
<input
|
||||
v-model="logsKeyword"
|
||||
placeholder="搜索用户名/姓名/任务标题"
|
||||
class="w-full h-9 pl-8 pr-3 text-sm border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-400"
|
||||
@keyup.enter="handleLogsSearch"
|
||||
/>
|
||||
</div>
|
||||
<Button variant="outline" class="h-9 text-sm" @click="handleLogsSearch">
|
||||
<Search class="w-3.5 h-3.5 mr-1" />
|
||||
搜索
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- 列表 -->
|
||||
<div class="flex-1 overflow-y-auto border-t border-gray-100">
|
||||
<!-- 加载骨架 -->
|
||||
<div v-if="logsLoading" class="p-5 space-y-3">
|
||||
<div v-for="i in 6" :key="i" class="h-10 bg-gray-50 rounded animate-pulse" />
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div v-else-if="logItems.length === 0" class="text-center py-16 text-gray-400">
|
||||
<ClipboardCheck class="w-10 h-10 mx-auto mb-3 opacity-30" />
|
||||
<p class="text-sm">该日期暂无执行记录</p>
|
||||
</div>
|
||||
|
||||
<!-- 日志表格 -->
|
||||
<table v-else class="w-full text-sm table-fixed min-w-[640px]">
|
||||
<thead>
|
||||
<tr class="bg-gray-50 text-left text-xs text-gray-500 sticky top-0">
|
||||
<th class="px-4 py-2.5 font-medium w-[80px]">时间</th>
|
||||
<th class="px-4 py-2.5 font-medium w-[130px]">用户</th>
|
||||
<th class="px-4 py-2.5 font-medium w-[64px]">类型</th>
|
||||
<th class="px-4 py-2.5 font-medium">任务标题</th>
|
||||
<th class="px-4 py-2.5 font-medium w-[60px]">状态</th>
|
||||
<th class="px-4 py-2.5 font-medium">说明</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="log in logItems"
|
||||
:key="log.id"
|
||||
class="border-t border-gray-50 hover:bg-gray-50/50 transition-colors align-top"
|
||||
>
|
||||
<td class="px-4 py-2.5 text-xs text-gray-500 whitespace-nowrap tabular-nums">{{ formatLogTime(log.createdAt) }}</td>
|
||||
<td class="px-4 py-2.5 truncate" :title="`${log.realName || ''}(${log.username})`">
|
||||
<p class="text-gray-900 truncate leading-tight">{{ log.realName || log.username || '--' }}</p>
|
||||
<p class="text-[11px] text-gray-400 truncate leading-tight">{{ log.schoolName || '--' }}</p>
|
||||
</td>
|
||||
<td class="px-4 py-2.5">
|
||||
<span class="text-[10px] px-1.5 py-0.5 rounded-full font-medium bg-gray-100 text-gray-600">
|
||||
{{ taskTypeLabel(log.taskType) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-2.5 truncate text-gray-700" :title="log.realTitle || log.title">
|
||||
{{ log.realTitle || log.title || '--' }}
|
||||
</td>
|
||||
<td class="px-4 py-2.5 whitespace-nowrap">
|
||||
<span
|
||||
class="text-[10px] px-1.5 py-0.5 rounded-full font-medium inline-block"
|
||||
:class="log.isSuccess ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'"
|
||||
>
|
||||
{{ log.isSuccess ? '成功' : '失败' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-2.5">
|
||||
<span v-if="log.errorMessage" class="text-xs text-red-500 break-words">{{ log.errorMessage }}</span>
|
||||
<span v-else class="text-xs text-gray-300">--</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div
|
||||
v-if="logsTotal > 0"
|
||||
class="flex items-center justify-between px-5 py-3 border-t border-gray-100 flex-shrink-0"
|
||||
>
|
||||
<span class="text-xs text-gray-400">第 {{ logsPage }} / {{ logsTotalPages }} 页</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
class="w-8 h-8 rounded-lg flex items-center justify-center hover:bg-gray-100 disabled:opacity-30"
|
||||
:disabled="logsPage <= 1"
|
||||
@click="handleLogsPage(logsPage - 1)"
|
||||
>
|
||||
<ChevronLeft class="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
class="w-8 h-8 rounded-lg flex items-center justify-center hover:bg-gray-100 disabled:opacity-30"
|
||||
:disabled="logsPage >= logsTotalPages"
|
||||
@click="handleLogsPage(logsPage + 1)"
|
||||
>
|
||||
<ChevronRight class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 头部 -->
|
||||
<div class="px-5 pt-4 pb-3 flex items-start justify-between flex-shrink-0">
|
||||
<div>
|
||||
<h3 class="text-base font-semibold text-gray-900">执行日志明细</h3>
|
||||
<p class="text-xs text-gray-400 mt-0.5">{{ logsDate }} · 共 {{ logsTotal }} 条记录</p>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
<button
|
||||
class="p-1.5 rounded-lg text-gray-400 hover:text-gray-600 hover:bg-gray-100 transition-colors"
|
||||
@click="showLogsModal = false"
|
||||
>
|
||||
<X class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 筛选栏 -->
|
||||
<div class="px-5 pb-3 flex flex-wrap items-center gap-2 flex-shrink-0">
|
||||
<input
|
||||
type="date"
|
||||
:value="logsDate"
|
||||
class="h-9 px-2.5 rounded-lg border border-gray-200 text-sm text-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-400"
|
||||
@change="handleLogsDateChange"
|
||||
/>
|
||||
<div class="flex rounded-lg border border-gray-200 overflow-hidden">
|
||||
<button
|
||||
v-for="opt in logsStatusOptions"
|
||||
:key="opt.key"
|
||||
class="h-9 px-3 text-xs font-medium transition-colors"
|
||||
:class="logsStatus === opt.key ? 'bg-blue-500 text-white' : 'bg-white text-gray-500 hover:bg-gray-50'"
|
||||
@click="handleLogsStatusChange(opt.key)"
|
||||
>
|
||||
{{ opt.label }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="relative flex-1 min-w-[180px]">
|
||||
<Search class="absolute left-3 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-gray-400" />
|
||||
<input
|
||||
v-model="logsKeyword"
|
||||
placeholder="搜索用户名/姓名/任务标题"
|
||||
class="w-full h-9 pl-8 pr-3 text-sm border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-400"
|
||||
@keyup.enter="handleLogsSearch"
|
||||
/>
|
||||
</div>
|
||||
<Button variant="outline" class="h-9 text-sm" @click="handleLogsSearch">
|
||||
<Search class="w-3.5 h-3.5 mr-1" />
|
||||
搜索
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- 列表 -->
|
||||
<div class="flex-1 overflow-y-auto border-t border-gray-100">
|
||||
<!-- 加载骨架 -->
|
||||
<div v-if="logsLoading" class="p-5 space-y-3">
|
||||
<div v-for="i in 6" :key="i" class="h-10 bg-gray-50 rounded animate-pulse" />
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div v-else-if="logItems.length === 0" class="text-center py-16 text-gray-400">
|
||||
<ClipboardCheck class="w-10 h-10 mx-auto mb-3 opacity-30" />
|
||||
<p class="text-sm">该日期暂无执行记录</p>
|
||||
</div>
|
||||
|
||||
<!-- 日志表格 -->
|
||||
<table v-else class="w-full text-sm table-fixed min-w-[640px]">
|
||||
<thead>
|
||||
<tr class="bg-gray-50 text-left text-xs text-gray-500 sticky top-0">
|
||||
<th class="px-4 py-2.5 font-medium w-[80px]">时间</th>
|
||||
<th class="px-4 py-2.5 font-medium w-[130px]">用户</th>
|
||||
<th class="px-4 py-2.5 font-medium w-[64px]">类型</th>
|
||||
<th class="px-4 py-2.5 font-medium">任务标题</th>
|
||||
<th class="px-4 py-2.5 font-medium w-[60px]">状态</th>
|
||||
<th class="px-4 py-2.5 font-medium">说明</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="log in logItems"
|
||||
:key="`${log.source}-${log.id}`"
|
||||
class="border-t border-gray-50 hover:bg-gray-50/50 transition-colors align-top"
|
||||
>
|
||||
<td class="px-4 py-2.5 text-xs text-gray-500 whitespace-nowrap tabular-nums">{{ formatLogTime(log.createdAt) }}</td>
|
||||
<td class="px-4 py-2.5 truncate" :title="`${log.realName || ''}(${log.username})`">
|
||||
<p class="text-gray-900 truncate leading-tight">{{ log.realName || log.username || '--' }}</p>
|
||||
<p class="text-[11px] text-gray-400 truncate leading-tight">{{ log.schoolName || '--' }}</p>
|
||||
</td>
|
||||
<td class="px-4 py-2.5">
|
||||
<span class="text-[10px] px-1.5 py-0.5 rounded-full font-medium bg-gray-100 text-gray-600">
|
||||
{{ taskTypeLabel(log.taskType) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-2.5 truncate text-gray-700" :title="log.realTitle || log.title">
|
||||
{{ log.realTitle || log.title || '--' }}
|
||||
</td>
|
||||
<td class="px-4 py-2.5 whitespace-nowrap">
|
||||
<span
|
||||
class="text-[10px] px-1.5 py-0.5 rounded-full font-medium inline-block"
|
||||
:class="log.isSuccess ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'"
|
||||
>
|
||||
{{ log.isSuccess ? '成功' : '失败' }}
|
||||
</span>
|
||||
<span
|
||||
v-if="log.deleted"
|
||||
class="text-[10px] px-1.5 py-0.5 rounded-full font-medium inline-block ml-1 bg-gray-100 text-gray-400 border border-gray-200"
|
||||
title="用户已在客户端删除该日志,后台统计仍计入"
|
||||
>
|
||||
已删除
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-2.5">
|
||||
<span v-if="log.errorMessage" class="text-xs text-red-500 break-words">{{ log.errorMessage }}</span>
|
||||
<span v-else class="text-xs text-gray-300">--</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div
|
||||
v-if="logsTotal > 0"
|
||||
class="flex items-center justify-between px-5 py-3 border-t border-gray-100 flex-shrink-0"
|
||||
>
|
||||
<span class="text-xs text-gray-400">第 {{ logsPage }} / {{ logsTotalPages }} 页</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
class="w-8 h-8 rounded-lg flex items-center justify-center hover:bg-gray-100 disabled:opacity-30"
|
||||
:disabled="logsPage <= 1"
|
||||
@click="handleLogsPage(logsPage - 1)"
|
||||
>
|
||||
<ChevronLeft class="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
class="w-8 h-8 rounded-lg flex items-center justify-center hover:bg-gray-100 disabled:opacity-30"
|
||||
:disabled="logsPage >= logsTotalPages"
|
||||
@click="handleLogsPage(logsPage + 1)"
|
||||
>
|
||||
<ChevronRight class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</AppDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Smartphone, RefreshCw, CheckCircle, AlertCircle, Download, LogOut } from 'lucide-vue-next'
|
||||
import { Smartphone, RefreshCw, CheckCircle, AlertCircle, Download, ArrowLeft } from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useMessage } from '@/composables/useMessage'
|
||||
import * as authApi from '@/api/auth'
|
||||
@@ -144,13 +144,9 @@ function stopPolling() {
|
||||
}
|
||||
}
|
||||
|
||||
/** 退出登录:清除登录态并返回登录页(未扫码时可随时退出,避免一直卡在绑定页) */
|
||||
function handleLogout() {
|
||||
stopPolling()
|
||||
if (countdownTimer) clearInterval(countdownTimer)
|
||||
localStorage.removeItem('access_token')
|
||||
localStorage.removeItem('refresh_token')
|
||||
router.push('/login')
|
||||
/** 返回上一页(本页仅由个人中心「重新绑定」进入,即回到个人中心) */
|
||||
function handleBack() {
|
||||
router.back()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
@@ -171,7 +167,7 @@ onUnmounted(() => {
|
||||
<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>
|
||||
<p class="text-sm text-gray-400 mt-1">请扫描二维码,绑定设备信息</p>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 flex flex-col items-center px-6">
|
||||
@@ -221,22 +217,15 @@ onUnmounted(() => {
|
||||
<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>
|
||||
<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">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>
|
||||
<p class="text-sm text-gray-800">打开今日校园右上角扫一扫</p>
|
||||
<p class="text-xs text-gray-400">扫描二维码,绑定成功后自动跳转</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -266,14 +255,14 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部:退出登录(未扫码时可随时退出当前账号) -->
|
||||
<!-- 底部:返回个人中心 -->
|
||||
<div class="px-6 pb-8 flex justify-center">
|
||||
<button
|
||||
class="inline-flex items-center gap-1.5 px-5 py-2 rounded-lg text-sm text-gray-500 hover:text-red-500 hover:bg-red-50 transition-colors"
|
||||
@click="handleLogout"
|
||||
class="inline-flex items-center gap-1.5 px-5 py-2 rounded-lg text-sm text-gray-500 hover:text-blue-600 hover:bg-blue-50 transition-colors"
|
||||
@click="handleBack"
|
||||
>
|
||||
<LogOut class="w-4 h-4" />
|
||||
退出登录
|
||||
<ArrowLeft class="w-4 h-4" />
|
||||
返回
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
// 网课:功能开发中占位页。
|
||||
// 底部 TabBar 第三个入口(个人中心左侧),点击后进入本页。
|
||||
import { BookOpen } from 'lucide-vue-next'
|
||||
</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">
|
||||
<h1 class="flex-1 text-center text-base font-semibold text-gray-900">网课</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 开发中占位 -->
|
||||
<div class="flex-1 flex flex-col items-center justify-center px-8 py-16 text-center">
|
||||
<div class="w-16 h-16 rounded-full bg-blue-50 flex items-center justify-center mb-4">
|
||||
<BookOpen class="w-8 h-8 text-blue-400" />
|
||||
</div>
|
||||
<h2 class="text-base font-semibold text-gray-900 mb-2">正在努力开发中</h2>
|
||||
<p class="text-sm text-gray-400 leading-relaxed">
|
||||
网课功能即将上线,敬请期待
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,7 @@ import { Search, User, Lock, Eye, EyeOff, RefreshCw, LogIn, GraduationCap, Loade
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import AppDialog from '@/components/ui/dialog/AppDialog.vue'
|
||||
import { useMessage } from '@/composables/useMessage'
|
||||
import * as authApi from '@/api/auth'
|
||||
import * as ticketApi from '@/api/ticket'
|
||||
@@ -600,309 +601,306 @@ async function handleReportSubmit() {
|
||||
</div>
|
||||
|
||||
<!-- ======== 多因子认证(MFA)Dialog:选择验证方式 → 输入验证码 ======== -->
|
||||
<Teleport to="body">
|
||||
<Transition name="fade">
|
||||
<div
|
||||
v-if="showMFADialog"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center px-6"
|
||||
>
|
||||
<div class="absolute inset-0 bg-black/50" @click="closeMFADialog" />
|
||||
<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-cyan-400" />
|
||||
<div class="p-6">
|
||||
<!-- 标题 -->
|
||||
<div class="flex items-center gap-2.5 mb-3">
|
||||
<div class="w-9 h-9 rounded-xl bg-blue-100 flex items-center justify-center">
|
||||
<ShieldCheck class="w-5 h-5 text-blue-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>
|
||||
|
||||
<!-- ===== 阶段一:选择验证方式 ===== -->
|
||||
<template v-if="mfaStage === 'select'">
|
||||
<div class="bg-blue-50 rounded-xl p-3.5 mb-4">
|
||||
<div class="flex items-start gap-2.5">
|
||||
<Smartphone class="w-4 h-4 text-blue-500 flex-shrink-0 mt-0.5" />
|
||||
<p class="text-sm text-blue-700 leading-relaxed">
|
||||
{{ mfaMessage || '请选择验证方式获取验证码' }}
|
||||
</p>
|
||||
</div>
|
||||
<p class="text-[10px] text-blue-400 mt-1.5 pl-6.5">验证会话 10 分钟内有效</p>
|
||||
</div>
|
||||
|
||||
<!-- 验证方式列表 -->
|
||||
<div class="space-y-2">
|
||||
<button
|
||||
v-for="m in mfaMethods"
|
||||
:key="m.type"
|
||||
class="w-full flex items-center justify-between px-3.5 py-3 rounded-xl border text-sm transition-colors disabled:opacity-60"
|
||||
:class="
|
||||
mfaSelectedType === m.type
|
||||
? 'border-blue-400 bg-blue-50/60 text-blue-700 font-medium'
|
||||
: 'border-gray-200 text-gray-600 hover:border-blue-200 hover:bg-gray-50'
|
||||
"
|
||||
:disabled="mfaSending"
|
||||
@click="handleMFASelect(m.type)"
|
||||
>
|
||||
<span>{{ m.name }}</span>
|
||||
<span v-if="mfaSelectedType === m.type" class="text-blue-500 font-bold">✓</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 发送验证码 -->
|
||||
<Button
|
||||
class="mt-4 w-full h-11 text-sm font-medium rounded-xl"
|
||||
:disabled="mfaSending || mfaCountdown <= 0 || !mfaSelectedType"
|
||||
@click="handleMFASend"
|
||||
>
|
||||
<RefreshCw v-if="mfaSending" class="h-4 w-4 animate-spin" />
|
||||
<template v-else-if="mfaCountdown <= 0">会话已过期,请重新登录</template>
|
||||
<template v-else>发送验证码</template>
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
<!-- ===== 阶段二:输入验证码 ===== -->
|
||||
<template v-else>
|
||||
<div class="bg-blue-50 rounded-xl p-3.5 mb-4">
|
||||
<div class="flex items-start gap-2.5">
|
||||
<Smartphone class="w-4 h-4 text-blue-500 flex-shrink-0 mt-0.5" />
|
||||
<p class="text-sm text-blue-700 leading-relaxed">{{ mfaMessage }}</p>
|
||||
</div>
|
||||
<p class="text-[10px] text-blue-400 mt-1.5 pl-6.5">验证会话 10 分钟内有效,输错可重新输入</p>
|
||||
</div>
|
||||
|
||||
<!-- 验证码输入 -->
|
||||
<div>
|
||||
<Label class="mb-1.5 block text-xs font-medium text-gray-500">验证码</Label>
|
||||
<div class="relative">
|
||||
<ShieldCheck class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
v-model="mfaCode"
|
||||
placeholder="请输入收到的验证码"
|
||||
class="pl-9 pr-9 h-10 text-sm tracking-widest"
|
||||
maxlength="8"
|
||||
@keyup.enter="handleMFAVerify"
|
||||
/>
|
||||
<span v-if="mfaCountdown > 0" class="absolute right-3 top-1/2 -translate-y-1/2 text-[10px] text-gray-300 tabular-nums">
|
||||
{{ Math.floor(mfaCountdown / 60) }}:{{ String(mfaCountdown % 60).padStart(2, '0') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 提交按钮 -->
|
||||
<Button
|
||||
class="mt-4 w-full h-11 text-sm font-medium rounded-xl"
|
||||
:disabled="mfaSubmitting || mfaCountdown <= 0"
|
||||
@click="handleMFAVerify"
|
||||
>
|
||||
<RefreshCw v-if="mfaSubmitting" class="h-4 w-4 animate-spin" />
|
||||
<template v-else-if="mfaCountdown <= 0">会话已过期,请重新登录</template>
|
||||
<template v-else>验证并登录</template>
|
||||
</Button>
|
||||
|
||||
<!-- 更换验证方式 -->
|
||||
<button
|
||||
class="mt-2.5 w-full text-center text-xs text-blue-400 hover:text-blue-600 transition-colors"
|
||||
@click="handleMFAReselect"
|
||||
>
|
||||
更换验证方式
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<!-- 取消 -->
|
||||
<button
|
||||
class="mt-2.5 w-full text-center text-xs text-gray-400 hover:text-gray-600 transition-colors"
|
||||
@click="closeMFADialog"
|
||||
>
|
||||
重新登录
|
||||
</button>
|
||||
</div>
|
||||
<AppDialog
|
||||
:open="showMFADialog"
|
||||
variant="center"
|
||||
wrapper-class="px-6"
|
||||
overlay-class="bg-black/50"
|
||||
panel-class="max-w-sm shadow-2xl"
|
||||
:fade="true"
|
||||
title="多因子认证"
|
||||
@update:open="(v: boolean) => { if (!v) closeMFADialog() }"
|
||||
>
|
||||
<!-- 顶部装饰条 -->
|
||||
<div class="h-1.5 bg-gradient-to-r from-blue-500 to-cyan-400" />
|
||||
<div class="p-6">
|
||||
<!-- 标题 -->
|
||||
<div class="flex items-center gap-2.5 mb-3">
|
||||
<div class="w-9 h-9 rounded-xl bg-blue-100 flex items-center justify-center">
|
||||
<ShieldCheck class="w-5 h-5 text-blue-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>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
|
||||
<!-- ===== 阶段一:选择验证方式 ===== -->
|
||||
<template v-if="mfaStage === 'select'">
|
||||
<div class="bg-blue-50 rounded-xl p-3.5 mb-4">
|
||||
<div class="flex items-start gap-2.5">
|
||||
<Smartphone class="w-4 h-4 text-blue-500 flex-shrink-0 mt-0.5" />
|
||||
<p class="text-sm text-blue-700 leading-relaxed">
|
||||
{{ mfaMessage || '请选择验证方式获取验证码' }}
|
||||
</p>
|
||||
</div>
|
||||
<p class="text-[10px] text-blue-400 mt-1.5 pl-6.5">验证会话 10 分钟内有效</p>
|
||||
</div>
|
||||
|
||||
<!-- 验证方式列表 -->
|
||||
<div class="space-y-2">
|
||||
<button
|
||||
v-for="m in mfaMethods"
|
||||
:key="m.type"
|
||||
class="w-full flex items-center justify-between px-3.5 py-3 rounded-xl border text-sm transition-colors disabled:opacity-60"
|
||||
:class="
|
||||
mfaSelectedType === m.type
|
||||
? 'border-blue-400 bg-blue-50/60 text-blue-700 font-medium'
|
||||
: 'border-gray-200 text-gray-600 hover:border-blue-200 hover:bg-gray-50'
|
||||
"
|
||||
:disabled="mfaSending"
|
||||
@click="handleMFASelect(m.type)"
|
||||
>
|
||||
<span>{{ m.name }}</span>
|
||||
<span v-if="mfaSelectedType === m.type" class="text-blue-500 font-bold">✓</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 发送验证码 -->
|
||||
<Button
|
||||
class="mt-4 w-full h-11 text-sm font-medium rounded-xl"
|
||||
:disabled="mfaSending || mfaCountdown <= 0 || !mfaSelectedType"
|
||||
@click="handleMFASend"
|
||||
>
|
||||
<RefreshCw v-if="mfaSending" class="h-4 w-4 animate-spin" />
|
||||
<template v-else-if="mfaCountdown <= 0">会话已过期,请重新登录</template>
|
||||
<template v-else>发送验证码</template>
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
<!-- ===== 阶段二:输入验证码 ===== -->
|
||||
<template v-else>
|
||||
<div class="bg-blue-50 rounded-xl p-3.5 mb-4">
|
||||
<div class="flex items-start gap-2.5">
|
||||
<Smartphone class="w-4 h-4 text-blue-500 flex-shrink-0 mt-0.5" />
|
||||
<p class="text-sm text-blue-700 leading-relaxed">{{ mfaMessage }}</p>
|
||||
</div>
|
||||
<p class="text-[10px] text-blue-400 mt-1.5 pl-6.5">验证会话 10 分钟内有效,输错可重新输入</p>
|
||||
</div>
|
||||
|
||||
<!-- 验证码输入 -->
|
||||
<div>
|
||||
<Label class="mb-1.5 block text-xs font-medium text-gray-500">验证码</Label>
|
||||
<div class="relative">
|
||||
<ShieldCheck class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
v-model="mfaCode"
|
||||
placeholder="请输入收到的验证码"
|
||||
class="pl-9 pr-9 h-10 text-sm tracking-widest"
|
||||
maxlength="8"
|
||||
@keyup.enter="handleMFAVerify"
|
||||
/>
|
||||
<span v-if="mfaCountdown > 0" class="absolute right-3 top-1/2 -translate-y-1/2 text-[10px] text-gray-300 tabular-nums">
|
||||
{{ Math.floor(mfaCountdown / 60) }}:{{ String(mfaCountdown % 60).padStart(2, '0') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 提交按钮 -->
|
||||
<Button
|
||||
class="mt-4 w-full h-11 text-sm font-medium rounded-xl"
|
||||
:disabled="mfaSubmitting || mfaCountdown <= 0"
|
||||
@click="handleMFAVerify"
|
||||
>
|
||||
<RefreshCw v-if="mfaSubmitting" class="h-4 w-4 animate-spin" />
|
||||
<template v-else-if="mfaCountdown <= 0">会话已过期,请重新登录</template>
|
||||
<template v-else>验证并登录</template>
|
||||
</Button>
|
||||
|
||||
<!-- 更换验证方式 -->
|
||||
<button
|
||||
class="mt-2.5 w-full text-center text-xs text-blue-400 hover:text-blue-600 transition-colors"
|
||||
@click="handleMFAReselect"
|
||||
>
|
||||
更换验证方式
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<!-- 取消 -->
|
||||
<button
|
||||
class="mt-2.5 w-full text-center text-xs text-gray-400 hover:text-gray-600 transition-colors"
|
||||
@click="closeMFADialog"
|
||||
>
|
||||
重新登录
|
||||
</button>
|
||||
</div>
|
||||
</AppDialog>
|
||||
|
||||
<!-- ======== 登录失败 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>
|
||||
<AppDialog
|
||||
:open="showLoginFailDialog"
|
||||
variant="center"
|
||||
wrapper-class="px-6"
|
||||
overlay-class="bg-black/50"
|
||||
panel-class="max-w-sm shadow-2xl"
|
||||
:fade="true"
|
||||
title="登录失败"
|
||||
@update:open="(v: boolean) => { if (!v) showLoginFailDialog = false }"
|
||||
>
|
||||
<!-- 顶部装饰条 -->
|
||||
<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="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="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
|
||||
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>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
|
||||
<!-- 关闭按钮 -->
|
||||
<Button class="mt-4 w-full h-11 text-sm font-medium rounded-xl" @click="showLoginFailDialog = false">
|
||||
我知道了
|
||||
</Button>
|
||||
</div>
|
||||
</AppDialog>
|
||||
|
||||
<!-- ======== 提交工单 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>
|
||||
<AppDialog
|
||||
:open="showReportDialog"
|
||||
variant="center"
|
||||
wrapper-class="px-6"
|
||||
overlay-class="bg-black/50"
|
||||
panel-class="max-w-sm shadow-2xl"
|
||||
:fade="true"
|
||||
title="提交工单"
|
||||
@update:open="(v: boolean) => { if (!v) closeReportDialog() }"
|
||||
>
|
||||
<!-- 顶部装饰条 -->
|
||||
<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 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">
|
||||
{{ 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="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">
|
||||
联系邮箱 <span class="text-red-400">*</span>
|
||||
</Label>
|
||||
<p class="text-[10px] text-gray-400 mb-1">适配完成后将通过该邮箱联系您,仅支持国内知名邮箱</p>
|
||||
<div class="flex gap-2">
|
||||
<Input
|
||||
v-model="reportEmailPrefix"
|
||||
placeholder="邮箱前缀"
|
||||
class="flex-1 h-10 text-sm"
|
||||
/>
|
||||
<select
|
||||
v-model="reportEmailDomain"
|
||||
class="flex-shrink-0 w-[110px] h-10 px-2 rounded-xl border border-gray-200 text-sm text-gray-700 bg-white focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-400"
|
||||
>
|
||||
<option v-for="d in emailDomains" :key="d" :value="d">@{{ d }}</option>
|
||||
</select>
|
||||
</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-if="!reportLoadingCaptcha && reportCaptchaError" class="w-full h-full px-1 flex items-center justify-center">
|
||||
<span class="text-[10px] leading-tight text-red-400 text-center">{{ reportCaptchaError }}</span>
|
||||
</div>
|
||||
<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"
|
||||
<!-- 联系邮箱(前缀 + 后缀下拉) -->
|
||||
<div>
|
||||
<Label class="mb-1 block text-xs font-medium text-gray-500">
|
||||
联系邮箱 <span class="text-red-400">*</span>
|
||||
</Label>
|
||||
<p class="text-[10px] text-gray-400 mb-1">适配完成后将通过该邮箱联系您,仅支持国内知名邮箱</p>
|
||||
<div class="flex gap-2">
|
||||
<Input
|
||||
v-model="reportEmailPrefix"
|
||||
placeholder="邮箱前缀"
|
||||
class="flex-1 h-10 text-sm"
|
||||
/>
|
||||
<select
|
||||
v-model="reportEmailDomain"
|
||||
class="flex-shrink-0 w-[110px] h-10 px-2 rounded-xl border border-gray-200 text-sm text-gray-700 bg-white focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-400"
|
||||
>
|
||||
<template v-if="reportSubmitting">提交中...</template>
|
||||
<template v-else>提交</template>
|
||||
</Button>
|
||||
<option v-for="d in emailDomains" :key="d" :value="d">@{{ d }}</option>
|
||||
</select>
|
||||
</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-if="!reportLoadingCaptcha && reportCaptchaError" class="w-full h-full px-1 flex items-center justify-center">
|
||||
<span class="text-[10px] leading-tight text-red-400 text-center">{{ reportCaptchaError }}</span>
|
||||
</div>
|
||||
<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>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
|
||||
<!-- 提交 -->
|
||||
<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>
|
||||
</AppDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useRouter } from 'vue-router'
|
||||
import { LogOut, RefreshCw, ClipboardCheck, Gift, Share2, X, MessageSquare, Dices, TriangleAlert } from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import AppDialog from '@/components/ui/dialog/AppDialog.vue'
|
||||
import { useMessage } from '@/composables/useMessage'
|
||||
import * as authApi from '@/api/auth'
|
||||
import type { UserInfo, MyInviteInfo } from '@/api/auth'
|
||||
@@ -101,6 +102,9 @@ function maskDeviceCode(code: string): string {
|
||||
return code.slice(0, 4) + '****' + code.slice(-4)
|
||||
}
|
||||
|
||||
// 是否已绑定设备码(决定「立即绑定 / 重新绑定」按钮文案)
|
||||
const hasDeviceCode = computed(() => !!userInfo.value?.mobileCode)
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const res = await authApi.getUserInfo()
|
||||
@@ -457,7 +461,7 @@ async function handleRedeemInvite() {
|
||||
@click="goRebind"
|
||||
>
|
||||
<RefreshCw class="w-3 h-3" />
|
||||
重新绑定
|
||||
{{ hasDeviceCode ? '重新绑定' : '立即绑定' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -478,152 +482,148 @@ async function handleRedeemInvite() {
|
||||
</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">
|
||||
{{ redeemKind === 'vip' ? '续费 VIP' : '兑换签到次数' }}
|
||||
</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>
|
||||
<AppDialog
|
||||
:open="showRedeemModal"
|
||||
variant="center"
|
||||
wrapper-class="px-4"
|
||||
panel-class="max-w-sm"
|
||||
:fade="true"
|
||||
:title="redeemKind === 'vip' ? '续费 VIP' : '兑换签到次数'"
|
||||
@update:open="(v: boolean) => { if (!v) closeRedeemModal() }"
|
||||
>
|
||||
<!-- 弹窗头部 -->
|
||||
<div class="flex items-center justify-between px-5 pt-5 pb-2">
|
||||
<h3 class="text-base font-semibold text-gray-900">
|
||||
{{ redeemKind === 'vip' ? '续费 VIP' : '兑换签到次数' }}
|
||||
</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">{{ redeemKind === 'vip' ? 'VIP 兑换码' : '兑换码' }}</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-if="captchaError"
|
||||
class="h-10 w-24 rounded-lg border border-red-200 bg-red-50 flex items-center justify-center text-[10px] leading-tight text-red-400 text-center px-1 cursor-pointer"
|
||||
title="点击重试"
|
||||
@click="refreshCaptcha"
|
||||
>
|
||||
{{ captchaError }}
|
||||
</div>
|
||||
|
||||
<!-- 弹窗内容 -->
|
||||
<div class="px-5 pb-5 space-y-4">
|
||||
<!-- 兑换码输入 -->
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-500 mb-1.5">{{ redeemKind === 'vip' ? 'VIP 兑换码' : '兑换码' }}</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-if="captchaError"
|
||||
class="h-10 w-24 rounded-lg border border-red-200 bg-red-50 flex items-center justify-center text-[10px] leading-tight text-red-400 text-center px-1 cursor-pointer"
|
||||
title="点击重试"
|
||||
@click="refreshCaptcha"
|
||||
>
|
||||
{{ captchaError }}
|
||||
</div>
|
||||
<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 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>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
|
||||
<!-- 兑换按钮 -->
|
||||
<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>
|
||||
</AppDialog>
|
||||
|
||||
<!-- 随机绑定设备码:连续两次风险确认弹窗 -->
|
||||
<Teleport to="body">
|
||||
<div v-if="randBindStep > 0" class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 px-4" @click.self="closeRandomBind">
|
||||
<div class="bg-white rounded-2xl shadow-xl w-full max-w-sm overflow-hidden">
|
||||
<!-- 第一次确认 -->
|
||||
<template v-if="randBindStep === 1">
|
||||
<div class="px-5 pt-5 pb-2 flex items-center justify-between">
|
||||
<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="closeRandomBind">
|
||||
<X class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="px-5 pb-5">
|
||||
<div class="flex items-start gap-2.5 bg-amber-50 border border-amber-200 rounded-lg p-3">
|
||||
<TriangleAlert class="w-4 h-4 text-amber-500 flex-shrink-0 mt-0.5" />
|
||||
<p class="text-xs text-amber-700 leading-relaxed">
|
||||
随机设备码与你真实手机不一致。若你已有绑定的手机在用本系统,随机后该手机执行签到时会出现
|
||||
<strong>「频繁切换设备」提示,无法正常签到</strong>。
|
||||
</p>
|
||||
</div>
|
||||
<p class="text-xs text-gray-400 mt-3">仅建议目前依旧因为其他人帮忙打卡/其他设备打卡导致已经出现「频繁切换设备」提示的用户使用。</p>
|
||||
<div class="flex gap-2 mt-4">
|
||||
<Button variant="outline" class="flex-1 h-10 text-sm" @click="closeRandomBind">取消</Button>
|
||||
<Button class="flex-1 h-10 text-sm font-medium" @click="confirmRandomBind">继续</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 第二次确认 -->
|
||||
<template v-else>
|
||||
<div class="px-5 pt-5 pb-2 flex items-center justify-between">
|
||||
<h3 class="text-base font-semibold text-red-600">再次确认:确定随机?</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="closeRandomBind">
|
||||
<X class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="px-5 pb-5">
|
||||
<p class="text-sm text-gray-600 leading-relaxed">
|
||||
随机后,你现有手机登录 App 执行签到时将提示
|
||||
<strong class="text-red-500">「频繁切换设备」而无法正常签到</strong>,
|
||||
需要重新扫码授权绑定才能恢复。
|
||||
</p>
|
||||
<p class="text-xs text-gray-400 mt-2">这是最后一次确认,确定要随机生成设备码吗?</p>
|
||||
<div class="flex gap-2 mt-4">
|
||||
<Button variant="outline" class="flex-1 h-10 text-sm" @click="closeRandomBind">取消</Button>
|
||||
<Button
|
||||
class="flex-1 h-10 text-sm font-medium bg-red-500 hover:bg-red-600"
|
||||
:disabled="randBinding"
|
||||
@click="confirmRandomBind"
|
||||
>
|
||||
<RefreshCw v-if="randBinding" class="h-4 w-4 animate-spin" />
|
||||
{{ randBinding ? '绑定中...' : '确定随机' }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<AppDialog
|
||||
:open="randBindStep > 0"
|
||||
variant="center"
|
||||
wrapper-class="px-4"
|
||||
panel-class="max-w-sm"
|
||||
title="随机绑定"
|
||||
@update:open="(v: boolean) => { if (!v) closeRandomBind() }"
|
||||
>
|
||||
<!-- 第一次确认 -->
|
||||
<template v-if="randBindStep === 1">
|
||||
<div class="px-5 pt-5 pb-2 flex items-center justify-between">
|
||||
<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="closeRandomBind">
|
||||
<X class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
<div class="px-5 pb-5">
|
||||
<div class="flex items-start gap-2.5 bg-amber-50 border border-amber-200 rounded-lg p-3">
|
||||
<TriangleAlert class="w-4 h-4 text-amber-500 flex-shrink-0 mt-0.5" />
|
||||
<p class="text-xs text-amber-700 leading-relaxed">
|
||||
随机设备码与你真实手机不一致。若你已有绑定的手机在用本系统,随机后该手机执行签到时会出现
|
||||
<strong>「频繁切换设备」提示,无法正常签到</strong>。
|
||||
</p>
|
||||
</div>
|
||||
<p class="text-xs text-gray-400 mt-3">仅建议目前依旧因为其他人帮忙打卡/其他设备打卡导致已经出现「频繁切换设备」提示的用户使用。</p>
|
||||
<div class="flex gap-2 mt-4">
|
||||
<Button variant="outline" class="flex-1 h-10 text-sm" @click="closeRandomBind">取消</Button>
|
||||
<Button class="flex-1 h-10 text-sm font-medium" @click="confirmRandomBind">继续</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 第二次确认 -->
|
||||
<template v-else>
|
||||
<div class="px-5 pt-5 pb-2 flex items-center justify-between">
|
||||
<h3 class="text-base font-semibold text-red-600">再次确认:确定随机?</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="closeRandomBind">
|
||||
<X class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="px-5 pb-5">
|
||||
<p class="text-sm text-gray-600 leading-relaxed">
|
||||
随机后,你现有手机登录 App 执行签到时将提示
|
||||
<strong class="text-red-500">「频繁切换设备」而无法正常签到</strong>,
|
||||
需要重新扫码授权绑定才能恢复。
|
||||
</p>
|
||||
<p class="text-xs text-gray-400 mt-2">这是最后一次确认,确定要随机生成设备码吗?</p>
|
||||
<div class="flex gap-2 mt-4">
|
||||
<Button variant="outline" class="flex-1 h-10 text-sm" @click="closeRandomBind">取消</Button>
|
||||
<Button
|
||||
class="flex-1 h-10 text-sm font-medium bg-red-500 hover:bg-red-600"
|
||||
:disabled="randBinding"
|
||||
@click="confirmRandomBind"
|
||||
>
|
||||
<RefreshCw v-if="randBinding" class="h-4 w-4 animate-spin" />
|
||||
{{ randBinding ? '绑定中...' : '确定随机' }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</AppDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* VIP 徽标:金色渐变 + 反光条扫过动画
|
||||
注意:不可设置 position,徽标由模板上的 Tailwind absolute 类控制浮层定位,
|
||||
若此处声明 position:relative 会覆盖 absolute,导致徽标挤进头像布局使其变形 */
|
||||
|
||||
@@ -4,7 +4,9 @@ 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 AppDialog from '@/components/ui/dialog/AppDialog.vue'
|
||||
import { useMessage } from '@/composables/useMessage'
|
||||
import { confirmDialog } from '@/composables/useConfirm'
|
||||
import * as ticketApi from '@/api/ticket'
|
||||
import type { TicketListItem } from '@/api/ticket'
|
||||
|
||||
@@ -74,7 +76,12 @@ async function handleCreate() {
|
||||
|
||||
async function handleClose(t: TicketListItem, e: Event) {
|
||||
e.stopPropagation()
|
||||
if (!confirm('确定要关闭此工单吗?')) return
|
||||
const ok = await confirmDialog({
|
||||
title: '关闭工单',
|
||||
content: '确定要关闭此工单吗?',
|
||||
confirmText: '关闭',
|
||||
})
|
||||
if (!ok) return
|
||||
try {
|
||||
await ticketApi.closeTicket(t.id)
|
||||
message.success('工单已关闭')
|
||||
@@ -86,7 +93,13 @@ async function handleClose(t: TicketListItem, e: Event) {
|
||||
|
||||
async function handleDelete(t: TicketListItem, e: Event) {
|
||||
e.stopPropagation()
|
||||
if (!confirm('确定要删除此工单吗?删除后不可恢复。')) return
|
||||
const ok = await confirmDialog({
|
||||
title: '删除工单',
|
||||
content: '确定要删除此工单吗?删除后不可恢复。',
|
||||
confirmText: '删除',
|
||||
danger: true,
|
||||
})
|
||||
if (!ok) return
|
||||
try {
|
||||
await ticketApi.deleteTicket(t.id)
|
||||
message.success('工单已删除')
|
||||
@@ -170,59 +183,48 @@ async function handleDelete(t: TicketListItem, e: Event) {
|
||||
</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>
|
||||
<AppDialog
|
||||
:open="showCreate"
|
||||
:fade="true"
|
||||
panel-class="max-w-sm"
|
||||
title="新建工单"
|
||||
@update:open="(v: boolean) => { if (!v) closeCreate() }"
|
||||
>
|
||||
<!-- 弹窗头部 -->
|
||||
<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 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>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
<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>
|
||||
</AppDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -10,6 +10,13 @@ export default defineConfig({
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
},
|
||||
},
|
||||
// 向下兼容国产浏览器/微信内置内核(日志实测存在 Chrome 78/85/91/97 等机型)。
|
||||
// 默认 target "baseline-widely-available" 会产出可选链/空值合并等新语法,
|
||||
// Chrome 80 以下直接解析失败白屏;统一降到 chrome78(微信 XWEB 常见下限)。
|
||||
// 注:CSS 侧的 @layer/oklch 兼容由 scripts/postbuild-css.mjs 处理。
|
||||
build: {
|
||||
target: 'chrome78',
|
||||
},
|
||||
server: {
|
||||
port: 3300,
|
||||
proxy: {
|
||||
|
||||
Reference in New Issue
Block a user