主要更新uniapp相关功能

This commit is contained in:
hero920103 committed 2026-10-07 00:59:05 +08:00
1 parent aa92ca40a2
commit 5b4dbb2b46
65 files changed
+8532 -1921

No files matched your search

+92 -15
View File
@@ -1,6 +1,7 @@
package controllers
import (
"encoding/json"
"errors"
"sort"
"strings"
@@ -10,6 +11,7 @@ import (
"server/pkg/jwtutil"
"server/services"
"github.com/beego/beego/v2/client/orm"
beego "github.com/beego/beego/v2/server/web"
)
@@ -53,9 +55,9 @@ type appMenuItem struct {
Icon string `json:"icon,omitempty"`
Type int8 `json:"type"`
Sort int64 `json:"sort"`
Module string `json:"module"` // 归属模块 code(crm/erp/oa/cms/ai),公共菜单为空
Locked bool `json:"locked"` // true = 不在租户套餐内,前端置灰并引导升级
Leaf bool `json:"leaf"` // true = 叶子菜单(无下级),可直接跳转
Module string `json:"module"` // 归属模块 code(crm/erp/oa/cms/ai),公共菜单为空
Locked bool `json:"locked"` // true = 不在租户套餐内,前端置灰并引导升级
Leaf bool `json:"leaf"` // true = 叶子菜单(无下级),可直接跳转
Docs int `json:"docs,omitempty"` // 该节点下可见的子菜单数量
}
@@ -128,25 +130,100 @@ func (c *AppAggregateController) Home() {
"code": 200,
"msg": "success",
"data": map[string]interface{}{
"user": c.appUser(claims, uid, tid),
"modules": modules,
"menus": menus,
"stats": c.appStats(claims, tid),
"user": c.appUser(claims, uid, tid),
"modules": modules,
"menus": menus,
"stats": c.appStats(claims, tid),
"quick_menu_ids": c.appQuickMenuIds(tid, uint64(claims.UserID)),
},
})
}
// appQuickMenuIds 读取当前用户自定义的常用功能菜单 ID 列表(有序)。
// 未设置或解析失败一律返回空数组,前端据此回落到默认取前 N 个叶子菜单。
func (c *AppAggregateController) appQuickMenuIds(tid, uid uint64) []uint64 {
out := []uint64{}
var row models.UniappUserQuickMenu
if err := models.Orm.QueryTable(new(models.UniappUserQuickMenu)).
Filter("tid", tid).Filter("uid", uid).One(&row); err != nil {
return out
}
raw := strings.TrimSpace(row.MenuIds)
if raw == "" {
return out
}
var ids []uint64
if err := json.Unmarshal([]byte(raw), &ids); err != nil {
return out
}
if ids == nil {
return out
}
return ids
}
// SaveQuickMenus POST /app/quick-menus
//
// 全量覆盖保存当前用户的常用功能菜单 ID 列表。请求体:{ "menu_ids": [81,102,...] }。
// 顺序即展示顺序;传空数组表示清空自定义(前端回落到默认)。
func (c *AppAggregateController) SaveQuickMenus() {
claims, err := c.appClaims()
if err != nil {
c.fail(err.Error())
return
}
tid := uint64(claims.TenantId)
uid := uint64(claims.UserID)
var body struct {
MenuIds []uint64 `json:"menu_ids"`
}
if err := json.Unmarshal(c.Ctx.Input.RequestBody, &body); err != nil {
c.Ctx.Output.SetStatus(400)
c.serveJSON(map[string]interface{}{"code": 400, "msg": "请求体格式错误"})
return
}
if body.MenuIds == nil {
body.MenuIds = []uint64{}
}
raw, _ := json.Marshal(body.MenuIds)
now := time.Now()
var row models.UniappUserQuickMenu
if err := models.Orm.QueryTable(new(models.UniappUserQuickMenu)).
Filter("tid", tid).Filter("uid", uid).One(&row); err == nil {
_, _ = models.Orm.QueryTable(new(models.UniappUserQuickMenu)).
Filter("id", row.ID).Update(orm.Params{
"menu_ids": string(raw),
"update_time": now,
})
} else {
_, _ = models.Orm.Insert(&models.UniappUserQuickMenu{
Tid: tid,
Uid: uid,
MenuIds: string(raw),
CreateTime: now,
UpdateTime: &now,
})
}
c.serveJSON(map[string]interface{}{
"code": 200,
"msg": "success",
"data": map[string]interface{}{"menu_ids": body.MenuIds},
})
}
// appUser 当前登录用户的展示信息(含企业名,供首页问候语使用)
func (c *AppAggregateController) appUser(claims *jwtutil.Claims, uid, tid uint64) map[string]interface{} {
out := map[string]interface{}{
"uid": uid,
"tid": tid,
"name": "用户",
"account": "",
"role_name": "",
"tenant": "",
"tenant_id": tid,
"avatar": "",
"uid": uid,
"tid": tid,
"name": "用户",
"account": "",
"role_name": "",
"tenant": "",
"tenant_id": tid,
"avatar": "",
}
if claims.Username != "" {
out["name"] = claims.Username
+205
View File
@@ -0,0 +1,205 @@
package controllers
import (
"strconv"
"strings"
"server/models"
"github.com/beego/beego/v2/client/orm"
)
// 移动端通讯录(企业内部通讯录)只读接口。
//
// 复用 AppOaController 的鉴权与响应封装(oaClaims / oaErr / oaOk):
// 鉴权放宽到 app / platform 令牌,数据一律按 JWT 中的 TenantId 隔离,前端无法指定租户。
// 写入能力(同步员工 / 新增 / 编辑 / 删除)仍留在 Web 管理端。
// appContactDTO 通讯录条目 DTO(移动端展示字段)。
func appContactDTO(row models.BackendErpContact, orgName string) map[string]interface{} {
return map[string]interface{}{
"id": row.ID,
"org_id": contactDerefUint64(row.OrgID),
"org_name": orgName,
"contact_type": row.ContactType,
"contact_name": row.ContactName,
"gender": row.Gender,
"phone": contactDerefString(row.Phone),
"mobiles": contactDerefString(row.Mobiles),
"work_phone": contactDerefString(row.WorkPhone),
"email": contactDerefString(row.Email),
"wechat": contactDerefString(row.Wechat),
"qq": contactDerefString(row.QQ),
"avatar": contactDerefString(row.Avatar),
"company_name": contactDerefString(row.CompanyName),
"dept_name": contactDerefString(row.DeptName),
"position_title": contactDerefString(row.PositionTitle),
"address": contactDerefString(row.Address),
"remark": contactDerefString(row.Remark),
"is_starred": row.IsStarred,
"status": row.Status,
}
}
// appContactOrgNames 批量查询组织 ID → 名称映射,避免逐行查询。
func appContactOrgNames(ids []uint64) map[uint64]string {
names := make(map[uint64]string)
if len(ids) == 0 {
return names
}
var orgs []models.BackendOrganization
if _, err := models.Orm.QueryTable(new(models.BackendOrganization)).
Filter("id__in", ids).All(&orgs); err == nil {
for _, o := range orgs {
names[o.ID] = o.OrgName
}
}
return names
}
// ContactList GET /app/oa/contact/list 通讯录分页列表。
// 支持 keyword(姓名/手机号/邮箱/职位/部门)、org_id(按组织筛选)、contact_type(1 内部 2 外部)。
func (c *AppOaController) ContactList() {
claims, err := c.oaClaims()
if err != nil {
c.oaErr(401, 401, "未登录或无权限")
return
}
page, _ := c.GetInt("page", 1)
pageSize, _ := c.GetInt("pageSize", 20)
if page < 1 {
page = 1
}
if pageSize < 1 || pageSize > 100 {
pageSize = 20
}
keyword := strings.TrimSpace(c.GetString("keyword"))
orgID, _ := c.GetUint64("org_id")
contactType, _ := c.GetInt("contact_type")
qs := models.Orm.QueryTable(new(models.BackendErpContact)).
Filter("delete_time__isnull", true).
Exclude("status", 0).
Filter("tid", claims.TenantId)
if orgID > 0 {
qs = qs.Filter("org_id", orgID)
}
if contactType > 0 {
qs = qs.Filter("contact_type", contactType)
}
if keyword != "" {
cond := orm.NewCondition()
cond = cond.Or("contact_name__contains", keyword)
cond = cond.Or("phone__contains", keyword)
cond = cond.Or("mobiles__contains", keyword)
cond = cond.Or("email__contains", keyword)
cond = cond.Or("position_title__contains", keyword)
cond = cond.Or("dept_name__contains", keyword)
// 用 GetCond().AndCond 追加,避免 SetCond 覆盖掉租户等前置过滤条件
qs = qs.SetCond(qs.GetCond().AndCond(cond))
}
total, _ := qs.Count()
var rows []models.BackendErpContact
if _, err := qs.OrderBy("-is_starred", "contact_name", "-id").
Limit(pageSize).Offset((page - 1) * pageSize).All(&rows); err != nil && err != orm.ErrNoRows {
c.oaErr(500, 500, "查询通讯录失败")
return
}
ids := make([]uint64, 0, len(rows))
for _, r := range rows {
if r.OrgID != nil && *r.OrgID > 0 {
ids = append(ids, *r.OrgID)
}
}
names := appContactOrgNames(ids)
list := make([]map[string]interface{}, 0, len(rows))
for _, r := range rows {
orgName := ""
if r.OrgID != nil {
orgName = names[*r.OrgID]
}
list = append(list, appContactDTO(r, orgName))
}
c.oaOk(map[string]interface{}{"list": list, "total": total, "page": page, "pageSize": pageSize})
}
// ContactOrgTree GET /app/oa/contact/org-tree 组织架构树(含各部门内部联系人数量)。
func (c *AppOaController) ContactOrgTree() {
claims, err := c.oaClaims()
if err != nil {
c.oaErr(401, 401, "未登录或无权限")
return
}
var orgs []models.BackendOrganization
if _, err := models.Orm.QueryTable(new(models.BackendOrganization)).
Filter("delete_time__isnull", true).
Exclude("status", 0).
Filter("tid", claims.TenantId).
OrderBy("sort", "id").All(&orgs); err != nil && err != orm.ErrNoRows {
c.oaErr(500, 500, "查询组织架构失败")
return
}
counts := make(map[uint64]int64)
for _, org := range orgs {
cnt, _ := models.Orm.QueryTable(new(models.BackendErpContact)).
Filter("org_id", org.ID).
Filter("tid", claims.TenantId).
Filter("delete_time__isnull", true).
Exclude("status", 0).
Filter("contact_type", 1).Count()
counts[org.ID] = cnt
}
total, _ := models.Orm.QueryTable(new(models.BackendErpContact)).
Filter("tid", claims.TenantId).
Filter("delete_time__isnull", true).
Exclude("status", 0).
Filter("contact_type", 1).Count()
tree := (&BackendErpContactController{}).buildContactOrgTree(orgs, counts, uint64(total))
c.oaOk(tree)
}
// ContactDetail GET /app/oa/contact/:id 联系人详情。
func (c *AppOaController) ContactDetail() {
claims, err := c.oaClaims()
if err != nil {
c.oaErr(401, 401, "未登录或无权限")
return
}
id, _ := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64)
if id == 0 {
c.oaErr(400, 400, "无效的联系人ID")
return
}
var row models.BackendErpContact
if models.Orm.QueryTable(new(models.BackendErpContact)).
Filter("id", id).
Filter("tid", claims.TenantId).
Filter("delete_time__isnull", true).
Exclude("status", 0).One(&row) != nil {
c.oaErr(404, 404, "联系人不存在")
return
}
orgName := ""
if row.OrgID != nil && *row.OrgID > 0 {
var org models.BackendOrganization
if models.Orm.QueryTable(new(models.BackendOrganization)).
Filter("id", *row.OrgID).One(&org) == nil {
orgName = org.OrgName
}
}
c.oaOk(appContactDTO(row, orgName))
}
+726
View File
@@ -0,0 +1,726 @@
package controllers
import (
"sort"
"strconv"
"strings"
"time"
"server/models"
"server/pkg/jwtutil"
"server/services"
"github.com/beego/beego/v2/client/orm"
beego "github.com/beego/beego/v2/server/web"
)
// AppOaController 移动端 OA 协同办公接口(通知公告 / 文档库 / 薪酬工资单 / 员工档案)。
//
// 与 Web 管理端(/backend/oa/*)的区别:
// - 鉴权放宽到 app 令牌(backend/app/platform),供 uniapp 调用;
// - 只提供读能力(浏览 / 下载 / 收藏);写入操作仍留在 Web 端;
// - 薪酬工资单与员工档案仅返回「本人」数据,通过登录账号匹配员工。
//
// 数据隔离:一律以 JWT 中的 TenantId 过滤,前端无法指定租户。
type AppOaController struct {
beego.Controller
}
func (c *AppOaController) oaClaims() (*jwtutil.Claims, error) {
auth := c.Ctx.Request.Header.Get("Authorization")
if auth == "" {
return nil, orm.ErrNoRows
}
parts := strings.SplitN(auth, " ", 2)
if len(parts) != 2 || parts[0] != "Bearer" {
return nil, orm.ErrNoRows
}
claims, err := jwtutil.ParseToken(parts[1])
if err != nil {
return nil, err
}
if claims.UserType != "backend" && claims.UserType != "app" && claims.UserType != "platform" {
return nil, orm.ErrNoRows
}
return claims, nil
}
func (c *AppOaController) oaErr(httpStatus, bizCode int, msg string) {
c.Ctx.Output.SetStatus(httpStatus)
c.Data["json"] = map[string]interface{}{"code": bizCode, "msg": msg}
_ = c.ServeJSON()
}
func (c *AppOaController) oaOk(data interface{}) {
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success", "data": data}
_ = c.ServeJSON()
}
// oaMyEmployee 解析当前登录用户对应的员工记录(tid + 账号匹配,口径与 backend_auth 一致)。
func (c *AppOaController) oaMyEmployee(claims *jwtutil.Claims) (*models.BackendEmployee, bool) {
uid := services.ResolveTenantUID(uint64(claims.TenantId), uint64(claims.UserID))
account := strings.TrimSpace(claims.Username)
var tu models.SystemTenantUser
if models.Orm.QueryTable(new(models.SystemTenantUser)).
Filter("uid", uid).Filter("tid", claims.TenantId).
Filter("delete_time__isnull", true).One(&tu) == nil {
if tu.Account != nil && strings.TrimSpace(*tu.Account) != "" {
account = strings.TrimSpace(*tu.Account)
}
}
if account == "" {
return nil, false
}
var emp models.BackendEmployee
if models.Orm.QueryTable(new(models.BackendEmployee)).
Filter("tid", claims.TenantId).Filter("account", account).
Filter("delete_time__isnull", true).One(&emp) != nil {
return nil, false
}
return &emp, true
}
// ---------------- 通知公告 ----------------
// noticeItem 公告列表项(正文只回传摘要,避免列表过大)。
func noticeItem(n models.OaNotice) map[string]interface{} {
publishTime := ""
if n.PublishTime != nil {
publishTime = n.PublishTime.Format("2006-01-02 15:04:05")
}
return map[string]interface{}{
"id": n.ID,
"title": n.Title,
"notice_type": n.NoticeType,
"level": n.Level,
"is_top": n.IsTop,
"publisher_name": n.PublisherName,
"publish_time": publishTime,
"read_count": n.ReadCount,
"summary": oaTextSummary(n.Content, 60),
}
}
// oaTextSummary 取正文纯文本摘要,按「字符数」截断(中文安全)。
func oaTextSummary(content string, limit int) string {
text := strings.Join(strings.Fields(content), " ")
runes := []rune(text)
if limit > 0 && len(runes) > limit {
return string(runes[:limit]) + "…"
}
return text
}
// NoticeList GET /app/oa/notice/list 仅返回已发布(status=1)的公告。
func (c *AppOaController) NoticeList() {
claims, err := c.oaClaims()
if err != nil {
c.oaErr(401, 401, "未登录或无权限")
return
}
page, _ := c.GetInt("page", 1)
pageSize, _ := c.GetInt("pageSize", 20)
if page < 1 {
page = 1
}
if pageSize < 1 || pageSize > 100 {
pageSize = 20
}
keyword := strings.TrimSpace(c.GetString("keyword"))
qs := models.Orm.QueryTable(new(models.OaNotice)).
Filter("is_deleted", 0).
Filter("tid", claims.TenantId).
Filter("status", 1)
if keyword != "" {
qs = qs.Filter("title__icontains", keyword)
}
total, _ := qs.Count()
var rows []models.OaNotice
_, err = qs.OrderBy("-is_top", "-publish_time", "-id").
Limit(pageSize).Offset((page - 1) * pageSize).All(&rows)
if err != nil && err != orm.ErrNoRows {
c.oaErr(500, 500, "查询失败")
return
}
list := make([]map[string]interface{}, 0, len(rows))
for _, n := range rows {
list = append(list, noticeItem(n))
}
c.oaOk(map[string]interface{}{"list": list, "total": total, "page": page, "pageSize": pageSize})
}
// NoticeDetail GET /app/oa/notice/:id 公告详情,同时累加阅读数。
func (c *AppOaController) NoticeDetail() {
claims, err := c.oaClaims()
if err != nil {
c.oaErr(401, 401, "未登录或无权限")
return
}
id, _ := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64)
if id == 0 {
c.oaErr(400, 400, "无效的公告ID")
return
}
var n models.OaNotice
if err := models.Orm.QueryTable(new(models.OaNotice)).
Filter("id", id).Filter("tid", claims.TenantId).
Filter("is_deleted", 0).Filter("status", 1).One(&n); err != nil {
c.oaErr(404, 404, "公告不存在")
return
}
_, _ = models.Orm.Raw(
"UPDATE yz_backend_oa_notice SET read_count = read_count + 1 WHERE id = ?", id).Exec()
n.ReadCount++
item := noticeItem(n)
item["content"] = n.Content
c.oaOk(item)
}
// ---------------- 文档库 ----------------
// oaDocActor 由 JWT 构造文档访问者上下文(租户 + 用户可见性)。
func (c *AppOaController) oaDocActor(claims *jwtutil.Claims) services.OaDocActor {
return services.NewOaDocActor(claims.TenantId, uint64(claims.UserID))
}
// docItem 文档列表/详情项(不回传体积较大的在线编辑源)。
func docItem(d models.OaDoc) map[string]interface{} {
updateTime := ""
if d.UpdateTime != nil {
updateTime = d.UpdateTime.Format("2006-01-02 15:04:05")
}
return map[string]interface{}{
"id": d.ID,
"title": d.Title,
"category_id": d.CategoryID,
"file_url": d.FileURL,
"file_name": d.FileName,
"ext": d.Ext,
"size": d.Size,
"doc_type": d.DocType,
"tags": d.Tags,
"summary": d.Summary,
"is_online": d.IsOnline,
"status": d.Status,
"version": d.Version,
"is_star": d.IsStar,
"owner_name": d.OwnerName,
"creator_name": d.CreatorName,
"view_count": d.ViewCount,
"download_count": d.DownCount,
"visibility": d.Visibility,
"create_time": d.CreateTime.Format("2006-01-02 15:04:05"),
"update_time": updateTime,
}
}
// DocCategories GET /app/oa/document/categories?scope=shared|personal|all 分类树(含数量)。
func (c *AppOaController) DocCategories() {
claims, err := c.oaClaims()
if err != nil {
c.oaErr(401, 401, "未登录或无权限")
return
}
tree, err := services.OaDocCategoryTree(c.oaDocActor(claims), strings.TrimSpace(c.GetString("scope")))
if err != nil {
c.oaErr(400, 400, err.Error())
return
}
c.oaOk(tree)
}
// DocList GET /app/oa/document/list 文档分页(按可见性 + 空间过滤)。
func (c *AppOaController) DocList() {
claims, err := c.oaClaims()
if err != nil {
c.oaErr(401, 401, "未登录或无权限")
return
}
page, _ := c.GetInt("page", 1)
pageSize, _ := c.GetInt("pageSize", 20)
if page < 1 {
page = 1
}
if pageSize < 1 || pageSize > 100 {
pageSize = 20
}
status, _ := c.GetInt("status", -1)
docType, _ := c.GetInt("doc_type", -1)
star, _ := c.GetInt("star", -1)
categoryID, _ := c.GetUint64("category_id")
actor := c.oaDocActor(claims)
list, total, err := services.OaDocList(actor, services.OaDocListParams{
Actor: actor,
Scope: strings.TrimSpace(c.GetString("scope")),
Keyword: strings.TrimSpace(c.GetString("keyword")),
CategoryID: categoryID,
Uncategorized: strings.TrimSpace(c.GetString("uncategorized")) == "1",
Visibility: -1,
Mine: strings.TrimSpace(c.GetString("mine")) == "1",
Status: status,
DocType: docType,
Star: star,
Page: page,
PageSize: pageSize,
})
if err != nil {
c.oaErr(400, 400, err.Error())
return
}
items := make([]map[string]interface{}, 0, len(list))
for _, d := range list {
items = append(items, docItem(d))
}
c.oaOk(map[string]interface{}{"list": items, "total": total, "page": page, "pageSize": pageSize})
}
// DocDetail GET /app/oa/document/:id 文档详情(累加查看数)。
func (c *AppOaController) DocDetail() {
claims, err := c.oaClaims()
if err != nil {
c.oaErr(401, 401, "未登录或无权限")
return
}
id, _ := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64)
if id == 0 {
c.oaErr(400, 400, "无效的文档ID")
return
}
doc, err := services.OaDocView(c.oaDocActor(claims), id)
if err != nil {
c.oaErr(404, 404, err.Error())
return
}
if strings.TrimSpace(doc.Content) != "" {
doc.IsOnline = 1
}
c.oaOk(docItem(*doc))
}
// DocDownload GET /app/oa/document/download/:id 记录下载并返回下载地址。
func (c *AppOaController) DocDownload() {
claims, err := c.oaClaims()
if err != nil {
c.oaErr(401, 401, "未登录或无权限")
return
}
id, _ := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64)
if id == 0 {
c.oaErr(400, 400, "无效的文档ID")
return
}
doc, err := services.OaDocDownload(c.oaDocActor(claims), id)
if err != nil {
c.oaErr(404, 404, err.Error())
return
}
c.oaOk(map[string]interface{}{
"id": doc.ID,
"title": doc.Title,
"file_url": doc.FileURL,
"file_name": doc.FileName,
"download_count": doc.DownCount,
})
}
// DocStar POST /app/oa/document/:id/star 收藏 / 取消收藏。
func (c *AppOaController) DocStar() {
claims, err := c.oaClaims()
if err != nil {
c.oaErr(401, 401, "未登录或无权限")
return
}
id, _ := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64)
if id == 0 {
c.oaErr(400, 400, "无效的文档ID")
return
}
isStar, err := services.OaDocToggleStar(c.oaDocActor(claims), id)
if err != nil {
c.oaErr(400, 400, err.Error())
return
}
c.oaOk(map[string]interface{}{"is_star": isStar})
}
// ---------------- 薪酬工资单(仅本人) ----------------
// PayrollList GET /app/oa/compensation/payrolls 仅返回当前登录用户本人的薪资单。
func (c *AppOaController) PayrollList() {
claims, err := c.oaClaims()
if err != nil {
c.oaErr(401, 401, "未登录或无权限")
return
}
page, _ := c.GetInt("page", 1)
pageSize, _ := c.GetInt("pageSize", 20)
if page < 1 {
page = 1
}
if pageSize < 1 || pageSize > 100 {
pageSize = 20
}
emp, ok := c.oaMyEmployee(claims)
if !ok {
c.oaOk(map[string]interface{}{"list": []map[string]interface{}{}, "total": 0})
return
}
qs := models.Orm.QueryTable(new(models.BackendOaPayroll)).
Filter("tid", claims.TenantId).
Filter("employee_id", emp.ID).
Filter("is_deleted", 0)
if month := strings.TrimSpace(c.GetString("payroll_month")); compensationMonthPattern.MatchString(month) {
qs = qs.Filter("payroll_month", month)
}
total, _ := qs.Count()
var rows []models.BackendOaPayroll
if _, err := qs.OrderBy("-payroll_month", "-id").
Limit(pageSize).Offset((page - 1) * pageSize).All(&rows); err != nil && err != orm.ErrNoRows {
c.oaErr(500, 500, "查询失败")
return
}
list := make([]map[string]interface{}, 0, len(rows))
for _, row := range rows {
list = append(list, payrollMap(row))
}
c.oaOk(map[string]interface{}{"list": list, "total": total, "page": page, "pageSize": pageSize})
}
// PayrollDetail GET /app/oa/compensation/payrolls/:id 本人薪资单明细(含自定义增减项)。
func (c *AppOaController) PayrollDetail() {
claims, err := c.oaClaims()
if err != nil {
c.oaErr(401, 401, "未登录或无权限")
return
}
id, _ := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64)
if id == 0 {
c.oaErr(400, 400, "无效的薪资单ID")
return
}
emp, ok := c.oaMyEmployee(claims)
if !ok {
c.oaErr(404, 404, "未找到您的员工信息")
return
}
var row models.BackendOaPayroll
if models.Orm.QueryTable(new(models.BackendOaPayroll)).
Filter("id", id).Filter("tid", claims.TenantId).
Filter("employee_id", emp.ID).Filter("is_deleted", 0).One(&row) != nil {
c.oaErr(404, 404, "薪资单不存在")
return
}
var items []models.BackendOaPayrollItem
_, _ = models.Orm.QueryTable(new(models.BackendOaPayrollItem)).
Filter("tid", claims.TenantId).Filter("payroll_id", id).
Filter("is_deleted", 0).OrderBy("sort_order", "id").All(&items)
if items == nil {
items = []models.BackendOaPayrollItem{}
}
data := payrollMap(row)
data["items"] = items
c.oaOk(data)
}
// ---------------- 员工档案(仅本人) ----------------
// MyEmployeeFile GET /app/oa/employee-file 返回当前登录用户本人的档案与子记录(只读)。
func (c *AppOaController) MyEmployeeFile() {
claims, err := c.oaClaims()
if err != nil {
c.oaErr(401, 401, "未登录或无权限")
return
}
emp, ok := c.oaMyEmployee(claims)
if !ok {
c.oaErr(404, 404, "未找到您的员工信息")
return
}
tid := claims.TenantId
var file models.BackendEmployeeFile
if models.Orm.QueryTable(new(models.BackendEmployeeFile)).
Filter("tid", tid).Filter("is_deleted", 0).
Filter("employee_id", emp.ID).One(&file) != nil {
c.oaErr(404, 404, "您暂无员工档案")
return
}
var records []models.BackendEmployeeFileRecord
_, _ = models.Orm.QueryTable(new(models.BackendEmployeeFileRecord)).
Filter("tid", tid).Filter("is_deleted", 0).
Filter("employee_id", emp.ID).OrderBy("sort", "id").All(&records)
if records == nil {
records = []models.BackendEmployeeFileRecord{}
}
dtos := (&BackendEmployeeFileController{}).efAssembleDTOList(tid, []models.BackendEmployeeFile{file})
c.oaOk(map[string]interface{}{"file": dtos[0], "records": records})
}
// ---------------- 工作台仪表盘(移动端首屏聚合) ----------------
// Dashboard GET /app/oa/dashboard
//
// 一次返回移动端 OA 首页顶部仪表盘所需的全部数据,口径对齐 Web 端
// /backend/oa/schedule/stats 与 /backend/reimbursements/dashboard:
//
// {
// schedule_stats:{today_total,today_done,week_total,week_done,month_total,month_done,overdue},
// reimburse:{week,month,year,trend,status_dist,expense_types},
// notices:[...], // 最近已发布公告(置顶优先,最多 5 条)
// today_schedules:[...] // 今日日程(含已完成,灰显沉底)
// }
func (c *AppOaController) Dashboard() {
claims, err := c.oaClaims()
if err != nil {
c.oaErr(401, 401, "未登录或无权限")
return
}
uid := uint64(claims.UserID)
c.oaOk(map[string]interface{}{
"schedule_stats": c.oaDashboardScheduleStats(claims.TenantId, uid),
"reimburse": c.oaDashboardReimburse(uint64(claims.TenantId), uid),
"notices": c.oaDashboardNotices(claims.TenantId),
"today_schedules": c.oaDashboardTodaySchedules(claims.TenantId, uid),
})
}
// oaDashboardScheduleStats 今日/本周/本月日程统计与逾期数(与 Web 端 Stats 同口径)。
func (c *AppOaController) oaDashboardScheduleStats(tid int, uid uint64) map[string]interface{} {
now := time.Now()
today := now.Format("2006-01-02")
weekday := int(now.Weekday())
if weekday == 0 {
weekday = 7
}
weekStart := now.AddDate(0, 0, -(weekday - 1)).Format("2006-01-02")
weekEnd := now.AddDate(0, 0, 7-weekday).Format("2006-01-02")
monthStart := now.Format("2006-01") + "-01"
base := func() orm.QuerySeter {
return models.Orm.QueryTable(new(models.OaSchedule)).
Filter("is_deleted", 0).
Filter("tid", tid).
Filter("user_id", uid)
}
todayTotal, _ := base().Filter("schedule_date", today).Count()
todayDone, _ := base().Filter("schedule_date", today).Filter("status", 1).Count()
weekTotal, _ := base().Filter("schedule_date__gte", weekStart).Filter("schedule_date__lte", weekEnd).Count()
weekDone, _ := base().Filter("schedule_date__gte", weekStart).Filter("schedule_date__lte", weekEnd).Filter("status", 1).Count()
monthTotal, _ := base().Filter("schedule_date__gte", monthStart).Count()
monthDone, _ := base().Filter("schedule_date__gte", monthStart).Filter("status", 1).Count()
overdue, _ := base().Filter("schedule_date__lt", today).Filter("status", 0).Count()
return map[string]interface{}{
"today_total": todayTotal,
"today_done": todayDone,
"week_total": weekTotal,
"week_done": weekDone,
"month_total": monthTotal,
"month_done": monthDone,
"overdue": overdue,
}
}
// oaDashboardTodaySchedules 今日日程列表(未完成在前,已完成灰显沉底)。
func (c *AppOaController) oaDashboardTodaySchedules(tid int, uid uint64) []map[string]interface{} {
today := time.Now().Format("2006-01-02")
var rows []models.OaSchedule
_, _ = models.Orm.QueryTable(new(models.OaSchedule)).
Filter("is_deleted", 0).Filter("tid", tid).Filter("user_id", uid).
Filter("schedule_date", today).
OrderBy("status", "start_time", "id").Limit(50).All(&rows)
out := make([]map[string]interface{}, 0, len(rows))
for _, s := range rows {
out = append(out, map[string]interface{}{
"id": s.ID,
"title": s.Title,
"schedule_date": s.ScheduleDate,
"start_time": s.StartTime,
"end_time": s.EndTime,
"all_day": s.AllDay,
"color": s.Color,
"priority": s.Priority,
"status": s.Status,
})
}
return out
}
// oaDashboardNotices 最近已发布公告(置顶优先,最多 5 条)。
func (c *AppOaController) oaDashboardNotices(tid int) []map[string]interface{} {
var rows []models.OaNotice
_, _ = models.Orm.QueryTable(new(models.OaNotice)).
Filter("is_deleted", 0).Filter("tid", tid).Filter("status", 1).
OrderBy("-is_top", "-publish_time", "-id").Limit(5).All(&rows)
out := make([]map[string]interface{}, 0, len(rows))
for _, n := range rows {
out = append(out, noticeItem(n))
}
return out
}
// oaDashboardReimburse 报销统计(周/月/年 + 近6月趋势 + 状态分布 + 类型分析),
// 个人报销单量小,一次全量查询内存聚合(与 Web 端 Dashboard 同口径)。
func (c *AppOaController) oaDashboardReimburse(tid, uid uint64) map[string]interface{} {
now := time.Now()
y, m, _ := now.Date()
today := time.Date(y, m, now.Day(), 0, 0, 0, 0, now.Location())
tomorrow := today.AddDate(0, 0, 1)
weekday := int(today.Weekday())
if weekday == 0 {
weekday = 7
}
weekStart := today.AddDate(0, 0, -(weekday - 1))
monthStart := time.Date(y, m, 1, 0, 0, 0, 0, now.Location())
yearStart := time.Date(y, 1, 1, 0, 0, 0, 0, now.Location())
newStat := func() map[string]interface{} {
return map[string]interface{}{"total": 0.0, "paid": 0.0, "unpaid": 0.0, "count": 0}
}
addTo := func(stat map[string]interface{}, row *models.BackendReimbursement) {
stat["total"] = stat["total"].(float64) + row.TotalAmount
stat["count"] = stat["count"].(int) + 1
if row.Status == 5 {
stat["paid"] = stat["paid"].(float64) + row.TotalAmount
} else {
stat["unpaid"] = stat["unpaid"].(float64) + row.TotalAmount
}
}
inRange := func(t, start, end time.Time) bool {
return !t.Before(start) && t.Before(end)
}
weekStat, monthStat, yearStat := newStat(), newStat(), newStat()
monthKeys := make([]string, 0, 6)
monthStats := map[string]map[string]interface{}{}
for i := 5; i >= 0; i-- {
key := time.Date(y, m-time.Month(i), 1, 0, 0, 0, 0, now.Location()).Format("2006-01")
monthKeys = append(monthKeys, key)
monthStats[key] = newStat()
}
statusNames := map[int8]string{0: "草稿", 1: "审批中", 2: "已通过", 3: "已驳回", 4: "已撤回", 5: "已打款"}
statusDist := map[string]interface{}{}
var rows []models.BackendReimbursement
if _, err := models.Orm.QueryTable(new(models.BackendReimbursement)).
Filter("tid", tid).Filter("user_id", uid).Filter("is_deleted", 0).
All(&rows); err != nil {
return map[string]interface{}{
"week": weekStat, "month": monthStat, "year": yearStat,
"trend": []map[string]interface{}{}, "status_dist": statusDist, "expense_types": []map[string]interface{}{},
}
}
for i := range rows {
row := &rows[i]
created := row.CreateTime
if inRange(created, weekStart, tomorrow) {
addTo(weekStat, row)
}
if inRange(created, monthStart, tomorrow) {
addTo(monthStat, row)
}
if inRange(created, yearStart, tomorrow) {
addTo(yearStat, row)
}
if st, ok := monthStats[created.Format("2006-01")]; ok {
addTo(st, row)
}
sk := strconv.Itoa(int(row.Status))
dist, ok := statusDist[sk].(map[string]interface{})
if !ok {
dist = map[string]interface{}{"name": statusNames[row.Status], "count": 0, "amount": 0.0}
statusDist[sk] = dist
}
dist["count"] = dist["count"].(int) + 1
dist["amount"] = dist["amount"].(float64) + row.TotalAmount
}
trend := make([]map[string]interface{}, 0, len(monthKeys))
for _, key := range monthKeys {
st := monthStats[key]
trend = append(trend, map[string]interface{}{
"month": key, "total": st["total"], "paid": st["paid"],
"unpaid": st["unpaid"], "count": st["count"],
})
}
// 报销类型分析:仅统计已打款(status=5)的报销单费用明细
paidIDs := map[uint64]bool{}
for i := range rows {
if rows[i].Status == 5 {
paidIDs[rows[i].ID] = true
}
}
expenseTypes := []map[string]interface{}{}
var items []models.BackendReimbursementItem
if _, err := models.Orm.QueryTable(new(models.BackendReimbursementItem)).
Filter("tid", tid).Filter("uid", uid).Filter("is_deleted", 0).
All(&items); err == nil {
typeAgg := map[string]*struct {
count int
amount float64
}{}
for i := range items {
if !paidIDs[items[i].ReimbursementID] {
continue
}
name := strings.TrimSpace(items[i].ExpenseType)
if name == "" {
name = "未分类"
}
agg, ok := typeAgg[name]
if !ok {
agg = &struct {
count int
amount float64
}{}
typeAgg[name] = agg
}
agg.count++
agg.amount += items[i].Amount
}
for name, agg := range typeAgg {
expenseTypes = append(expenseTypes, map[string]interface{}{
"name": name, "count": agg.count, "amount": agg.amount,
})
}
sort.Slice(expenseTypes, func(i, j int) bool {
return expenseTypes[i]["amount"].(float64) > expenseTypes[j]["amount"].(float64)
})
}
return map[string]interface{}{
"week": weekStat, "month": monthStat, "year": yearStat,
"trend": trend, "status_dist": statusDist, "expense_types": expenseTypes,
}
}
+4
View File
@@ -149,6 +149,8 @@ func (c *AppReminderController) GetList() {
if len(reminders) > 0 {
first := reminders[0]
item["advance_minutes"] = first.AdvanceMinutes
item["repeat_interval_minutes"] = first.RepeatIntervalMinutes
item["max_send_count"] = first.MaxSendCount
if !first.CreateTime.IsZero() {
item["created_at"] = first.CreateTime.Format("2006-01-02 15:04:05")
}
@@ -227,6 +229,8 @@ func (c *AppReminderController) GetDetail() {
}
if first.ID > 0 {
data["advance_minutes"] = first.AdvanceMinutes
data["repeat_interval_minutes"] = first.RepeatIntervalMinutes
data["max_send_count"] = first.MaxSendCount
if !first.CreateTime.IsZero() {
data["created_at"] = first.CreateTime.Format("2006-01-02 15:04:05")
}
+12 -9
View File
@@ -108,15 +108,18 @@ func (c *AuthLoginController) LoginSubmit() {
}
// 开启人机验证时:先极验通过,再走账号密码校验
if err := authsvc.VerifyLoginCaptcha(0, authsvc.CaptchaResult{
CaptchaID: req.CaptchaID,
LotNumber: req.LotNumber,
PassToken: req.PassToken,
GenTime: req.GenTime,
CaptchaOutput: req.CaptchaOutput,
}); err != nil {
c.serveJSON(map[string]interface{}{"code": 400, "msg": err.Error()})
return
// 小程序端(yz-miniapp)无法集成极验 SDK 且运行于微信沙箱内,跳过人机验证
if strings.TrimSpace(req.ClientID) != "yz-miniapp" {
if err := authsvc.VerifyLoginCaptcha(0, authsvc.CaptchaResult{
CaptchaID: req.CaptchaID,
LotNumber: req.LotNumber,
PassToken: req.PassToken,
GenTime: req.GenTime,
CaptchaOutput: req.CaptchaOutput,
}); err != nil {
c.serveJSON(map[string]interface{}{"code": 400, "msg": err.Error()})
return
}
}
clientIP := c.Ctx.Input.IP()
+53
View File
@@ -177,6 +177,59 @@ func (c *AuthWechatMiniController) Login() {
c.serveJSON(resp)
}
// Bind 把当前小程序 openid 绑定到已登录身份
// POST /auth/wechat/miniapp/bind { "code": "wx.login的code" } Header: Bearer 令牌
//
// 用途:小程序认证中 / 未开通「手机号快速验证」时,getPhoneNumber 拿不到手机号,
// 一键登录退化为「按 openid 匹配已绑定身份」。用户先用账号密码登录一次,
// 前端调用本接口静默绑定 openid,之后即可无手机号完成微信一键登录。
// 显式绑定视为本人操作(同时持有微信与该账号登录态),允许换绑覆盖旧绑定。
func (c *AuthWechatMiniController) Bind() {
claims := claimsFromCtxThird(c.Ctx)
if claims == nil || claims.UserID <= 0 {
c.Ctx.Output.SetStatus(401)
c.serveJSON(map[string]interface{}{"code": 401, "msg": "未登录"})
return
}
var req struct {
Code string `json:"code"`
}
body := c.Ctx.Input.RequestBody
if len(body) == 0 {
body, _ = io.ReadAll(c.Ctx.Request.Body)
}
if err := json.Unmarshal(body, &req); err != nil {
c.fail("", 400, "参数错误")
return
}
req.Code = strings.TrimSpace(req.Code)
if req.Code == "" {
c.fail("", 400, "缺少登录凭证,请重新进入小程序")
return
}
cfg, err := wechatmini.LoadEnabledConfig()
if err != nil {
c.fail(MiniReasonNotConfigured, 400, "小程序登录未开通")
return
}
session, err := wechatmini.Code2Session(cfg, req.Code)
if err != nil {
msg := err.Error()
if hint, ok := wechatmini.ExplainError(err); ok {
msg = hint
}
logs.Warn("[wechat-mini] 绑定 code 换取 openid 失败: %v", err)
c.fail(MiniReasonWechatAPI, 400, msg)
return
}
c.syncBinding(cfg, uint64(claims.UserID), session, true)
c.serveJSON(map[string]interface{}{"code": 200, "msg": "绑定成功"})
}
// resolveIdentity 定位身份并维护小程序绑定关系。
// mobile 非空表示本次已通过微信手机号验证,是可信的强证据,优先采用。
func (c *AuthWechatMiniController) resolveIdentity(cfg *wechatmini.Config, mobile string, session *wechatmini.Session) (*models.AuthIdentity, error) {