From ff46a9cbb8491d50739e90cf403ea42e49818898 Mon Sep 17 00:00:00 2001 From: tamakyi Date: Thu, 1 Oct 2026 01:47:48 +0800 Subject: [PATCH] =?UTF-8?q?=E7=BB=9F=E8=AE=A1=E9=A1=B5=E6=8A=8A=E3=80=8C?= =?UTF-8?q?=E6=88=91=E7=9A=84=E7=BB=93=E8=AE=BA=E3=80=8D=E4=B8=8E=E3=80=8C?= =?UTF-8?q?=E8=AE=BF=E5=AE=A2=E7=82=B9=E8=B5=9E=E3=80=8D=E6=8B=86=E6=88=90?= =?UTF-8?q?=E4=B8=A4=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户问「数据统计处的推荐地点和踩雷地点只针对站长吗?应该分开,用户的点赞点踩单独做一块」。 他看到的没错:那张卡的**列表全是站长结论**(verdictPlaces = place_votes),访客票只在副标题里 露了个总数(👍 N · 👎 M)——既看不出哪几个地点被顶上去,也容易被当成两者合并的榜。 - 新增 votes.TopVisitorVoted:按访客票数列地点榜。HAVING 只算**至少有一票的那个方向**, 否则 0 票的地点会把榜塞满;同票数按 visit_count DESC, p.id 排,刷新之间顺序不跳。 票按坐标键挂,所以 join 回当前数据集的地点才定位得到(与 Tallies/verdictPlaces 同一口径)。 - 「推荐 & 踩雷(我的结论)」只讲站长,副标题只留「推荐 N · 踩雷 M」,标题与列表都改成「我」的口吻。 - 新增「访客点赞」卡:副标题总票数,两列列 👍/👎 票最多的地点并带票数,各有空态。 - 加载拆成两个 pageJob(verdicts / visitorvotes)并行,两块互不依赖就不该串着等。 - 两块共用同一个可见性开关(IsAdmin || Settings.MarksPublic),与地点页投票区一致。 用例(stats_votes_split_test.go)按卡片标题切出两段 HTML,断言「我标的地点只出现在站长块、 访客顶的地点只出现在访客块」,并核对两边各自的计数——比单看总数更能证明没串台。 ⚠️ 又一次同一个坑:两张票表都按坐标键读、与数据集无关,place_visitor_votes 还没有 user_id、 不随删用户清理 ⇒ 上一轮跑剩的票会被算进来、票数越堆越多。新增 isolateVoteState 按 place_key 清两张表(前置 + cleanup 各一次)。 顺带订正 README:那里还写着站长结论「按 src_pk 存」,其实早就是坐标键了。 全量验收 PASS=338 FAIL=0 SKIP=0。 --- .workbuddy/memory/2026-10-01.md | 16 +++ .workbuddy/memory/MEMORY-details.md | 28 ++++ .workbuddy/memory/MEMORY.md | 5 +- README.md | 17 ++- internal/votes/votes.go | 40 ++++++ internal/web/handlers_public.go | 12 ++ internal/web/stats_votes_split_test.go | 187 +++++++++++++++++++++++++ internal/web/templates/stats.html | 41 +++++- internal/web/view.go | 7 +- 9 files changed, 340 insertions(+), 13 deletions(-) create mode 100644 internal/web/stats_votes_split_test.go diff --git a/.workbuddy/memory/2026-10-01.md b/.workbuddy/memory/2026-10-01.md index d65f350..f462a21 100644 --- a/.workbuddy/memory/2026-10-01.md +++ b/.workbuddy/memory/2026-10-01.md @@ -34,3 +34,19 @@ 改成整段排在 `place_images` 建表之后(它是五张里最后一张),并把这个坑写进 schema.sql 的注释里。 教训:**凡是 ALTER 某个表的迁移块,先确认那张表在文件里已经建过了**—— 只在本机库上跑一遍是测不出来的,必须跑 fresh-database 用例。 + +## 统计页:站长结论与访客点赞拆成两块(01:40) + +用户问「数据统计处的推荐地点和踩雷地点只针对站长吗?应该分开,用户的点赞点踩单独做一块」。 +他看到的没错:那张卡的**列表全是站长结论**,访客票只在副标题露了个总数。 + +- 新增 `votes.TopVisitorVoted`(`HAVING` 只算至少有一票的方向,否则 0 票地点塞满榜; + 同票按 `visit_count DESC, p.id` 保证顺序稳定)。 +- 「推荐 & 踩雷(我的结论)」只讲站长;另起一张卡「访客点赞」,列 👍/👎 票最多的地点并带票数。 +- 两个 pageJob 并行加载;两块共用 `IsAdmin || MarksPublic`(与地点页投票区一致)。 +- 用例按卡片标题切 HTML 断言「A 只在站长块、B 只在访客块」,比数总数更能证明没串台。 + +⚠️ 又踩同一个坑:票表按坐标键跨数据集读、`place_visitor_votes` 还没有 user_id ⇒ +上一轮跑剩的票会被算进来。新增 `isolateVoteState` 按 place_key 清两张表(前置 + cleanup)。 + +顺带订正 README:那里还写着站长结论「按 src_pk 存」,其实早就是坐标键了。 diff --git a/.workbuddy/memory/MEMORY-details.md b/.workbuddy/memory/MEMORY-details.md index e2c8ae4..464a713 100644 --- a/.workbuddy/memory/MEMORY-details.md +++ b/.workbuddy/memory/MEMORY-details.md @@ -725,3 +725,31 @@ Core Data 元数据、索引、持久化历史、`ZRAWVISIT` 采样点、`ZTRIP* (更早那次改坐标键时摘的,老库/生产库上未必摘干净)**迁移并不会摘**—— 原来只写了 `place_images` 与 `topics` 两句 DROP。已补成四张自建数据表全覆盖 (含 `place_visitor_votes` 兜底)。**部署出去的库要能自愈,DROP 必须覆盖全部同类表,不是只覆盖这次动过的那两张。** + + +--- + +## 三十六、统计页:站长结论与访客点赞拆成两块(2026-10-01) + +用户问「数据统计处的推荐地点和踩雷地点只针对站长吗?我觉得应该分开,用户的点赞点踩单独做一块」。 +查下来他看到的没错:`/stats` 那张卡**列表全是站长结论**(`verdictPlaces` = `place_votes`), +访客票只在副标题里露了个总数(`👍 N · 👎 M`)——既看不出哪几个地点被顶上去,也容易被当成两者合并的榜。 + +改动(`internal/votes` + `internal/web` + `templates/stats.html`): +- 新增 `votes.TopVisitorVoted(ctx, datasetID, vote, limit)`:按访客票数列地点榜。 + `HAVING count(*) FILTER (WHERE vv.vote=$2) > 0` —— **只算至少有一票的那个方向**, + 否则 0 票的地点会把榜塞满;同票数按 `visit_count DESC, p.id` 排,保证刷新之间顺序不跳。 + 票按坐标键挂,所以要 join 回当前数据集的地点才定位得到(与 `Tallies`/`verdictPlaces` 同一口径)。 +- 「推荐 & 踩雷(我的结论)」:只讲站长,副标题只留 `推荐 N · 踩雷 M`,标题与列表都改成「我」的口吻。 +- 新增「访客点赞」:副标题总票数(复用 `voteTotals` 的 `VoteUp/VoteDown`),两列分别列 + 👍/👎 票最多的地点(带票数),空态「还没有访客点赞 / 点踩」。 +- 加载拆成**两个 pageJob**(`verdicts` / `visitorvotes`):两者互不依赖,串在一个任务里只会白等。 +- 两块共用同一个可见性开关(`IsAdmin || Settings.MarksPublic`),与地点页的投票区一致。 + +⚠️ 用例隔离(又一次同一个坑):**两张票表都按坐标键读,与数据集无关**, +`place_visitor_votes` 还没有 `user_id`、不随删用户清理 ⇒ 上一轮跑剩的票会被算进来、票数越堆越多。 +新增 `isolateVoteState`(按 place_key 清两张表,前置 + `t.Cleanup` 各一次)。 +断言手法:按卡片标题切出两段 HTML(`htmlSection`),再断言「A 只在站长块、B 只在访客块」—— +比数总数更能证明两块真的没串台。 + +顺带订正 README:那里还写着站长结论「按 `src_pk` 存」,其实早就是**坐标键**了。 diff --git a/.workbuddy/memory/MEMORY.md b/.workbuddy/memory/MEMORY.md index 4779e1b..ae2a2bc 100644 --- a/.workbuddy/memory/MEMORY.md +++ b/.workbuddy/memory/MEMORY.md @@ -1,6 +1,6 @@ # rond-with-you 硬规矩(索引) -> 全文:`.workbuddy/memory/MEMORY-details.md`(36 节);验证机制:技能 `rond-fix-verify`;备份包解析/导出:技能 `rond-backup-parse`。本文件只做关键词索引——**新规矩写 details,别堆这里**。 +> 全文:`.workbuddy/memory/MEMORY-details.md`(37 节);验证机制:技能 `rond-fix-verify`;备份包解析/导出:技能 `rond-backup-parse`。本文件只做关键词索引——**新规矩写 details,别堆这里**。 > > ⚠️ 本文件**按字符数**注入,硬上限 `MAX_MEMORY_CHARS = 1e4`(WorkBuddy `app.asar` 里 `getWorkingMemoryContent`;中文 1 字算 1,不是按字节)。超了会被 `substring(0,1e4)` 截断并写明「先整理再干活」——即整段尾部静默丢失。日常日志与本 details **不参与注入**,可以随便写。现值见 `wc -m`,留 20% 余量。 @@ -64,6 +64,9 @@ ## 跨备份 / 附属数据(details 十九~廿一、卅四) - 预演按 `VisitUUID`/`ActivityUUID` 对齐、只读;`raw #>> '{ZIDENTIFIER_,$blob}'` 是 base64 须转 hex 比;地点只认坐标(`PlaceCoordKey` 6 位)。 - 附属数据(备注/投票/图片)主键=`place_key` 坐标键(非 dataset+src_pk);Go 与 SQL 两套须逐位一致。 +- **站长结论(`place_votes`)与访客票(`place_visitor_votes`)是两码事**:各存各表、各看各榜; + 统计页拆成「我的结论」与「访客点赞」两块(`votes.TopVisitorVoted`,`HAVING` 只算有票的那个方向)。 + ⚠️ 票表也按坐标键跨数据集读 ⇒ 用例必须按 `place_key` 清历史票,否则票数越跑越多。(details 卅六) - ⚠️ **附属数据不能挂数据集外键**:`place_images` 曾按 `dataset_id` CASCADE ⇒ 删旧数据集=删图片行、**文件却留在盘上**(读取按坐标键、行按数据集级联,两套口径打架)。已摘掉外键;恢复走后台「图片挂载恢复」,认地点靠旧数据集留下的 `baselines/.sqlite`(**别清 baselines 目录**);⚠️ 来源必须**全部读进来合并**(places → entity_raw 留档 → 该 sqlite 及**每份 `.bak`**)——换基线会覆盖底座文件,只看当前那份会整片认不出(实测 40 张只挂回 7 张)。`image_recovery_test.go`(details 卅四) - ⚠️ 恢复**同时扫对象存储**(R2/S3):`media.ListObjects`(ListObjectsV2 + SigV4,查询串先编码再签), 需 `s3:ListBucket` 权限;列不动时本地照救 + warning。**专题同理**:`topics` 已改成归人不归数据集 diff --git a/README.md b/README.md index a573530..c92dfb0 100644 --- a/README.md +++ b/README.md @@ -259,14 +259,17 @@ go build -o rond . (把「未命名地点」改成「外婆家」);备注默认只有站长自己能看到,可在「站点设置 → 地点备注对访客公开」打开对外展示。 两者按 rond 的地点 ID(`src_pk`)单独存表,重新上传备份、地点换了数据库 id 也不会丢。 -- **推荐 / 踩雷**:与自由文本备注分开的两类表态,用按钮交互。 - - **站长结论**(`place_votes`,按 `src_pk` 存):详情页「我的结论」一键 👍推荐 / 👎踩雷, +- **推荐 / 踩雷**:与自由文本备注分开的两类表态,用按钮交互。**站长的结论与访客的票是两码事**, + 各存各的表、各看各的榜,互不影响。 + - **站长结论**(`place_votes`,按**坐标键**存):详情页「我的结论」一键 👍推荐 / 👎踩雷, 再点已选按钮取消;后台备注页也能逐行或批量打标。 - - **访客投票**(`place_visitor_votes`):详情页「大家怎么说」可点赞 / 点踩,一人一票、可改可撤; - 匿名身份由 cookie + IP 派生并哈希,清 cookie 也会被识别为同一来源,起轻度防刷作用。 - - 展示:地点详情、地点库(行内徽章 + 「推荐 / 踩雷」筛选)、地图气泡;统计页有 - 「推荐 & 踩雷」区块(站长结论计数、访客票数、两边榜单)。是否对访客公开由 - 「站点设置 → 推荐 / 踩雷标记对访客公开」控制(默认公开)。 + - **访客投票**(`place_visitor_votes`,同样按坐标键):详情页「大家怎么说」可点赞 / 点踩, + 一人一票、可改可撤;匿名身份由 cookie + IP 派生并哈希,清 cookie 也会被识别为同一来源, + 起轻度防刷作用。 + - 展示:地点详情、地点库(行内徽章 + 「推荐 / 踩雷」筛选,都是**站长结论**)、地图气泡; + **统计页分成两块**——「推荐 & 踩雷(我的结论)」只算站长标注的, + 「访客点赞」单独一块,按 👍/👎 票数列出**被顶得最高的地点**并带票数。 + 是否对访客公开由「站点设置 → 推荐 / 踩雷标记对访客公开」控制(默认公开),两块同一开关。 - **备注批量管理**:后台「地点备注」页列出**当前数据集的全部地点**(不只已写备注的), 支持关键词 / 城市 / 状态(全部 / 有备注 / 无备注)/ 排序筛选与分页;每行可就地编辑别名、 备注并快速打标。顶部批量条可对勾选项**批量设置结论 / 清空备注(保留别名)/ 删除备注**; diff --git a/internal/votes/votes.go b/internal/votes/votes.go index 6f3205e..0ce27de 100644 --- a/internal/votes/votes.go +++ b/internal/votes/votes.go @@ -160,6 +160,46 @@ func (s *Store) Tally(ctx context.Context, datasetID int64, srcPK int) (Tally, e return t, err } +// VotedPlace 是「访客票榜」上的一条:某个地点收到的 👍/👎 票数。 +type VotedPlace struct { + ID int64 + SrcPK int + Name string + City string + Up int + Down int +} + +// TopVisitorVoted 按访客票数排地点榜:vote 传 1 取「👍 最多」、传 -1 取「👎 最多」。 +// +// 只算**至少有一票**的那个方向(HAVING),否则「0 票」的地点会把榜单塞满; +// 同票数时按到访次数、再按编号排,保证顺序稳定(不同页面刷新之间不会跳来跳去)。 +// +// 票按坐标键挂(换数据集仍跟着同一地点),所以得 join 回当前数据集的地点才定位得到。 +func (s *Store) TopVisitorVoted(ctx context.Context, datasetID int64, vote, limit int) ([]VotedPlace, error) { + rows, err := s.DB.QueryContext(ctx, `SELECT p.id, p.src_pk, COALESCE(p.name,''), COALESCE(p.city,''), + count(*) FILTER (WHERE vv.vote=1), count(*) FILTER (WHERE vv.vote=-1) + FROM places p JOIN place_visitor_votes vv ON vv.place_key = `+placeKeyExpr+` + WHERE p.dataset_id=$1 AND p.lat IS NOT NULL AND p.lon IS NOT NULL + GROUP BY p.id + HAVING count(*) FILTER (WHERE vv.vote=$2) > 0 + ORDER BY count(*) FILTER (WHERE vv.vote=$2) DESC, p.visit_count DESC, p.id + LIMIT $3`, datasetID, vote, limit) + if err != nil { + return nil, err + } + defer rows.Close() + var out []VotedPlace + for rows.Next() { + var vp VotedPlace + if err := rows.Scan(&vp.ID, &vp.SrcPK, &vp.Name, &vp.City, &vp.Up, &vp.Down); err != nil { + return nil, err + } + out = append(out, vp) + } + return out, rows.Err() +} + // VoterAll 返回该访客投过的全部票:src_pk -> vote,供地图一次性标注「我投的」。 func (s *Store) VoterAll(ctx context.Context, datasetID int64, voterKey string) (map[int]int, error) { out := map[int]int{} diff --git a/internal/web/handlers_public.go b/internal/web/handlers_public.go index eea6e45..2befcbb 100644 --- a/internal/web/handlers_public.go +++ b/internal/web/handlers_public.go @@ -704,6 +704,18 @@ func (s *Server) statsPage(w http.ResponseWriter, r *http.Request) { d.Avoided, err = s.verdictPlaces(ctx, p.Dataset.ID, votes.VerdictAvoid, 8) return err }}, + // 访客点赞榜另起一个任务:与站长结论互不依赖,串在同一个任务里只会白等 + pageJob{"visitorvotes", func(ctx context.Context) error { + if !p.IsAdmin() && !p.Settings.MarksPublic { + return nil + } + var err error + if d.VisitorUp, err = s.votes.TopVisitorVoted(ctx, p.Dataset.ID, 1, 8); err != nil { + return err + } + d.VisitorDown, err = s.votes.TopVisitorVoted(ctx, p.Dataset.ID, -1, 8) + return err + }}, ); err != nil { s.serverError(w, r, err) return diff --git a/internal/web/stats_votes_split_test.go b/internal/web/stats_votes_split_test.go new file mode 100644 index 0000000..f1fe3c3 --- /dev/null +++ b/internal/web/stats_votes_split_test.go @@ -0,0 +1,187 @@ +package web + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "rond-with-you/internal/rond" + "rond-with-you/internal/votes" +) + +// ---------- 统计页:站长结论 / 访客点赞 是两码事 ---------- +// +// 早先两块挤在一张卡上:列表是**站长结论**,访客票只在副标题里露个总数。 +// 既看不出哪几个地点被访客顶上去,也容易被当成「站长 + 访客一起算」的榜单。 +// 现在拆成两张卡,这里钉住「两块各说各的、互不串台」。 + +type namedPlace struct { + srcPK int + name string + lat, lon float64 +} + +// pickTwoNamedPlaces 挑两个**有名字、有坐标**的不同地点(要按名字断言,没名字的渲染成「未命名」)。 +func pickTwoNamedPlaces(t *testing.T, f *movementFixture) (namedPlace, namedPlace) { + t.Helper() + rows, err := f.pg.QueryContext(f.ctx, `SELECT src_pk, COALESCE(name,''), lat, lon FROM places + WHERE dataset_id=$1 AND lat IS NOT NULL AND lon IS NOT NULL AND (lat<>0 OR lon<>0) + AND COALESCE(name,'') <> '' + ORDER BY src_pk LIMIT 2`, f.dsID) + if err != nil { + t.Fatalf("取地点失败: %v", err) + } + defer rows.Close() + var out []namedPlace + for rows.Next() { + var p namedPlace + if err := rows.Scan(&p.srcPK, &p.name, &p.lat, &p.lon); err != nil { + t.Fatal(err) + } + out = append(out, p) + } + if len(out) < 2 { + t.Fatalf("这个数据集里带名字又带坐标的地点不足两个(只有 %d 个),用例前提不成立", len(out)) + } + return out[0], out[1] +} + +// isolateVoteState 清掉这几个地点上的历史表态。 +// +// **两张票表都按坐标键读**(跟数据集无关),所以上一个用例、甚至上一轮跑剩下的行都会被算进来: +// `place_visitor_votes` 没有 user_id、也不随用户删除而清理,不清就会把票数越堆越多 +// (实测图片那批用例就是这么被污染的:应当 2 张、实际 10 张)。 +func isolateVoteState(t *testing.T, f *movementFixture, places ...namedPlace) { + t.Helper() + keys := make([]string, 0, len(places)) + for _, p := range places { + keys = append(keys, rond.PlaceCoordKey(p.lat, p.lon)) + } + clear := func() { + for _, k := range keys { + f.pg.ExecContext(f.ctx, `DELETE FROM place_visitor_votes WHERE place_key=$1`, k) + f.pg.ExecContext(f.ctx, `DELETE FROM place_votes WHERE place_key=$1`, k) + } + } + clear() + t.Cleanup(clear) +} + +// htmlSection 取渲染结果里 from 到 to 之间的那一段(两端都必须出现,否则用例本身就不成立)。 +func htmlSection(t *testing.T, body, from, to string) string { + t.Helper() + i := strings.Index(body, from) + if i < 0 { + t.Fatalf("页面里找不到 %q——那一块没渲染出来?", from) + } + rest := body[i:] + j := strings.Index(rest, to) + if j < 0 { + t.Fatalf("页面里 %q 之后找不到 %q", from, to) + } + return rest[:j] +} + +func renderStatsPage(t *testing.T, f *movementFixture) string { + t.Helper() + rec := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/stats", nil).WithContext(f.ctx) + f.srv.statsPage(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("统计页状态码 %d:%s", rec.Code, rec.Body.String()) + } + body := rec.Body.String() + if !strings.HasSuffix(strings.TrimSpace(body), "") { + t.Fatalf("统计页没渲染到底(模板中途报错了?):…%s", body[len(body)-300:]) + } + return body +} + +func TestStatsSplitsOwnerVerdictFromVisitorVotes(t *testing.T) { + f := newMovementFixture(t) + ctx, dsID, srv := f.ctx, f.dsID, f.srv + uid := userFrom(ctx).ID + mine, their := pickTwoNamedPlaces(t, f) + isolateVoteState(t, f, mine, their) + + // 站长结论只标 A「推荐」;B 那侧一个站长结论都不打 + if err := srv.votes.SaveVerdict(ctx, uid, dsID, mine.srcPK, votes.VerdictRecommend); err != nil { + t.Fatalf("写站长结论失败: %v", err) + } + // 访客票只投 B:两个不同访客各点一次 👍(都点同一处,票数才看得出是「榜」) + for _, voter := range []string{"test-voter-1", "test-voter-2"} { + if _, _, err := srv.votes.Cast(ctx, dsID, their.srcPK, voter, 1); err != nil { + t.Fatalf("访客投票失败: %v", err) + } + } + + body := renderStatsPage(t, f) + owner := htmlSection(t, body, "推荐 & 踩雷(我的结论)", "访客点赞") + visitor := htmlSection(t, body, "访客点赞", "月度分布") + + // ① 站长结论块:只应出现我标的那一处 + if !strings.Contains(owner, mine.name) { + t.Errorf("站长结论块里没有「%s」(我给它标了推荐)", mine.name) + } + if strings.Contains(owner, their.name) { + t.Errorf("被访客点赞的「%s」串进了站长结论块", their.name) + } + if !strings.Contains(owner, "推荐 1 · 踩雷 0") { + t.Errorf("站长结论块的总数不对(应当只有 1 条推荐、0 条踩雷)") + } + + // ② 访客块:只应出现访客投的那一处,并且要带票数 + if !strings.Contains(visitor, their.name) { + t.Errorf("访客点赞块里没有「%s」(两个访客都点了赞)", their.name) + } + if strings.Contains(visitor, mine.name) { + t.Errorf("我标了推荐的「%s」串进了访客点赞块", mine.name) + } + if !strings.Contains(visitor, "👍 2") { + t.Errorf("访客点赞块里没显示票数(应能看出「👍 2」)") + } + if !strings.Contains(visitor, "👍 2 票 · 👎 0 票") { + t.Errorf("访客点赞块的总票数不对(应当是 2 赞 0 踩)") + } +} + +// TestStatsVisitorRankCountsRealVotes 榜单排序与票数要跟实际投票一致(两票的排在一票前面)。 +func TestStatsVisitorRankCountsRealVotes(t *testing.T) { + f := newMovementFixture(t) + ctx, dsID, srv := f.ctx, f.dsID, f.srv + first, second := pickTwoNamedPlaces(t, f) + isolateVoteState(t, f, first, second) + + // first 收 2 票、second 收 1 票(都是 👍) + for _, voter := range []string{"test-voter-1", "test-voter-2"} { + if _, _, err := srv.votes.Cast(ctx, dsID, first.srcPK, voter, 1); err != nil { + t.Fatal(err) + } + } + if _, _, err := srv.votes.Cast(ctx, dsID, second.srcPK, "test-voter-3", 1); err != nil { + t.Fatal(err) + } + + body := renderStatsPage(t, f) + visitor := htmlSection(t, body, "访客点赞", "月度分布") + i, j := strings.Index(visitor, first.name), strings.Index(visitor, second.name) + if i < 0 || j < 0 { + t.Fatalf("两处地点都该出现在访客榜里(%q:%d %q:%d)", first.name, i, second.name, j) + } + if i > j { + t.Errorf("票多的「%s」应当排在票少的「%s」前面", first.name, second.name) + } + // 票数分别标出来 + seg := visitor[i:] + if !strings.Contains(seg, "👍 2") { + t.Errorf("「%s」的票数应显示 2:%s", first.name, seg[:min(len(seg), 200)]) + } +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/internal/web/templates/stats.html b/internal/web/templates/stats.html index 6251999..4c1c83a 100644 --- a/internal/web/templates/stats.html +++ b/internal/web/templates/stats.html @@ -21,14 +21,18 @@ {{if or .IsAdmin .Settings.MarksPublic}} + {{/* 第一块:站长自己的结论。只有站长能标,所以这里只说「我的」。 */}}
-

推荐 & 踩雷

+
+

推荐 & 踩雷(我的结论)

+ 推荐 {{num .VoteRec}} · 踩雷 {{num .VoteAvoid}} +
- 站长结论 推荐 {{num .VoteRec}} · 踩雷 {{num .VoteAvoid}} | 访客票数 👍 {{num .VoteUp}} · 👎 {{num .VoteDown}} + 只统计你自己在地点页或地图气泡里标的 👍/👎——按坐标键存,重新上传备份也不会丢。
-
👍 推荐的地点
+
👍 我推荐的地点
{{if .Recommended}}
{{range .Recommended}}{{if .Name}}{{.Name}}{{else}}未命名{{end}}{{if .City}} · {{.City}}{{end}}{{end}} @@ -36,7 +40,7 @@ {{else}}
还没有标记
{{end}}
-
👎 踩雷的地点
+
👎 我踩雷的地点
{{if .Avoided}}
+ + {{/* 第二块:访客的点赞点踩。匿名一人一票、可改可撤,与上面的站长结论是两码事。 */}} +
+
+

访客点赞

+ 👍 {{num .VoteUp}} 票 · 👎 {{num .VoteDown}} 票 +
+
+ 访客在地点页点的 👍/👎,一人一票、可改可撤。与「我的结论」互不影响:你标你的,访客投访客的。 +
+
+
+
👍 被点赞最多的地点
+ {{if .VisitorUp}} + + {{else}}
还没有访客点赞
{{end}} +
+
+
👎 被点踩最多的地点
+ {{if .VisitorDown}} + + {{else}}
还没有访客点踩
{{end}} +
+
+
{{end}}
diff --git a/internal/web/view.go b/internal/web/view.go index 50d5ffe..f4bb2c1 100644 --- a/internal/web/view.go +++ b/internal/web/view.go @@ -601,13 +601,18 @@ type StatsData struct { Weather []stats.WeatherStat Temps []TempPoint Fog *FogCoverage - // 推荐 / 踩雷 汇总与榜单 + // 站长结论(推荐 / 踩雷)与访客票数 —— 两块分开: + // VoteRec/VoteAvoid + Recommended/Avoided 是**站长的结论**(只由站长标注); + // VoteUp/VoteDown + VisitorUp/VisitorDown 是**访客的点赞点踩**(匿名一人一票)。 + // 早先两块挤在一张卡上、访客票只在副标题里露个总数,看不出「哪几个地点被顶上去」。 VoteRec int VoteAvoid int VoteUp int VoteDown int Recommended []stats.Place Avoided []stats.Place + VisitorUp []votes.VotedPlace + VisitorDown []votes.VotedPlace MaxMonthly int MaxHourly int MaxActivity int