feat: 补齐五块缺失功能(记录丢数据/文章正文/提醒管理/我的页入口/分享/关注)

P0 修数据问题
- 记录弹层有 5 个输入框从未绑定,用户填的便便备注、饮食内容、药品名称、
  剂量、消费备注全部丢弃;用药记录因此只存下「用药记录」四个字。已全部绑定并入库
- 异常观察保存时写死「风险中」,AI 实际判定的等级被丢掉。改为使用 riskData
  的真实 risk_level,并把成因与建议存进备注

文章正文
- 早期 seed 的 4 篇文章 Content 全空,点开只是打开关联记录弹层,内容运营等于白做
- 补写 4 篇真实正文(疫苗驱虫/软便判断/换粮方案/养宠花销),遵循不诊断、
  异常必提就医的护栏;对已存在的空正文做幂等回填
- 新增文章详情页,学习页与首页卡片改为打开正文

提醒管理
- 后端 CRUD 早已具备但小程序只读,用户改不了疫苗驱虫日期。提醒弹层支持增删改
- 修复后端 UpdateReminder 漏掉 type 字段导致改类型不生效

「我的」页 7 个死入口接活
- 提醒设置、健康记录、多宠物管理、会员权益分别落到对应功能
- 数据导出:汇总档案与记录为文本,可复制发给医生
- 意见反馈:新增 feedback 表与提交接口,后台加「意见反馈」管理页
- 关于我们:应用说明与免责声明

分享
- 各页面补 onShareAppMessage/onShareTimeline
- 帖子分享按钮改用微信原生 open-type="share",删除原来点了没反应的空壳弹层

社区关注
- 新增 follows 表与关注/取关接口,「关注」tab 此前等同「推荐」,
  现只显示已关注用户的帖子;帖子头部可直接关注,标记 followed / is_self

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-07-25 18:11:08 +08:00
parent 11c85da65d
commit 3fde90e97b
42 changed files with 1028 additions and 117 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -5,8 +5,8 @@
<link rel="icon" type="image/svg+xml" href="/admin/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>admin</title>
<script type="module" crossorigin src="/admin/assets/index-1LtHYasw.js"></script>
<link rel="stylesheet" crossorigin href="/admin/assets/index-CIpysjXR.css">
<script type="module" crossorigin src="/admin/assets/index-BmWfHKYa.js"></script>
<link rel="stylesheet" crossorigin href="/admin/assets/index-BjLxu16U.css">
</head>
<body>
<div id="root"></div>
+152 -8
View File
@@ -13,7 +13,11 @@ func Seed(db *gorm.DB, cfg *config.Config) error {
if err := seedAdmin(db, cfg); err != nil {
return err
}
return seedArticles(db)
if err := seedArticles(db); err != nil {
return err
}
// 早期版本 seed 出来的文章没有正文,这里补上(不覆盖已在后台编辑过的)
return backfillArticleContent(db)
}
func seedAdmin(db *gorm.DB, cfg *config.Config) error {
@@ -43,11 +47,151 @@ func seedArticles(db *gorm.DB) error {
if count > 0 {
return nil
}
articles := []model.Article{
{Icon: "💉", Title: "幼猫疫苗和驱虫怎么安排?", Description: "适合搜索流量和社群传播,引导生成提醒。", Category: "vaccine", RelatedSheetType: "vaccine", Published: true},
{Icon: "💩", Title: "猫咪软便要不要去医院?", Description: "接异常记录和 AI 观察建议。", Category: "symptom", RelatedSheetType: "symptom", Published: true},
{Icon: "🍽️", Title: "7 天换粮计划怎么做?", Description: "接计划模板,适合一次性付费。", Category: "food", RelatedSheetType: "applyPlan", Published: true},
{Icon: "💰", Title: "一个月养猫大概花多少钱?", Description: "接养宠账本和年度账单。", Category: "cost", RelatedSheetType: "cost", Published: true},
}
return db.Create(&articles).Error
return db.Create(builtinArticles()).Error
}
// backfillArticleContent 给早期 seed 出来的空正文文章补内容(按标题匹配,只补空的,不覆盖已编辑过的)
func backfillArticleContent(db *gorm.DB) error {
for _, a := range builtinArticles() {
if err := db.Model(&model.Article{}).
Where("title = ? AND (content IS NULL OR content = '')", a.Title).
Updates(map[string]any{"content": a.Content, "description": a.Description}).Error; err != nil {
return err
}
}
return nil
}
// builtinArticles 内置新手文章。内容遵循与 AI 助手一致的安全护栏:
// 不做诊断、不给药物剂量,异常一律说明何时就医。
func builtinArticles() []model.Article {
return []model.Article{
{
Icon: "💉", Title: "幼猫疫苗和驱虫怎么安排?",
Description: "疫苗针次、驱虫间隔和常见误区,看完顺手设个提醒。",
Category: "vaccine", RelatedSheetType: "vaccine", Published: true,
Content: `刚接回家的猫,疫苗和驱虫是最容易漏掉、也最不能漏的两件事。
【核心疫苗:猫三联】
· 一般从 8 周龄左右开始,共打 3 针,每针间隔 3-4 周。
· 之后每年加强 1 针。
· 狂犬疫苗按当地规定接种,多数地区建议满 3 月龄后接种。
【接种前后注意】
· 只有健康的猫才能打疫苗。有腹泻、打喷嚏、精神差时先缓一缓。
· 接种前后一周不要洗澡、不要换粮、不要长途出行,减少应激。
· 打完在医院观察 20-30 分钟,回家后再留意 24 小时。
· 少数猫会精神略差或轻微发热,通常一两天缓解。
【驱虫】
· 体内驱虫:幼猫期约每月 1 次,成年后每 3 个月 1 次。
· 体外驱虫:出门或家里有狗时更要做,按产品说明周期。
· 完全室内、不接触其他动物的成年猫可适当拉长间隔,但不要长期不做。
【常见误区】
· 「猫不出门就不用打疫苗」——你的鞋底、衣物都可能带病原回家。
· 「三针打完就一劳永逸」——每年仍需加强。
· 「驱虫药随便买」——务必按体重选择,剂量以医生或说明书为准。
【什么时候要去医院】
接种后出现持续呕吐、面部肿胀、呼吸急促、精神明显萎靡,请立即就医。
小程序里可以直接把下一针疫苗和驱虫时间设成提醒,到期会通知你。`,
},
{
Icon: "💩", Title: "猫咪软便要不要去医院?",
Description: "先分清程度,学会记录观察,明确什么情况必须就医。",
Category: "symptom", RelatedSheetType: "symptom", Published: true,
Content: `软便很常见,但「常见」不等于「不用管」。关键是分清程度和持续时间。
【先看程度】
· 成形但偏软、能捡起来:多数是饮食或轻微应激,可以先观察。
· 糊状、不成形:需要留意,记录次数和颜色。
· 水样、喷射状:脱水风险高,别拖。
【常见诱因】
· 换粮太快(最常见):新旧粮要 7 天以上过渡。
· 应激:刚到家、搬家、家里来客人。
· 吃多了、吃了人食或零食过量。
· 寄生虫:尤其是没做过驱虫的幼猫。
· 着凉、毛球、食物不耐受。
【在家可以做的】
· 记录:什么时候开始、一天几次、颜色形状、有没有带血或黏液。
· 暂停新食物和零食,回到之前吃惯的粮。
· 保证饮水,注意保暖。
· 不要自行喂人用止泻药。
【出现这些情况,尽快就医】
· 持续超过 24 小时没有好转;
· 便中带血、发黑,或有大量黏液;
· 伴随呕吐、不吃不喝、精神明显变差;
· 幼猫、老年猫或本身有基础病的猫——它们脱水很快,标准要更严。
【就医时带上什么】
排便照片、开始时间、次数、近期饮食变化、驱虫和疫苗记录。小程序里的「异常观察」可以直接生成一份就医前摘要。
本文只提供观察思路,不能替代医生诊断。`,
},
{
Icon: "🍽️", Title: "7 天换粮计划怎么做?",
Description: "按比例过渡,配合排便记录,把换粮的肠胃风险降到最低。",
Category: "food", RelatedSheetType: "applyPlan", Published: true,
Content: `直接换粮是软便的头号原因。正确做法是按比例、分阶段过渡。
【标准 7 天方案】
· 第 1-2 天:新粮 25% + 旧粮 75%
· 第 3-4 天:新粮 50% + 旧粮 50%
· 第 5-6 天:新粮 75% + 旧粮 25%
· 第 7 天起:新粮 100%
肠胃敏感、幼猫或老年猫,把周期拉长到 10-14 天更稳。
【过渡期要盯什么】
· 每天记录排便状态:成形 / 软便 / 拉稀。
· 食欲有没有下降,有没有呕吐。
· 一旦出现软便:退回上一个比例,稳定 2-3 天再往前推,别硬推。
【几个细节】
· 一次只换一种变量。别同时换粮又换猫砂、又带去洗澡。
· 新旧粮要拌匀,不然它会挑出爱吃的。
· 换粮期间别加新零食,否则出问题分不清是谁的锅。
· 湿粮开封后冷藏并尽快吃完,别放到下一顿还常温摆着。
【什么时候暂停换粮去看医生】
拉稀持续超过 24 小时、便中带血、频繁呕吐或精神变差,先就医,别继续过渡。
在小程序里每天记一次「饮食」和「便便」,7 天后能直接看到这次换粮顺不顺。`,
},
{
Icon: "💰", Title: "一个月养猫大概花多少钱?",
Description: "拆开算固定支出和一次性支出,重点是留出医疗备用金。",
Category: "cost", RelatedSheetType: "cost", Published: true,
Content: `养猫的花销差异很大,但结构是固定的。看清结构再决定预算。
【每月固定支出】
· 猫粮:口粮占大头,按体重和粮的档次浮动。
· 猫砂:用量比想象中大,膨润土和豆腐砂消耗速度不同。
· 零食 / 营养品:可选,不是必需。
【周期性支出】
· 驱虫:体内体外,按周期。
· 疫苗:幼猫第一年针次多,之后每年一次。
· 体检:成年猫每年 1 次,老年猫建议每半年 1 次。
【一次性支出(第一年)】
猫砂盆、猫窝、food/水碗、猫爬架、航空箱、指甲剪、梳子;绝育是第一年最大的一笔。
【最容易被低估的一项:医疗】
真正让预算失控的从来不是猫粮,是突发医疗。常见的口炎、尿闭、异物、肾病,一次就可能超过全年口粮开销。
建议:单独留一笔医疗备用金,或了解一下宠物医疗保险。这比省几十块钱的粮重要得多。
【怎么省得聪明】
· 该省:花哨玩具、频繁换的网红零食。
· 不该省:口粮质量、驱虫疫苗、该做的体检——省这些通常是把钱挪到医疗账单上。
在小程序里每次消费顺手记一笔,月底就能看到自己的真实结构,比拍脑袋估算准得多。`,
},
}
}
+19 -1
View File
@@ -20,7 +20,7 @@ func (h *Handler) ListPosts(c *gin.Context) {
_ = c.ShouldBindQuery(&req)
req.Normalize()
posts, total, err := h.svc.ListPosts(req.Tab, req.Offset(), req.Limit())
posts, total, err := h.svc.ListPosts(middleware.UserID(c), req.Tab, req.Offset(), req.Limit())
if err != nil {
response.FailErr(c, err)
return
@@ -134,3 +134,21 @@ func (h *Handler) CreateComment(c *gin.Context) {
}
response.OK(c, comment)
}
// FollowUser POST /api/users/:id/follow
func (h *Handler) FollowUser(c *gin.Context) {
if err := h.svc.FollowUser(middleware.UserID(c), idParam(c, "id")); err != nil {
respondErr(c, err)
return
}
response.OK(c, gin.H{"followed": true})
}
// UnfollowUser DELETE /api/users/:id/follow
func (h *Handler) UnfollowUser(c *gin.Context) {
if err := h.svc.UnfollowUser(middleware.UserID(c), idParam(c, "id")); err != nil {
response.FailErr(c, err)
return
}
response.OK(c, gin.H{"followed": false})
}
+51
View File
@@ -115,3 +115,54 @@ func (h *Handler) AssessSymptom(c *gin.Context) {
}
response.OK(c, res)
}
type feedbackReq struct {
Content string `json:"content"`
Contact string `json:"contact"`
}
// CreateFeedback POST /api/feedback
func (h *Handler) CreateFeedback(c *gin.Context) {
var req feedbackReq
if err := c.ShouldBindJSON(&req); err != nil {
response.FailParams(c, err.Error())
return
}
f, err := h.svc.CreateFeedback(middleware.UserID(c), req.Content, req.Contact)
if err != nil {
response.FailParams(c, err.Error())
return
}
response.OK(c, f)
}
// AdminListFeedback GET /api/admin/feedback
func (h *Handler) AdminListFeedback(c *gin.Context) {
var req adminListReq
_ = c.ShouldBindQuery(&req)
req.Normalize()
list, total, err := h.svc.ListFeedbackAdmin(req.Offset(), req.Limit())
if err != nil {
response.FailErr(c, err)
return
}
response.OK(c, response.NewPage(list, total, req.PageQuery))
}
type handledReq struct {
Handled bool `json:"handled"`
}
// AdminSetFeedbackHandled PUT /api/admin/feedback/:id/handled
func (h *Handler) AdminSetFeedbackHandled(c *gin.Context) {
var req handledReq
if err := c.ShouldBindJSON(&req); err != nil {
response.FailParams(c, err.Error())
return
}
if err := h.svc.SetFeedbackHandled(idParam(c, "id"), req.Handled); err != nil {
respondErr(c, err)
return
}
response.OK(c, gin.H{"ok": true})
}
+3
View File
@@ -56,6 +56,9 @@ func (h *Handler) UpdateReminder(c *gin.Context) {
return
}
fields := map[string]any{}
if req.Type != "" {
fields["type"] = req.Type
}
if req.Title != "" {
fields["title"] = req.Title
}
+2
View File
@@ -45,5 +45,7 @@ func AllModels() []any {
&CareTemplateItem{},
&CommunityBotConfig{},
&File{},
&Feedback{},
&Follow{},
}
}
+11
View File
@@ -0,0 +1,11 @@
package model
// Feedback 用户意见反馈
type Feedback struct {
Base
UserID string `gorm:"size:24;index" json:"user_id"`
Nickname string `gorm:"size:64" json:"nickname"`
Content string `gorm:"type:text" json:"content"`
Contact string `gorm:"size:128" json:"contact"` // 可选,方便回访
Handled bool `gorm:"index" json:"handled"`
}
+8
View File
@@ -0,0 +1,8 @@
package model
// Follow 关注关系:FollowerID 关注了 FolloweeID
type Follow struct {
Base
FollowerID string `gorm:"size:24;uniqueIndex:idx_follower_followee" json:"follower_id"`
FolloweeID string `gorm:"size:24;uniqueIndex:idx_follower_followee;index" json:"followee_id"`
}
+4
View File
@@ -25,4 +25,8 @@ type Post struct {
CommentCount int `json:"comment_count"`
Status string `gorm:"size:16;default:published;index" json:"status"`
IsAI bool `gorm:"size:24;index" json:"is_ai"` // AI 运营生成的帖子,前端打「AI」标
// 以下为计算字段,按当前登录用户填充
Followed bool `gorm:"-" json:"followed"` // 我是否已关注该作者
IsSelf bool `gorm:"-" json:"is_self"` // 是不是我自己发的
}
+7
View File
@@ -84,6 +84,9 @@ func registerUserAPI(api *gin.RouterGroup, h *handler.Handler, jm *appjwt.Manage
g.GET("/pets/:id/health-summary", h.HealthSummary)
g.GET("/pets/:id/poster", h.Poster)
g.POST("/users/:id/follow", h.FollowUser)
g.DELETE("/users/:id/follow", h.UnfollowUser)
g.GET("/posts", h.ListPosts)
g.POST("/posts", h.CreatePost)
g.GET("/posts/:id", h.GetPost)
@@ -93,6 +96,7 @@ func registerUserAPI(api *gin.RouterGroup, h *handler.Handler, jm *appjwt.Manage
g.POST("/posts/:id/comments", h.CreateComment)
g.GET("/articles", h.ListArticles)
g.POST("/feedback", h.CreateFeedback)
g.GET("/articles/:id", h.GetArticle)
g.GET("/pro", h.GetPro)
@@ -137,5 +141,8 @@ func registerAdminAPI(api *gin.RouterGroup, h *handler.Handler, jm *appjwt.Manag
g.PUT("/community-bot", h.AdminSaveCommunityBot)
g.POST("/community-bot/generate", h.AdminGenerateCommunityPosts)
g.GET("/feedback", h.AdminListFeedback)
g.PUT("/feedback/:id/handled", h.AdminSetFeedbackHandled)
g.GET("/memberships", h.AdminListPro)
}
+11 -2
View File
@@ -58,12 +58,20 @@ func tabTag(tab string) string {
}
}
// ListPosts 帖子分页列表
func (s *Service) ListPosts(tab string, offset, limit int) ([]model.Post, int64, error) {
// ListPosts 帖子分页列表。userID 用于「关注」tab 过滤和标记关注状态(可为空)。
func (s *Service) ListPosts(userID, tab string, offset, limit int) ([]model.Post, int64, error) {
q := s.db.Model(&model.Post{}).Where("status = ?", model.PostPublished)
if tag := tabTag(tab); tag != "" {
q = q.Where("JSON_CONTAINS(tags, ?)", `"`+tag+`"`)
}
if tab == "关注" {
ids := s.followeeIDs(userID)
if len(ids) == 0 {
// 一个都没关注:返回空列表,而不是退化成「推荐」
return []model.Post{}, 0, nil
}
q = q.Where("user_id IN ?", ids)
}
var total int64
if err := q.Count(&total).Error; err != nil {
return nil, 0, err
@@ -73,6 +81,7 @@ func (s *Service) ListPosts(tab string, offset, limit int) ([]model.Post, int64,
return nil, 0, err
}
s.attachPostImages(posts)
s.markFollowed(userID, posts)
return posts, total, nil
}
+50
View File
@@ -0,0 +1,50 @@
package service
import (
"errors"
"strings"
"github.com/sundynix/pets-be/internal/model"
)
// CreateFeedback 提交意见反馈
func (s *Service) CreateFeedback(userID, content, contact string) (*model.Feedback, error) {
content = strings.TrimSpace(content)
if content == "" {
return nil, errors.New("反馈内容不能为空")
}
if len([]rune(content)) > 1000 {
return nil, errors.New("反馈内容太长了")
}
name := ""
var u model.User
if err := s.db.Select("nickname").First(&u, "id = ?", userID).Error; err == nil {
name = u.Nickname
}
f := model.Feedback{UserID: userID, Nickname: name, Content: content, Contact: strings.TrimSpace(contact)}
if err := s.db.Create(&f).Error; err != nil {
return nil, err
}
return &f, nil
}
// ListFeedbackAdmin 后台反馈分页
func (s *Service) ListFeedbackAdmin(offset, limit int) ([]model.Feedback, int64, error) {
var total int64
s.db.Model(&model.Feedback{}).Count(&total)
var list []model.Feedback
err := s.db.Order("id desc").Offset(offset).Limit(limit).Find(&list).Error
return list, total, err
}
// SetFeedbackHandled 标记反馈已处理
func (s *Service) SetFeedbackHandled(id string, handled bool) error {
res := s.db.Model(&model.Feedback{}).Where("id = ?", id).Update("handled", handled)
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 0 {
return ErrNotFound
}
return nil
}
+70
View File
@@ -0,0 +1,70 @@
package service
import (
"errors"
"gorm.io/gorm"
"github.com/sundynix/pets-be/internal/model"
)
// FollowUser 关注某人(幂等;不能关注自己)
func (s *Service) FollowUser(followerID, followeeID string) error {
if followerID == "" || followeeID == "" {
return errors.New("参数不完整")
}
if followerID == followeeID {
return errors.New("不能关注自己")
}
var u model.User
if err := s.db.Select("id").First(&u, "id = ?", followeeID).Error; err != nil {
return ErrNotFound
}
f := model.Follow{FollowerID: followerID, FolloweeID: followeeID}
return s.db.Where("follower_id = ? AND followee_id = ?", followerID, followeeID).
FirstOrCreate(&f).Error
}
// UnfollowUser 取消关注(幂等)
func (s *Service) UnfollowUser(followerID, followeeID string) error {
return s.db.Where("follower_id = ? AND followee_id = ?", followerID, followeeID).
Delete(&model.Follow{}).Error
}
// followeeIDs 我关注的所有人
func (s *Service) followeeIDs(userID string) []string {
var ids []string
s.db.Model(&model.Follow{}).Where("follower_id = ?", userID).Pluck("followee_id", &ids)
return ids
}
// markFollowed 标记帖子作者是否已被我关注(用于前端显示「关注」按钮)
func (s *Service) markFollowed(userID string, posts []model.Post) {
if userID == "" || len(posts) == 0 {
return
}
authors := make([]string, 0, len(posts))
for i := range posts {
if posts[i].UserID != "" && posts[i].UserID != userID {
authors = append(authors, posts[i].UserID)
}
}
if len(authors) == 0 {
return
}
var followed []string
s.db.Model(&model.Follow{}).
Where("follower_id = ? AND followee_id IN ?", userID, authors).
Pluck("followee_id", &followed)
set := make(map[string]bool, len(followed))
for _, id := range followed {
set[id] = true
}
for i := range posts {
posts[i].IsSelf = posts[i].UserID == userID
posts[i].Followed = set[posts[i].UserID]
}
}
// ensureNotFound 供 handler 区分错误类型
var _ = gorm.ErrRecordNotFound
+2
View File
@@ -11,6 +11,7 @@ import Comments from '@/pages/Comments'
import Articles from '@/pages/Articles'
import CareTemplates from '@/pages/CareTemplates'
import CommunityBot from '@/pages/CommunityBot'
import Feedback from '@/pages/Feedback'
import Members from '@/pages/Members'
function RequireAuth({ children }: { children: JSX.Element }) {
@@ -37,6 +38,7 @@ export default function App() {
<Route path="articles" element={<Articles />} />
<Route path="care-templates" element={<CareTemplates />} />
<Route path="community-bot" element={<CommunityBot />} />
<Route path="feedback" element={<Feedback />} />
<Route path="members" element={<Members />} />
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
+2 -1
View File
@@ -1,5 +1,5 @@
import { NavLink, Outlet, useNavigate } from 'react-router-dom'
import { LayoutDashboard, Users, PawPrint, MessageSquare, MessagesSquare, FileText, ClipboardList, Sparkles, Crown, LogOut } from 'lucide-react'
import { LayoutDashboard, Users, PawPrint, MessageSquare, MessagesSquare, FileText, ClipboardList, Sparkles, MessageCircle, Crown, LogOut } from 'lucide-react'
import { clearToken } from '@/lib/api'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
@@ -13,6 +13,7 @@ const nav = [
{ to: '/articles', label: '文章管理', icon: FileText },
{ to: '/care-templates', label: '养护模板', icon: ClipboardList },
{ to: '/community-bot', label: '社区运营', icon: Sparkles },
{ to: '/feedback', label: '意见反馈', icon: MessageCircle },
{ to: '/members', label: '会员管理', icon: Crown },
]
+4
View File
@@ -81,6 +81,10 @@ export const api = {
stage,
}),
feedback: (params: any) => http.get<any, Page<any>>('/admin/feedback', { params }),
setFeedbackHandled: (id: string, handled: boolean) =>
http.put(`/admin/feedback/${id}/handled`, { handled }),
communityBot: () =>
http.get<any, { enabled: boolean; daily_count: number; start_hour: number; end_hour: number }>(
'/admin/community-bot',
+69
View File
@@ -0,0 +1,69 @@
import { api } from '@/lib/api'
import { usePaged } from '@/lib/usePaged'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Card, CardContent } from '@/components/ui/card'
import { Pager } from '@/components/Pager'
export default function Feedback() {
const { page, setPage, data, totalPages, reload } = usePaged<any>(api.feedback)
async function toggle(f: any) {
await api.setFeedbackHandled(f.id, !f.handled)
reload()
}
return (
<div>
<div className="mb-6">
<h1 className="text-2xl font-bold"></h1>
<p className="text-sm text-muted-foreground mt-1"> </p>
</div>
<Card>
<CardContent className="pt-6">
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data?.list?.map((f) => (
<TableRow key={f.id}>
<TableCell className="whitespace-nowrap text-muted-foreground">
{String(f.created_at).slice(0, 16).replace('T', ' ')}
</TableCell>
<TableCell>{f.nickname || '—'}</TableCell>
<TableCell className="max-w-md whitespace-pre-wrap">{f.content}</TableCell>
<TableCell>{f.contact || '—'}</TableCell>
<TableCell>
{f.handled ? <Badge variant="success"></Badge> : <Badge variant="muted"></Badge>}
</TableCell>
<TableCell className="text-right">
<Button variant="outline" size="sm" onClick={() => toggle(f)}>
{f.handled ? '标为待处理' : '标为已处理'}
</Button>
</TableCell>
</TableRow>
))}
{!data?.list?.length && (
<TableRow>
<TableCell colSpan={6} className="text-center text-muted-foreground py-8">
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
<Pager page={page} totalPages={totalPages} total={data?.total ?? 0} onChange={setPage} />
</CardContent>
</Card>
</div>
)
}