Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| edeabb0fc0 | |||
| 559b146408 | |||
| d8f2c3676c | |||
| cd963d3834 | |||
| c5e0888639 | |||
| 3bcc32b74e | |||
| 8de7470b9d | |||
| c90784de67 | |||
| 186af6b604 | |||
| a3ec85f4d3 | |||
| ee466b253d | |||
| 2effdbaaf9 | |||
| e979e3cc9f | |||
| 4ec3e55030 | |||
| 0bd354cfee | |||
| 6e145f75e1 | |||
| 879628d0eb | |||
| 8435095b4f |
+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)
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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 简单通配符匹配(仅支持 *)
|
||||
@@ -349,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 匹配(支持 * 通配符)
|
||||
@@ -376,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)
|
||||
|
||||
@@ -204,7 +204,10 @@ func FetchLeaveList(cookieMap map[string]string, host string) ([]LeaveItem, erro
|
||||
list := make([]LeaveItem, 0, len(result.Datas.Rows))
|
||||
for _, row := range result.Datas.Rows {
|
||||
status := leaveMapStr(row, "status")
|
||||
if status == "4" { // 已完成,无需销假
|
||||
// 待销假列表只保留可销假的记录:
|
||||
// 4=已完成、3=已驳回、10=逾期自动驳回 均无需(也不应)销假,直接过滤。
|
||||
// 注:驳回状态若不过滤,已驳回的请假会出现在待销假列表中(用户无法对其销假)。
|
||||
if status == "4" || status == "3" || status == "10" {
|
||||
continue
|
||||
}
|
||||
id := leaveMapStr(row, "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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -498,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)
|
||||
@@ -547,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)
|
||||
@@ -725,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: "签到完毕后查看时最底部那个最详细的地址信息",
|
||||
})
|
||||
|
||||
// 签到照片(任务要求时保留)
|
||||
@@ -816,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
|
||||
}
|
||||
|
||||
@@ -968,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,
|
||||
}
|
||||
|
||||
// 签到照片(任务要求时保留,与普通签到一致)
|
||||
@@ -1079,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 |
Generated
+6
@@ -11,6 +11,7 @@
|
||||
"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",
|
||||
@@ -1954,6 +1955,11 @@
|
||||
"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.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"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",
|
||||
|
||||
@@ -3,6 +3,7 @@ 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()
|
||||
@@ -30,6 +31,9 @@ watch(
|
||||
<!-- 全局新版本检测(无 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">
|
||||
|
||||
@@ -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,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>
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
<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'
|
||||
@@ -22,6 +22,7 @@ const avatarFailed = ref(false)
|
||||
const tabs = [
|
||||
{ name: 'home', label: '打卡', icon: CalendarCheck2 },
|
||||
{ name: 'leave', label: '请/销假', icon: CalendarX2 },
|
||||
{ name: 'course', label: '网课', icon: BookOpen },
|
||||
{ name: 'profile', label: '个人中心', icon: User },
|
||||
]
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -6,8 +6,25 @@
|
||||
* 若先 await Clipboard API 被拒(移动端 WebView 常见 NotAllowedError),
|
||||
* 手势上下文已丢失,后续 execCommand 会静默失败(返回 false 但剪贴板无内容)。
|
||||
*
|
||||
* 弹窗内复制的坑:AppDialog(reka-ui)会用 FocusScope 锁定焦点——
|
||||
* document 上的 focusin 监听发现焦点跑到 dialog 容器之外时会立即抢回。
|
||||
* 若把临时 textarea 挂到 document.body(容器外),ta.focus() 会立刻被抢走,
|
||||
* 选区失效,execCommand('copy') 仍返回 true 但复制的是空内容
|
||||
*(表现为「提示已复制,实际粘贴不出东西」,且只发生在弹窗内的复制按钮)。
|
||||
* 因此:优先挂到当前打开的 dialog 容器内;并显式校验选区是否真的取到文本。
|
||||
*
|
||||
* 返回是否复制成功
|
||||
*/
|
||||
function findActiveDialogContainer(): HTMLElement | null {
|
||||
const dialogs = document.querySelectorAll<HTMLElement>('[role="dialog"]')
|
||||
// 取最后一次渲染(DOM 中靠后)且可见的那个
|
||||
for (let i = dialogs.length - 1; i >= 0; i--) {
|
||||
const el = dialogs[i]
|
||||
if (el && el.offsetParent !== null) return el
|
||||
}
|
||||
return dialogs.length > 0 ? dialogs[dialogs.length - 1] : null
|
||||
}
|
||||
|
||||
export async function copyToClipboard(text: string): Promise<boolean> {
|
||||
// 1) 同步路径:隐藏 textarea + execCommand(用户手势内,兼容性最好)
|
||||
const ta = document.createElement('textarea')
|
||||
@@ -16,17 +33,24 @@ export async function copyToClipboard(text: string): Promise<boolean> {
|
||||
// font-size ≥ 16px 避免 iOS focus 时触发页面缩放
|
||||
ta.style.cssText = 'position:fixed;top:0;left:0;width:1px;height:1px;opacity:0;font-size:16px;border:0;padding:0;margin:0;'
|
||||
ta.setAttribute('readonly', '')
|
||||
document.body.appendChild(ta)
|
||||
ta.focus()
|
||||
ta.select()
|
||||
ta.setSelectionRange(0, text.length)
|
||||
// 关键:挂到 dialog 容器内(若有弹窗打开),否则焦点会被 FocusScope 抢回导致为空复制
|
||||
const host: HTMLElement = findActiveDialogContainer() || document.body
|
||||
host.appendChild(ta)
|
||||
let ok = false
|
||||
try {
|
||||
ok = document.execCommand('copy')
|
||||
ta.focus()
|
||||
ta.select()
|
||||
ta.setSelectionRange(0, text.length)
|
||||
// 校验选区确实落在本 textarea 上(焦点被抢时 selectionStart/End 会异常)
|
||||
const selectionValid =
|
||||
ta.selectionStart === 0 && ta.selectionEnd === text.length && document.activeElement === ta
|
||||
if (selectionValid) {
|
||||
ok = document.execCommand('copy')
|
||||
}
|
||||
} catch {
|
||||
ok = false
|
||||
}
|
||||
document.body.removeChild(ta)
|
||||
if (ta.parentNode) ta.parentNode.removeChild(ta)
|
||||
if (ok) return true
|
||||
|
||||
// 2) 异步回退:Clipboard API(仅 HTTPS/localhost;注意此时手势可能已丢失,仅作兜底)
|
||||
|
||||
@@ -6,6 +6,7 @@ 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()
|
||||
@@ -134,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('已删除')
|
||||
|
||||
@@ -868,7 +868,7 @@ function todayStatusLabel(s: string) {
|
||||
<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 w-[72px]">状态</th>
|
||||
<th class="px-4 py-2.5 font-medium">说明</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -891,20 +891,22 @@ function todayStatusLabel(s: string) {
|
||||
<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 class="px-4 py-2.5">
|
||||
<div class="flex flex-col items-start gap-1">
|
||||
<span
|
||||
class="text-[10px] px-1.5 py-0.5 rounded-full font-medium inline-block whitespace-nowrap"
|
||||
: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 whitespace-nowrap bg-gray-100 text-gray-400 border border-gray-200"
|
||||
title="用户已在客户端删除该日志,后台统计仍计入"
|
||||
>
|
||||
已删除
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-2.5">
|
||||
<span v-if="log.errorMessage" class="text-xs text-red-500 break-words">{{ log.errorMessage }}</span>
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
<script setup lang="ts">
|
||||
// 网课:功能开发中占位页。
|
||||
// 底部 TabBar 第三个入口(个人中心左侧),点击后进入本页。
|
||||
import { useRouter } from 'vue-router'
|
||||
import { BookOpen, MessageSquare } from 'lucide-vue-next'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
/** 跳转工单页面(有网课需反馈时提交工单) */
|
||||
function goTicket() {
|
||||
router.push('/ticket')
|
||||
}
|
||||
</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>
|
||||
|
||||
<!-- ======== 我有网课:右侧贴边窄竖条(样式同打卡页「收藏本站」/请假页「无法请销假」),点击跳转工单页 ======== -->
|
||||
<button
|
||||
class="fixed right-0 bottom-32 z-40 flex flex-col items-center gap-1.5 rounded-l-md bg-rose-500/95 py-3 px-1.5 text-white text-xs font-medium shadow-lg shadow-rose-500/30 hover:bg-rose-600 active:scale-95 transition-all"
|
||||
@click="goTicket"
|
||||
>
|
||||
<MessageSquare class="w-3.5 h-3.5 flex-shrink-0" />
|
||||
<span class="bookmark-vtext">我有网课,点击这里</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 贴边按钮竖排文字:样式与打卡页「收藏本站」一致
|
||||
(仅文字本身竖排,容器保持常规纵排:图标在上、文字在下) */
|
||||
.bookmark-vtext {
|
||||
writing-mode: vertical-rl;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
</style>
|
||||
@@ -4,11 +4,13 @@ import { useRouter } from 'vue-router'
|
||||
import { CheckCircle, Clock, Bell, Calendar, Plus, Play, Trash2, ListTodo, MapPin, LoaderCircle, Edit3, Upload, Pencil, X, Gift, Smartphone, Star } from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import AppDialog from '@/components/ui/dialog/AppDialog.vue'
|
||||
import jsQR from 'jsqr'
|
||||
import * as authApi from '@/api/auth'
|
||||
import { CPD_PASSWORD_ERROR } from '@/api'
|
||||
import { useMessage } from '@/composables/useMessage'
|
||||
import { confirmDialog, alertDialog } from '@/composables/useConfirm'
|
||||
|
||||
const { message } = useMessage()
|
||||
const message = useMessage()
|
||||
const router = useRouter()
|
||||
const loading = ref(true)
|
||||
|
||||
@@ -136,7 +138,13 @@ async function loadTaskLogs() {
|
||||
}
|
||||
|
||||
async function removeTaskLog(id: number) {
|
||||
if (!confirm('确定要删除此条日志吗?')) return
|
||||
const ok = await confirmDialog({
|
||||
title: '删除日志',
|
||||
content: '确定要删除此条日志吗?',
|
||||
confirmText: '删除',
|
||||
danger: true,
|
||||
})
|
||||
if (!ok) return
|
||||
try {
|
||||
await authApi.deleteTaskLog(id)
|
||||
taskLogs.value = taskLogs.value.filter(l => l.id !== id)
|
||||
@@ -324,6 +332,11 @@ function isImageField(item: authApi.FormItem) {
|
||||
return item.type === 4 || item.colName === 'signImg'
|
||||
}
|
||||
|
||||
// ======== 是否为二维码字段(存 uuid 而非图片;兼容历史 colName=qrCode)========
|
||||
function isQrField(item: authApi.FormItem) {
|
||||
return item.colName === 'uuid' || item.colName === 'qrCode'
|
||||
}
|
||||
|
||||
// ======== 更新范围外签到原因 ========
|
||||
function updateAbnormalReason(listName: string, value: string) {
|
||||
const items = listName === 'editFormItems' ? editFormItems.value : formItems.value
|
||||
@@ -339,38 +352,260 @@ function authHeaders(): Record<string, string> {
|
||||
return { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' }
|
||||
}
|
||||
|
||||
// ======== 从二维码文本中提取 uuid(兼容完整链接、纯 uuid)========
|
||||
function extractQrUuid(text: string): string {
|
||||
const m = /uuid=([0-9a-zA-Z_-]+)/.exec(text)
|
||||
if (m) return m[1]
|
||||
const t = text.trim()
|
||||
return /^[0-9a-zA-Z_-]{8,64}$/.test(t) ? t : ''
|
||||
}
|
||||
|
||||
// ======== 前端解码二维码(jsQR)========
|
||||
// 读取文件 → 绘制到 canvas → 取像素解码。
|
||||
//
|
||||
// 为什么不能只试一两档缩放:jsQR 用固定的 8x8 分块自适应二值化,
|
||||
// 只有当"二维码模块边长"与块尺寸量级相当时才能正确二值化,因此对缩放
|
||||
// 极其敏感——同一张图在某些档位能解、相邻档位就失效,且不同浏览器/内核的
|
||||
// canvas 重采样算法不同,可解档位会整体漂移(桌面能过、手机 WebView 失败)。
|
||||
//
|
||||
// 策略:两阶段多尺度扫描
|
||||
// 阶段一:按"目标长边"从大到小逐档缩放,直接解码;
|
||||
// 阶段二:同样逐档,但对每档先做全局 Otsu 二值化再解码(补足对比度差/斜拍屏幕)。
|
||||
// 用时间预算封顶,避免无二维码的图长时间占用主线程。
|
||||
const QR_DECODE_BUDGET_MS = 3000
|
||||
const QR_MAX_LONG_EDGE = 1400
|
||||
|
||||
function qrTargetLongEdges(longEdge: number): number[] {
|
||||
const targets: number[] = []
|
||||
let t = Math.min(longEdge, QR_MAX_LONG_EDGE)
|
||||
while (t >= 200) {
|
||||
targets.push(t)
|
||||
t = Math.round(t * 0.72)
|
||||
}
|
||||
if (targets.length === 0) targets.push(longEdge)
|
||||
return targets
|
||||
}
|
||||
|
||||
// 全局 Otsu 阈值二值化(原地修改),用于对比度不佳的图片。
|
||||
function otsuBinarizeInPlace(data: Uint8ClampedArray, w: number, h: number) {
|
||||
const n = w * h
|
||||
const hist = new Array<number>(256).fill(0)
|
||||
for (let i = 0; i < n; i++) {
|
||||
const g = (0.299 * data[4 * i] + 0.587 * data[4 * i + 1] + 0.114 * data[4 * i + 2]) | 0
|
||||
hist[g]++
|
||||
data[4 * i] = data[4 * i + 1] = data[4 * i + 2] = g
|
||||
}
|
||||
let sum = 0
|
||||
for (let i = 0; i < 256; i++) sum += i * hist[i]
|
||||
let sumB = 0
|
||||
let wB = 0
|
||||
let maxVar = 0
|
||||
let threshold = 0
|
||||
for (let t = 0; t < 256; t++) {
|
||||
wB += hist[t]
|
||||
if (wB === 0) continue
|
||||
const wF = n - wB
|
||||
if (wF === 0) break
|
||||
sumB += t * hist[t]
|
||||
const mB = sumB / wB
|
||||
const mF = (sum - sumB) / wF
|
||||
const variance = wB * wF * (mB - mF) * (mB - mF)
|
||||
if (variance > maxVar) {
|
||||
maxVar = variance
|
||||
threshold = t
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < n; i++) {
|
||||
const v = data[4 * i] <= threshold ? 0 : 255
|
||||
data[4 * i] = data[4 * i + 1] = data[4 * i + 2] = v
|
||||
data[4 * i + 3] = 255
|
||||
}
|
||||
}
|
||||
|
||||
function loadImageFromFile(file: File): Promise<HTMLImageElement | null> {
|
||||
return new Promise((resolve) => {
|
||||
const url = URL.createObjectURL(file)
|
||||
const img = new Image()
|
||||
img.onload = () => {
|
||||
URL.revokeObjectURL(url)
|
||||
resolve(img)
|
||||
}
|
||||
img.onerror = () => {
|
||||
URL.revokeObjectURL(url)
|
||||
resolve(null)
|
||||
}
|
||||
img.src = url
|
||||
})
|
||||
}
|
||||
|
||||
async function decodeQrInBrowser(file: File): Promise<string> {
|
||||
const img = await loadImageFromFile(file)
|
||||
if (!img || !img.naturalWidth || !img.naturalHeight) return ''
|
||||
|
||||
const W = img.naturalWidth
|
||||
const H = img.naturalHeight
|
||||
const longEdge = Math.max(W, H)
|
||||
const targets = qrTargetLongEdges(longEdge)
|
||||
|
||||
const canvas = document.createElement('canvas')
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return ''
|
||||
|
||||
// 按目标长边渲染一帧,返回其像素数据
|
||||
const render = (target: number): ImageData => {
|
||||
const scale = target / longEdge
|
||||
const w = Math.max(1, Math.round(W * scale))
|
||||
const h = Math.max(1, Math.round(H * scale))
|
||||
canvas.width = w
|
||||
canvas.height = h
|
||||
ctx.clearRect(0, 0, w, h)
|
||||
ctx.drawImage(img, 0, 0, w, h)
|
||||
return ctx.getImageData(0, 0, w, h)
|
||||
}
|
||||
|
||||
const startedAt = performance.now()
|
||||
const yieldToUi = () => new Promise((r) => setTimeout(r, 0))
|
||||
// 阶段一(不二值化)只分到一半预算,剩下留给阶段二,避免阶段一"吃光"预算
|
||||
// 导致 Otsu 兜底完全没机会执行(大图 raw 扫描较慢时尤其明显)。
|
||||
const phase1Deadline = startedAt + QR_DECODE_BUDGET_MS / 2
|
||||
|
||||
// 单次尝试:渲染 →(可选)Otsu → jsQR
|
||||
const attempt = (target: number, useOtsu: boolean): string => {
|
||||
const frame = render(target)
|
||||
if (useOtsu) otsuBinarizeInPlace(frame.data, frame.width, frame.height)
|
||||
try {
|
||||
const code = jsQR(frame.data, frame.width, frame.height, { inversionAttempts: 'attemptBoth' })
|
||||
return code?.data || ''
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
// 阶段一:逐档直接解码(超出本阶段子预算则进入阶段二,而非直接放弃)
|
||||
for (const target of targets) {
|
||||
if (performance.now() > phase1Deadline) break
|
||||
const text = attempt(target, false)
|
||||
if (text) return text
|
||||
// 逐档让出主线程,避免多档尝试时"解析中" loading 卡住
|
||||
await yieldToUi()
|
||||
}
|
||||
|
||||
// 阶段二:逐档 Otsu 二值化后再解码(用剩余预算)
|
||||
for (const target of targets) {
|
||||
if (performance.now() - startedAt > QR_DECODE_BUDGET_MS) break
|
||||
const text = attempt(target, true)
|
||||
if (text) return text
|
||||
await yieldToUi()
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
// ======== 前端压缩图片(默认目标 ≤300KB),返回压缩后的 File ========
|
||||
// 仅对签到照片等需上传 OBS 的图片使用;二维码不上传、无需压缩。
|
||||
async function compressImage(file: File, maxBytes = 300 * 1024): Promise<File> {
|
||||
if (file.size <= maxBytes) return file
|
||||
const dataUrl = await new Promise<string>((resolve) => {
|
||||
const r = new FileReader()
|
||||
r.onload = (e) => resolve((e.target?.result as string) || '')
|
||||
r.onerror = () => resolve('')
|
||||
r.readAsDataURL(file)
|
||||
})
|
||||
if (!dataUrl) return file
|
||||
|
||||
const img = await new Promise<HTMLImageElement | null>((resolve) => {
|
||||
const i = new Image()
|
||||
i.onload = () => resolve(i)
|
||||
i.onerror = () => resolve(null)
|
||||
i.src = dataUrl
|
||||
})
|
||||
if (!img) return file
|
||||
|
||||
const canvas = document.createElement('canvas')
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return file
|
||||
|
||||
// 逐步降低质量/尺寸,直到体积达标(下限保证清晰度)
|
||||
let scale = 1
|
||||
let quality = 0.85
|
||||
let out: Blob | null = null
|
||||
for (let i = 0; i < 8; i++) {
|
||||
const w = Math.max(1, Math.round(img.naturalWidth * scale))
|
||||
const h = Math.max(1, Math.round(img.naturalHeight * scale))
|
||||
canvas.width = w
|
||||
canvas.height = h
|
||||
ctx.clearRect(0, 0, w, h)
|
||||
ctx.drawImage(img, 0, 0, w, h)
|
||||
out = await new Promise<Blob | null>((resolve) =>
|
||||
canvas.toBlob((b) => resolve(b), 'image/jpeg', quality),
|
||||
)
|
||||
if (!out) break
|
||||
if (out.size <= maxBytes) break
|
||||
// 先降质量,质量到底后再降尺寸
|
||||
if (quality > 0.5) quality -= 0.15
|
||||
else scale *= 0.8
|
||||
if (scale < 0.3) break
|
||||
}
|
||||
if (!out) return file
|
||||
const name = file.name.replace(/\.[^.]+$/, '') + '.jpg'
|
||||
return new File([out], name, { type: 'image/jpeg' })
|
||||
}
|
||||
|
||||
// ======== 图片上传 → 浏览器直传到今日校园 OBS(不经过服务器中转),成功后立即保存表单 ========
|
||||
const uploadingImage = ref(false)
|
||||
const uploadingLabel = ref('上传中...')
|
||||
async function handleImageUpload(index: number, isEdit: boolean) {
|
||||
if (uploadingImage.value) return
|
||||
const input = document.createElement('input')
|
||||
input.type = 'file'
|
||||
input.accept = 'image/*'
|
||||
input.onchange = async () => {
|
||||
const file = input.files?.[0]
|
||||
if (!file) return
|
||||
const picked = input.files?.[0]
|
||||
if (!picked) return
|
||||
const taskType = isEdit ? (editTask.value?.taskType || 'sign') : (selectedItem.value?.taskType || 'sign')
|
||||
const items = isEdit ? editFormItems.value : formItems.value
|
||||
|
||||
// 签到二维码仅用于后台解析 uuid,无需上传到今日校园 OBS,本地预览即可
|
||||
if (items[index].colName === 'qrCode') {
|
||||
const qrReader = new FileReader()
|
||||
qrReader.onload = (e) => {
|
||||
items[index].content = (e.target?.result as string) || ''
|
||||
// 记录原值:任何失败路径都回滚,避免本地预览的 base64 被当成正式值保存入库
|
||||
const prevContent = items[index].content || ''
|
||||
|
||||
// ---- 二维码字段:前端 jsQR 直接解析出 uuid,不上传、不压缩 ----
|
||||
if (isQrField(items[index])) {
|
||||
uploadingImage.value = true
|
||||
uploadingLabel.value = '解析二维码中...'
|
||||
try {
|
||||
const text = await decodeQrInBrowser(picked)
|
||||
const uuid = text ? extractQrUuid(text) : ''
|
||||
if (!uuid) {
|
||||
message.error('未识别到二维码,请换一张更清晰的二维码图片')
|
||||
return
|
||||
}
|
||||
items[index].content = uuid
|
||||
message.success('二维码已解析')
|
||||
} finally {
|
||||
uploadingImage.value = false
|
||||
}
|
||||
qrReader.readAsDataURL(file)
|
||||
return
|
||||
}
|
||||
|
||||
// 先显示本地预览
|
||||
const reader = new FileReader()
|
||||
reader.onload = (e) => {
|
||||
items[index].content = (e.target?.result as string) || ''
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
|
||||
// ---- 其它图片(签到照片等):先压缩到 ≤300KB,再上传 OBS ----
|
||||
let file = picked
|
||||
uploadingImage.value = true
|
||||
uploadingLabel.value = '压缩中...'
|
||||
try {
|
||||
try {
|
||||
file = await compressImage(picked, 300 * 1024)
|
||||
} catch {
|
||||
file = picked
|
||||
}
|
||||
|
||||
// 本地预览(仅界面展示,上传成功后会覆盖为 OBS 正式 URL)
|
||||
const reader = new FileReader()
|
||||
reader.onload = (e) => {
|
||||
items[index].content = (e.target?.result as string) || ''
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
|
||||
uploadingLabel.value = '上传中...'
|
||||
// Step 1: 从服务器获取 OBS 上传策略(仅获取凭证,不传文件)
|
||||
const policyRes = await fetch('/api/task/upload-policy', {
|
||||
method: 'POST',
|
||||
@@ -380,6 +615,7 @@ async function handleImageUpload(index: number, isEdit: boolean) {
|
||||
const policyJson = await policyRes.json()
|
||||
if (policyJson.code !== 0) {
|
||||
message.error('获取上传策略失败: ' + (policyJson.message || policyRes.status))
|
||||
items[index].content = prevContent
|
||||
return
|
||||
}
|
||||
const policy = policyJson.data
|
||||
@@ -407,6 +643,7 @@ async function handleImageUpload(index: number, isEdit: boolean) {
|
||||
})
|
||||
if (obsRes.status > 299) {
|
||||
message.error('图片上传到 OBS 失败(' + obsRes.status + '),请重试')
|
||||
items[index].content = prevContent
|
||||
return
|
||||
}
|
||||
|
||||
@@ -422,6 +659,7 @@ async function handleImageUpload(index: number, isEdit: boolean) {
|
||||
const previewJson = await previewRes.json()
|
||||
if (previewJson.code !== 0) {
|
||||
message.error('获取预览 URL 失败: ' + (previewJson.message || previewRes.status))
|
||||
items[index].content = prevContent
|
||||
return
|
||||
}
|
||||
|
||||
@@ -430,6 +668,7 @@ async function handleImageUpload(index: number, isEdit: boolean) {
|
||||
message.success('图片上传成功,保存表单后生效')
|
||||
} catch (e: any) {
|
||||
message.error('图片上传失败: ' + (e?.message || '未知错误'))
|
||||
items[index].content = prevContent
|
||||
} finally {
|
||||
uploadingImage.value = false
|
||||
}
|
||||
@@ -437,7 +676,21 @@ async function handleImageUpload(index: number, isEdit: boolean) {
|
||||
input.click()
|
||||
}
|
||||
|
||||
// ======== 保存表单(创建流程) ========
|
||||
// ======== 保存前校验:图片字段不允许是本地预览(base64) ========
|
||||
// 界面会先把所选图片读成 dataURL 预览,上传 OBS 成功后覆盖为正式 URL。
|
||||
// 若在上传未完成/失败时保存,会把 base64 写进 formData(历史上单条曾达 2.9MB),
|
||||
// 故此处拦截,提示用户等待上传完成或重新上传。
|
||||
function guardBase64Images(items: authApi.FormItem[] | null | undefined): boolean {
|
||||
const bad = (items || []).find(
|
||||
i => i.type === 4 && typeof i.content === 'string' && i.content.startsWith('data:image'),
|
||||
)
|
||||
if (bad) {
|
||||
message.warning(`「${bad.title || bad.colName}」图片尚未上传完成,请稍候或重新上传后再保存`)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// ======== 表单完整性:必填项为空时提交必然失败(如签到地点未配置 → 服务端报"纬度不能为空") ========
|
||||
function missingRequiredFields(items: authApi.FormItem[] | null | undefined): string[] {
|
||||
if (!items?.length) return []
|
||||
@@ -446,11 +699,6 @@ function missingRequiredFields(items: authApi.FormItem[] | null | undefined): st
|
||||
.map(i => i.title || i.colName)
|
||||
}
|
||||
|
||||
function parseFormItems(formData?: string | null): authApi.FormItem[] {
|
||||
if (!formData) return []
|
||||
try { return JSON.parse(formData) as authApi.FormItem[] } catch { return [] }
|
||||
}
|
||||
|
||||
// ======== 范围外签到原因必填校验 ========
|
||||
function validateMalpositionReason(items: authApi.FormItem[]): boolean {
|
||||
const mal = items.find(i => i.colName === 'isMalposition')
|
||||
@@ -466,6 +714,7 @@ function validateMalpositionReason(items: authApi.FormItem[]): boolean {
|
||||
|
||||
async function saveForm() {
|
||||
if (!validateMalpositionReason(formItems.value)) return
|
||||
if (!guardBase64Images(formItems.value)) return
|
||||
savingForm.value = true
|
||||
try {
|
||||
await authApi.saveTaskForm(formTaskId.value, formItems.value)
|
||||
@@ -476,7 +725,10 @@ async function saveForm() {
|
||||
message.warning(`已保存,但以下必填项为空:${missing.join('、')},执行签到前请在「编辑」中补全`)
|
||||
}
|
||||
} catch (e: any) {
|
||||
alert(e?.response?.data?.message || e.message || '保存失败')
|
||||
await alertDialog({
|
||||
title: '保存失败',
|
||||
content: e?.response?.data?.message || e.message || '保存失败',
|
||||
})
|
||||
} finally {
|
||||
savingForm.value = false
|
||||
}
|
||||
@@ -489,23 +741,28 @@ async function openEditModal(task: authApi.TaskInfo) {
|
||||
showEditModal.value = true
|
||||
loadingEditItems.value = true
|
||||
|
||||
// 如果已有 formData,直接解析显示
|
||||
if (task.formData) {
|
||||
try {
|
||||
editFormItems.value = JSON.parse(task.formData)
|
||||
// 优先用该任务已保存的表单内容(列表接口已不再携带 formData)
|
||||
try {
|
||||
const saved = await authApi.getTaskSavedForm(task.id)
|
||||
const fd = saved.data.data?.formData
|
||||
if (fd) {
|
||||
editFormItems.value = JSON.parse(fd)
|
||||
loadingEditItems.value = false
|
||||
return
|
||||
} catch {
|
||||
// 解析失败,重新获取表单
|
||||
}
|
||||
} catch {
|
||||
// 获取已保存表单失败,回退到从今日校园重新拉取
|
||||
}
|
||||
|
||||
// 重新从 cpdaily 拉取表单
|
||||
// 回退:从 cpdaily 拉取表单(会丢失用户已填值)
|
||||
try {
|
||||
const res = await authApi.getTaskForm(task.id)
|
||||
editFormItems.value = res.data.data.formItems || []
|
||||
editFormItems.value = res.data.data?.formItems || []
|
||||
} catch (e: any) {
|
||||
alert('获取表单失败: ' + (e?.response?.data?.message || e.message))
|
||||
await alertDialog({
|
||||
title: '获取表单失败',
|
||||
content: e?.response?.data?.message || e.message,
|
||||
})
|
||||
showEditModal.value = false
|
||||
return
|
||||
} finally {
|
||||
@@ -517,6 +774,7 @@ async function openEditModal(task: authApi.TaskInfo) {
|
||||
async function saveEdit() {
|
||||
if (!editTask.value) return
|
||||
if (!validateMalpositionReason(editFormItems.value)) return
|
||||
if (!guardBase64Images(editFormItems.value)) return
|
||||
savingEdit.value = true
|
||||
try {
|
||||
await authApi.saveTaskForm(editTask.value.id, editFormItems.value)
|
||||
@@ -527,7 +785,10 @@ async function saveEdit() {
|
||||
message.warning(`已保存,但以下必填项为空:${missing.join('、')},执行签到前请补全`)
|
||||
}
|
||||
} catch (e: any) {
|
||||
alert(e?.response?.data?.message || e.message || '保存失败')
|
||||
await alertDialog({
|
||||
title: '保存失败',
|
||||
content: e?.response?.data?.message || e.message || '保存失败',
|
||||
})
|
||||
} finally {
|
||||
savingEdit.value = false
|
||||
}
|
||||
@@ -548,7 +809,8 @@ function executeTask(task: authApi.TaskInfo, confirmed = false) {
|
||||
}
|
||||
|
||||
// 表单完整性预检:必填项为空时直接引导去编辑补全(后端同样会拦截,此处提示更快更直观)
|
||||
const missing = missingRequiredFields(parseFormItems(task.formData))
|
||||
// missingFields 由列表接口服务端预计算,避免前端解析可能达 MB 级的 formData
|
||||
const missing = task.missingFields || []
|
||||
if (missing.length) {
|
||||
message.warning(`签到信息未配置完整,缺少:${missing.join('、')},请先点「编辑」补全`)
|
||||
openEditModal(task)
|
||||
@@ -656,7 +918,13 @@ function executeTask(task: authApi.TaskInfo, confirmed = false) {
|
||||
|
||||
// ======== 删除任务 ========
|
||||
async function removeTask(id: number) {
|
||||
if (!confirm('确定要删除此任务吗?')) return
|
||||
const ok = await confirmDialog({
|
||||
title: '删除任务',
|
||||
content: '确定要删除此任务吗?',
|
||||
confirmText: '删除',
|
||||
danger: true,
|
||||
})
|
||||
if (!ok) return
|
||||
deletingId.value = id
|
||||
try {
|
||||
await authApi.deleteTask(id)
|
||||
@@ -1213,6 +1481,35 @@ function getGPSLocation(source: string) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 二维码字段:只保存解析出的二维码ID(uuid),不存图片 -->
|
||||
<div v-else-if="isQrField(item)" class="space-y-1">
|
||||
<label class="text-xs font-medium text-gray-600">{{ item.title }} <span v-if="item.required" class="text-red-400">*</span></label>
|
||||
<div @click="handleImageUpload(idx, false)" class="cursor-pointer">
|
||||
<div
|
||||
v-if="uploadingImage"
|
||||
class="h-12 flex items-center gap-2 px-3 border-2 border-dashed border-gray-300 rounded-lg text-blue-500 text-xs"
|
||||
>
|
||||
<LoaderCircle class="w-4 h-4 animate-spin" /> 解析中...
|
||||
</div>
|
||||
<div
|
||||
v-else-if="item.content"
|
||||
class="h-12 flex items-center gap-2 px-3 rounded-lg border border-green-200 bg-green-50 text-xs text-green-700 hover:opacity-80 transition-opacity"
|
||||
>
|
||||
<CheckCircle class="w-4 h-4 shrink-0" />
|
||||
<span class="shrink-0">二维码ID:</span>
|
||||
<span class="font-mono truncate">{{ item.content }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="h-12 flex items-center justify-center gap-2 border-2 border-dashed border-gray-300 rounded-lg hover:border-blue-400 hover:text-blue-500 text-gray-400 text-xs transition-colors"
|
||||
>
|
||||
<Upload class="w-4 h-4" />
|
||||
<span>上传二维码图片</span>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="item.tip" class="text-[10px] text-gray-400">{{ item.tip }}</p>
|
||||
</div>
|
||||
|
||||
<!-- 图片:点击直接选文件替换,不拉伸、无输入框 -->
|
||||
<div v-else-if="isImageField(item)" class="space-y-1">
|
||||
<label class="text-xs font-medium text-gray-600">{{ item.title }} <span v-if="item.required" class="text-red-400">*</span></label>
|
||||
@@ -1223,9 +1520,10 @@ function getGPSLocation(source: string) {
|
||||
<!-- 上传中:loading 动画 -->
|
||||
<div
|
||||
v-if="uploadingImage"
|
||||
class="w-24 h-24 flex items-center justify-center border-2 border-dashed border-gray-300 rounded-lg text-blue-500"
|
||||
class="w-24 h-24 flex flex-col items-center justify-center gap-1 border-2 border-dashed border-gray-300 rounded-lg text-blue-500"
|
||||
>
|
||||
<LoaderCircle class="w-6 h-6 animate-spin" />
|
||||
<span class="text-[10px]">{{ uploadingLabel }}</span>
|
||||
</div>
|
||||
<!-- 已有图片:显示缩略图,点击替换 -->
|
||||
<img
|
||||
@@ -1376,6 +1674,35 @@ function getGPSLocation(source: string) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 二维码字段:只保存解析出的二维码ID(uuid),不存图片 -->
|
||||
<div v-else-if="isQrField(item)" class="space-y-1">
|
||||
<label class="text-xs font-medium text-gray-600">{{ item.title }} <span v-if="item.required" class="text-red-400">*</span></label>
|
||||
<div @click="handleImageUpload(idx, true)" class="cursor-pointer">
|
||||
<div
|
||||
v-if="uploadingImage"
|
||||
class="h-12 flex items-center gap-2 px-3 border-2 border-dashed border-gray-300 rounded-lg text-blue-500 text-xs"
|
||||
>
|
||||
<LoaderCircle class="w-4 h-4 animate-spin" /> 解析中...
|
||||
</div>
|
||||
<div
|
||||
v-else-if="item.content"
|
||||
class="h-12 flex items-center gap-2 px-3 rounded-lg border border-green-200 bg-green-50 text-xs text-green-700 hover:opacity-80 transition-opacity"
|
||||
>
|
||||
<CheckCircle class="w-4 h-4 shrink-0" />
|
||||
<span class="shrink-0">二维码ID:</span>
|
||||
<span class="font-mono truncate">{{ item.content }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="h-12 flex items-center justify-center gap-2 border-2 border-dashed border-gray-300 rounded-lg hover:border-blue-400 hover:text-blue-500 text-gray-400 text-xs transition-colors"
|
||||
>
|
||||
<Upload class="w-4 h-4" />
|
||||
<span>上传二维码图片</span>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="item.tip" class="text-[10px] text-gray-400">{{ item.tip }}</p>
|
||||
</div>
|
||||
|
||||
<!-- 图片:点击直接选文件替换 -->
|
||||
<div v-else-if="isImageField(item)" class="space-y-1">
|
||||
<label class="text-xs font-medium text-gray-600">{{ item.title }} <span v-if="item.required" class="text-red-400">*</span></label>
|
||||
@@ -1386,9 +1713,10 @@ function getGPSLocation(source: string) {
|
||||
<!-- 上传中:loading 动画 -->
|
||||
<div
|
||||
v-if="uploadingImage"
|
||||
class="w-24 h-24 flex items-center justify-center border-2 border-dashed border-gray-300 rounded-lg text-blue-500"
|
||||
class="w-24 h-24 flex flex-col items-center justify-center gap-1 border-2 border-dashed border-gray-300 rounded-lg text-blue-500"
|
||||
>
|
||||
<LoaderCircle class="w-6 h-6 animate-spin" />
|
||||
<span class="text-[10px]">{{ uploadingLabel }}</span>
|
||||
</div>
|
||||
<img
|
||||
v-else-if="item.content && (item.content.startsWith('http') || item.content.startsWith('data:image'))"
|
||||
|
||||
@@ -626,9 +626,10 @@ import { CalendarCheck, CalendarPlus, LoaderCircle, Trash2, X, MapPin, Upload, M
|
||||
import * as leaveApi from '@/api/leave'
|
||||
import type { LeaveItem, LeaveReportPlace, LeaveType } from '@/api/leave'
|
||||
import { useMessage } from '@/composables/useMessage'
|
||||
import { confirmDialog } from '@/composables/useConfirm'
|
||||
import AppDialog from '@/components/ui/dialog/AppDialog.vue'
|
||||
|
||||
const { message } = useMessage()
|
||||
const message = useMessage()
|
||||
const router = useRouter()
|
||||
|
||||
/** 跳转工单页面(无法请/销假时提交工单反馈) */
|
||||
@@ -653,7 +654,13 @@ async function loadLogs() {
|
||||
}
|
||||
|
||||
async function removeLog(id: number) {
|
||||
if (!confirm('确定要删除此条日志吗?')) return
|
||||
const ok = await confirmDialog({
|
||||
title: '删除日志',
|
||||
content: '确定要删除此条日志吗?',
|
||||
confirmText: '删除',
|
||||
danger: true,
|
||||
})
|
||||
if (!ok) return
|
||||
try {
|
||||
await leaveApi.deleteLeaveLog(id)
|
||||
leaveLogs.value = leaveLogs.value.filter(l => l.id !== id)
|
||||
|
||||
@@ -564,7 +564,7 @@ async function handleReportSubmit() {
|
||||
v-if="captchaImage && !loadingCaptcha"
|
||||
:src="captchaImage"
|
||||
alt="验证码"
|
||||
class="max-w-full max-h-full object-contain"
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
<div v-else-if="!loadingCaptcha && captchaError" class="w-full h-full px-1 flex items-center justify-center">
|
||||
<span class="text-[10px] leading-tight text-red-400 text-center">{{ captchaError }}</span>
|
||||
@@ -877,7 +877,7 @@ async function handleReportSubmit() {
|
||||
v-if="reportCaptchaImage && !reportLoadingCaptcha"
|
||||
:src="reportCaptchaImage"
|
||||
alt="验证码"
|
||||
class="max-w-full max-h-full object-contain"
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
<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>
|
||||
|
||||
@@ -102,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()
|
||||
@@ -458,7 +461,7 @@ async function handleRedeemInvite() {
|
||||
@click="goRebind"
|
||||
>
|
||||
<RefreshCw class="w-3 h-3" />
|
||||
重新绑定
|
||||
{{ hasDeviceCode ? '重新绑定' : '立即绑定' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -524,19 +527,19 @@ async function handleRedeemInvite() {
|
||||
<img
|
||||
v-if="captchaImage"
|
||||
:src="captchaImage"
|
||||
class="h-10 w-24 rounded-lg border border-gray-200 cursor-pointer bg-gray-50 object-contain"
|
||||
class="h-10 w-[120px] rounded-lg border border-gray-200 cursor-pointer bg-gray-50 object-cover"
|
||||
@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"
|
||||
class="h-10 w-[120px] 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 v-else class="h-10 w-[120px] rounded-lg border border-gray-200 bg-gray-50 flex items-center justify-center text-xs text-gray-400">
|
||||
加载中
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,7 @@ 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'
|
||||
|
||||
@@ -75,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('工单已关闭')
|
||||
@@ -87,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('工单已删除')
|
||||
|
||||
Reference in New Issue
Block a user