更新若干功能

This commit is contained in:
hero920103 committed 2026-10-21 17:45:50 +08:00
1 parent 892ba5886d
commit 79d5ae6b66
45 files changed
+6755 -38

No files matched your search

+282
View File
@@ -0,0 +1,282 @@
package dailyreport
import (
"errors"
"fmt"
"strings"
"time"
"server/models"
"server/services"
"github.com/beego/beego/v2/client/orm"
)
// 日报补写窗口:含今天在内的最近 7 天。
const BackfillDays = 7
// 业务错误:控制器按 Code 决定 HTTP 状态码与提示。
type Error struct {
Code int
Msg string
}
func (e *Error) Error() string { return e.Msg }
func errBad(msg string) error { return &Error{Code: 400, Msg: msg} }
func errForbidden(msg string) error { return &Error{Code: 403, Msg: msg} }
func errNotFound(msg string) error { return &Error{Code: 404, Msg: msg} }
// StatusOf 取业务错误的 HTTP 状态码,非业务错误回退 fallback。
func StatusOf(err error, fallback int) int {
var e *Error
if errors.As(err, &e) {
return e.Code
}
return fallback
}
// UserContext 当前操作人在日报模块内的身份与可见范围。
//
// UID 是 yz_system_tenant_user.uid(老 uid),不是 JWT 的 identity_id。
// TeamOrgIDs 是「当前用户作为负责人的部门」及其全部下级部门;
// 为空且非管理员时,只能查看/操作自己的日报。
type UserContext struct {
Tid uint64
UID uint64
Name string
OrgID uint64
OrgName string
IsAdmin bool
TeamOrgIDs []uint64
CanViewTeam bool
}
// InTeamOrg 判断部门是否落在当前用户的团队范围内。
func (u *UserContext) InTeamOrg(orgID uint64) bool {
for _, id := range u.TeamOrgIDs {
if id == orgID {
return true
}
}
return false
}
// Today 本地日期 "YYYY-MM-DD"。
func Today() string {
return time.Now().Format("2006-01-02")
}
// WindowStart 补写窗口起点(今天往前 BackfillDays-1 天)。
func WindowStart() string {
return time.Now().AddDate(0, 0, -(BackfillDays - 1)).Format("2006-01-02")
}
// InWindow 判断日期是否在补写窗口内。
func InWindow(date string) bool {
return date >= WindowStart() && date <= Today()
}
// ParseDate 校验 "YYYY-MM-DD" 并返回规范化的字符串。
func ParseDate(date string) (string, error) {
date = strings.TrimSpace(date)
if date == "" {
return Today(), nil
}
t, err := time.ParseInLocation("2006-01-02", date, time.Local)
if err != nil {
return "", errBad("日报日期格式应为 YYYY-MM-DD")
}
return t.Format("2006-01-02"), nil
}
// dateOnly 把驱动返回的 DATE 值统一成 "YYYY-MM-DD"。
// go-sql-driver 在 parseTime=true 时把 DATE 读成 time.Time,
// Beego ORM 赋给 string 字段后会得到 "2026-10-21 00:00:00 +0800 CST",
// 直接参与 InWindow 的字符串比较会误判为“不在窗口内”。
func dateOnly(s string) string {
s = strings.TrimSpace(s)
if len(s) >= 10 {
return s[:10]
}
return s
}
// BuildContext 组装当前用户的日报身份与团队范围。
// identityUserID 为 JWT 中的 UserID,platformAdmin 表示平台端管理员。
func BuildContext(tid uint64, identityUserID uint64, fallbackName string, platformAdmin bool) (*UserContext, error) {
if models.Orm == nil || tid == 0 {
return nil, errBad("缺少租户信息")
}
uid := services.ResolveTenantUID(tid, identityUserID)
if uid == 0 {
return nil, errBad("无法识别当前用户")
}
u := &UserContext{Tid: tid, UID: uid, Name: strings.TrimSpace(fallbackName), IsAdmin: platformAdmin}
var tu models.SystemTenantUser
err := models.Orm.QueryTable(new(models.SystemTenantUser)).
Filter("tid", tid).
Filter("uid", uid).
Filter("delete_time__isnull", true).
One(&tu)
if err == nil {
u.OrgID = tu.OrgID
if n := strings.TrimSpace(ptrString(tu.Name)); n != "" {
u.Name = n
} else if a := strings.TrimSpace(ptrString(tu.Account)); a != "" {
u.Name = a
}
if !u.IsAdmin {
u.IsAdmin = tenantAdmin(tid, &tu)
}
} else if err != orm.ErrNoRows {
return nil, err
}
if u.Name == "" {
u.Name = fmt.Sprintf("用户%d", uid)
}
orgs, err := loadOrgs(tid)
if err != nil {
return nil, err
}
orgByID := make(map[uint64]*models.BackendOrganization, len(orgs))
for i := range orgs {
orgByID[orgs[i].ID] = &orgs[i]
}
if o := orgByID[u.OrgID]; o != nil {
u.OrgName = o.OrgName
}
if !u.IsAdmin {
leaderKeys := leaderKeys(tid, &tu, identityUserID, uid)
roots := make([]uint64, 0, 2)
for i := range orgs {
if orgs[i].LeaderID != nil && containsID(leaderKeys, *orgs[i].LeaderID) {
roots = append(roots, orgs[i].ID)
}
}
u.TeamOrgIDs = collectSubtree(roots, orgs)
}
u.CanViewTeam = u.IsAdmin || len(u.TeamOrgIDs) > 0
return u, nil
}
// tenantAdmin 与 controllers.isTenantAdmin 判定口径一致:
// 未分配角色 / 租户管理员角色 / 角色未配置权限,均视为全权限。
//
// 角色查询额外兼容全局预置角色:「租户管理员」「租户用户」在
// yz_system_admin_role 中是 cid=2 且 tenant_id=0 的全局记录,
// 只有租户自建角色才带自己的 tenant_id,因此这里同时接受 0 与本租户。
func tenantAdmin(tid uint64, tu *models.SystemTenantUser) bool {
if tu.GroupID == 0 {
return true
}
cond := orm.NewCondition().
And("id", tu.GroupID).
And("cid", 2).
AndCond(orm.NewCondition().Or("tenant_id", 0).Or("tenant_id", tid))
var role models.AdminRole
if err := models.Orm.QueryTable(new(models.AdminRole)).SetCond(cond).One(&role); err != nil {
return false
}
if role.Name == "租户管理员" {
return true
}
rights := ""
if role.Rights != nil {
rights = strings.TrimSpace(*role.Rights)
}
return rights == "" || rights == "[]" || rights == "null"
}
// leaderKeys 负责人在库里可能存员工ID,也可能存租户用户 uid / 认证中心 uid,
// 这里把三种可能都收进来,避免因历史数据差异漏判主管身份。
func leaderKeys(tid uint64, tu *models.SystemTenantUser, identityUserID, uid uint64) []uint64 {
keys := []uint64{uid}
if identityUserID > 0 && identityUserID != uid {
keys = append(keys, identityUserID)
}
account := strings.TrimSpace(ptrString(tu.Account))
if account == "" {
return keys
}
var emps []models.BackendEmployee
if _, err := models.Orm.QueryTable(new(models.BackendEmployee)).
Filter("tid", tid).
Filter("account", account).
Filter("delete_time__isnull", true).
All(&emps); err != nil {
return keys
}
for _, e := range emps {
keys = append(keys, uint64(e.ID))
}
return keys
}
// loadOrgs 读取租户内未删除的部门。
func loadOrgs(tid uint64) ([]models.BackendOrganization, error) {
var orgs []models.BackendOrganization
_, err := models.Orm.QueryTable(new(models.BackendOrganization)).
Filter("tid", tid).
Filter("delete_time__isnull", true).
OrderBy("sort", "id").
All(&orgs)
if err != nil {
return nil, err
}
return orgs, nil
}
// collectSubtree 返回 roots 及其全部下级部门 ID。
func collectSubtree(roots []uint64, orgs []models.BackendOrganization) []uint64 {
if len(roots) == 0 {
return nil
}
children := make(map[uint64][]uint64, len(orgs))
for _, o := range orgs {
if o.ParentID > 0 {
children[o.ParentID] = append(children[o.ParentID], o.ID)
}
}
seen := make(map[uint64]bool, len(roots))
queue := make([]uint64, 0, len(roots))
for _, id := range roots {
if id > 0 && !seen[id] {
seen[id] = true
queue = append(queue, id)
}
}
out := make([]uint64, 0, len(queue))
for len(queue) > 0 {
id := queue[0]
queue = queue[1:]
out = append(out, id)
for _, child := range children[id] {
if !seen[child] {
seen[child] = true
queue = append(queue, child)
}
}
}
return out
}
func containsID(ids []uint64, id uint64) bool {
for _, v := range ids {
if v == id {
return true
}
}
return false
}
func ptrString(p *string) string {
if p == nil {
return ""
}
return *p
}
+568
View File
@@ -0,0 +1,568 @@
package dailyreport
import (
"encoding/json"
"fmt"
"strings"
"time"
"unicode/utf8"
"server/models"
"github.com/beego/beego/v2/client/orm"
)
// Attachment 日报附件,与 PC / 小程序上传接口返回的文件信息对齐。
type Attachment struct {
ID uint64 `json:"id"`
URL string `json:"url"`
Name string `json:"name"`
Size int64 `json:"size"`
}
// 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"`
}
// Comment 日报评论(两级回复)。
type Comment struct {
ID uint64 `json:"id"`
UserID uint64 `json:"user_id"`
UserName string `json:"user_name"`
Content string `json:"content"`
ReplyToID uint64 `json:"reply_to_id"`
ReplyToName string `json:"reply_to_name"`
CreateTime string `json:"create_time"`
IsMine bool `json:"is_mine"`
CanDelete bool `json:"can_delete"`
}
// 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"`
}
// SaveInput 保存日报入参;Submit=true 表示提交,否则保存草稿。
type SaveInput struct {
Date string `json:"date"`
TodayDone string `json:"today_done"`
TomorrowPlan string `json:"tomorrow_plan"`
Issues string `json:"issues"`
Attachments string `json:"attachments"`
Submit bool `json:"submit"`
}
// MineQuery 我的日报列表查询。
type MineQuery struct {
StartDate string
EndDate string
Page int
PageSize int
}
// MineSummary 我的日报页头统计。
type MineSummary struct {
MonthTotal int `json:"month_total"`
MonthSubmitted int `json:"month_submitted"`
TodayStatus int8 `json:"today_status"` // -1=今天未填写
TodayReportID uint64 `json:"today_report_id"`
WindowStart string `json:"window_start"`
WindowEnd string `json:"window_end"`
}
// MineResult 我的日报列表结果。
type MineResult struct {
List []ReportItem `json:"list"`
Total int64 `json:"total"`
Summary MineSummary `json:"summary"`
}
// Save 新建或更新一条日报(同一人同一天唯一)。
func Save(u *UserContext, in SaveInput) (*ReportItem, error) {
date, err := ParseDate(in.Date)
if err != nil {
return nil, err
}
if !InWindow(date) {
return nil, errBad(fmt.Sprintf("只能补写最近%d天(%s 至 %s)的日报", BackfillDays, WindowStart(), Today()))
}
in.TodayDone = strings.TrimSpace(in.TodayDone)
in.TomorrowPlan = strings.TrimSpace(in.TomorrowPlan)
in.Issues = strings.TrimSpace(in.Issues)
for name, text := range map[string]string{
"今日完成": in.TodayDone,
"明日计划": in.TomorrowPlan,
"问题与建议": in.Issues,
} {
if utf8.RuneCountInString(text) > 5000 {
return nil, errBad(name + "不能超过5000字")
}
}
if in.TodayDone == "" && in.TomorrowPlan == "" && in.Issues == "" {
return nil, errBad("请至少填写今日完成、明日计划、问题与建议中的一项")
}
attachmentJSON, _, err := normalizeAttachments(in.Attachments)
if err != nil {
return nil, err
}
var existing models.OaDailyReport
findErr := models.Orm.QueryTable(new(models.OaDailyReport)).
Filter("tid", u.Tid).
Filter("user_id", u.UID).
Filter("report_date", date).
Filter("is_deleted", 0).
One(&existing)
status := int8(0)
if in.Submit {
status = 1
}
now := time.Now()
if findErr == orm.ErrNoRows {
report := models.OaDailyReport{
Tid: u.Tid,
UserID: u.UID,
UserName: u.Name,
OrgID: u.OrgID,
OrgName: u.OrgName,
ReportDate: date,
TodayDone: in.TodayDone,
TomorrowPlan: in.TomorrowPlan,
Issues: in.Issues,
Attachments: attachmentJSON,
Status: status,
}
if status == 1 {
report.SubmitTime = &now
}
if _, err := models.Orm.Insert(&report); err != nil {
return nil, errBad("保存失败,该日期可能已存在日报,请刷新后重试")
}
return buildItem(&report, 0), nil
}
if findErr != nil {
return nil, findErr
}
// 已提交的日报在窗口内允许继续修改,状态保持已提交。
if existing.Status == 1 {
status = 1
}
if status == 1 && existing.SubmitTime == nil {
existing.SubmitTime = &now
}
existing.TodayDone = in.TodayDone
existing.TomorrowPlan = in.TomorrowPlan
existing.Issues = in.Issues
existing.Attachments = attachmentJSON
existing.Status = status
existing.UserName = u.Name
existing.OrgID = u.OrgID
existing.OrgName = u.OrgName
if _, err := models.Orm.Update(&existing, "today_done", "tomorrow_plan", "issues",
"attachments", "status", "submit_time", "user_name", "org_id", "org_name"); err != nil {
return nil, err
}
count := commentCount(u.Tid, existing.ID)
return buildItem(&existing, count), nil
}
// ListMine 我的日报分页列表 + 页头统计。
func ListMine(u *UserContext, q MineQuery) (*MineResult, error) {
if q.Page < 1 {
q.Page = 1
}
if q.PageSize < 1 || q.PageSize > 100 {
q.PageSize = 20
}
base := models.Orm.QueryTable(new(models.OaDailyReport)).
Filter("tid", u.Tid).
Filter("user_id", u.UID).
Filter("is_deleted", 0)
start, err := optionalDate(q.StartDate)
if err != nil {
return nil, err
}
end, err := optionalDate(q.EndDate)
if err != nil {
return nil, err
}
if start != "" {
base = base.Filter("report_date__gte", start)
}
if end != "" {
base = base.Filter("report_date__lte", end)
}
total, _ := base.Count()
var rows []models.OaDailyReport
if _, err := base.OrderBy("-report_date", "-id").
Limit(q.PageSize).Offset((q.Page - 1) * q.PageSize).
All(&rows); err != nil {
return nil, err
}
ids := make([]uint64, 0, len(rows))
for i := range rows {
ids = append(ids, rows[i].ID)
}
counts := commentCounts(u.Tid, ids)
list := make([]ReportItem, 0, len(rows))
for i := range rows {
list = append(list, *buildItem(&rows[i], counts[rows[i].ID]))
}
summary, err := mineSummary(u)
if err != nil {
return nil, err
}
return &MineResult{List: list, Total: total, Summary: *summary}, nil
}
// mineSummary 本月提交统计与今天的状态。
func mineSummary(u *UserContext) (*MineSummary, error) {
now := time.Now()
monthStart := now.Format("2006-01") + "-01"
monthEnd := now.AddDate(0, 1, -1).Format("2006-01-02")
out := &MineSummary{
TodayStatus: -1,
WindowStart: WindowStart(),
WindowEnd: Today(),
}
base := models.Orm.QueryTable(new(models.OaDailyReport)).
Filter("tid", u.Tid).
Filter("user_id", u.UID).
Filter("is_deleted", 0)
out.MonthTotal = int(countWhere(base, "report_date__gte", monthStart, "report_date__lte", monthEnd))
out.MonthSubmitted = int(countWhere(base.Filter("status", 1), "report_date__gte", monthStart, "report_date__lte", monthEnd))
var today models.OaDailyReport
if err := models.Orm.QueryTable(new(models.OaDailyReport)).
Filter("tid", u.Tid).Filter("user_id", u.UID).
Filter("report_date", Today()).
Filter("is_deleted", 0).One(&today); err == nil {
out.TodayStatus = today.Status
out.TodayReportID = today.ID
}
return out, nil
}
// GetDetail 读取日报详情;无权限时返回 403。
func GetDetail(u *UserContext, id uint64) (*Detail, error) {
report, err := loadReport(u.Tid, id)
if err != nil {
return nil, err
}
if !canViewReport(u, report) {
return nil, errForbidden("无权查看该日报")
}
comments := make([]Comment, 0)
if report.Status == 1 {
comments, err = loadComments(u, report.ID)
if err != nil {
return nil, err
}
}
author := report.UserID == u.UID
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)),
}, nil
}
// Delete 软删除日报及其评论:作者限补写窗口内,管理员不受限。
//
// 日报的 is_deleted 写入记录 ID(而非 1),既标记删除,又让唯一键
// (tid, user_id, report_date, is_deleted) 在「删除后同一天重新填写」时不冲突。
func Delete(u *UserContext, id uint64) error {
report, err := loadReport(u.Tid, id)
if err != nil {
return err
}
author := report.UserID == u.UID
if !author && !u.IsAdmin {
return errForbidden("无权删除该日报")
}
if author && !u.IsAdmin && !InWindow(report.ReportDate) {
return errBad(fmt.Sprintf("只能删除最近%d天(%s 至 %s)的日报", BackfillDays, WindowStart(), Today()))
}
now := time.Now()
tx, err := models.Orm.Begin()
if err != nil {
return err
}
if _, err := tx.QueryTable(new(models.OaDailyReportComment)).
Filter("tid", u.Tid).Filter("report_id", report.ID).Filter("is_deleted", 0).
Update(map[string]interface{}{"is_deleted": 1, "delete_time": now}); err != nil {
_ = tx.Rollback()
return err
}
if _, err := tx.QueryTable(new(models.OaDailyReport)).
Filter("tid", u.Tid).Filter("id", report.ID).
Update(map[string]interface{}{"is_deleted": report.ID, "delete_time": now}); err != nil {
_ = tx.Rollback()
return err
}
return tx.Commit()
}
// AddComment 对已提交日报发表评论或回复;replyToID=0 表示直接评论。
func AddComment(u *UserContext, reportID uint64, content string, replyToID uint64) (*Comment, error) {
content = strings.TrimSpace(content)
if content == "" {
return nil, errBad("请输入评论内容")
}
if utf8.RuneCountInString(content) > 1000 {
return nil, errBad("评论不能超过1000字")
}
report, err := loadReport(u.Tid, reportID)
if err != nil {
return nil, err
}
if !canViewReport(u, report) {
return nil, errForbidden("无权评论该日报")
}
if report.Status != 1 {
return nil, errBad("日报提交后才能评论")
}
replyName := ""
if replyToID > 0 {
var parent models.OaDailyReportComment
if err := models.Orm.QueryTable(new(models.OaDailyReportComment)).
Filter("tid", u.Tid).Filter("id", replyToID).Filter("report_id", reportID).
Filter("is_deleted", 0).
One(&parent); err != nil {
return nil, errBad("回复的评论不存在")
}
replyName = parent.UserName
}
row := models.OaDailyReportComment{
Tid: u.Tid,
ReportID: reportID,
UserID: u.UID,
UserName: u.Name,
Content: content,
ReplyToID: replyToID,
ReplyToName: replyName,
}
if _, err := models.Orm.Insert(&row); err != nil {
return nil, err
}
c := buildComment(u, &row)
return &c, nil
}
// DeleteComment 软删除评论:评论作者本人或管理员。
func DeleteComment(u *UserContext, commentID uint64) error {
var row models.OaDailyReportComment
if err := models.Orm.QueryTable(new(models.OaDailyReportComment)).
Filter("tid", u.Tid).Filter("id", commentID).Filter("is_deleted", 0).One(&row); err != nil {
return errNotFound("评论不存在或已删除")
}
if row.UserID != u.UID && !u.IsAdmin {
return errForbidden("只能删除自己的评论")
}
_, err := models.Orm.QueryTable(new(models.OaDailyReportComment)).
Filter("tid", u.Tid).Filter("id", commentID).Filter("is_deleted", 0).
Update(map[string]interface{}{"is_deleted": 1, "delete_time": time.Now()})
return err
}
// ========================== 内部工具 ==========================
func loadReport(tid, id uint64) (*models.OaDailyReport, error) {
var report models.OaDailyReport
if err := models.Orm.QueryTable(new(models.OaDailyReport)).
Filter("tid", tid).Filter("id", id).Filter("is_deleted", 0).One(&report); err != nil {
if err == orm.ErrNoRows {
return nil, errNotFound("日报不存在或已删除")
}
return nil, err
}
// 统一成 YYYY-MM-DD,保证 InWindow 与前端日期比较正确。
report.ReportDate = dateOnly(report.ReportDate)
return &report, nil
}
// canViewReport 本人 / 管理员 / 作者所在部门的主管;草稿仅本人与管理员可见。
func canViewReport(u *UserContext, r *models.OaDailyReport) bool {
if r.UserID == u.UID || u.IsAdmin {
return true
}
if r.Status != 1 {
return false
}
return u.InTeamOrg(r.OrgID)
}
func loadComments(u *UserContext, reportID uint64) ([]Comment, error) {
var rows []models.OaDailyReportComment
if _, err := models.Orm.QueryTable(new(models.OaDailyReportComment)).
Filter("tid", u.Tid).Filter("report_id", reportID).
Filter("is_deleted", 0).
OrderBy("id").All(&rows); err != nil {
return nil, err
}
out := make([]Comment, 0, len(rows))
for i := range rows {
out = append(out, buildComment(u, &rows[i]))
}
if out == nil {
out = []Comment{}
}
return out, nil
}
func buildComment(u *UserContext, r *models.OaDailyReportComment) Comment {
return Comment{
ID: r.ID,
UserID: r.UserID,
UserName: r.UserName,
Content: r.Content,
ReplyToID: r.ReplyToID,
ReplyToName: r.ReplyToName,
CreateTime: r.CreateTime.Format("2006-01-02 15:04"),
IsMine: r.UserID == u.UID,
CanDelete: r.UserID == u.UID || u.IsAdmin,
}
}
func buildItem(r *models.OaDailyReport, commentCount int) *ReportItem {
attachments := make([]Attachment, 0)
if strings.TrimSpace(r.Attachments) != "" {
_ = json.Unmarshal([]byte(r.Attachments), &attachments)
}
return &ReportItem{
ID: r.ID,
UserID: r.UserID,
UserName: r.UserName,
OrgID: r.OrgID,
OrgName: r.OrgName,
ReportDate: dateOnly(r.ReportDate),
TodayDone: r.TodayDone,
TomorrowPlan: r.TomorrowPlan,
Issues: r.Issues,
Attachments: attachments,
Status: r.Status,
SubmitTime: formatTime(r.SubmitTime),
CreateTime: r.CreateTime.Format("2006-01-02 15:04"),
UpdateTime: formatTime(r.UpdateTime),
CommentCount: commentCount,
}
}
func formatTime(t *time.Time) string {
if t == nil || t.IsZero() {
return ""
}
return t.Format("2006-01-02 15:04")
}
// normalizeAttachments 校验并规范化附件 JSON,最多 20 个。
func normalizeAttachments(raw string) (string, []Attachment, error) {
raw = strings.TrimSpace(raw)
if raw == "" || raw == "null" {
return "[]", []Attachment{}, nil
}
var items []Attachment
if err := json.Unmarshal([]byte(raw), &items); err != nil {
return "", nil, errBad("附件数据格式错误")
}
out := make([]Attachment, 0, len(items))
for _, it := range items {
if strings.TrimSpace(it.URL) == "" {
continue
}
if len(out) >= 20 {
return "", nil, errBad("附件最多20个")
}
out = append(out, it)
}
data, _ := json.Marshal(out)
return string(data), out, nil
}
func optionalDate(v string) (string, error) {
v = strings.TrimSpace(v)
if v == "" {
return "", nil
}
return ParseDate(v)
}
// countWhere 依次应用列值条件后计数。
func countWhere(qs orm.QuerySeter, kv ...string) int64 {
for i := 0; i+1 < len(kv); i += 2 {
qs = qs.Filter(kv[i], kv[i+1])
}
n, _ := qs.Count()
return n
}
func commentCount(tid, reportID uint64) int {
counts := commentCounts(tid, []uint64{reportID})
return counts[reportID]
}
// commentCounts 批量统计评论数,避免列表逐条查询。
func commentCounts(tid uint64, ids []uint64) map[uint64]int {
out := make(map[uint64]int, len(ids))
if len(ids) == 0 {
return out
}
holders := make([]string, 0, len(ids))
args := make([]interface{}, 0, len(ids)+1)
args = append(args, tid)
for _, id := range ids {
holders = append(holders, "?")
args = append(args, id)
}
var rows []struct {
ReportID uint64 `orm:"column(report_id)"`
Cnt int `orm:"column(cnt)"`
}
sql := "SELECT report_id, COUNT(*) AS cnt FROM yz_backend_oa_daily_report_comment" +
" WHERE tid = ? AND report_id IN (" + strings.Join(holders, ",") + ") AND is_deleted = 0 GROUP BY report_id"
if _, err := models.Orm.Raw(sql, args...).QueryRows(&rows); err != nil {
return out
}
for _, r := range rows {
out[r.ReportID] = r.Cnt
}
return out
}
+250
View File
@@ -0,0 +1,250 @@
package dailyreport
import (
"math"
"strings"
"server/models"
"github.com/beego/beego/v2/client/orm"
)
// TeamQuery 团队日报汇总查询。
type TeamQuery struct {
Date string
OrgID uint64
Keyword string
Page int
PageSize int
}
// TeamStats 团队提交统计。
type TeamStats struct {
Total int `json:"total"`
Submitted int `json:"submitted"`
Missing int `json:"missing"`
Rate float64 `json:"rate"` // 提交率百分比,保留一位小数
}
// TeamRow 团队汇总中的一名成员。
type TeamRow struct {
UserID uint64 `json:"user_id"`
UserName string `json:"user_name"`
Account string `json:"account"`
OrgID uint64 `json:"org_id"`
OrgName string `json:"org_name"`
Submitted bool `json:"submitted"`
ReportID uint64 `json:"report_id"`
Status int8 `json:"status"` // -1未填写 0草稿 1已提交
SubmitTime string `json:"submit_time"`
HasIssue bool `json:"has_issue"`
}
// TeamResult 团队汇总结果。
type TeamResult struct {
Date string `json:"date"`
List []TeamRow `json:"list"`
Total int64 `json:"total"`
Stats TeamStats `json:"stats"`
}
// OrgOption 团队筛选用的部门选项(前端按 parent_id 组装树)。
type OrgOption struct {
ID uint64 `json:"id"`
ParentID uint64 `json:"parent_id"`
Name string `json:"name"`
MemberCount int `json:"member_count"`
}
// TeamStatus 团队日报汇总:按日期返回管辖范围内每位成员的提交情况。
// 管理员不选部门时统计全租户(含未分配部门的成员)。
func TeamStatus(u *UserContext, q TeamQuery) (*TeamResult, error) {
if !u.CanViewTeam {
return nil, errForbidden("无权查看团队日报")
}
date, err := ParseDate(q.Date)
if err != nil {
return nil, err
}
if date > Today() {
return nil, errBad("不能查看未来日期的团队日报")
}
if q.Page < 1 {
q.Page = 1
}
if q.PageSize < 1 || q.PageSize > 100 {
q.PageSize = 20
}
orgs, err := loadOrgs(u.Tid)
if err != nil {
return nil, err
}
orgByID := make(map[uint64]*models.BackendOrganization, len(orgs))
for i := range orgs {
orgByID[orgs[i].ID] = &orgs[i]
}
var scopeIDs []uint64
if q.OrgID > 0 {
if !u.IsAdmin && !u.InTeamOrg(q.OrgID) {
return nil, errForbidden("无权查看该部门")
}
if _, ok := orgByID[q.OrgID]; !ok {
return nil, errBad("部门不存在")
}
scopeIDs = collectSubtree([]uint64{q.OrgID}, orgs)
} else if !u.IsAdmin {
scopeIDs = u.TeamOrgIDs
}
if scopeIDs != nil && len(scopeIDs) == 0 {
return nil, errForbidden("你还没有管辖的部门")
}
cond := orm.NewCondition().
And("tid", u.Tid).
And("delete_time__isnull", true).
And("status", 1).
AndNot("uid", u.UID)
if scopeIDs != nil {
cond = cond.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 {
return nil, err
}
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++
}
}
stats.Missing = stats.Total - stats.Submitted
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)
}
return &TeamResult{Date: date, List: list, Total: total, Stats: stats}, nil
}
// TeamOrgs 返回团队筛选可选部门;管理员为全租户,主管仅自己管辖的部门子树。
func TeamOrgs(u *UserContext) ([]OrgOption, error) {
if !u.CanViewTeam {
return nil, errForbidden("无权查看团队日报")
}
orgs, err := loadOrgs(u.Tid)
if err != nil {
return nil, err
}
allow := make(map[uint64]bool, len(orgs))
if u.IsAdmin {
for _, o := range orgs {
allow[o.ID] = true
}
} else {
for _, id := range u.TeamOrgIDs {
allow[id] = true
}
}
var users []models.SystemTenantUser
if _, err := models.Orm.QueryTable(new(models.SystemTenantUser)).
Filter("tid", u.Tid).
Filter("delete_time__isnull", true).
Filter("status", 1).
All(&users, "org_id"); err != nil {
return nil, err
}
counts := make(map[uint64]int, len(orgs))
for _, m := range users {
if m.OrgID > 0 {
counts[m.OrgID]++
}
}
out := make([]OrgOption, 0, len(allow))
for i := range orgs {
o := &orgs[i]
if !allow[o.ID] {
continue
}
out = append(out, OrgOption{
ID: o.ID,
ParentID: o.ParentID,
Name: o.OrgName,
MemberCount: counts[o.ID],
})
}
return out, nil
}
// loadReportsByDate 一次取回某日全部已提交/草稿日报,按作者 uid 建索引。
func loadReportsByDate(tid uint64, date string) (map[uint64]*models.OaDailyReport, error) {
var rows []models.OaDailyReport
if _, err := models.Orm.QueryTable(new(models.OaDailyReport)).
Filter("tid", tid).
Filter("report_date", date).
Filter("is_deleted", 0).
All(&rows); err != nil {
return nil, err
}
out := make(map[uint64]*models.OaDailyReport, len(rows))
for i := range rows {
out[rows[i].UserID] = &rows[i]
}
return out, nil
}
func userNameOf(m *models.SystemTenantUser) string {
if n := strings.TrimSpace(ptrString(m.Name)); n != "" {
return n
}
if a := strings.TrimSpace(ptrString(m.Account)); a != "" {
return a
}
return "未命名成员"
}
+42 -6
View File
@@ -24,6 +24,7 @@ type Config struct {
Name string
AppID string
AppSecret string // 明文
QrcodeURL string // 小程序码图片地址(对外展示)
Enabled bool
AutoBind bool
Remark string
@@ -35,12 +36,13 @@ var ErrNotConfigured = errors.New("微信小程序未配置或未启用")
// appFromRow 解密行数据为运行配置
func appFromRow(row models.WechatMiniApp) (*Config, error) {
cfg := &Config{
ID: row.ID,
Name: row.Name,
AppID: strings.TrimSpace(row.AppID),
Enabled: row.Enabled == 1,
AutoBind: row.AutoBind != 0,
Remark: row.Remark,
ID: row.ID,
Name: row.Name,
AppID: strings.TrimSpace(row.AppID),
QrcodeURL: strings.TrimSpace(row.QrcodeURL),
Enabled: row.Enabled == 1,
AutoBind: row.AutoBind != 0,
Remark: row.Remark,
}
if row.AppSecret != "" {
plain, derr := wechatmp.DecryptSecret(row.AppSecret)
@@ -140,12 +142,44 @@ func ListApps() ([]models.WechatMiniApp, error) {
return rows, err
}
// PublicApp 对外公布的小程序信息(供官网 / H5 等其他端展示二维码)
type PublicApp struct {
ID uint64 `json:"id"`
Name string `json:"name"`
AppID string `json:"app_id"`
QrcodeURL string `json:"qrcode_url"`
}
// ListEnabledApps 已启用的小程序(含二维码地址),供其他端公开读取
func ListEnabledApps() ([]PublicApp, error) {
var rows []models.WechatMiniApp
_, err := models.Orm.QueryTable(new(models.WechatMiniApp)).
Filter("delete_time__isnull", true).
Filter("enabled", 1).
OrderBy("id").
All(&rows)
if err != nil {
return nil, err
}
list := make([]PublicApp, 0, len(rows))
for _, r := range rows {
list = append(list, PublicApp{
ID: r.ID,
Name: strings.TrimSpace(r.Name),
AppID: strings.TrimSpace(r.AppID),
QrcodeURL: strings.TrimSpace(r.QrcodeURL),
})
}
return list, nil
}
// SaveAppInput 保存应用入参(AppSecret 支持掩码表示不修改)
type SaveAppInput struct {
ID uint64
Name string
AppID string
AppSecret string // 掩码 / 空串 = 保持不变
QrcodeURL string // 空串 = 清空二维码
Enabled bool
AutoBind bool
Remark string
@@ -202,6 +236,7 @@ func SaveApp(in SaveAppInput) (uint64, error) {
if in.Enabled {
row.Enabled = 1
}
row.QrcodeURL = strings.TrimSpace(in.QrcodeURL)
row.AutoBind = 0
if in.AutoBind {
row.AutoBind = 1
@@ -273,6 +308,7 @@ func SaveConfig(in SaveInput) error {
Name: row.Name,
AppID: in.AppID,
AppSecret: in.AppSecret,
QrcodeURL: row.QrcodeURL, // 旧端点不改动二维码
Enabled: in.Enabled,
AutoBind: in.AutoBind,
Remark: in.Remark,