From 5b4dbb2b4661e89b993148020baa5e2b2e5872e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=89=AB=E5=9C=B0=E5=83=A7?= <357099073@qq.com> Date: Wed, 7 Oct 2026 00:59:05 +0800 Subject: [PATCH] =?UTF-8?q?=E4=B8=BB=E8=A6=81=E6=9B=B4=E6=96=B0uniapp?= =?UTF-8?q?=E7=9B=B8=E5=85=B3=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .trae/documents/小程序端OA功能实现计划.md | 113 +++ go/controllers/app_aggregate.go | 107 ++- go/controllers/app_contact.go | 205 +++++ go/controllers/app_oa.go | 726 +++++++++++++++ go/controllers/app_reminder.go | 4 + go/controllers/auth/login.go | 21 +- go/controllers/auth/wechat_miniapp.go | 53 ++ go/docs/sql/create_uniapp_user_quick_menu.sql | 33 + go/models/init.go | 2 + go/models/uniapp_user_quick_menu.go | 24 + go/routers/app/app.go | 27 + go/routers/auth/auth.go | 2 + platform/src/api/wechatMp.js | 21 + .../components/notificationSettings.vue | 92 ++ uniapp/api/auth.js | 21 +- uniapp/api/contact.js | 71 ++ uniapp/api/oa.js | 184 ++++ uniapp/api/schedule.js | 10 +- uniapp/components/AppTabbar.vue | 70 +- uniapp/components/FaIcon.vue | 28 +- uniapp/components/MainTabbar.vue | 4 +- uniapp/components/sub/SubFunctions.vue | 339 ++++--- uniapp/components/sub/SubHome.vue | 828 +++++++++++++----- uniapp/components/sub/SubMine.vue | 487 +++++----- uniapp/components/sub/SubOaDashboard.vue | 656 ++++++++++++++ uniapp/components/sub/SubTodo.vue | 28 +- uniapp/config/subsystems.js | 22 +- uniapp/manifest.json | 3 +- uniapp/package-lock.json | 7 + uniapp/package.json | 1 + uniapp/pages.json | 77 ++ uniapp/pages/dashboard/dashboard.vue | 449 ++++------ uniapp/pages/features/features.vue | 327 ++++--- uniapp/pages/login/forget.vue | 8 +- uniapp/pages/login/login.vue | 170 ++-- uniapp/pages/login/register.vue | 8 +- uniapp/pages/nav/nav.vue | 119 +-- uniapp/pages/oa/compensation/detail.vue | 367 ++++++++ uniapp/pages/oa/compensation/index.vue | 230 +++++ uniapp/pages/oa/contact/detail.vue | 408 +++++++++ uniapp/pages/oa/contact/index.vue | 364 ++++++++ uniapp/pages/oa/document/detail.vue | 346 ++++++++ uniapp/pages/oa/document/index.vue | 304 +++++++ uniapp/pages/oa/employeefile/index.vue | 481 ++++++++++ uniapp/pages/oa/notice/detail.vue | 189 ++++ uniapp/pages/oa/notice/index.vue | 194 ++++ uniapp/pages/profile/profile.vue | 118 +-- uniapp/pages/sub/tabs.vue | 182 ++++ uniapp/pages/tools/notepad/edit.vue | 126 +-- uniapp/pages/tools/notepad/index.vue | 69 +- uniapp/pages/tools/schedule/detail.vue | 100 +-- uniapp/pages/tools/schedule/edit.vue | 197 +---- uniapp/pages/tools/schedule/index.vue | 792 ++++++++++++----- uniapp/pages/tools/schedule/reminder.vue | 441 ++++++++++ uniapp/project.config.json | 25 + uniapp/project.private.config.json | 15 + uniapp/scripts/gen-fa-fonts.js | 48 + uniapp/src/styles/fontawesome.scss | 6 +- uniapp/src/styles/theme.scss | 47 +- uniapp/src/styles/tint.scss | 23 +- uniapp/static/webfonts/fa-brands-400.woff2 | Bin 114412 -> 0 bytes uniapp/static/webfonts/fa-solid-900.woff2 | Bin 119156 -> 0 bytes uniapp/utils/mpHead.js | 17 + uniapp/utils/nav.js | 12 +- uniapp/utils/subsystem.js | 5 +- 65 files changed, 8532 insertions(+), 1921 deletions(-) create mode 100644 .trae/documents/小程序端OA功能实现计划.md create mode 100644 go/controllers/app_contact.go create mode 100644 go/controllers/app_oa.go create mode 100644 go/docs/sql/create_uniapp_user_quick_menu.sql create mode 100644 go/models/uniapp_user_quick_menu.go create mode 100644 uniapp/api/contact.js create mode 100644 uniapp/api/oa.js create mode 100644 uniapp/components/sub/SubOaDashboard.vue create mode 100644 uniapp/pages/oa/compensation/detail.vue create mode 100644 uniapp/pages/oa/compensation/index.vue create mode 100644 uniapp/pages/oa/contact/detail.vue create mode 100644 uniapp/pages/oa/contact/index.vue create mode 100644 uniapp/pages/oa/document/detail.vue create mode 100644 uniapp/pages/oa/document/index.vue create mode 100644 uniapp/pages/oa/employeefile/index.vue create mode 100644 uniapp/pages/oa/notice/detail.vue create mode 100644 uniapp/pages/oa/notice/index.vue create mode 100644 uniapp/pages/sub/tabs.vue create mode 100644 uniapp/pages/tools/schedule/reminder.vue create mode 100644 uniapp/project.config.json create mode 100644 uniapp/project.private.config.json create mode 100644 uniapp/scripts/gen-fa-fonts.js delete mode 100644 uniapp/static/webfonts/fa-brands-400.woff2 delete mode 100644 uniapp/static/webfonts/fa-solid-900.woff2 create mode 100644 uniapp/utils/mpHead.js diff --git a/.trae/documents/小程序端OA功能实现计划.md b/.trae/documents/小程序端OA功能实现计划.md new file mode 100644 index 0000000..913818f --- /dev/null +++ b/.trae/documents/小程序端OA功能实现计划.md @@ -0,0 +1,113 @@ +# 小程序端 OA 功能实现计划 + +## Context + +Web 管理端(`backend/`)已具备完整的 OA 四大功能(通知公告、文档库、薪酬工资单、员工档案), +但移动端(`uniapp/`)目前只有统一 webview 兜底,OA 子系统里除「日程提醒/记事本」外均无原生页面。 +本次目标:参照 `backend/` 对应功能,在小程序端提供 OA 四大功能的原生体验。 + +**已确认的范围与约束** +- 四大功能:通知公告(只读浏览)、文档库(浏览+下载+收藏)、薪酬工资单(**仅本人**)、员工档案(**仅本人**、只读)。 +- 后端管理端 OA 接口均为 `/backend/oa/*` 且强制 `user_type=backend`,移动端令牌调不到 → + 必须新增面向客户端的 `/app/oa/*` 只读接口。 +- 写操作(发布公告、上传文档、编辑工资单、维护档案)继续留在 Web 端。 +- 仅本人数据通过「登录账号 → 员工」映射解析(与 `backend_auth.go` 一致)。 +- 微信小程序为主端,兼容 H5/APK;沿用既有 `Sub*`/工具页的卡片风格与 `FaIcon` 图标。 + +## 现状关键参考 + +| 用途 | 位置 | +| --- | --- | +| 移动端控制器鉴权/响应范式 | [app_reminder.go](file:///e:/Demo/Go/yunzerwebsiteallinone/go/controllers/app_reminder.go) | +| 移动端路由注册 | [app.go](file:///e:/Demo/Go/yunzerwebsiteallinone/go/routers/app/app.go) | +| 公告控制器/模型 | [backend_oa_notice.go](file:///e:/Demo/Go/yunzerwebsiteallinone/go/controllers/backend_oa_notice.go) / [oa_notice.go](file:///e:/Demo/Go/yunzerwebsiteallinone/go/models/oa_notice.go) | +| 文档控制器/服务/模型 | [backend_oa_document.go](file:///e:/Demo/Go/yunzerwebsiteallinone/go/controllers/backend_oa_document.go) / [oa_document.go](file:///e:/Demo/Go/yunzerwebsiteallinone/go/services/oa_document.go) / [oa_document.go](file:///e:/Demo/Go/yunzerwebsiteallinone/go/models/oa_document.go) | +| 薪酬控制器/模型 | [backend_oa_compensation.go](file:///e:/Demo/Go/yunzerwebsiteallinone/go/controllers/backend_oa_compensation.go) / [backend_oa_compensation.go](file:///e:/Demo/Go/yunzerwebsiteallinone/go/models/backend_oa_compensation.go) | +| 员工档案控制器/模型 | [backend_employee_file.go](file:///e:/Demo/Go/yunzerwebsiteallinone/go/controllers/backend_employee_file.go) / [backend_employee_file.go](file:///e:/Demo/Go/yunzerwebsiteallinone/go/models/backend_employee_file.go) | +| 账号→员工映射范式 | [backend_auth.go](file:///e:/Demo/Go/yunzerwebsiteallinone/go/controllers/backend_auth.go#L266-L289) | +| 前端请求封装 | [request.js](file:///e:/Demo/Go/yunzerwebsiteallinone/uniapp/api/request.js) | +| 前端 API 封装范式 | [schedule.js](file:///e:/Demo/Go/yunzerwebsiteallinone/uniapp/api/schedule.js) | +| 列表/详情页样式范式 | [schedule/index.vue](file:///e:/Demo/Go/yunzerwebsiteallinone/uniapp/pages/tools/schedule/index.vue) | +| 菜单跳转分发 | [nav.js](file:///e:/Demo/Go/yunzerwebsiteallinone/uniapp/utils/nav.js) | +| OA 概览页组件 | [SubHome.vue](file:///e:/Demo/Go/yunzerwebsiteallinone/uniapp/components/sub/SubHome.vue) | + +## 一、后端:新增客户端 OA 只读接口 + +### 1. 新增 `go/controllers/app_oa.go` + +`AppOaController` + 复用式辅助方法: +- `oaClaims()`:允许 `backend/app/platform`(与 `AppReminderController.appClaims` 同口径)。 +- `oaOk/oaErr()`:统一 `{code,msg,data}` 响应。 +- `oaTenantUID(claims)`:`services.ResolveTenantUID(tid, claims.UserID)`。 +- `oaMyEmployee(tid, userID) (*models.BackendEmployee, bool)`:查 `SystemTenantUser`(uid+tid)取 + `account`,再按 `tid + account` 查 `BackendEmployee`(对齐 `backend_auth.go` L266-289)。 + +### 2. 接口清单 + +| 方法 | 路由 | 说明 | 复用 | +| --- | --- | --- | --- | +| GET | `/app/oa/notice/list` | 仅 `status=1`、`is_deleted=0`、`tid`;`is_top` 优先 + `publish_time` 倒序;`keyword/page/pageSize` | `models.OaNotice` | +| GET | `/app/oa/notice/:id` | 详情,`read_count+1`(沿用 Raw UPDATE 写法) | 同上 | +| GET | `/app/oa/document/categories` | `scope=shared/personal/all` 分类树 | `services.OaDocCategoryTree` | +| GET | `/app/oa/document/list` | 文档分页(scope/category_id/uncategorized/keyword/mine/star/doc_type) | `services.OaDocList` | +| GET | `/app/oa/document/:id` | 文档详情(累加查看数) | `services.OaDocView` | +| GET | `/app/oa/document/download/:id` | 记录下载数并返回下载地址 | `services.OaDocDownload` | +| POST | `/app/oa/document/:id/star` | 收藏/取消收藏 | `services.OaDocToggleStar` | +| GET | `/app/oa/compensation/payrolls` | **仅本人**薪资单,`employee_id = 我的员工ID` + `is_deleted=0`;`payroll_month` 可选 | `payrollMap()`(包级函数,可直接复用) | +| GET | `/app/oa/compensation/payrolls/:id` | 明细 + 自定义增减项,二次校验 `employee_id == 我的员工ID` | `models.BackendOaPayrollItem` | +| GET | `/app/oa/employee-file` | **仅本人**档案 + 子记录(教育/工作/合同/证照) | `(&BackendEmployeeFileController{}).efAssembleDTOList`、`efFileCompleteness`、`efOrgNameMap`(均只依赖 `models.Orm`,零值控制器可安全调用) | + +### 3. 路由注册(`go/routers/app/app.go`) + +追加 `// OA 协同办公` 段。**注意 beego 按注册顺序匹配**:字面量路由必须排在 `:id` 通配之前 +(如 `document/categories`、`document/list`、`document/download/:id` 先于 `document/:id`)。 + +## 二、前端:API 封装与页面 + +### 1. 新增 `uniapp/api/oa.js` + +依据 [schedule.js](file:///e:/Demo/Go/yunzerwebsiteallinone/uniapp/api/schedule.js) 范式封装: +`fetchNotices/getNotice`、`fetchDocCategories/fetchDocs/getDoc/downloadDoc/toggleDocStar`、 +`fetchPayrolls/getPayroll`、`getMyEmployeeFile`,并导出展示用枚举标签 +(公告类型/级别、文档类型、薪资单状态、在职状态、婚姻状况)。 + +### 2. 新增页面(统一沿用卡片 + `FaIcon` + `page-common.scss`) + +| 页面 | 文件 | 内容 | +| --- | --- | --- | +| 公告列表 | `pages/oa/notice/index.vue` | 搜索、置顶/重要标记、发布人+时间+阅读数,点击进详情 | +| 公告详情 | `pages/oa/notice/detail.vue` | 标题、类型/级别标签、发布信息、正文 | +| 文档库 | `pages/oa/document/index.vue` | 共享/私密空间切换、分类横向筛选、搜索、文档列表(类型图标+大小+分类) | +| 文档详情 | `pages/oa/document/detail.vue` | 元信息、摘要、下载(`uni.downloadFile`+`uni.openDocument`,失败降级复制链接)、收藏切换 | +| 我的工资单 | `pages/oa/compensation/index.vue` | 月份筛选、实发金额高亮、发放状态,点击进明细 | +| 工资单明细 | `pages/oa/compensation/detail.vue` | 应发/扣款分项、社保公积金个税、自定义增减项、实发合计 | +| 我的档案 | `pages/oa/employeefile/index.vue` | 基本信息、在岗状态、完整度、教育/工作/合同经历列表(只读) | + +### 3. 注册路由(`uniapp/pages.json`) + +`pages` 数组追加上述 7 个页面,`navigationBarTitleText` 分别为 +「通知公告 / 公告详情 / 文档库 / 文档详情 / 我的工资单 / 工资单明细 / 我的档案」, +背景统一 `#F8FAFC`。 + +### 4. 菜单跳转映射(`uniapp/utils/nav.js`) + +在 `NATIVE_RULES` 中补充 OA 原生路由(顺序放在既有规则之后,模式从具体到宽泛): +- 通知公告:「通知公告|公告|notice」 +- 文档库:「文档库|文档管理|文档图谱|document」 +- 薪酬工资单:「薪酬|工资|薪资|compensation|payroll」 +- 员工档案:「员工档案|人事档案|档案|employeefile|employeeFile」 + +### 5. OA 概览页新增「最新公告」卡片(`SubHome.vue`) + +在「日程待办」区块后新增一个 `v-if="sub.module === 'oa'"` 的区块: +调用 `/app/oa/notice/list`(pageSize=3)展示标题 + 发布时间,「全部」跳转公告列表页。 +仅对 OA 生效,不影响 crm/erp/cms/tools 概览。 + +## 三、验证 + +1. **后端编译**:`cd go && go build ./...` 通过。 +2. **前端编译**:`cd uniapp && npm run dev:mp-weixin`,确认产物 `dist/dev/mp-weixin` 无报错。 +3. **接口自测**(若可连库):以 app 令牌请求 `/app/oa/notice/list`、`/app/oa/document/list?scope=shared`、 + `/app/oa/compensation/payrolls`、`/app/oa/employee-file`,确认鉴权、租户隔离与本人限制生效。 +4. **端上验证**:微信开发者工具打开 OA 子系统,从菜单进入四个功能页,检查列表/详情/下载/收藏与公告卡片。 + (注:数据库当前连接超时,接口数据验证受限于环境,必要时由用户提供实际层级/菜单信息。) \ No newline at end of file diff --git a/go/controllers/app_aggregate.go b/go/controllers/app_aggregate.go index 822cbff..4ca3ef5 100644 --- a/go/controllers/app_aggregate.go +++ b/go/controllers/app_aggregate.go @@ -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 diff --git a/go/controllers/app_contact.go b/go/controllers/app_contact.go new file mode 100644 index 0000000..71b563f --- /dev/null +++ b/go/controllers/app_contact.go @@ -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)) +} \ No newline at end of file diff --git a/go/controllers/app_oa.go b/go/controllers/app_oa.go new file mode 100644 index 0000000..d44ad8a --- /dev/null +++ b/go/controllers/app_oa.go @@ -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, + } +} diff --git a/go/controllers/app_reminder.go b/go/controllers/app_reminder.go index f17edbe..51ce8a4 100644 --- a/go/controllers/app_reminder.go +++ b/go/controllers/app_reminder.go @@ -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") } diff --git a/go/controllers/auth/login.go b/go/controllers/auth/login.go index f2e0b3c..440e265 100644 --- a/go/controllers/auth/login.go +++ b/go/controllers/auth/login.go @@ -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() diff --git a/go/controllers/auth/wechat_miniapp.go b/go/controllers/auth/wechat_miniapp.go index c000ecc..3bf538f 100644 --- a/go/controllers/auth/wechat_miniapp.go +++ b/go/controllers/auth/wechat_miniapp.go @@ -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) { diff --git a/go/docs/sql/create_uniapp_user_quick_menu.sql b/go/docs/sql/create_uniapp_user_quick_menu.sql new file mode 100644 index 0000000..17eeda6 --- /dev/null +++ b/go/docs/sql/create_uniapp_user_quick_menu.sql @@ -0,0 +1,33 @@ +-- ============================================================= +-- 移动端「常用功能」自定义入口 +-- +-- 说明:uniapp 子系统首页「常用功能」宫格由用户自行挑选要展示的功能, +-- 末位固定为「+」用于打开选择器增删。每个租户下的每个用户维护一份有序 +-- 的功能菜单 ID 列表,存于本表。 +-- +-- 关联代码: +-- 模型 go/models/uniapp_user_quick_menu.go (UniappUserQuickMenu) +-- 接口 GET /app/home → data.quick_menu_ids(读取) +-- POST /app/quick-menus → 保存(全量覆盖) +-- +-- 约定: +-- - yz_uniapp_ 前缀代表移动端(uniapp)专属数据表 +-- - tid 租户 ID(对应 JWT claims.TenantId) +-- - uid 用户身份 ID(对应 JWT claims.UserID,与业务表 user_id 同源) +-- - menu_ids JSON 数组字符串,元素为 yz_system_menu.id,顺序即展示顺序 +-- 例:[81,101,102,113,116] +-- - 一个 (tid, uid) 仅一条记录,保存走 upsert,唯一键保证。 +-- +-- 本表由人工执行建表,代码不做自动建表(见 models/init.go 约定)。 +-- ============================================================= + +CREATE TABLE IF NOT EXISTS `yz_uniapp_user_quick_menu` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键', + `tid` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '租户ID', + `uid` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '用户身份ID(claims.UserID)', + `menu_ids` TEXT COMMENT '常用功能菜单ID有序JSON数组', + `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_time` DATETIME NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_tid_uid` (`tid`, `uid`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='移动端用户自定义常用功能入口'; diff --git a/go/models/init.go b/go/models/init.go index 6300f75..f109595 100644 --- a/go/models/init.go +++ b/go/models/init.go @@ -54,6 +54,8 @@ func Init(_ string) { new(BackendReimbursementItem), new(BackendReimbursementExpenseType), new(SystemMenu), + // 移动端用户自定义常用功能(docs/sql/create_uniapp_user_quick_menu.sql) + new(UniappUserQuickMenu), new(AdminUser), new(AdminRole), new(SystemFile), diff --git a/go/models/uniapp_user_quick_menu.go b/go/models/uniapp_user_quick_menu.go new file mode 100644 index 0000000..2a379ae --- /dev/null +++ b/go/models/uniapp_user_quick_menu.go @@ -0,0 +1,24 @@ +package models + +import "time" + +// UniappUserQuickMenu 移动端用户自定义「常用功能」入口: yz_uniapp_user_quick_menu +// +// 每个租户下的每个用户维护一份有序的功能菜单 ID 列表(menu_ids 为 JSON 数组字符串), +// 供 uniapp 子系统首页「常用功能」宫格渲染;末位「+」打开选择器增删后全量覆盖保存。 +// +// yz_uniapp_ 前缀代表移动端(uniapp)专属数据表。 +// ID 口径与 /app/* 一致:uid = JWT claims.UserID(业务身份 ID),tid = claims.TenantId。 +// 建表 SQL:docs/sql/create_uniapp_user_quick_menu.sql(人工执行,代码不自动建表)。 +type UniappUserQuickMenu struct { + ID uint64 `orm:"column(id);pk;auto" json:"id"` + Tid uint64 `orm:"column(tid)" json:"tid"` + Uid uint64 `orm:"column(uid)" json:"uid"` + MenuIds string `orm:"column(menu_ids);type(text);null" json:"menu_ids"` + CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"` + UpdateTime *time.Time `orm:"column(update_time);type(datetime);null" json:"update_time"` +} + +func (m *UniappUserQuickMenu) TableName() string { + return "yz_uniapp_user_quick_menu" +} diff --git a/go/routers/app/app.go b/go/routers/app/app.go index 46e56d9..5a5dff6 100644 --- a/go/routers/app/app.go +++ b/go/routers/app/app.go @@ -43,9 +43,36 @@ func Register() { beego.Router("/app/notebook/:id", &controllers.AppNotebookController{}, "get:GetDetail;put:Update;delete:Delete") beego.Router("/app/notebook/:id/togglePin", &controllers.AppNotebookController{}, "post:TogglePin") + // OA 协同办公(移动端只读:通知公告 / 文档库 / 薪酬工资单 / 员工档案) + // 注意:字面量路由必须注册在 :id 通配之前,beego 按注册顺序匹配。 + beego.Router("/app/oa/notice/list", &controllers.AppOaController{}, "get:NoticeList") + beego.Router("/app/oa/notice/:id", &controllers.AppOaController{}, "get:NoticeDetail") + + beego.Router("/app/oa/document/categories", &controllers.AppOaController{}, "get:DocCategories") + beego.Router("/app/oa/document/list", &controllers.AppOaController{}, "get:DocList") + beego.Router("/app/oa/document/download/:id", &controllers.AppOaController{}, "get:DocDownload") + beego.Router("/app/oa/document/:id/star", &controllers.AppOaController{}, "post:DocStar") + beego.Router("/app/oa/document/:id", &controllers.AppOaController{}, "get:DocDetail") + + beego.Router("/app/oa/compensation/payrolls", &controllers.AppOaController{}, "get:PayrollList") + beego.Router("/app/oa/compensation/payrolls/:id", &controllers.AppOaController{}, "get:PayrollDetail") + + beego.Router("/app/oa/employee-file", &controllers.AppOaController{}, "get:MyEmployeeFile") + + // OA 工作台仪表盘(移动端首屏顶部聚合:待办/报销统计 + 通知公告 + 今日日程) + beego.Router("/app/oa/dashboard", &controllers.AppOaController{}, "get:Dashboard") + + // 通讯录(移动端只读:列表 / 组织树 / 详情) + // 同样:字面量路由必须排在 :id 通配之前。 + beego.Router("/app/oa/contact/list", &controllers.AppOaController{}, "get:ContactList") + beego.Router("/app/oa/contact/org-tree", &controllers.AppOaController{}, "get:ContactOrgTree") + beego.Router("/app/oa/contact/:id", &controllers.AppOaController{}, "get:ContactDetail") + // 活动日志 beego.Router("/app/activity/list", &controllers.AppActivityController{}, "get:GetList") // 工作台聚合(ERP/OA/CRM/CMS 应用门户首屏数据) beego.Router("/app/home", &controllers.AppAggregateController{}, "get:Home") + // 移动端用户自定义「常用功能」保存(读取随 /app/home 下发 quick_menu_ids) + beego.Router("/app/quick-menus", &controllers.AppAggregateController{}, "post:SaveQuickMenus") } diff --git a/go/routers/auth/auth.go b/go/routers/auth/auth.go index 6bf0478..90013be 100644 --- a/go/routers/auth/auth.go +++ b/go/routers/auth/auth.go @@ -52,6 +52,8 @@ func Register() { // 微信小程序一键登录(wx.login code + getPhoneNumber 动态 code 换登录态) beego.Router("/auth/wechat/miniapp/config", &authctl.AuthWechatMiniController{}, "get:Config") beego.Router("/auth/wechat/miniapp/login", &authctl.AuthWechatMiniController{}, "post:Login") + // 已登录态下绑定 openid(认证中/无手机号能力时的兜底一键登录前置步骤) + beego.Router("/auth/wechat/miniapp/bind", &authctl.AuthWechatMiniController{}, "post:Bind") // 移动端免登录票据:把移动端登录态桥接到 Web 端,避免 webview 内二次登录 beego.Router("/auth/app-ticket", &authctl.AuthAppTicketController{}, "post:Create") diff --git a/platform/src/api/wechatMp.js b/platform/src/api/wechatMp.js index 3bc1ae3..9c1ee3e 100644 --- a/platform/src/api/wechatMp.js +++ b/platform/src/api/wechatMp.js @@ -141,3 +141,24 @@ export function pushWechatMpTest(data) { data, }); } + +/************************************************* + * 微信小程序:一键登录配置(AppID / AppSecret / 启用) + *************************************************/ + +/** 获取小程序配置(AppSecret 返回掩码) */ +export function getWechatMiniConfig() { + return request({ + url: "/platform/wechatMini/config", + method: "get", + }); +} + +/** 保存小程序配置(掩码 / 空串表示不修改原值) */ +export function saveWechatMiniConfig(data) { + return request({ + url: "/platform/wechatMini/config", + method: "post", + data, + }); +} diff --git a/platform/src/views/system/platformsettings/components/notificationSettings.vue b/platform/src/views/system/platformsettings/components/notificationSettings.vue index 90d2804..4124a23 100644 --- a/platform/src/views/system/platformsettings/components/notificationSettings.vue +++ b/platform/src/views/system/platformsettings/components/notificationSettings.vue @@ -410,6 +410,44 @@ + + + + + + + 开启后 uniapp 小程序端登录页展示「微信手机号一键登录」主入口 + + + + + + + + + + 开启后未通过手机号验证的 openid 也会自动绑定账号;关闭时仅在手机号验证通过后建立绑定 + + + + + + 保存配置 +
+ ① 此处 AppID 必须与 uniapp manifest.json「mp-weixin.appid」一致,否则 code 换 openid 会报 40029;
+ ② 「手机号一键登录」需小程序为已认证企业主体,并在小程序后台开通「手机号快速验证」能力;
+ ③ 保存并启用后,小程序端登录页自动切换为微信认证登录优先、账号密码其次。 +
+
+
+
+ - - - - + + + + + + {{ group.title }} + {{ group.items.length }} + + + + + + + + {{ item.title }} + - {{ item.title }} - {{ item.leaf === false ? (item.docs || 0) + ' 个下级' : shortPath(item.path) }} - + - + @@ -166,11 +412,19 @@ onShow(() => { .page { @include page-shell; + + // 嵌入模式下由父页(tabs.vue)的 scroll-view 统一滚动,这里改为自然高度 + &.embedded { + height: auto; + } } .hero { flex-shrink: 0; padding: calc(var(--status-bar-height, 44px) + 20rpx) $page-padding-x 40rpx; + /* #ifdef MP-WEIXIN */ + padding-top: calc(env(safe-area-inset-top, 25px) + 84rpx); + /* #endif */ color: #ffffff; } @@ -232,7 +486,11 @@ onShow(() => { min-height: 0; overflow-y: auto; -webkit-overflow-scrolling: touch; - padding: 24rpx $page-padding-x 0; + padding: 28rpx $page-padding-x 0; +} + +.block + .block { + margin-top: 32rpx; } .block-head { @@ -243,15 +501,129 @@ onShow(() => { @include section-title; } -.block-sub { +.block-more { + display: flex; + align-items: center; + gap: 4rpx; +} + +.block-more-text { font-size: 24rpx; color: $color-text-muted; } -.empty-box { +/* ============ 概览卡 ============ */ +.kpi-card { @include card; - padding: 56rpx 24rpx; - text-align: center; + display: flex; + padding: 30rpx 0; + margin-bottom: 32rpx; +} + +.kpi-item { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + gap: 6rpx; + position: relative; + + & + &::before { + content: ''; + position: absolute; + left: 0; + top: 6rpx; + bottom: 6rpx; + width: 1rpx; + background: $color-divider; + } +} + +.kpi-value { + font-size: 40rpx; + font-weight: 600; + line-height: 1.1; + color: $color-text; + + &.warn { + color: $color-danger; + } +} + +.kpi-label { + font-size: 22rpx; + color: $color-text-muted; +} + +/* ============ 列表卡 ============ */ +.list-card { + @include card; + overflow: hidden; +} + +.list-item { + display: flex; + align-items: center; + gap: 18rpx; + padding: 26rpx 24rpx; + + & + & { + border-top: 1rpx solid $color-divider; + } + + &:active { + background: $color-bg-page; + } +} + +.list-body { + flex: 1; + min-width: 0; +} + +.list-title { + display: block; + font-size: 28rpx; + color: $color-text; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.list-meta, +.list-time { + display: block; + margin-top: 4rpx; + font-size: 22rpx; + color: $color-text-muted; +} + +.list-time.warn { + color: $color-danger; +} + +.dot { + width: 14rpx; + height: 14rpx; + border-radius: 50%; + background: $color-primary; + flex-shrink: 0; + + &.warn { + background: $color-danger; + } + + &.dot-top { + background: $color-danger; + } +} + +.empty-box { + padding: 56rpx 0; + display: flex; + flex-direction: column; + align-items: center; + gap: 14rpx; } .empty-text { @@ -259,6 +631,182 @@ onShow(() => { color: $color-text-muted; } +/* ============ 常用功能宫格:一行 4 个 ============ + cell 占 25% 宽并自带右侧间隙,容器用负 margin 抵消末列间隙, + 避免使用 calc 除法——小程序端 wxss 对复杂 calc 支持不稳。 */ +.quick-card { + @include card; + padding: 24rpx 20rpx 8rpx; +} + +.grid { + display: flex; + flex-wrap: wrap; + margin-right: -16rpx; +} + +.cell { + width: 25%; + padding-right: 16rpx; + margin-bottom: 16rpx; + box-sizing: border-box; +} + +.fn-card { + height: 100%; + padding: 18rpx 6rpx 16rpx; + border-radius: $radius-md; + background: $color-bg-page; + display: flex; + flex-direction: column; + align-items: center; + gap: 12rpx; + transition: transform 0.15s ease; + + &:active { + transform: scale(0.95); + } +} + +.fn-icon { + @include tint-icon(64rpx, 18rpx); +} + +.fn-name { + max-width: 100%; + font-size: 22rpx; + line-height: 1.3; + color: $color-text; + text-align: center; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.fn-card.locked .fn-name { + color: $color-text-muted; +} + +/* 末位「+」添加格:虚线框,区别于普通功能 */ +.fn-card.fn-add { + background: transparent; + border: 1rpx dashed #c7cede; +} + +.add-icon { + width: 64rpx; + height: 64rpx; + border-radius: 18rpx; + background: #eef1f6; + display: flex; + align-items: center; + justify-content: center; +} + +/* ============ 常用功能选择器 ============ */ +.picker-mask { + position: fixed; + left: 0; + top: 0; + right: 0; + bottom: 0; + background: rgba(15, 23, 42, 0.45); + z-index: 100; + display: flex; + align-items: flex-end; +} + +.picker-sheet { + width: 100%; + max-height: 72vh; + background: $color-card; + border-radius: 28rpx 28rpx 0 0; + display: flex; + flex-direction: column; + padding-bottom: env(safe-area-inset-bottom); +} + +.picker-head { + display: flex; + align-items: center; + justify-content: space-between; + padding: 28rpx $page-padding-x; + border-bottom: 1rpx solid $color-divider; +} + +.picker-title { + font-size: 30rpx; + font-weight: 600; + color: $color-text; +} + +.picker-cancel { + font-size: 28rpx; + color: $color-text-muted; +} + +.picker-save { + font-size: 28rpx; + font-weight: 600; + color: $color-primary; +} + +.picker-tip { + padding: 18rpx $page-padding-x 4rpx; + font-size: 22rpx; + color: $color-text-muted; +} + +.picker-body { + flex: 1; + min-height: 0; + max-height: 52vh; + padding: 8rpx $page-padding-x 24rpx; +} + +.picker-empty { + padding: 80rpx 0; + text-align: center; + font-size: 26rpx; + color: $color-text-muted; +} + +.picker-item { + display: flex; + align-items: center; + gap: 20rpx; + padding: 22rpx 0; + border-bottom: 1rpx solid $color-divider; +} + +.picker-icon { + @include tint-icon(56rpx, 16rpx); + flex-shrink: 0; +} + +.picker-name { + flex: 1; + min-width: 0; + font-size: 28rpx; + color: $color-text; +} + +.picker-check { + width: 40rpx; + height: 40rpx; + border-radius: 50%; + border: 2rpx solid #c7cede; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + + &.on { + background: $color-primary; + border-color: $color-primary; + } +} + .err-box { display: flex; align-items: center; @@ -311,117 +859,7 @@ onShow(() => { line-height: 1.6; } -.grid { - @include card; - display: grid; - grid-template-columns: repeat(4, 1fr); - padding: 28rpx 8rpx 20rpx; -} - -.grid-item { - display: flex; - flex-direction: column; - align-items: center; - gap: 14rpx; - padding: 12rpx 0; - - &:active { - opacity: 0.7; - } -} - -.grid-icon { - @include tint-icon(84rpx, 22rpx); -} - -.grid-name { - font-size: 23rpx; - color: $color-text-secondary; - text-align: center; -} - -.list-card { - @include card; - overflow: hidden; -} - -.list-item { - display: flex; - align-items: center; - gap: 18rpx; - padding: 26rpx 24rpx; - - & + & { - border-top: 1rpx solid $color-divider; - } - - &:active { - background: $color-bg-page; - } -} - -.dot { - width: 14rpx; - height: 14rpx; - border-radius: 50%; - background: $color-primary; - flex-shrink: 0; -} - -.list-body { - flex: 1; - min-width: 0; -} - -.list-title { - display: block; - font-size: 28rpx; - color: $color-text; -} - -.list-meta { - display: block; - margin-top: 4rpx; - font-size: 22rpx; - color: $color-text-muted; -} - -.enter-card { - @include card; - display: flex; - align-items: center; - gap: 18rpx; - padding: 26rpx 24rpx; - - &:active { - opacity: 0.85; - } -} - -.enter-icon { - @include tint-icon(76rpx, 20rpx); -} - -.enter-meta { - flex: 1; - min-width: 0; -} - -.enter-title { - display: block; - font-size: 28rpx; - font-weight: 500; - color: $color-text; -} - -.enter-desc { - display: block; - margin-top: 4rpx; - font-size: 22rpx; - color: $color-text-muted; -} - .bottom-space { - height: 40rpx; + height: 32rpx; } - + \ No newline at end of file diff --git a/uniapp/components/sub/SubMine.vue b/uniapp/components/sub/SubMine.vue index 6d3251a..1044a17 100644 --- a/uniapp/components/sub/SubMine.vue +++ b/uniapp/components/sub/SubMine.vue @@ -1,6 +1,7 @@ -