优化日报模块

This commit is contained in:
hero920103 committed 2026-10-22 00:27:26 +08:00
1 parent 79d5ae6b66
commit 51fd90fc51
17 files changed
+1767 -257

No files matched your search

+257 -30
View File
@@ -22,21 +22,21 @@ type Attachment struct {
// ReportItem 日报列表/详情中的主体。
type ReportItem struct {
ID uint64 `json:"id"`
UserID uint64 `json:"user_id"`
UserName string `json:"user_name"`
OrgID uint64 `json:"org_id"`
OrgName string `json:"org_name"`
ReportDate string `json:"report_date"`
TodayDone string `json:"today_done"`
TomorrowPlan string `json:"tomorrow_plan"`
Issues string `json:"issues"`
Attachments []Attachment `json:"attachments"`
Status int8 `json:"status"`
SubmitTime string `json:"submit_time"`
CreateTime string `json:"create_time"`
UpdateTime string `json:"update_time"`
CommentCount int `json:"comment_count"`
ID uint64 `json:"id"`
UserID uint64 `json:"user_id"`
UserName string `json:"user_name"`
OrgID uint64 `json:"org_id"`
OrgName string `json:"org_name"`
ReportDate string `json:"report_date"`
TodayDone string `json:"today_done"`
TomorrowPlan string `json:"tomorrow_plan"`
Issues string `json:"issues"`
Attachments []Attachment `json:"attachments"`
Status int8 `json:"status"`
SubmitTime string `json:"submit_time"`
CreateTime string `json:"create_time"`
UpdateTime string `json:"update_time"`
CommentCount int `json:"comment_count"`
}
// Comment 日报评论(两级回复)。
@@ -52,13 +52,29 @@ type Comment struct {
CanDelete bool `json:"can_delete"`
}
// Detail 日报详情:主体 + 评论 + 当前用户可执行的操作。
// Detail 日报详情:主体 + 评论 + 修改痕迹 + 当前用户可执行的操作。
type Detail struct {
Report ReportItem `json:"report"`
Comments []Comment `json:"comments"`
CanEdit bool `json:"can_edit"`
CanDelete bool `json:"can_delete"`
CanComment bool `json:"can_comment"`
Report ReportItem `json:"report"`
Comments []Comment `json:"comments"`
Histories []HistoryItem `json:"histories"`
IsAuthor bool `json:"is_author"`
CanEdit bool `json:"can_edit"`
CanDelete bool `json:"can_delete"`
CanComment bool `json:"can_comment"`
CanWithdraw bool `json:"can_withdraw"`
}
// HistoryItem 日报修改痕迹:撤销提交 / 撤销后重新提交时的内容快照。
type HistoryItem struct {
ID uint64 `json:"id"`
Action int8 `json:"action"` // 1撤销提交 2重新提交
UserName string `json:"user_name"`
TodayDone string `json:"today_done"`
TomorrowPlan string `json:"tomorrow_plan"`
Issues string `json:"issues"`
Attachments []Attachment `json:"attachments"`
SubmitTime string `json:"submit_time"`
CreateTime string `json:"create_time"`
}
// SaveInput 保存日报入参;Submit=true 表示提交,否则保存草稿。
@@ -97,6 +113,7 @@ type MineResult struct {
}
// Save 新建或更新一条日报(同一人同一天唯一)。
// 已提交的日报处于锁定状态,必须先撤销提交退回草稿才能修改。
func Save(u *UserContext, in SaveInput) (*ReportItem, error) {
date, err := ParseDate(in.Date)
if err != nil {
@@ -166,11 +183,18 @@ func Save(u *UserContext, in SaveInput) (*ReportItem, error) {
return nil, findErr
}
// 已提交的日报在窗口内允许继续修改,状态保持已提交。
// 已提交的日报锁定:必须先撤销提交退回草稿,才能修改内容。
if existing.Status == 1 {
status = 1
return nil, errBad("日报已提交并锁定,如需修改请先撤销提交")
}
if status == 1 && existing.SubmitTime == nil {
// 撤销过提交的日报再次提交时,留下重新提交痕迹。
resubmit := false
if in.Submit {
if existing.SubmitTime != nil {
resubmit = true
} else {
resubmit = hasWithdrawHistory(u.Tid, existing.ID)
}
existing.SubmitTime = &now
}
existing.TodayDone = in.TodayDone
@@ -182,8 +206,35 @@ func Save(u *UserContext, in SaveInput) (*ReportItem, error) {
existing.OrgID = u.OrgID
existing.OrgName = u.OrgName
if _, err := models.Orm.Update(&existing, "today_done", "tomorrow_plan", "issues",
tx, err := models.Orm.Begin()
if err != nil {
return nil, err
}
if _, err := tx.Update(&existing, "today_done", "tomorrow_plan", "issues",
"attachments", "status", "submit_time", "user_name", "org_id", "org_name"); err != nil {
_ = tx.Rollback()
return nil, err
}
if resubmit {
history := models.OaDailyReportHistory{
Tid: u.Tid,
ReportID: existing.ID,
UserID: u.UID,
UserName: u.Name,
ReportDate: date,
Action: 2,
TodayDone: existing.TodayDone,
TomorrowPlan: existing.TomorrowPlan,
Issues: existing.Issues,
Attachments: existing.Attachments,
SubmitTime: existing.SubmitTime,
}
if _, err := tx.Insert(&history); err != nil {
_ = tx.Rollback()
return nil, err
}
}
if err := tx.Commit(); err != nil {
return nil, err
}
count := commentCount(u.Tid, existing.ID)
@@ -245,6 +296,67 @@ func ListMine(u *UserContext, q MineQuery) (*MineResult, error) {
return &MineResult{List: list, Total: total, Summary: *summary}, nil
}
// CalendarDay 我的日报日历热力图单日状态。
type CalendarDay struct {
Date string `json:"date"`
Status int8 `json:"status"` // 0草稿 1已提交
ID uint64 `json:"id"`
}
// CalendarResult 近期热力图数据(默认近三个月)。
type CalendarResult struct {
StartDate string `json:"start_date"`
EndDate string `json:"end_date"`
Total int `json:"total"`
Submitted int `json:"submitted"`
Days []CalendarDay `json:"days"`
}
// Calendar 返回最近 months 个月(含本月)本人日报的逐日状态,供热力图按月/周渲染。
// months 越界时回落到 3 个月。
func Calendar(u *UserContext, months int) (*CalendarResult, error) {
if months < 1 || months > 12 {
months = 3
}
now := time.Now()
monthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location())
start := monthStart.AddDate(0, -(months - 1), 0)
end := monthStart.AddDate(0, 1, -1)
startDate := start.Format("2006-01-02")
endDate := end.Format("2006-01-02")
var rows []models.OaDailyReport
if _, err := models.Orm.QueryTable(new(models.OaDailyReport)).
Filter("tid", u.Tid).
Filter("user_id", u.UID).
Filter("is_deleted", 0).
Filter("report_date__gte", startDate).
Filter("report_date__lte", endDate).
OrderBy("report_date").
All(&rows, "id", "report_date", "status"); err != nil {
return nil, err
}
res := &CalendarResult{
StartDate: startDate,
EndDate: endDate,
Days: make([]CalendarDay, 0, len(rows)),
}
for i := range rows {
res.Days = append(res.Days, CalendarDay{
// DATE 列在 parseTime=true 下会带上时间与时区,必须归一化为 YYYY-MM-DD,
// 否则前端按日期匹配热力图格子时永远匹配不到。
Date: dateOnly(rows[i].ReportDate),
Status: rows[i].Status,
ID: rows[i].ID,
})
if rows[i].Status == 1 {
res.Submitted++
}
}
res.Total = len(rows)
return res, nil
}
// mineSummary 本月提交统计与今天的状态。
func mineSummary(u *UserContext) (*MineSummary, error) {
now := time.Now()
@@ -293,15 +405,85 @@ func GetDetail(u *UserContext, id uint64) (*Detail, error) {
}
author := report.UserID == u.UID
canEdit := author && report.Status == 0 && InWindow(report.ReportDate)
canWithdraw := author && report.Status == 1 && InWindow(report.ReportDate)
// 删除仅限草稿:已提交日报需先撤销提交才能删除。
canDelete := report.Status == 0 && (u.IsAdmin || (author && InWindow(report.ReportDate)))
// 修改痕迹只对作者本人与租户管理员可见。
histories := make([]HistoryItem, 0)
if author || u.IsAdmin {
histories, err = loadHistories(u.Tid, report.ID)
if err != nil {
// 痕迹表缺失或查询异常时降级为空,避免详情整体 500(部署时需执行 create_oa_dailyreport_history.sql)。
histories = make([]HistoryItem, 0)
}
}
return &Detail{
Report: *buildItem(report, len(comments)),
Comments: comments,
CanEdit: author && InWindow(report.ReportDate),
CanDelete: (author && InWindow(report.ReportDate)) || u.IsAdmin,
CanComment: report.Status == 1 && (author || u.IsAdmin || u.InTeamOrg(report.OrgID)),
Report: *buildItem(report, len(comments)),
Comments: comments,
Histories: histories,
IsAuthor: author,
CanEdit: canEdit,
CanDelete: canDelete,
CanComment: report.Status == 1 && (author || u.IsAdmin || u.InTeamOrg(report.OrgID)),
CanWithdraw: canWithdraw,
}, nil
}
// Withdraw 撤销提交:把已提交日报退回草稿,并把本次提交的内容快照写入修改痕迹。
// 仅作者本人、且日报在补写窗口内时可操作;撤销后需修改并重新提交。
func Withdraw(u *UserContext, id uint64) (*ReportItem, error) {
report, err := loadReport(u.Tid, id)
if err != nil {
return nil, err
}
if report.UserID != u.UID {
return nil, errForbidden("只能撤销自己提交的日报")
}
if report.Status != 1 {
return nil, errBad("日报尚未提交,无需撤销")
}
if !InWindow(report.ReportDate) {
return nil, errBad(fmt.Sprintf("只能撤销最近%d天(%s 至 %s)的日报", BackfillDays, WindowStart(), Today()))
}
tx, err := models.Orm.Begin()
if err != nil {
return nil, err
}
history := models.OaDailyReportHistory{
Tid: u.Tid,
ReportID: report.ID,
UserID: u.UID,
UserName: u.Name,
ReportDate: report.ReportDate,
Action: 1,
TodayDone: report.TodayDone,
TomorrowPlan: report.TomorrowPlan,
Issues: report.Issues,
Attachments: report.Attachments,
SubmitTime: report.SubmitTime,
}
if _, err := tx.Insert(&history); err != nil {
_ = tx.Rollback()
return nil, err
}
if _, err := tx.QueryTable(new(models.OaDailyReport)).
Filter("tid", u.Tid).Filter("id", report.ID).
Update(map[string]interface{}{"status": 0, "submit_time": nil}); err != nil {
_ = tx.Rollback()
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, err
}
report.Status = 0
count := commentCount(u.Tid, report.ID)
return buildItem(report, count), nil
}
// Delete 软删除日报及其评论:作者限补写窗口内,管理员不受限。
//
// 日报的 is_deleted 写入记录 ID(而非 1),既标记删除,又让唯一键
@@ -311,6 +493,10 @@ func Delete(u *UserContext, id uint64) error {
if err != nil {
return err
}
// 已提交日报锁定:不允许直接删除,需先撤销提交退回草稿。
if report.Status == 1 {
return errBad("日报已提交并锁定,请先撤销提交后再删除")
}
author := report.UserID == u.UID
if !author && !u.IsAdmin {
return errForbidden("无权删除该日报")
@@ -462,6 +648,47 @@ func buildComment(u *UserContext, r *models.OaDailyReportComment) Comment {
}
}
// hasWithdrawHistory 判断一条日报是否被撤销过提交,用于识别“重新提交”。
func hasWithdrawHistory(tid, reportID uint64) bool {
n, err := models.Orm.QueryTable(new(models.OaDailyReportHistory)).
Filter("tid", tid).Filter("report_id", reportID).Filter("action", 1).
Count()
return err == nil && n > 0
}
// loadHistories 读取一条日报的全部修改痕迹,按写入顺序返回。
func loadHistories(tid, reportID uint64) ([]HistoryItem, error) {
var rows []models.OaDailyReportHistory
if _, err := models.Orm.QueryTable(new(models.OaDailyReportHistory)).
Filter("tid", tid).Filter("report_id", reportID).
OrderBy("id").All(&rows); err != nil {
return nil, err
}
out := make([]HistoryItem, 0, len(rows))
for i := range rows {
out = append(out, buildHistory(&rows[i]))
}
return out, nil
}
func buildHistory(h *models.OaDailyReportHistory) HistoryItem {
attachments := make([]Attachment, 0)
if strings.TrimSpace(h.Attachments) != "" {
_ = json.Unmarshal([]byte(h.Attachments), &attachments)
}
return HistoryItem{
ID: h.ID,
Action: h.Action,
UserName: h.UserName,
TodayDone: h.TodayDone,
TomorrowPlan: h.TomorrowPlan,
Issues: h.Issues,
Attachments: attachments,
SubmitTime: formatTime(h.SubmitTime),
CreateTime: h.CreateTime.Format("2006-01-02 15:04"),
}
}
func buildItem(r *models.OaDailyReport, commentCount int) *ReportItem {
attachments := make([]Attachment, 0)
if strings.TrimSpace(r.Attachments) != "" {
+114 -49
View File
@@ -2,6 +2,7 @@ package dailyreport
import (
"math"
"sort"
"strings"
"server/models"
@@ -26,7 +27,7 @@ type TeamStats struct {
Rate float64 `json:"rate"` // 提交率百分比,保留一位小数
}
// TeamRow 团队汇总中的一名成员。
// TeamRow 团队汇总中的一名成员(仅包含当日已提交日报的成员)。
type TeamRow struct {
UserID uint64 `json:"user_id"`
UserName string `json:"user_name"`
@@ -35,9 +36,10 @@ type TeamRow struct {
OrgName string `json:"org_name"`
Submitted bool `json:"submitted"`
ReportID uint64 `json:"report_id"`
Status int8 `json:"status"` // -1未填写 0草稿 1已提交
Status int8 `json:"status"` // 0草稿 1已提交(团队列表只返回 1)
SubmitTime string `json:"submit_time"`
HasIssue bool `json:"has_issue"`
Summary string `json:"summary"` // 日报内容摘要(优先今日完成)
}
// TeamResult 团队汇总结果。
@@ -56,7 +58,8 @@ type OrgOption struct {
MemberCount int `json:"member_count"`
}
// TeamStatus 团队日报汇总:按日期返回管辖范围内每位成员的提交情况。
// TeamStatus 团队日报日志池:只返回当日已提交的日报,未提交(含仅存草稿)的成员不展示。
// 列表按提交时间倒序排列(按“谁发布了内容”展示,不按人分组),右侧根据选中项展示日报内容。
// 管理员不选部门时统计全租户(含未分配部门的成员)。
func TeamStatus(u *UserContext, q TeamQuery) (*TeamResult, error) {
if !u.CanViewTeam {
@@ -101,73 +104,135 @@ func TeamStatus(u *UserContext, q TeamQuery) (*TeamResult, error) {
return nil, errForbidden("你还没有管辖的部门")
}
cond := orm.NewCondition().
// 管辖范围内的成员:用于姓名/部门补全与提交统计,不受关键词与分页影响。
memberCond := orm.NewCondition().
And("tid", u.Tid).
And("delete_time__isnull", true).
And("status", 1).
AndNot("uid", u.UID)
And("status", 1)
if scopeIDs != nil {
cond = cond.And("org_id__in", scopeIDs)
memberCond = memberCond.And("org_id__in", scopeIDs)
}
if kw := strings.TrimSpace(q.Keyword); kw != "" {
cond = cond.AndCond(orm.NewCondition().
Or("name__icontains", kw).
Or("account__icontains", kw))
}
base := models.Orm.QueryTable(new(models.SystemTenantUser)).SetCond(cond)
total, _ := base.Count()
var members []models.SystemTenantUser
if _, err := base.OrderBy("org_id", "uid").
Limit(q.PageSize).Offset((q.Page - 1) * q.PageSize).
All(&members); err != nil {
return nil, err
}
// 统计口径覆盖全部成员,不受分页影响。
var allMembers []models.SystemTenantUser
if _, err := base.OrderBy("uid").All(&allMembers, "uid"); err != nil {
if _, err := models.Orm.QueryTable(new(models.SystemTenantUser)).
SetCond(memberCond).OrderBy("org_id", "uid").All(&allMembers); err != nil {
return nil, err
}
memberByUID := make(map[uint64]*models.SystemTenantUser, len(allMembers))
for i := range allMembers {
memberByUID[allMembers[i].Uid] = &allMembers[i]
}
reports, err := loadReportsByDate(u.Tid, date)
if err != nil {
return nil, err
}
stats := TeamStats{Total: len(allMembers)}
for _, m := range allMembers {
if r := reports[m.Uid]; r != nil && r.Status == 1 {
stats.Submitted++
kw := strings.TrimSpace(q.Keyword)
rows := make([]TeamRow, 0, len(allMembers))
for i := range allMembers {
m := &allMembers[i]
r := reports[m.Uid]
if r == nil || r.Status != 1 {
continue
}
stats.Submitted++
row := buildTeamRow(r, m.OrgID, userNameOf(m), ptrString(m.Account), "")
if o := orgByID[m.OrgID]; o != nil {
row.OrgName = o.OrgName
}
if kw != "" && !matchTeamKeyword(row.UserName, row.Account, kw) {
continue
}
rows = append(rows, row)
}
// 作者不在成员表(成员已删除 / 组织映射异常)时用日报快照补齐,
// 保证已提交的内容不会因人员变动而看不到。
for uid, r := range reports {
if r.Status != 1 {
continue
}
if _, ok := memberByUID[uid]; ok {
continue
}
if scopeIDs != nil && !containsID(scopeIDs, r.OrgID) {
continue
}
row := buildTeamRow(r, r.OrgID, r.UserName, "", r.OrgName)
if kw != "" && !matchTeamKeyword(row.UserName, row.Account, kw) {
continue
}
rows = append(rows, row)
}
// 日志池排序:按发布时间(提交时间)倒序,最新提交排在最前。
sort.SliceStable(rows, func(i, j int) bool {
if rows[i].SubmitTime != rows[j].SubmitTime {
return rows[i].SubmitTime > rows[j].SubmitTime
}
return rows[i].ReportID > rows[j].ReportID
})
stats.Missing = stats.Total - stats.Submitted
if stats.Missing < 0 {
stats.Missing = 0
}
if stats.Total > 0 {
stats.Rate = math.Round(float64(stats.Submitted)*1000/float64(stats.Total)) / 10
}
list := make([]TeamRow, 0, len(members))
for i := range members {
m := &members[i]
row := TeamRow{
UserID: m.Uid,
UserName: userNameOf(m),
Account: ptrString(m.Account),
OrgID: m.OrgID,
Status: -1,
}
if o := orgByID[m.OrgID]; o != nil {
row.OrgName = o.OrgName
}
if r := reports[m.Uid]; r != nil {
row.ReportID = r.ID
row.Status = r.Status
row.Submitted = r.Status == 1
row.SubmitTime = formatTime(r.SubmitTime)
row.HasIssue = strings.TrimSpace(r.Issues) != ""
}
list = append(list, row)
// 列表分页:只对已提交的日报分页。
total := len(rows)
start := (q.Page - 1) * q.PageSize
if start > total {
start = total
}
return &TeamResult{Date: date, List: list, Total: total, Stats: stats}, nil
end := start + q.PageSize
if end > total {
end = total
}
return &TeamResult{Date: date, List: rows[start:end], Total: int64(total), Stats: stats}, nil
}
// buildTeamRow 组装一条团队日报行;摘要取日报正文。
func buildTeamRow(r *models.OaDailyReport, orgID uint64, userName, account, orgName string) TeamRow {
return TeamRow{
UserID: r.UserID,
UserName: userName,
Account: account,
OrgID: orgID,
OrgName: orgName,
Submitted: true,
ReportID: r.ID,
Status: r.Status,
SubmitTime: formatTime(r.SubmitTime),
HasIssue: strings.TrimSpace(r.Issues) != "",
Summary: reportSummary(r),
}
}
// reportSummary 生成团队列表用的内容摘要(优先今日完成),超长截断。
func reportSummary(r *models.OaDailyReport) string {
for _, text := range []string{r.TodayDone, r.TomorrowPlan, r.Issues} {
s := strings.TrimSpace(text)
if s == "" {
continue
}
runes := []rune(s)
if len(runes) > 60 {
return string(runes[:60]) + "…"
}
return s
}
return ""
}
// matchTeamKeyword 按姓名/账号模糊匹配团队筛选项。
func matchTeamKeyword(name, account, kw string) bool {
kw = strings.ToLower(kw)
return strings.Contains(strings.ToLower(name), kw) ||
strings.Contains(strings.ToLower(account), kw)
}
// TeamOrgs 返回团队筛选可选部门;管理员为全租户,主管仅自己管辖的部门子树。