Skip to content

Commit dff416f

Browse files
committed
feat(people): 人物隐藏/恢复 + 编辑弹窗合并搜索 + 批量管理模式
- Person 模型新增 `hidden` 字段(带索引),控制人物是否出现在管理主列表, 不影响分类、人脸、聚类、合并建议及照片展示 - 后端新增 `PATCH /people/visibility` 批量接口,单次事务更新, 支持去重、上限 500 条、不存在的 ID 静默跳过 - ListPeople 查询新增 `visibility` 筛选(visible/hidden/all),默认 all - PersonCard 新增下拉菜单(隐藏/恢复)+ 批量选择复选框 + 隐藏态半透明样式 - PersonEditDialog 布局改为 label-position right,新增「可能是同一个人」 同名搜索区(300ms 去抖 + 代际丢弃 + 分页加载) - 新增 PersonMergeConfirmDialog 组件:来源→目标双卡片 + 不可撤销警告 - 人物列表页新增「批量管理」模式(全选/批量隐藏/批量恢复), 可见性筛选切换时自动清空选择,单卡操作后按筛选条件决定是否移除 - 前后端新增 UpdatePeopleVisibilityRequest DTO、PeopleVisibility 类型 - 后端测试覆盖 UpdateVisibility + ListPeople visibility 筛选
1 parent f3cd753 commit dff416f

14 files changed

Lines changed: 1593 additions & 27 deletions

File tree

backend/internal/api/v1/handler/people_handler.go

Lines changed: 64 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -58,13 +58,15 @@ func (h *PeopleHandler) ListPeople(c *gin.Context) {
5858
hasAvatar := strings.TrimSpace(c.Query("has_avatar"))
5959
category := strings.TrimSpace(c.Query("category"))
6060
search := strings.ToLower(strings.TrimSpace(c.Query("search")))
61+
visibility := strings.TrimSpace(c.Query("visibility"))
6162

6263
opts := repository.ListPeopleOptions{
63-
Page: page,
64-
PageSize: pageSize,
65-
Category: category,
66-
Search: search,
67-
HasAvatar: hasAvatar == "true",
64+
Page: page,
65+
PageSize: pageSize,
66+
Category: category,
67+
Search: search,
68+
HasAvatar: hasAvatar == "true",
69+
Visibility: visibility,
6870
}
6971

7072
people, total, err := h.personRepo.ListPeople(opts)
@@ -370,6 +372,62 @@ func (h *PeopleHandler) MergePeople(c *gin.Context) {
370372
})
371373
}
372374

375+
// UpdateVisibility 批量设置人物隐藏状态。单个操作复用同一接口。
376+
// 仅修改 hidden 字段,不触发分类更新、聚类、合并建议重算或照片变更。
377+
func (h *PeopleHandler) UpdateVisibility(c *gin.Context) {
378+
var req model.UpdatePeopleVisibilityRequest
379+
if err := c.ShouldBindJSON(&req); err != nil {
380+
writePeopleError(c, http.StatusBadRequest, "INVALID_REQUEST", err.Error())
381+
return
382+
}
383+
384+
// 去重 + 数量限制,防止超大批量请求
385+
seen := make(map[uint]struct{}, len(req.PersonIDs))
386+
ids := make([]uint, 0, len(req.PersonIDs))
387+
for _, id := range req.PersonIDs {
388+
if id == 0 {
389+
continue
390+
}
391+
if _, ok := seen[id]; ok {
392+
continue
393+
}
394+
seen[id] = struct{}{}
395+
ids = append(ids, id)
396+
}
397+
if len(ids) == 0 {
398+
writePeopleError(c, http.StatusBadRequest, "INVALID_REQUEST", "person_ids 不能为空")
399+
return
400+
}
401+
const maxVisibilityBatch = 500
402+
if len(ids) > maxVisibilityBatch {
403+
writePeopleError(c, http.StatusBadRequest, "INVALID_REQUEST", "单次最多操作 500 个人物")
404+
return
405+
}
406+
407+
updated, err := h.personRepo.UpdateVisibility(ids, *req.Hidden)
408+
if err != nil {
409+
writePeopleError(c, http.StatusInternalServerError, "UPDATE_FAILED", err.Error())
410+
return
411+
}
412+
413+
// 全部 ID 都不存在 → 明确错误;部分不存在则更新其余并返回实际更新数
414+
if updated == 0 {
415+
writePeopleError(c, http.StatusNotFound, "NOT_FOUND", "未找到任何指定的人物")
416+
return
417+
}
418+
419+
c.JSON(http.StatusOK, model.Response{
420+
Success: true,
421+
Message: "人物可见性已更新",
422+
Data: gin.H{
423+
"updated": updated,
424+
"requested": len(ids),
425+
"hidden": *req.Hidden,
426+
"missing_count": len(ids) - int(updated),
427+
},
428+
})
429+
}
430+
373431
// GetMergeJob 获取合并任务状态
374432
func (h *PeopleHandler) GetMergeJob(c *gin.Context) {
375433
jobID, err := strconv.ParseUint(c.Param("job_id"), 10, 32)
@@ -896,6 +954,7 @@ func personToResponse(person *model.Person, faces []model.FaceResponse) model.Pe
896954
AvatarLocked: person.AvatarLocked,
897955
FaceCount: person.FaceCount,
898956
PhotoCount: person.PhotoCount,
957+
Hidden: person.Hidden,
899958
CreatedAt: person.CreatedAt,
900959
UpdatedAt: person.UpdatedAt,
901960
Faces: faces,

backend/internal/api/v1/handler/people_handler_test.go

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -958,3 +958,124 @@ func TestPeopleHandlerGetWorkerTasksRequiresRuntimeLease(t *testing.T) {
958958
resp := decodeAPIResponse(t, rec)
959959
require.False(t, resp.Success)
960960
}
961+
962+
// TestPeopleHandler_ListPeopleVisibility 验证 visibility 查询参数与响应 hidden 字段。
963+
func TestPeopleHandler_ListPeopleVisibility(t *testing.T) {
964+
handler, _, _, db, _ := newPeopleHandlerForTest(t)
965+
fixture := seedPeopleHandlerFixture(t, db)
966+
967+
// 将 friend 标记为隐藏
968+
require.NoError(t, db.Model(&model.Person{}).Where("id = ?", fixture.FriendPerson.ID).Update("hidden", true).Error)
969+
970+
// visible:仅返回 family(显示中)
971+
rec := performJSONRequest(t, http.MethodGet, "/api/v1/people?visibility=visible&page=1&page_size=20", nil, nil, handler.ListPeople)
972+
require.Equal(t, http.StatusOK, rec.Code)
973+
resp := decodeAPIResponse(t, rec)
974+
require.True(t, resp.Success)
975+
payload := decodeResponseData[peopleListPayload](t, resp)
976+
assert.Equal(t, int64(1), payload.Total)
977+
require.Len(t, payload.Items, 1)
978+
assert.Equal(t, fixture.FamilyPerson.ID, payload.Items[0].ID)
979+
assert.False(t, payload.Items[0].Hidden)
980+
981+
// hidden:仅返回 friend(已隐藏)
982+
rec = performJSONRequest(t, http.MethodGet, "/api/v1/people?visibility=hidden&page=1&page_size=20", nil, nil, handler.ListPeople)
983+
require.Equal(t, http.StatusOK, rec.Code)
984+
resp = decodeAPIResponse(t, rec)
985+
payload = decodeResponseData[peopleListPayload](t, resp)
986+
assert.Equal(t, int64(1), payload.Total)
987+
require.Len(t, payload.Items, 1)
988+
assert.Equal(t, fixture.FriendPerson.ID, payload.Items[0].ID)
989+
assert.True(t, payload.Items[0].Hidden)
990+
991+
// all:返回全部
992+
rec = performJSONRequest(t, http.MethodGet, "/api/v1/people?visibility=all&page=1&page_size=20", nil, nil, handler.ListPeople)
993+
require.Equal(t, http.StatusOK, rec.Code)
994+
resp = decodeAPIResponse(t, rec)
995+
payload = decodeResponseData[peopleListPayload](t, resp)
996+
assert.Equal(t, int64(2), payload.Total)
997+
assert.Len(t, payload.Items, 2)
998+
999+
// 缺省按 all 处理
1000+
rec = performJSONRequest(t, http.MethodGet, "/api/v1/people?page=1&page_size=20", nil, nil, handler.ListPeople)
1001+
require.Equal(t, http.StatusOK, rec.Code)
1002+
resp = decodeAPIResponse(t, rec)
1003+
payload = decodeResponseData[peopleListPayload](t, resp)
1004+
assert.Equal(t, int64(2), payload.Total)
1005+
}
1006+
1007+
// TestPeopleHandler_UpdateVisibility 验证批量隐藏/恢复、参数校验、不存在 ID 行为及副作用隔离。
1008+
func TestPeopleHandler_UpdateVisibility(t *testing.T) {
1009+
handler, _, _, db, _ := newPeopleHandlerForTest(t)
1010+
fixture := seedPeopleHandlerFixture(t, db)
1011+
1012+
// 记录 family 原始分类,用于验证隐藏操作不修改分类
1013+
originalCategory := fixture.FamilyPerson.Category
1014+
1015+
t.Run("batch hide then restore", func(t *testing.T) {
1016+
body := []byte(`{"person_ids":[` + strconv.FormatUint(uint64(fixture.FamilyPerson.ID), 10) + `],"hidden":true}`)
1017+
rec := performJSONRequest(t, http.MethodPatch, "/api/v1/people/visibility", body, nil, handler.UpdateVisibility)
1018+
require.Equal(t, http.StatusOK, rec.Code)
1019+
resp := decodeAPIResponse(t, rec)
1020+
require.True(t, resp.Success)
1021+
data := decodeResponseData[map[string]interface{}](t, resp)
1022+
assert.EqualValues(t, 1, data["updated"])
1023+
1024+
// 验证 DB:hidden=true,category 未变
1025+
var got model.Person
1026+
require.NoError(t, db.First(&got, fixture.FamilyPerson.ID).Error)
1027+
assert.True(t, got.Hidden)
1028+
assert.Equal(t, originalCategory, got.Category)
1029+
1030+
// 恢复
1031+
body = []byte(`{"person_ids":[` + strconv.FormatUint(uint64(fixture.FamilyPerson.ID), 10) + `],"hidden":false}`)
1032+
rec = performJSONRequest(t, http.MethodPatch, "/api/v1/people/visibility", body, nil, handler.UpdateVisibility)
1033+
require.Equal(t, http.StatusOK, rec.Code)
1034+
require.NoError(t, db.First(&got, fixture.FamilyPerson.ID).Error)
1035+
assert.False(t, got.Hidden)
1036+
})
1037+
1038+
t.Run("dedup and missing ids", func(t *testing.T) {
1039+
// 部分不存在:更新存在的,返回 updated=1,missing_count=1
1040+
body := []byte(`{"person_ids":[` + strconv.FormatUint(uint64(fixture.FriendPerson.ID), 10) + `,999999],"hidden":true}`)
1041+
rec := performJSONRequest(t, http.MethodPatch, "/api/v1/people/visibility", body, nil, handler.UpdateVisibility)
1042+
require.Equal(t, http.StatusOK, rec.Code)
1043+
resp := decodeAPIResponse(t, rec)
1044+
require.True(t, resp.Success)
1045+
data := decodeResponseData[map[string]interface{}](t, resp)
1046+
assert.EqualValues(t, 1, data["updated"])
1047+
assert.EqualValues(t, 1, data["missing_count"])
1048+
1049+
// 全部不存在:404
1050+
body = []byte(`{"person_ids":[999998,999999],"hidden":true}`)
1051+
rec = performJSONRequest(t, http.MethodPatch, "/api/v1/people/visibility", body, nil, handler.UpdateVisibility)
1052+
assert.Equal(t, http.StatusNotFound, rec.Code)
1053+
})
1054+
1055+
t.Run("empty person_ids rejected", func(t *testing.T) {
1056+
body := []byte(`{"person_ids":[],"hidden":true}`)
1057+
rec := performJSONRequest(t, http.MethodPatch, "/api/v1/people/visibility", body, nil, handler.UpdateVisibility)
1058+
assert.Equal(t, http.StatusBadRequest, rec.Code)
1059+
})
1060+
1061+
t.Run("missing hidden field rejected", func(t *testing.T) {
1062+
body := []byte(`{"person_ids":[` + strconv.FormatUint(uint64(fixture.FamilyPerson.ID), 10) + `]}`)
1063+
rec := performJSONRequest(t, http.MethodPatch, "/api/v1/people/visibility", body, nil, handler.UpdateVisibility)
1064+
assert.Equal(t, http.StatusBadRequest, rec.Code)
1065+
})
1066+
1067+
t.Run("hidden does not change top_person_category", func(t *testing.T) {
1068+
// 记录 photoOne 的 top_person_category,隐藏 family 后应保持不变
1069+
var photoBefore model.Photo
1070+
require.NoError(t, db.First(&photoBefore, fixture.PhotoOne.ID).Error)
1071+
categoryBefore := photoBefore.TopPersonCategory
1072+
1073+
body := []byte(`{"person_ids":[` + strconv.FormatUint(uint64(fixture.FamilyPerson.ID), 10) + `],"hidden":true}`)
1074+
rec := performJSONRequest(t, http.MethodPatch, "/api/v1/people/visibility", body, nil, handler.UpdateVisibility)
1075+
require.Equal(t, http.StatusOK, rec.Code)
1076+
1077+
var photoAfter model.Photo
1078+
require.NoError(t, db.First(&photoAfter, fixture.PhotoOne.ID).Error)
1079+
assert.Equal(t, categoryBefore, photoAfter.TopPersonCategory)
1080+
})
1081+
}

backend/internal/api/v1/router/router.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,7 @@ func Setup(db *gorm.DB, cfg *config.Config, appState *lifecycle.State) (*gin.Eng
258258
people.POST("/merge-suggestions/:id/apply", handlers.People.ApplyMergeSuggestion)
259259
people.POST("/merge", handlers.People.MergePeople)
260260
people.GET("/merge-jobs/:job_id", handlers.People.GetMergeJob)
261+
people.PATCH("/visibility", handlers.People.UpdateVisibility)
261262
people.POST("/split", handlers.People.SplitPerson)
262263
people.POST("/move-faces", handlers.People.MoveFaces)
263264
people.GET("", handlers.People.ListPeople)

backend/internal/model/dto.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -557,6 +557,7 @@ type PersonResponse struct {
557557
AvatarLocked bool `json:"avatar_locked"`
558558
FaceCount int `json:"face_count"`
559559
PhotoCount int `json:"photo_count"`
560+
Hidden bool `json:"hidden"`
560561
CreatedAt time.Time `json:"created_at"`
561562
UpdatedAt time.Time `json:"updated_at"`
562563
Faces []FaceResponse `json:"faces,omitempty"`
@@ -596,6 +597,14 @@ type MoveFacesRequest struct {
596597
TargetPersonID uint `json:"target_person_id" binding:"required"`
597598
}
598599

600+
// UpdatePeopleVisibilityRequest 批量设置人物隐藏状态。
601+
// Hidden=true 表示从人物管理主列表隐藏,false 表示恢复显示。
602+
// 该操作仅修改 hidden 字段,不触发分类更新、聚类、合并建议重算或照片变更。
603+
type UpdatePeopleVisibilityRequest struct {
604+
PersonIDs []uint `json:"person_ids" binding:"required,min=1"`
605+
Hidden *bool `json:"hidden" binding:"required"`
606+
}
607+
599608
// ReclusterResult holds the outcome of an automatic re-clustering pass
600609
type ReclusterResult struct {
601610
Evaluated int `json:"recluster_evaluated"`

backend/internal/model/person.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,10 @@ type Person struct {
3030
AvatarLocked bool `gorm:"not null;default:false" json:"avatar_locked"`
3131
FaceCount int `gorm:"not null;default:0" json:"face_count"`
3232
PhotoCount int `gorm:"not null;default:0" json:"photo_count"`
33+
34+
// Hidden 仅控制人物是否出现在人物管理主列表,与人物分类完全独立,
35+
// 不影响照片展示、人物识别、聚类、合并建议及合并/移动候选。
36+
Hidden bool `gorm:"not null;default:false;index:idx_people_hidden" json:"hidden"`
3337
}
3438

3539
func (Person) TableName() string {

backend/internal/repository/person_repo.go

Lines changed: 57 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,14 +20,25 @@ type PersonRepository interface {
2020
ListPeople(opts ListPeopleOptions) ([]*model.Person, int64, error) // 数据库层分页查询
2121
RefreshStats(personID uint) error
2222
MergeInto(targetPersonID uint, sourcePersonIDs []uint) ([]uint, error)
23+
// UpdateVisibility 批量设置人物隐藏状态,单次事务更新,返回实际更新行数。
24+
// 仅修改 hidden 字段,不影响分类、人脸、照片及 top_person_category。
25+
UpdateVisibility(personIDs []uint, hidden bool) (int64, error)
2326
}
2427

28+
// 可见性筛选值:仅显示中 / 仅已隐藏 / 全部。空值按全部处理,保持合并、移动候选等现有调用兼容。
29+
const (
30+
PersonVisibilityVisible = "visible"
31+
PersonVisibilityHidden = "hidden"
32+
PersonVisibilityAll = "all"
33+
)
34+
2535
type ListPeopleOptions struct {
26-
Page int
27-
PageSize int
28-
Category string
29-
Search string
30-
HasAvatar bool
36+
Page int
37+
PageSize int
38+
Category string
39+
Search string
40+
HasAvatar bool
41+
Visibility string // visible | hidden | all,缺省按 all
3142
}
3243

3344
type personRepository struct {
@@ -120,6 +131,12 @@ func (r *personRepository) ListPeople(opts ListPeopleOptions) ([]*model.Person,
120131
if opts.Category != "" {
121132
q = q.Where("category = ?", opts.Category)
122133
}
134+
switch opts.Visibility {
135+
case PersonVisibilityVisible:
136+
q = q.Where("hidden = ?", false)
137+
case PersonVisibilityHidden:
138+
q = q.Where("hidden = ?", true)
139+
} // all 或空值:不附加可见性条件
123140
if opts.Search != "" {
124141
like := "%" + opts.Search + "%"
125142
q = q.Where("LOWER(name) LIKE ? OR LOWER(category) LIKE ? OR CAST(id AS TEXT) LIKE ?", like, like, like)
@@ -204,6 +221,41 @@ func (r *personRepository) MergeInto(targetPersonID uint, sourcePersonIDs []uint
204221
return affectedPhotoIDs, nil
205222
}
206223

224+
// UpdateVisibility 批量设置人物隐藏状态。
225+
// 单次事务内按 ID 分块更新,仅写入 hidden 字段;返回受影响行数。
226+
// 不存在的 ID 不会报错,仅不计入更新行数。
227+
func (r *personRepository) UpdateVisibility(personIDs []uint, hidden bool) (int64, error) {
228+
if len(personIDs) == 0 {
229+
return 0, nil
230+
}
231+
232+
// 去重
233+
seen := make(map[uint]struct{}, len(personIDs))
234+
uniqueIDs := make([]uint, 0, len(personIDs))
235+
for _, id := range personIDs {
236+
if _, ok := seen[id]; ok {
237+
continue
238+
}
239+
seen[id] = struct{}{}
240+
uniqueIDs = append(uniqueIDs, id)
241+
}
242+
243+
var total int64
244+
err := r.db.Transaction(func(tx *gorm.DB) error {
245+
for _, chunk := range chunkIDs(uniqueIDs) {
246+
res := tx.Model(&model.Person{}).
247+
Where("id IN ?", chunk).
248+
Update("hidden", hidden)
249+
if res.Error != nil {
250+
return res.Error
251+
}
252+
total += res.RowsAffected
253+
}
254+
return nil
255+
})
256+
return total, err
257+
}
258+
207259
func refreshPersonStats(tx *gorm.DB, personID uint) error {
208260
type stats struct {
209261
FaceCount int

0 commit comments

Comments
 (0)