Compare commits

...

4 Commits

Author SHA1 Message Date
becomer 3bcc32b74e v1.2.70:网课页新增「我有网课,点击这里」贴边按钮(跳转工单)
ci/woodpecker/tag/build Pipeline was successful
模仿打卡页「收藏本站」与请假页「无法请/销假?提交工单」的右侧贴边窄竖条样式,
在网课页加入「我有网课,点击这里」按钮,点击跳转 /ticket 工单页。

- CourseView.vue:新增按钮(fixed right-0 bottom-32, bg-rose-500/95)与 goTicket();
- 复用同款竖排文字样式 .bookmark-vtext(writing-mode: vertical-rl)。
2026-09-18 22:36:27 +08:00
becomer 8de7470b9d v1.2.69:底部 TabBar 新增「网课」入口(开发中占位页)
ci/woodpecker/tag/build Pipeline was successful
① 需求:底部导航新增「网课」,放在个人中心左侧(第三个位置):
   打卡 → 请/销假 → 网课 → 个人中心。

② 改动:
   - 新增 src/views/course/CourseView.vue:顶部标题栏 + 居中占位「正在努力开发中」;
   - src/layouts/MobileLayout.vue:tabs 数组插入 { name: 'course', label: '网课', icon: BookOpen },
     并补充 BookOpen 的 import;
   - src/router/index.ts:MobileLayout 子路由新增 /course(置于 leave 与 profile 之间),
     注册 CourseView。

③ 说明:网课页不走 home/leave 的顶部蓝色渐变头(第 258 行仅这两者显示),
   使用自身标题栏。引导流程仅涉及 home/profile,不受影响。
2026-09-18 22:20:49 +08:00
becomer c90784de67 v1.2.68:编辑表单优先取历史记录的真实经纬度/地址(并校验在打卡范围内)
ci/woodpecker/tag/build Pipeline was successful
① 问题:编辑打卡任务表单时,经纬度与地址取到的是学校配置的"打卡范围信息"
   (如 signPlaceSelected[0].address = "新建的签到地点"),而非历史记录里的真实
   签到位置。实测用户 1095(兰州财经大学,晚归寝打卡)确认。

② 根因:ParseTaskForm 的定位取值顺序是「signPlaceSelected[0] 优先」——
   该字段是学校配置的范围点(含范围点名与范围点坐标),被排在历史记录的实际
   签到经纬度之前,于是历史真实坐标/地址永远取不到。

③ 修复(backed/internal/service/cpdaily/task.go):
   - 改为「历史记录优先」:先取 histRows 的 longitude/latitude 与 signAddress/position;
   - 但新增范围校验 coordsWithinSignRange:历史坐标必须落在打卡范围内
     (复用已有 parseSignPlaces + haversineMeters,逐点比对 radius);
     超出全部范围点时放弃历史坐标与地址,回退到 signPlaceSelected[0] 范围点,
     避免服务端以"位置不在签到范围内"拒绝提交;
   - 无范围点配置时不做限制(沿用历史坐标);历史坐标缺失同样回退范围点。
   - 无历史记录(histRows == rows)时行为与改动前一致,无回归。

④ 验证(真实数据,只读):
   - 修复前 lon=103.89427283364513 / lat=36.053086828712225 / address=新建的签到地点;
     修复后 lon=103.894854 / lat=36.052122 /
     address=中国甘肃省兰州市城关区拱星墩街道段家滩路426号(历史真实值);
   - 实测该历史坐标距范围点 119.3m < 240m,判定在范围内 → 正确采用历史值;
   - 超范围/空坐标/无范围点三类边界均按预期;
   - 无历史记录分支输出与改动前一致。
   - go build / go vet / go test ./... 全过。
     顺带修正 v1.2.66 调序后未同步的两个测试断言(二维码项已移至末尾)。
2026-09-18 21:48:33 +08:00
becomer 186af6b604 v1.2.67:修复 MFA 登录报「生成验证会话失败」——运维配置 Redis 改用容器名
ci/woodpecker/tag/build Pipeline was successful
① 问题:中国矿业大学(cumt,需 MFA)登录报「生成验证会话失败」。
   日志证据:MFA参数解析 userId=TS26040188P31(中国矿业大学)后,
   ERROR MFA会话暂存失败 error="dial tcp 172.22.0.7:6379: connect: connection refused"。

② 根因:宿主机 /opt/cpdaily-xiaozhushou/config.toml 的 [redis] host 写死为
   "172.22.0.7"。该 IP 曾是 Redis 容器地址,但容器重启后 IP 漂移,现已被
   likedream-go 占用;原 Redis 现为 172.22.0.8 / likedream-redis 为 172.22.0.6。
   即写死 IP 的经典陷阱:容器重建后 IP 易主,配置指向了非 Redis 的容器。
   MFA 中间会话必须跨请求保留(start → send → verify 三步),硬依赖 Redis,
   故 Redis 不可用时该步直接失败——captcha 有内存降级,MFA 没有(设计上不可降级)。

③ 修复(仅运维配置,不入库):
   /opt/cpdaily-xiaozhushou/config.toml 的 host 由 "172.22.0.7" 改为容器名
   "1Panel-redis-zDUv"(同属 likedream-net,容器名 DNS 稳定,不随 IP 漂移;
   端口 6379 与密码 redis_4APk8B 本就对应此实例,无需改动)。
   修改前已备份为 config.toml.bak.<时间戳>。

④ 验证:两个槽位(8884/8885)重启后,
   - 启动日志不再出现 "Redis 连接失败(...connection refused)";
   - 调用 /api/auth/captcha 后,Redis 相关警告行数由每次必现降为 0
     (此前每次操作必打「验证码写入 Redis 失败,降级内存存储」)。
   已确认 Redis 连通,未复测完整登录流程。

⑤ 本提交为空提交(--allow-empty):本次修复全部在宿主机运维配置,
   无仓库代码变更,仅以此 commit 记录版本事件与排查结论。
2026-09-18 21:28:43 +08:00
5 changed files with 144 additions and 47 deletions
+69 -38
View File
@@ -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)
@@ -1229,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
@@ -86,21 +86,23 @@ func TestFillScanSubmitForm(t *testing.T) {
}
}
// 扫码任务表单项解析:ParseTaskForm 应生成「二维码ID」图片项 + 经纬度/地址项
// 扫码任务表单项解析:ParseTaskForm 应生成经纬度/地址项 + 「二维码ID」图片项(二维码在最后)
func TestParseScanTaskForm(t *testing.T) {
detail := &TaskFormDetail{
Rows: map[string]interface{}{"signMode": float64(2)},
}
items := ParseTaskForm(detail, nil)
// 二维码ID + 经度 + 纬度 + 地址
// 经度 + 纬度 + 地址 + 二维码ID
if len(items) != 4 {
t.Fatalf("scan form items = %d, want 4: %+v", len(items), items)
}
if items[0].ColName != "uuid" || items[0].Type != cpdmodel.ItemTypeImage || !items[0].Required {
t.Fatalf("unexpected uuid 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[1:] {
for _, it := range items[:len(items)-1] {
if _, ok := wantPos[it.ColName]; ok {
wantPos[it.ColName] = true
}
@@ -119,10 +121,14 @@ func TestParseScanTaskFormNormalizesUuid(t *testing.T) {
"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 || items[0].ColName != "uuid" {
if len(items) < 1 {
t.Fatalf("unexpected items: %+v", items)
}
if items[0].Content != "1583a66272854585833eb13b56fc1937" {
t.Fatalf("history link should normalize to uuid, got %q", items[0].Content)
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)
}
}
+2 -1
View File
@@ -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 },
]
+6
View File
@@ -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',
+53
View File
@@ -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>