Files
code-utils/team.go

754 lines
19 KiB
Go
Raw Normal View History

2026-08-14 07:52:01 +08:00
package main
2026-08-15 17:18:00 +08:00
// team.go 团队协作:团队/成员/角色、团队任务、日报与 AI 摘要、团队通知。
// 团队数据经 nl-pms-apiJWT通知在同步循环里增量拉取后转本地消息。
2026-08-14 07:52:01 +08:00
import (
"context"
"errors"
"fmt"
2026-08-15 17:18:00 +08:00
"net/http"
"net/url"
2026-08-14 07:52:01 +08:00
"regexp"
"strings"
"time"
"view/service"
"view/service/ai"
)
// ---------- 结构 ----------
type TeamInfo struct {
ID int64 `json:"id"`
Name string `json:"name"`
OwnerID int64 `json:"ownerId"`
DigestTime string `json:"digestTime"`
Role string `json:"role"`
Members int `json:"members"`
Current bool `json:"current"`
}
type TeamMember struct {
UserID int64 `json:"userId"`
Username string `json:"username"`
Nickname string `json:"nickname"`
Title string `json:"title"`
Bio string `json:"bio"`
TechTags []string `json:"techTags"`
Avatar string `json:"avatar"`
Role string `json:"role"`
JoinedAt string `json:"joinedAt"`
}
type TeamTask struct {
ID int64 `json:"id"`
TeamID int64 `json:"teamId"`
Kind string `json:"kind"` // todo | ticket
Title string `json:"title"`
Description string `json:"description"`
Priority string `json:"priority"`
Status string `json:"status"` // open | doing | done | closed
CreatorID int64 `json:"creatorId"`
Creator string `json:"creator"`
AssigneeID int64 `json:"assigneeId"`
Assignee string `json:"assignee"`
StartAt string `json:"startAt"`
DueAt string `json:"dueAt"`
UrgedAt string `json:"urgedAt"`
History string `json:"history"`
UpdatedAt string `json:"updatedAt"`
}
type TeamSharedItem struct {
Kind string `json:"kind"` // todo | ticket
UUID string `json:"uuid"`
UserID int64 `json:"userId"`
Owner string `json:"owner"`
Title string `json:"title"`
Status string `json:"status"`
Priority string `json:"priority"`
DueAt string `json:"dueAt"`
UpdatedAt string `json:"updatedAt"`
}
type TeamReport struct {
UserID int64 `json:"userId"`
User string `json:"user"`
Date string `json:"date"`
Content string `json:"content"`
SubmittedAt string `json:"submittedAt"`
}
type TeamDigest struct {
Date string `json:"date"`
Content string `json:"content"`
Provider string `json:"provider"`
GeneratedAt string `json:"generatedAt"`
}
// TeamReportBoard 一天的日报全景:成员提交内容 + 未交名单 + AI 摘要。
type TeamReportBoard struct {
Date string `json:"date"`
Role string `json:"role"`
Reports []TeamReport `json:"reports"`
Missing []TeamMember `json:"missing"`
Digest *TeamDigest `json:"digest"`
}
// ---------- 基础设施 ----------
var teamDateRe = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`)
2026-08-15 17:18:00 +08:00
// requireSyncLogin 校验本地登录态与 API 基址。
func (a *App) requireSyncLogin() (int64, error) {
2026-08-14 07:52:01 +08:00
if e := a.ready(); e != nil {
2026-08-15 17:18:00 +08:00
return 0, e
2026-08-14 07:52:01 +08:00
}
uid := a.syncUserID()
2026-08-15 17:18:00 +08:00
if uid <= 0 || a.store.Meta("sync_access_token") == "" {
return 0, errors.New("SYNC_NOT_LOGGED_IN")
2026-08-14 07:52:01 +08:00
}
2026-08-15 17:18:00 +08:00
if a.apiBaseURL() == "" {
return 0, errors.New("SYNC_NOT_CONFIGURED")
2026-08-14 07:52:01 +08:00
}
2026-08-15 17:18:00 +08:00
return uid, nil
2026-08-14 07:52:01 +08:00
}
func teamRoleRank(role string) int {
switch role {
case "owner":
return 3
case "admin":
return 2
case "member":
return 1
}
return 0
}
2026-08-15 17:18:00 +08:00
func teamPath(teamID int64, suffix string) string {
p := fmt.Sprintf("/api/v1/teams/%d", teamID)
if suffix != "" {
p += suffix
2026-08-14 07:52:01 +08:00
}
2026-08-15 17:18:00 +08:00
return p
2026-08-14 07:52:01 +08:00
}
// ---------- 团队 CRUD ----------
func (a *App) TeamCreate(name string) (TeamInfo, error) {
name = strings.TrimSpace(name)
if name == "" || len([]rune(name)) > 64 {
return TeamInfo{}, errors.New("TEAM_NAME_INVALID")
}
2026-08-15 17:18:00 +08:00
if _, e := a.requireSyncLogin(); e != nil {
2026-08-14 07:52:01 +08:00
return TeamInfo{}, e
}
2026-08-15 17:18:00 +08:00
var out TeamInfo
if e := a.apiDecode(http.MethodPost, "/api/v1/teams", map[string]string{"name": name}, &out, true); e != nil {
return TeamInfo{}, e
2026-08-14 07:52:01 +08:00
}
2026-08-15 17:18:00 +08:00
out.Current = true
_ = a.store.SetMeta("current_team_id", fmt.Sprint(out.ID))
2026-08-14 07:52:01 +08:00
a.store.Log("info", "团队", "团队创建成功", name)
2026-08-15 17:18:00 +08:00
return out, nil
2026-08-14 07:52:01 +08:00
}
func (a *App) TeamList() ([]TeamInfo, error) {
2026-08-15 17:18:00 +08:00
if _, e := a.requireSyncLogin(); e != nil {
2026-08-14 07:52:01 +08:00
return nil, e
}
2026-08-15 17:18:00 +08:00
var resp struct {
Items []TeamInfo `json:"items"`
2026-08-14 07:52:01 +08:00
}
2026-08-15 17:18:00 +08:00
if e := a.apiDecode(http.MethodGet, "/api/v1/teams", nil, &resp, true); e != nil {
2026-08-14 07:52:01 +08:00
return nil, e
}
2026-08-15 17:18:00 +08:00
out := resp.Items
if out == nil {
out = []TeamInfo{}
}
2026-08-14 07:52:01 +08:00
cur := a.store.Meta("current_team_id")
found := false
for i := range out {
if fmt.Sprint(out[i].ID) == cur {
out[i].Current = true
found = true
}
}
if !found && len(out) > 0 {
out[0].Current = true
_ = a.store.SetMeta("current_team_id", fmt.Sprint(out[0].ID))
}
return out, nil
}
func (a *App) TeamSwitch(teamID int64) error {
2026-08-15 17:18:00 +08:00
uid, e := a.requireSyncLogin()
2026-08-14 07:52:01 +08:00
if e != nil {
return e
}
2026-08-15 17:18:00 +08:00
teams, e := a.TeamList()
if e != nil {
2026-08-14 07:52:01 +08:00
return e
}
2026-08-15 17:18:00 +08:00
ok := false
for _, t := range teams {
if t.ID == teamID {
ok = true
break
}
}
if !ok {
return errors.New("TEAM_FORBIDDEN")
}
2026-08-14 07:52:01 +08:00
if e := a.store.SetMeta("current_team_id", fmt.Sprint(teamID)); e != nil {
return e
}
2026-08-15 17:18:00 +08:00
_ = uid
a.refreshTeamAssignedCache()
2026-08-14 07:52:01 +08:00
return nil
}
func (a *App) TeamRename(teamID int64, name string) error {
name = strings.TrimSpace(name)
if name == "" || len([]rune(name)) > 64 {
return errors.New("TEAM_NAME_INVALID")
}
2026-08-15 17:18:00 +08:00
if _, e := a.requireSyncLogin(); e != nil {
2026-08-14 07:52:01 +08:00
return e
}
2026-08-15 17:18:00 +08:00
return a.apiDecode(http.MethodPut, teamPath(teamID, ""), map[string]string{"name": name}, nil, true)
2026-08-14 07:52:01 +08:00
}
func (a *App) TeamSetDigestTime(teamID int64, at string) error {
if _, ok := parseClockOK(at); !ok {
return errors.New("TEAM_TIME_INVALID")
}
2026-08-15 17:18:00 +08:00
if _, e := a.requireSyncLogin(); e != nil {
2026-08-14 07:52:01 +08:00
return e
}
2026-08-15 17:18:00 +08:00
return a.apiDecode(http.MethodPut, teamPath(teamID, "/digest-time"), map[string]string{"digestTime": at}, nil, true)
2026-08-14 07:52:01 +08:00
}
func parseClockOK(at string) (string, bool) {
parts := strings.SplitN(at, ":", 2)
if len(parts) != 2 {
return "", false
}
var h, m int
if _, e := fmt.Sscanf(at, "%d:%d", &h, &m); e != nil || h < 0 || h > 23 || m < 0 || m > 59 {
return "", false
}
return at, true
}
func (a *App) TeamDissolve(teamID int64) error {
2026-08-15 17:18:00 +08:00
uid, e := a.requireSyncLogin()
2026-08-14 07:52:01 +08:00
if e != nil {
return e
}
2026-08-15 17:18:00 +08:00
if e := a.apiDecode(http.MethodDelete, teamPath(teamID, ""), nil, nil, true); e != nil {
2026-08-14 07:52:01 +08:00
return e
}
if a.store.Meta("current_team_id") == fmt.Sprint(teamID) {
_ = a.store.SetMeta("current_team_id", "")
}
2026-08-15 17:18:00 +08:00
a.refreshTeamAssignedCache()
_ = uid
a.store.Log("warning", "团队", "团队已解散", fmt.Sprint(teamID))
2026-08-14 07:52:01 +08:00
return nil
}
func (a *App) TeamLeave(teamID int64) error {
2026-08-15 17:18:00 +08:00
uid, e := a.requireSyncLogin()
2026-08-14 07:52:01 +08:00
if e != nil {
return e
}
2026-08-15 17:18:00 +08:00
if e := a.apiDecode(http.MethodPost, teamPath(teamID, "/leave"), nil, nil, true); e != nil {
2026-08-14 07:52:01 +08:00
return e
}
if a.store.Meta("current_team_id") == fmt.Sprint(teamID) {
_ = a.store.SetMeta("current_team_id", "")
}
2026-08-15 17:18:00 +08:00
a.refreshTeamAssignedCache()
_ = uid
2026-08-14 07:52:01 +08:00
return nil
}
// ---------- 成员管理 ----------
func (a *App) TeamMembers(teamID int64) ([]TeamMember, error) {
2026-08-15 17:18:00 +08:00
if _, e := a.requireSyncLogin(); e != nil {
2026-08-14 07:52:01 +08:00
return nil, e
}
2026-08-15 17:18:00 +08:00
var resp struct {
Items []TeamMember `json:"items"`
}
if e := a.apiDecode(http.MethodGet, teamPath(teamID, "/members"), nil, &resp, true); e != nil {
2026-08-14 07:52:01 +08:00
return nil, e
}
2026-08-15 17:18:00 +08:00
if resp.Items == nil {
return []TeamMember{}, nil
}
for i := range resp.Items {
if resp.Items[i].TechTags == nil {
resp.Items[i].TechTags = []string{}
2026-08-14 07:52:01 +08:00
}
}
2026-08-15 17:18:00 +08:00
return resp.Items, nil
2026-08-14 07:52:01 +08:00
}
func (a *App) TeamInvite(teamID int64, username, role string) error {
if role != "member" && role != "admin" {
role = "member"
}
username = strings.TrimSpace(username)
2026-08-15 17:18:00 +08:00
if _, e := a.requireSyncLogin(); e != nil {
2026-08-14 07:52:01 +08:00
return e
}
2026-08-15 17:18:00 +08:00
return a.apiDecode(http.MethodPost, teamPath(teamID, "/invite"), map[string]string{
"username": username,
"role": role,
}, nil, true)
2026-08-14 07:52:01 +08:00
}
func (a *App) TeamSetRole(teamID, userID int64, role string) error {
if role != "member" && role != "admin" {
return errors.New("TEAM_ROLE_INVALID")
}
2026-08-15 17:18:00 +08:00
if _, e := a.requireSyncLogin(); e != nil {
2026-08-14 07:52:01 +08:00
return e
}
2026-08-15 17:18:00 +08:00
return a.apiDecode(http.MethodPut, teamPath(teamID, fmt.Sprintf("/members/%d/role", userID)),
map[string]string{"role": role}, nil, true)
2026-08-14 07:52:01 +08:00
}
func (a *App) TeamRemoveMember(teamID, userID int64) error {
2026-08-15 17:18:00 +08:00
if _, e := a.requireSyncLogin(); e != nil {
2026-08-14 07:52:01 +08:00
return e
}
2026-08-15 17:18:00 +08:00
return a.apiDecode(http.MethodDelete, teamPath(teamID, fmt.Sprintf("/members/%d", userID)), nil, nil, true)
2026-08-14 07:52:01 +08:00
}
// ---------- 团队任务 ----------
func validTeamTask(t *TeamTask) error {
t.Title = strings.TrimSpace(t.Title)
if t.Title == "" {
return errors.New("TEAM_TASK_TITLE_REQUIRED")
}
if t.Kind != "ticket" {
t.Kind = "todo"
}
switch t.Priority {
case "low", "medium", "high":
default:
t.Priority = "medium"
}
return nil
}
2026-08-15 17:18:00 +08:00
// TeamTaskSave 创建/编辑团队任务admin+)。
2026-08-14 07:52:01 +08:00
func (a *App) TeamTaskSave(t TeamTask) (TeamTask, error) {
if e := validTeamTask(&t); e != nil {
return TeamTask{}, e
}
2026-08-15 17:18:00 +08:00
if _, e := a.requireSyncLogin(); e != nil {
2026-08-14 07:52:01 +08:00
return TeamTask{}, e
}
2026-08-15 17:18:00 +08:00
path := teamPath(t.TeamID, "/tasks")
method := http.MethodPost
if t.ID > 0 {
path = teamPath(t.TeamID, fmt.Sprintf("/tasks/%d", t.ID))
method = http.MethodPut
2026-08-14 07:52:01 +08:00
}
2026-08-15 17:18:00 +08:00
var out TeamTask
if e := a.apiDecode(method, path, t, &out, true); e != nil {
return TeamTask{}, e
2026-08-14 07:52:01 +08:00
}
2026-08-15 17:18:00 +08:00
a.refreshTeamAssignedCache()
return out, nil
2026-08-14 07:52:01 +08:00
}
func (a *App) TeamTaskSetStatus(teamID, id int64, status string) error {
switch status {
case "open", "doing", "done", "closed":
default:
return errors.New("TEAM_STATUS_INVALID")
}
2026-08-15 17:18:00 +08:00
if _, e := a.requireSyncLogin(); e != nil {
2026-08-14 07:52:01 +08:00
return e
}
2026-08-15 17:18:00 +08:00
if e := a.apiDecode(http.MethodPut, teamPath(teamID, fmt.Sprintf("/tasks/%d/status", id)),
map[string]string{"status": status}, nil, true); e != nil {
2026-08-14 07:52:01 +08:00
return e
}
2026-08-15 17:18:00 +08:00
a.refreshTeamAssignedCache()
2026-08-14 07:52:01 +08:00
return nil
}
2026-08-15 17:18:00 +08:00
func (a *App) TeamTaskUrge(teamID, id int64) error {
if _, e := a.requireSyncLogin(); e != nil {
return e
}
return a.apiDecode(http.MethodPost, teamPath(teamID, fmt.Sprintf("/tasks/%d/urge", id)), nil, nil, true)
2026-08-14 07:52:01 +08:00
}
2026-08-15 17:18:00 +08:00
// appendHistoryNode 追加一个生命周期节点(本地单测与兼容保留;服务端也会写 history
2026-08-14 07:52:01 +08:00
func appendHistoryNode(history, status, at string) string {
node := fmt.Sprintf(`{"status":%q,"at":%q}`, status, at)
h := strings.TrimSpace(history)
if h == "" || h == "[]" {
return "[" + node + "]"
}
if strings.HasSuffix(h, "]") {
return h[:len(h)-1] + "," + node + "]"
}
return "[" + node + "]"
}
func (a *App) TeamTaskDelete(teamID, id int64) error {
2026-08-15 17:18:00 +08:00
if _, e := a.requireSyncLogin(); e != nil {
2026-08-14 07:52:01 +08:00
return e
}
2026-08-15 17:18:00 +08:00
if e := a.apiDecode(http.MethodDelete, teamPath(teamID, fmt.Sprintf("/tasks/%d", id)), nil, nil, true); e != nil {
2026-08-14 07:52:01 +08:00
return e
}
2026-08-15 17:18:00 +08:00
a.refreshTeamAssignedCache()
2026-08-14 07:52:01 +08:00
return nil
}
// TeamTaskList 列出团队任务filter: all | mine | created | open。
func (a *App) TeamTaskList(teamID int64, filter string) ([]TeamTask, error) {
2026-08-15 17:18:00 +08:00
if _, e := a.requireSyncLogin(); e != nil {
2026-08-14 07:52:01 +08:00
return nil, e
}
2026-08-15 17:18:00 +08:00
q := teamPath(teamID, "/tasks")
if filter != "" {
q += "?filter=" + url.QueryEscape(filter)
}
var resp struct {
Items []TeamTask `json:"items"`
}
if e := a.apiDecode(http.MethodGet, q, nil, &resp, true); e != nil {
2026-08-14 07:52:01 +08:00
return nil, e
}
2026-08-15 17:18:00 +08:00
if resp.Items == nil {
return []TeamTask{}, nil
2026-08-14 07:52:01 +08:00
}
2026-08-15 17:18:00 +08:00
return resp.Items, nil
2026-08-14 07:52:01 +08:00
}
// ---------- 个人条目共享 ----------
func (a *App) SetTodoTeam(id, teamID int64) error {
return a.setItemTeam("todos", id, teamID)
}
func (a *App) SetTicketTeam(id, teamID int64) error {
return a.setItemTeam("tickets", id, teamID)
}
func (a *App) setItemTeam(table string, id, teamID int64) error {
if e := a.ready(); e != nil {
return e
}
if teamID < 0 {
teamID = 0
}
res, e := a.store.db.Exec(`UPDATE `+table+` SET team_id=?,dirty=1,updated_at=? WHERE id=? AND deleted=0`, teamID, nowRFC(), id)
if e != nil {
return e
}
if n, _ := res.RowsAffected(); n == 0 {
return errors.New("NOT_FOUND")
}
if a.syncUserID() > 0 {
go a.syncOnce(true)
}
return nil
}
func (a *App) TeamSharedItems(teamID int64) ([]TeamSharedItem, error) {
2026-08-15 17:18:00 +08:00
if _, e := a.requireSyncLogin(); e != nil {
2026-08-14 07:52:01 +08:00
return nil, e
}
2026-08-15 17:18:00 +08:00
var resp struct {
Items []TeamSharedItem `json:"items"`
}
if e := a.apiDecode(http.MethodGet, teamPath(teamID, "/shared"), nil, &resp, true); e != nil {
2026-08-14 07:52:01 +08:00
return nil, e
}
2026-08-15 17:18:00 +08:00
if resp.Items == nil {
return []TeamSharedItem{}, nil
2026-08-14 07:52:01 +08:00
}
2026-08-15 17:18:00 +08:00
return resp.Items, nil
2026-08-14 07:52:01 +08:00
}
func (a *App) TeamUrgeShared(teamID int64, kind, uuid string) error {
2026-08-15 17:18:00 +08:00
if _, e := a.requireSyncLogin(); e != nil {
2026-08-14 07:52:01 +08:00
return e
}
2026-08-15 17:18:00 +08:00
return a.apiDecode(http.MethodPost, teamPath(teamID, "/shared/urge"), map[string]string{
"kind": kind,
"uuid": uuid,
}, nil, true)
2026-08-14 07:52:01 +08:00
}
// ---------- 日报 ----------
func (a *App) TeamReportSubmit(teamID int64, date, content string) error {
if !teamDateRe.MatchString(date) {
return errors.New("TEAM_DATE_INVALID")
}
content = strings.TrimSpace(content)
if content == "" {
return errors.New("TEAM_REPORT_EMPTY")
}
2026-08-15 17:18:00 +08:00
if _, e := a.requireSyncLogin(); e != nil {
2026-08-14 07:52:01 +08:00
return e
}
2026-08-15 17:18:00 +08:00
return a.apiDecode(http.MethodPost, teamPath(teamID, "/reports"), map[string]string{
"date": date,
"content": content,
}, nil, true)
2026-08-14 07:52:01 +08:00
}
func (a *App) TeamReportBoardGet(teamID int64, date string) (TeamReportBoard, error) {
if !teamDateRe.MatchString(date) {
return TeamReportBoard{}, errors.New("TEAM_DATE_INVALID")
}
2026-08-15 17:18:00 +08:00
if _, e := a.requireSyncLogin(); e != nil {
2026-08-14 07:52:01 +08:00
return TeamReportBoard{}, e
}
2026-08-15 17:18:00 +08:00
var board TeamReportBoard
if e := a.apiDecode(http.MethodGet, teamPath(teamID, "/reports/"+url.PathEscape(date)), nil, &board, true); e != nil {
2026-08-14 07:52:01 +08:00
return TeamReportBoard{}, e
}
2026-08-15 17:18:00 +08:00
if board.Reports == nil {
board.Reports = []TeamReport{}
2026-08-14 07:52:01 +08:00
}
2026-08-15 17:18:00 +08:00
if board.Missing == nil {
board.Missing = []TeamMember{}
2026-08-14 07:52:01 +08:00
}
2026-08-15 17:18:00 +08:00
if board.Digest != nil {
board.Digest.Content = stripFence(board.Digest.Content)
2026-08-14 07:52:01 +08:00
}
return board, nil
}
func (a *App) TeamReportUrge(teamID, userID int64, date string) error {
if !teamDateRe.MatchString(date) {
return errors.New("TEAM_DATE_INVALID")
}
2026-08-15 17:18:00 +08:00
if _, e := a.requireSyncLogin(); e != nil {
2026-08-14 07:52:01 +08:00
return e
}
2026-08-15 17:18:00 +08:00
return a.apiDecode(http.MethodPost, teamPath(teamID, "/reports/urge"), map[string]any{
"userId": userID,
"date": date,
}, nil, true)
2026-08-14 07:52:01 +08:00
}
// ---------- 日报 AI 摘要 ----------
type teamDigestEvent struct {
TeamID int64 `json:"teamId"`
Date string `json:"date"`
Error string `json:"error,omitempty"`
}
func (a *App) TeamDigestGenerate(teamID int64, date string) error {
if !teamDateRe.MatchString(date) {
return errors.New("TEAM_DATE_INVALID")
}
if _, e := a.aiProvider(); e != nil {
return e
}
2026-08-15 17:18:00 +08:00
board, e := a.TeamReportBoardGet(teamID, date)
2026-08-14 07:52:01 +08:00
if e != nil {
return e
}
2026-08-15 17:18:00 +08:00
if teamRoleRank(board.Role) < teamRoleRank("admin") {
return errors.New("TEAM_FORBIDDEN")
2026-08-14 07:52:01 +08:00
}
go a.generateTeamDigest(teamID, date)
return nil
}
func (a *App) generateTeamDigest(teamID int64, date string) {
emitErr := func(msg string) {
a.emit("team:digest", teamDigestEvent{TeamID: teamID, Date: date, Error: msg})
}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
defer cancel()
2026-08-15 17:18:00 +08:00
board, e := a.TeamReportBoardGet(teamID, date)
2026-08-14 07:52:01 +08:00
if e != nil {
emitErr(e.Error())
return
}
var sb strings.Builder
n := 0
2026-08-15 17:18:00 +08:00
for _, r := range board.Reports {
if strings.TrimSpace(r.Content) == "" {
continue
2026-08-14 07:52:01 +08:00
}
2026-08-15 17:18:00 +08:00
fmt.Fprintf(&sb, "### %s\n%s\n\n", r.User, r.Content)
n++
2026-08-14 07:52:01 +08:00
}
if n == 0 {
emitErr("TEAM_NO_REPORTS")
return
}
provider, e := a.aiProvider()
if e != nil {
emitErr(e.Error())
return
}
2026-08-15 17:18:00 +08:00
teamName := fmt.Sprintf("#%d", teamID)
if teams, le := a.TeamList(); le == nil {
for _, t := range teams {
if t.ID == teamID {
teamName = t.Name
break
}
}
}
2026-08-14 07:52:01 +08:00
prompt := fmt.Sprintf("以下是团队「%s」%s 的 %d 份成员日报请生成一份团队日报摘要Markdown\n"+
"1. 今日完成事项汇总(合并同类项)\n2. 进行中的工作\n3. 风险与阻塞(没有则省略)\n4. 每位成员一句话点评\n5. 明日建议\n\n%s",
teamName, date, n, sb.String())
stream, e := provider.ChatStream(ctx, []ai.Message{
{Role: "system", Content: "你是资深研发团队负责人助理,输出简洁、结构化的中文 Markdown 团队日报摘要,不要编造日报里没有的内容。"},
{Role: "user", Content: prompt},
})
if e != nil {
emitErr(e.Error())
return
}
var out strings.Builder
for chunk := range stream {
if chunk.Err != nil {
emitErr(chunk.Err.Error())
return
}
out.WriteString(chunk.Content)
}
content := dedentCommon(stripFence(strings.TrimSpace(out.String())))
if content == "" {
emitErr("AI_EMPTY_RESPONSE")
return
}
2026-08-15 17:18:00 +08:00
if e := a.apiDecode(http.MethodPut, teamPath(teamID, "/digests/"+url.PathEscape(date)), map[string]string{
"content": content,
"provider": provider.Name(),
}, nil, true); e != nil {
emitErr(e.Error())
2026-08-14 07:52:01 +08:00
return
}
a.store.Log("info", "团队", "团队日报摘要已生成", fmt.Sprintf("team=%d date=%s", teamID, date))
a.emit("team:digest", teamDigestEvent{TeamID: teamID, Date: date})
}
func (a *App) runTeamDigestLoop() {
ticker := time.NewTicker(5 * time.Minute)
defer ticker.Stop()
for range ticker.C {
a.teamDigestTick(time.Now())
}
}
func (a *App) teamDigestTick(now time.Time) {
if a.store == nil || a.syncUserID() <= 0 || !a.syncOnline.Load() {
return
}
2026-08-15 17:18:00 +08:00
teams, e := a.TeamList()
2026-08-14 07:52:01 +08:00
if e != nil {
return
}
today := now.Format("2006-01-02")
2026-08-15 17:18:00 +08:00
for _, t := range teams {
if teamRoleRank(t.Role) < teamRoleRank("admin") {
continue
}
if !service.DigestDue(t.DigestTime, now) {
2026-08-14 07:52:01 +08:00
continue
}
2026-08-15 17:18:00 +08:00
markKey := fmt.Sprintf("team_digest_auto_%d", t.ID)
2026-08-14 07:52:01 +08:00
if a.store.Meta(markKey) == today {
continue
}
2026-08-15 17:18:00 +08:00
board, e := a.TeamReportBoardGet(t.ID, today)
if e != nil {
continue
}
if board.Digest != nil && strings.TrimSpace(board.Digest.Content) != "" {
2026-08-14 07:52:01 +08:00
_ = a.store.SetMeta(markKey, today)
continue
}
2026-08-15 17:18:00 +08:00
if len(board.Reports) == 0 {
continue
2026-08-14 07:52:01 +08:00
}
if _, e := a.aiProvider(); e != nil {
continue
}
_ = a.store.SetMeta(markKey, today)
2026-08-15 17:18:00 +08:00
go a.generateTeamDigest(t.ID, today)
2026-08-14 07:52:01 +08:00
}
}
2026-08-15 17:18:00 +08:00
// refreshTeamAssignedCache 缓存"指派给我的未完成团队任务数"(导航徽标用)。
func (a *App) refreshTeamAssignedCache() {
2026-08-14 07:52:01 +08:00
n := 0
2026-08-15 17:18:00 +08:00
if teamID := a.currentTeamID(); teamID > 0 && a.syncUserID() > 0 && a.store.Meta("sync_access_token") != "" {
tasks, e := a.TeamTaskList(teamID, "mine")
if e == nil {
for _, t := range tasks {
if t.Status == "open" || t.Status == "doing" {
n++
}
}
2026-08-14 07:52:01 +08:00
}
}
_ = a.store.SetMeta("team_assigned_cache", fmt.Sprint(n))
}
// pullTeamNotices 增量拉取发给本人的团队通知并转成本地消息。
2026-08-15 17:18:00 +08:00
func (a *App) pullTeamNotices() int {
2026-08-14 07:52:01 +08:00
cursor := a.store.Meta("team_notice_cursor")
if cursor == "" {
cursor = "0"
}
2026-08-15 17:18:00 +08:00
var resp struct {
Items []struct {
ID int64 `json:"id"`
TeamID int64 `json:"teamId"`
Kind string `json:"kind"`
Content string `json:"content"`
} `json:"items"`
}
if e := a.apiDecode(http.MethodGet, "/api/v1/notices?after="+url.QueryEscape(cursor), nil, &resp, true); e != nil {
2026-08-14 07:52:01 +08:00
return 0
}
titles := map[string]string{
"assign": "团队任务指派", "urge": "团队催办", "report_urge": "日报催交",
"member": "团队成员变动", "role": "团队角色变更", "status": "团队任务动态",
}
n := 0
maxID := cursor
2026-08-15 17:18:00 +08:00
for _, it := range resp.Items {
title := titles[it.Kind]
2026-08-14 07:52:01 +08:00
if title == "" {
title = "团队通知"
}
2026-08-15 17:18:00 +08:00
a.pushMessage("team", title, it.Content, "team", it.TeamID, true)
maxID = fmt.Sprint(it.ID)
2026-08-14 07:52:01 +08:00
n++
}
if maxID != cursor {
_ = a.store.SetMeta("team_notice_cursor", maxID)
}
return n
}