From 36ff960fbd7e0e74c91388cd0e3b6150c43d1d93 Mon Sep 17 00:00:00 2001 From: lzq <357099073@qq.com> Date: Tue, 3 Nov 2026 11:13:59 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- go/controllers/app_aggregate.go | 111 ++++++ go/controllers/app_notebook.go | 148 ++++++- go/controllers/app_password_store.go | 163 +++++++- go/controllers/app_reminder.go | 207 +++++++++- go/controllers/backend_menu.go | 23 +- go/controllers/platform_modules.go | 132 ++++--- .../sql/alter_system_menu_require_login.sql | 49 +++ .../alter_system_modules_require_login.sql | 46 +++ go/docs/sql/create_guest_tables.sql | 33 ++ go/models/init.go | 3 + go/models/system_menu.go | 40 +- go/models/system_modules.go | 59 +-- go/pkg/identity/identity.go | 9 + go/routers/api/api.go | 363 +++++++++--------- .../views/system/menus/components/edit.vue | 15 + platform/src/views/system/menus/manager.vue | 7 + platform/src/views/system/modules/index.vue | 3 + uniapp/App.vue | 10 +- uniapp/api/guest.js | 32 ++ uniapp/api/request.js | 55 ++- uniapp/api/workspace.js | 29 ++ uniapp/components/sub/SubFunctions.vue | 9 +- uniapp/components/sub/SubHome.vue | 10 +- uniapp/components/sub/SubMine.vue | 6 +- uniapp/components/sub/SubTodo.vue | 6 +- uniapp/pages/login/login.vue | 69 +++- uniapp/pages/nav/nav.vue | 254 +++++++++++- uniapp/pages/tools/notepad/index.vue | 8 +- uniapp/pages/tools/schedule/reminder.vue | 9 +- uniapp/project.config.json | 10 +- uniapp/project.private.config.json | 9 +- uniapp/utils/guest.js | 28 ++ uniapp/utils/subsystem.js | 33 +- 33 files changed, 1644 insertions(+), 344 deletions(-) create mode 100644 go/docs/sql/alter_system_menu_require_login.sql create mode 100644 go/docs/sql/alter_system_modules_require_login.sql create mode 100644 go/docs/sql/create_guest_tables.sql create mode 100644 uniapp/api/guest.js create mode 100644 uniapp/utils/guest.js diff --git a/go/controllers/app_aggregate.go b/go/controllers/app_aggregate.go index 2eb724c..d54030a 100644 --- a/go/controllers/app_aggregate.go +++ b/go/controllers/app_aggregate.go @@ -125,6 +125,117 @@ func (c *AppAggregateController) Home() { }) } +// publicModuleItem 移动端免登录可见的模块卡片:只暴露展示与安全开关,不含菜单/KPI。 +// 供未登录用户渲染 nav 宫格,require_login 决定点击是「进登录引导」还是「直接进入」。 +type publicModuleItem struct { + ID uint64 `json:"id"` + Code string `json:"code"` + Name string `json:"name"` + Icon string `json:"icon"` + Path string `json:"path"` + Description string `json:"description"` + RequireLogin int8 `json:"require_login"` +} + +// PublicModules GET /api/modules/public +// +// 公开接口(无需登录,路径不在 jwt 中间件 protectedPrefixes 内): +// 返回移动端可见的模块宫格 + 每个模块的 require_login 标记, +// 让未登录用户也能进入 nav 默认页并分辨「哪些能免登录用、哪些必须先登录」。 +func (c *AppAggregateController) PublicModules() { + var rows []models.SystemModules + if _, err := models.Orm.QueryTable(new(models.SystemModules)). + Filter("delete_time__isnull", true). + Filter("status", 1). + Filter("is_show", 1). + OrderBy("sort", "id"). + All(&rows); err != nil { + c.serveJSON(map[string]interface{}{"code": 200, "msg": "success", "data": []publicModuleItem{}}) + return + } + // 移动端只展示 app 可见模块(未配置 platforms 视为全端) + rows = services.FilterModulesByPlatform(rows, models.PlatformApp) + + out := make([]publicModuleItem, 0, len(rows)) + for _, m := range rows { + out = append(out, publicModuleItem{ + ID: m.ID, + Code: m.Code, + Name: m.Name, + Icon: m.Icon, + Path: m.Path, + Description: m.Description, + RequireLogin: m.RequireLogin, + }) + } + c.serveJSON(map[string]interface{}{"code": 200, "msg": "success", "data": out}) +} + +// PublicMenus GET /api/menus/public +// +// 公开接口(无需登录):返回移动端免登录菜单(require_login=0)。 +// 免登录控制按菜单粒度配置(Platform 菜单管理),游客持游客令牌直接使用; +// nav 宫格据此判定模块卡片是否可免登录进入(模块级 require_login 不再作为判定依据)。 +// 不做角色/套餐过滤:免登录功能对所有人开放,未配 platforms 的菜单继承所属模块。 +func (c *AppAggregateController) PublicMenus() { + var rows []models.SystemMenu + if _, err := models.Orm.QueryTable(new(models.SystemMenu)). + Filter("status", 1). + Filter("require_login", 0). + All(&rows); err != nil { + c.serveJSON(map[string]interface{}{"code": 200, "msg": "success", "data": []appMenuItem{}}) + return + } + + allModules := c.allModules() + sortedModules := services.SortedModulesForScope(allModules) + + visible := make([]models.SystemMenu, 0, len(rows)) + for _, m := range rows { + if m.Type == 3 { // 接口类菜单是权限标识载体,不下发 + continue + } + if m.IsVisible != nil && *m.IsVisible == 0 { + continue + } + if !services.MenuVisibleOn(m, sortedModules, allModules, models.PlatformApp) { + continue + } + visible = append(visible, m) + } + + // 公开集合内统计子节点数:目录若只包含未放开(或不存在)的子菜单,leaf 为 true 但不跳任何页, + // 前端按 NATIVE_RULES 解析不到路由时会落「开发中」占位,不会误入受保护页。 + childCount := map[int64]int{} + for _, m := range visible { + if m.Pid != 0 { + childCount[m.Pid]++ + } + } + + out := make([]appMenuItem, 0, len(visible)) + for _, m := range visible { + item := appMenuItem{ + ID: m.ID, + Pid: m.Pid, + Type: m.Type, + Sort: m.Sort, + Title: strings.TrimSpace(m.Title), + } + if m.Path != nil { + item.Path = strings.TrimSpace(*m.Path) + } + if m.Icon != nil { + item.Icon = strings.TrimSpace(*m.Icon) + } + item.Module = appModuleCodeOfPath(item.Path, allModules) + item.Leaf = childCount[int64(m.ID)] == 0 + out = append(out, item) + } + sort.SliceStable(out, func(i, j int) bool { return out[i].Sort < out[j].Sort }) + c.serveJSON(map[string]interface{}{"code": 200, "msg": "success", "data": out}) +} + // appQuickMenuIds 读取当前用户自定义的常用功能菜单 ID 列表(有序)。 // 未设置或解析失败一律返回空数组,前端据此回落到默认取前 N 个叶子菜单。 func (c *AppAggregateController) appQuickMenuIds(tid, uid uint64) []uint64 { diff --git a/go/controllers/app_notebook.go b/go/controllers/app_notebook.go index 47df563..e6480a8 100644 --- a/go/controllers/app_notebook.go +++ b/go/controllers/app_notebook.go @@ -7,6 +7,8 @@ import ( "server/models" "server/pkg/identity" "server/pkg/jwtutil" + "server/services/gueststore" + "sort" "strconv" "strings" "time" @@ -19,9 +21,9 @@ type AppNotebookController struct { beego.Controller } -// 只放行移动端与租户端令牌:/api/** 仅被 uniapp 调用,平台令牌不该能打移动端接口。 +// 放行移动端、租户端与游客令牌:游客调用时不进业务表,分流到 gueststore。 func (c *AppNotebookController) appNotebookClaims() (*jwtutil.Claims, error) { - return identity.Claims(c.Ctx.Request.Header.Get("Authorization"), identity.EndApp, identity.EndBackend) + return identity.Claims(c.Ctx.Request.Header.Get("Authorization"), identity.EndApp, identity.EndBackend, identity.EndGuest) } func (c *AppNotebookController) nbJsonErr(httpStatus, bizCode int, msg string) { @@ -45,6 +47,11 @@ func (c *AppNotebookController) GetList() { keyword := strings.TrimSpace(c.GetString("keyword")) + if identity.IsGuest(claims) { + c.guestList(claims, keyword) + return + } + qs := models.Orm.QueryTable(new(models.BackendNotebook)). Filter("is_deleted", 0). Filter("user_id", claims.UserID) @@ -109,6 +116,11 @@ func (c *AppNotebookController) GetDetail() { return } + if identity.IsGuest(claims) { + c.guestDetail(claims, id) + return + } + var note models.BackendNotebook err = models.Orm.QueryTable(new(models.BackendNotebook)). Filter("id", id). @@ -164,6 +176,11 @@ func (c *AppNotebookController) Create() { title = "无标题" } + if identity.IsGuest(claims) { + c.guestCreateItem(claims, title, payload.Content, payload.Pinned) + return + } + userID := uint64(claims.UserID) pinned := int8(0) if payload.Pinned { @@ -224,6 +241,11 @@ func (c *AppNotebookController) Update() { return } + if identity.IsGuest(claims) { + c.guestUpdateItem(claims, id, payload.Title, payload.Content, payload.Pinned) + return + } + var note models.BackendNotebook err = models.Orm.QueryTable(new(models.BackendNotebook)). Filter("id", id). @@ -286,6 +308,19 @@ func (c *AppNotebookController) Delete() { return } + if identity.IsGuest(claims) { + if err := gueststore.Delete(uint64(claims.UserID), guestModuleNotebook, id); err != nil { + if err == gueststore.ErrNotFound { + c.nbJsonErr(404, 404, "笔记不存在") + return + } + c.nbJsonErr(500, 500, "删除失败") + return + } + c.nbOk(nil) + return + } + var note models.BackendNotebook err = models.Orm.QueryTable(new(models.BackendNotebook)). Filter("id", id). @@ -327,6 +362,11 @@ func (c *AppNotebookController) TogglePin() { return } + if identity.IsGuest(claims) { + c.guestTogglePin(claims, id) + return + } + var note models.BackendNotebook err = models.Orm.QueryTable(new(models.BackendNotebook)). Filter("id", id). @@ -361,3 +401,107 @@ func (c *AppNotebookController) TogglePin() { "pinned": newPinned == 1, }) } + +// ---------- 游客分支(免登录态):数据存 yz_guest_data,响应结构与登录态一致 ---------- + +// guestNoteFull 读取并补全一条游客笔记(ID/时间以存储行为准)。 +func guestNoteFull(r gueststore.Row) guestNoteItem { + var it guestNoteItem + if json.Unmarshal(r.Payload, &it) != nil { + it = guestNoteItem{Title: "无标题"} + } + it.ID = r.ID + it.CreatedAt = r.CreateTime.Format("2006-01-02 15:04:05") + it.UpdatedAt = it.CreatedAt + if r.UpdateTime != nil { + it.UpdatedAt = r.UpdateTime.Format("2006-01-02 15:04:05") + } + return it +} + +func (c *AppNotebookController) guestList(claims *jwtutil.Claims, keyword string) { + rows, err := gueststore.List(uint64(claims.UserID), guestModuleNotebook) + if err != nil { + c.nbJsonErr(500, 500, "查询失败") + return + } + items := make([]guestNoteItem, 0, len(rows)) + for _, r := range rows { + it := guestNoteFull(r) + if keyword != "" && !strings.Contains(it.Title, keyword) { + continue + } + items = append(items, it) + } + // 与登录态一致:置顶优先,再按更新时间倒序 + sort.SliceStable(items, func(i, j int) bool { + if items[i].Pinned != items[j].Pinned { + return items[i].Pinned + } + return items[i].UpdatedAt > items[j].UpdatedAt + }) + c.nbOk(map[string]interface{}{"list": items, "total": len(items)}) +} + +func (c *AppNotebookController) guestDetail(claims *jwtutil.Claims, id uint64) { + r, err := gueststore.Get(uint64(claims.UserID), guestModuleNotebook, id) + if err != nil { + c.nbJsonErr(404, 404, "笔记不存在") + return + } + c.nbOk(guestNoteFull(r)) +} + +func (c *AppNotebookController) guestCreateItem(claims *jwtutil.Claims, title, content string, pinned bool) { + now := time.Now().Format("2006-01-02 15:04:05") + it := guestNoteItem{Title: title, Content: content, Pinned: pinned, CreatedAt: now, UpdatedAt: now} + newID, err := gueststore.Insert(uint64(claims.UserID), guestModuleNotebook, it) + if err != nil { + c.nbJsonErr(500, 500, "创建失败") + return + } + c.nbOk(map[string]interface{}{ + "id": newID, "title": title, "content": content, "pinned": pinned, + }) +} + +func (c *AppNotebookController) guestUpdateItem(claims *jwtutil.Claims, id uint64, title, content string, pinned *bool) { + r, err := gueststore.Get(uint64(claims.UserID), guestModuleNotebook, id) + if err != nil { + c.nbJsonErr(404, 404, "笔记不存在") + return + } + it := guestNoteFull(r) + if t := strings.TrimSpace(title); t != "" { + it.Title = t + } + it.Content = content + if pinned != nil { + it.Pinned = *pinned + } + it.UpdatedAt = time.Now().Format("2006-01-02 15:04:05") + if err = gueststore.Update(uint64(claims.UserID), guestModuleNotebook, id, it); err != nil { + c.nbJsonErr(500, 500, "更新失败") + return + } + c.nbOk(map[string]interface{}{ + "id": it.ID, "title": it.Title, "content": it.Content, "pinned": it.Pinned, + "created_at": it.CreatedAt, "updated_at": it.UpdatedAt, + }) +} + +func (c *AppNotebookController) guestTogglePin(claims *jwtutil.Claims, id uint64) { + r, err := gueststore.Get(uint64(claims.UserID), guestModuleNotebook, id) + if err != nil { + c.nbJsonErr(404, 404, "笔记不存在") + return + } + it := guestNoteFull(r) + it.Pinned = !it.Pinned + it.UpdatedAt = time.Now().Format("2006-01-02 15:04:05") + if err = gueststore.Update(uint64(claims.UserID), guestModuleNotebook, id, it); err != nil { + c.nbJsonErr(500, 500, "操作失败") + return + } + c.nbOk(map[string]interface{}{"pinned": it.Pinned}) +} diff --git a/go/controllers/app_password_store.go b/go/controllers/app_password_store.go index 92c0a71..12a7671 100644 --- a/go/controllers/app_password_store.go +++ b/go/controllers/app_password_store.go @@ -1,13 +1,16 @@ package controllers import ( + "encoding/json" "strconv" "strings" + "time" "server/middleware" "server/models" "server/pkg/identity" "server/pkg/jwtutil" + "server/services/gueststore" "github.com/beego/beego/v2/client/orm" "github.com/beego/beego/v2/core/logs" @@ -24,12 +27,18 @@ type AppPasswordStoreController struct { beego.Controller } -// 只放行移动端与租户端令牌:/api/** 仅被 uniapp 调用,平台令牌不该能打移动端接口。 +// 放行移动端、租户端与游客令牌:游客 tid=0 合法,数据进 gueststore 而非业务表。 func (c *AppPasswordStoreController) appPwdClaims() (*jwtutil.Claims, error) { - claims, err := identity.Claims(c.Ctx.Request.Header.Get("Authorization"), identity.EndApp, identity.EndBackend) + claims, err := identity.Claims(c.Ctx.Request.Header.Get("Authorization"), identity.EndApp, identity.EndBackend, identity.EndGuest) if err != nil { return nil, err } + if identity.IsGuest(claims) { + if claims.UserID <= 0 { + return nil, orm.ErrNoRows + } + return claims, nil + } if claims.UserID <= 0 || claims.TenantId <= 0 { return nil, orm.ErrNoRows } @@ -43,6 +52,10 @@ func (c *AppPasswordStoreController) GetList() { passwordReply(&c.Controller, 401, "未登录或无权限", nil) return } + if identity.IsGuest(cl) { + c.guestList(cl, strings.TrimSpace(c.GetString("keyword"))) + return + } qs := models.Orm.QueryTable(new(models.BackendPasswordStore)). Filter("is_deleted", 0).Filter("tid", cl.TenantId).Filter("user_id", cl.UserID) if k := strings.TrimSpace(c.GetString("keyword")); k != "" { @@ -82,6 +95,10 @@ func (c *AppPasswordStoreController) GetDetail() { passwordReply(&c.Controller, 400, "参数错误", nil) return } + if identity.IsGuest(cl) { + c.guestDetail(cl, uint64(id)) + return + } var m models.BackendPasswordStore e = models.Orm.QueryTable(new(models.BackendPasswordStore)). Filter("id", id).Filter("is_deleted", 0).Filter("tid", cl.TenantId).Filter("user_id", cl.UserID).One(&m) @@ -121,6 +138,10 @@ func (c *AppPasswordStoreController) Create() { passwordReply(&c.Controller, 400, e.Error(), nil) return } + if identity.IsGuest(cl) { + c.guestCreate(cl, p) + return + } qs := models.Orm.QueryTable(new(models.BackendPasswordStore)). Filter("is_deleted", 0).Filter("tid", cl.TenantId).Filter("user_id", cl.UserID) if existing, e := findSamePlatformRecord(qs, 0, p.Platform, p.URL); e != nil { @@ -172,6 +193,10 @@ func (c *AppPasswordStoreController) Update() { passwordReply(&c.Controller, 400, e.Error(), nil) return } + if identity.IsGuest(cl) { + c.guestUpdate(cl, uint64(id), p) + return + } var m models.BackendPasswordStore e = models.Orm.QueryTable(new(models.BackendPasswordStore)). Filter("id", id).Filter("is_deleted", 0).Filter("tid", cl.TenantId).Filter("user_id", cl.UserID).One(&m) @@ -221,6 +246,18 @@ func (c *AppPasswordStoreController) Delete() { passwordReply(&c.Controller, 400, "参数错误", nil) return } + if identity.IsGuest(cl) { + if err := gueststore.Delete(uint64(cl.UserID), guestModulePasswordStore, uint64(id)); err != nil { + if err == gueststore.ErrNotFound { + passwordReply(&c.Controller, 404, "记录不存在", nil) + return + } + passwordReply(&c.Controller, 500, "删除失败", nil) + return + } + passwordReply(&c.Controller, 200, "success", nil) + return + } var m models.BackendPasswordStore e = models.Orm.QueryTable(new(models.BackendPasswordStore)). Filter("id", id).Filter("is_deleted", 0).Filter("tid", cl.TenantId).Filter("user_id", cl.UserID).One(&m) @@ -241,3 +278,125 @@ func (c *AppPasswordStoreController) Delete() { middleware.WriteDeleteLog(uint64(cl.UserID), cl.TenantId, "passwordStore", m.Platform) passwordReply(&c.Controller, 200, "success", nil) } + +// ---------- 游客分支(免登录态):数据存 yz_guest_data,响应结构与登录态一致 ---------- + +func guestPwdFull(r gueststore.Row) PasswordStoreItemDTO { + var it PasswordStoreItemDTO + if json.Unmarshal(r.Payload, &it) != nil { + it = PasswordStoreItemDTO{} + } + it.ID = r.ID + it.Tid = 0 + it.UserID = 0 + it.CreateTime = r.CreateTime + it.UpdateTime = r.UpdateTime + if it.Accounts == nil { + it.Accounts = []models.PasswordAccountItem{} + } + return it +} + +// guestPwdSamePlatform 与登录态 findSamePlatformRecord 同语义:平台+网址重复。 +func guestPwdSamePlatform(it PasswordStoreItemDTO, platform, url string, excludeID uint64) bool { + if it.ID == excludeID { + return false + } + return strings.EqualFold(strings.TrimSpace(it.Platform), strings.TrimSpace(platform)) && + strings.TrimSpace(it.URL) == strings.TrimSpace(url) +} + +func guestPwdKeywordHit(it PasswordStoreItemDTO, k string) bool { + k = strings.ToLower(k) + if strings.Contains(strings.ToLower(it.Platform), k) || + strings.Contains(strings.ToLower(it.URL), k) || + strings.Contains(strings.ToLower(it.Remark), k) { + return true + } + for _, a := range it.Accounts { + if strings.Contains(strings.ToLower(a.Username), k) { + return true + } + } + return false +} + +func (c *AppPasswordStoreController) guestList(cl *jwtutil.Claims, keyword string) { + rows, err := gueststore.List(uint64(cl.UserID), guestModulePasswordStore) + if err != nil { + passwordReply(&c.Controller, 500, "查询失败", nil) + return + } + res := make([]PasswordStoreItemDTO, 0, len(rows)) + for _, r := range rows { + it := guestPwdFull(r) + if keyword != "" && !guestPwdKeywordHit(it, keyword) { + continue + } + res = append(res, it) + } + passwordReply(&c.Controller, 200, "success", map[string]interface{}{"list": res, "total": len(res)}) +} + +func (c *AppPasswordStoreController) guestDetail(cl *jwtutil.Claims, id uint64) { + r, err := gueststore.Get(uint64(cl.UserID), guestModulePasswordStore, id) + if err != nil { + passwordReply(&c.Controller, 404, "记录不存在", nil) + return + } + passwordReply(&c.Controller, 200, "success", guestPwdFull(r)) +} + +func (c *AppPasswordStoreController) guestCreate(cl *jwtutil.Claims, p passwordPayload) { + rows, err := gueststore.List(uint64(cl.UserID), guestModulePasswordStore) + if err != nil { + passwordReply(&c.Controller, 500, "查询失败", nil) + return + } + for _, r := range rows { + if guestPwdSamePlatform(guestPwdFull(r), p.Platform, p.URL, 0) { + passwordReply(&c.Controller, 400, "同平台同网址记录已存在,请勿重复添加", nil) + return + } + } + it := PasswordStoreItemDTO{ + Platform: p.Platform, URL: p.URL, Accounts: p.Accounts, Remark: p.Remark, + CreateTime: time.Now(), + } + newID, err := gueststore.Insert(uint64(cl.UserID), guestModulePasswordStore, it) + if err != nil { + passwordReply(&c.Controller, 500, "创建失败", nil) + return + } + it.ID = newID + passwordReply(&c.Controller, 200, "success", it) +} + +func (c *AppPasswordStoreController) guestUpdate(cl *jwtutil.Claims, id uint64, p passwordPayload) { + r, err := gueststore.Get(uint64(cl.UserID), guestModulePasswordStore, id) + if err != nil { + passwordReply(&c.Controller, 404, "记录不存在", nil) + return + } + rows, err := gueststore.List(uint64(cl.UserID), guestModulePasswordStore) + if err != nil { + passwordReply(&c.Controller, 500, "查询失败", nil) + return + } + for _, rr := range rows { + if guestPwdSamePlatform(guestPwdFull(rr), p.Platform, p.URL, id) { + passwordReply(&c.Controller, 400, "同平台同网址记录已存在,请勿重复添加", nil) + return + } + } + it := guestPwdFull(r) + it.Platform = p.Platform + it.URL = p.URL + it.Accounts = p.Accounts + it.Remark = p.Remark + if err = gueststore.Update(uint64(cl.UserID), guestModulePasswordStore, id, it); err != nil { + passwordReply(&c.Controller, 500, "更新失败", nil) + return + } + passwordReply(&c.Controller, 200, "success", it) +} diff --git a/go/controllers/app_reminder.go b/go/controllers/app_reminder.go index f3abff0..9669046 100644 --- a/go/controllers/app_reminder.go +++ b/go/controllers/app_reminder.go @@ -13,6 +13,7 @@ import ( "server/models" "server/pkg/identity" "server/pkg/jwtutil" + "server/services/gueststore" "github.com/beego/beego/v2/client/orm" beego "github.com/beego/beego/v2/server/web" @@ -22,9 +23,9 @@ type AppReminderController struct { beego.Controller } -// 只放行移动端与租户端令牌:/api/** 仅被 uniapp 调用,平台令牌不该能打移动端接口。 +// 放行移动端、租户端与游客令牌:游客调用时不进业务表,分流到 gueststore。 func (c *AppReminderController) appClaims() (*jwtutil.Claims, error) { - return identity.Claims(c.Ctx.Request.Header.Get("Authorization"), identity.EndApp, identity.EndBackend) + return identity.Claims(c.Ctx.Request.Header.Get("Authorization"), identity.EndApp, identity.EndBackend, identity.EndGuest) } func (c *AppReminderController) jsonErr(httpStatus, bizCode int, msg string) { @@ -75,6 +76,11 @@ func (c *AppReminderController) GetList() { pageSize = 20 } + if identity.IsGuest(claims) { + c.guestList(claims, page, pageSize, keyword, status) + return + } + qs := models.Orm.QueryTable(new(models.BackendSchedule)). Filter("user_id", claims.UserID). Filter("tid", claims.TenantId) @@ -171,6 +177,11 @@ func (c *AppReminderController) GetDetail() { return } + if identity.IsGuest(claims) { + c.guestDetail(claims, id) + return + } + var schedule models.BackendSchedule err = models.Orm.QueryTable(new(models.BackendSchedule)). Filter("id", id). @@ -272,6 +283,11 @@ func (c *AppReminderController) Create() { title = "日程提醒" } + if identity.IsGuest(claims) { + c.guestCreate(claims, p, content, schedTime) + return + } + schedule := models.BackendSchedule{ Tid: claims.TenantId, Title: title, @@ -363,6 +379,11 @@ func (c *AppReminderController) Update() { return } + if identity.IsGuest(claims) { + c.guestUpdate(claims, id, p) + return + } + var schedule models.BackendSchedule err = models.Orm.QueryTable(new(models.BackendSchedule)). Filter("id", id). @@ -482,6 +503,11 @@ func (c *AppReminderController) Delete() { return } + if identity.IsGuest(claims) { + c.guestDelete(claims, id) + return + } + var schedule models.BackendSchedule err = models.Orm.QueryTable(new(models.BackendSchedule)). Filter("id", id). @@ -543,6 +569,11 @@ func (c *AppReminderController) ToggleComplete() { return } + if identity.IsGuest(claims) { + c.guestToggle(claims, id) + return + } + var schedule models.BackendSchedule err = models.Orm.QueryTable(new(models.BackendSchedule)). Filter("id", id). @@ -587,3 +618,175 @@ func (c *AppReminderController) ToggleComplete() { "is_finished": newStatus == 2, }) } + +// ---------- 游客分支(免登录态):数据存 yz_guest_data,响应结构与登录态一致。 +// 游客日程不接提醒发送链路(无短信/邮件推送),仅作待办记录;转正合并时重建提醒。 ---------- + +func guestScheduleItemMap(it guestScheduleItem) map[string]interface{} { + return map[string]interface{}{ + "id": it.ID, + "content": it.Content, + "schedule_time": it.ScheduleTime, + "remind_channels": it.RemindChannels, + "is_finished": it.IsFinished, + "advance_minutes": it.AdvanceMinutes, + "repeat_interval_minutes": it.RepeatIntervalMinutes, + "max_send_count": it.MaxSendCount, + "created_at": it.CreatedAt, + "updated_at": it.UpdatedAt, + } +} + +func guestScheduleFull(r gueststore.Row) guestScheduleItem { + var it guestScheduleItem + if json.Unmarshal(r.Payload, &it) != nil { + it = guestScheduleItem{Content: "(数据异常)"} + } + it.ID = r.ID + it.CreatedAt = r.CreateTime.Format("2006-01-02 15:04:05") + it.UpdatedAt = it.CreatedAt + if r.UpdateTime != nil { + it.UpdatedAt = r.UpdateTime.Format("2006-01-02 15:04:05") + } + if it.RemindChannels == nil { + it.RemindChannels = []string{} + } + return it +} + +func (c *AppReminderController) guestList(claims *jwtutil.Claims, page, pageSize int, keyword, status string) { + rows, err := gueststore.List(uint64(claims.UserID), guestModuleSchedule) + if err != nil { + c.jsonErr(500, 500, "查询失败") + return + } + items := make([]map[string]interface{}, 0, len(rows)) + for _, r := range rows { + it := guestScheduleFull(r) + if keyword != "" && !strings.Contains(it.Content, keyword) { + continue + } + if status == "pending" && it.IsFinished { + continue + } + if status == "done" && !it.IsFinished { + continue + } + items = append(items, guestScheduleItemMap(it)) + } + total := len(items) + start := (page - 1) * pageSize + if start > total { + start = total + } + end := start + pageSize + if end > total { + end = total + } + c.ok(map[string]interface{}{ + "list": items[start:end], "total": total, "page": page, "pageSize": pageSize, + }) +} + +func (c *AppReminderController) guestDetail(claims *jwtutil.Claims, id uint64) { + r, err := gueststore.Get(uint64(claims.UserID), guestModuleSchedule, id) + if err != nil { + c.jsonErr(404, 404, "日程未找到") + return + } + c.ok(guestScheduleItemMap(guestScheduleFull(r))) +} + +func (c *AppReminderController) guestCreate(claims *jwtutil.Claims, p appSchedulePayload, content string, schedTime time.Time) { + _ = schedTime + now := time.Now().Format("2006-01-02 15:04:05") + channels := p.RemindChannels + if channels == nil { + channels = []string{} + } + it := guestScheduleItem{ + Content: content, + ScheduleTime: p.ScheduleTime, + RemindChannels: channels, + AdvanceMinutes: p.AdvanceMinutes, + RepeatIntervalMinutes: p.RepeatIntervalMinutes, + MaxSendCount: p.MaxSendCount, + IsFinished: false, + CreatedAt: now, + UpdatedAt: now, + } + newID, err := gueststore.Insert(uint64(claims.UserID), guestModuleSchedule, it) + if err != nil { + c.jsonErr(500, 500, "保存日程失败") + return + } + c.ok(map[string]interface{}{"id": newID}) +} + +func (c *AppReminderController) guestUpdate(claims *jwtutil.Claims, id uint64, p appSchedulePayload) { + r, err := gueststore.Get(uint64(claims.UserID), guestModuleSchedule, id) + if err != nil { + c.jsonErr(404, 404, "日程未找到") + return + } + it := guestScheduleFull(r) + if it.IsFinished { + c.jsonErr(400, 400, "该日程提醒已全部结束,无法编辑") + return + } + if content := strings.TrimSpace(p.Content); content != "" { + it.Content = content + } + if strings.TrimSpace(p.ScheduleTime) != "" { + if _, perr := time.ParseInLocation("2006-01-02 15:04:05", p.ScheduleTime, time.Local); perr != nil { + c.jsonErr(400, 400, "日程时间格式不合法") + return + } + it.ScheduleTime = p.ScheduleTime + } + if p.RemindChannels != nil { + it.RemindChannels = p.RemindChannels + } + it.AdvanceMinutes = p.AdvanceMinutes + it.RepeatIntervalMinutes = p.RepeatIntervalMinutes + it.MaxSendCount = p.MaxSendCount + it.UpdatedAt = time.Now().Format("2006-01-02 15:04:05") + if err = gueststore.Update(uint64(claims.UserID), guestModuleSchedule, id, it); err != nil { + c.jsonErr(500, 500, "更新失败") + return + } + c.ok(nil) +} + +func (c *AppReminderController) guestDelete(claims *jwtutil.Claims, id uint64) { + r, err := gueststore.Get(uint64(claims.UserID), guestModuleSchedule, id) + if err != nil { + c.jsonErr(404, 404, "日程未找到") + return + } + if guestScheduleFull(r).IsFinished { + c.jsonErr(400, 400, "该日程提醒已全部结束,无法删除") + return + } + if err = gueststore.Delete(uint64(claims.UserID), guestModuleSchedule, id); err != nil { + c.jsonErr(500, 500, "删除失败") + return + } + c.ok(nil) +} + +func (c *AppReminderController) guestToggle(claims *jwtutil.Claims, id uint64) { + r, err := gueststore.Get(uint64(claims.UserID), guestModuleSchedule, id) + if err != nil { + c.jsonErr(404, 404, "日程未找到") + return + } + it := guestScheduleFull(r) + it.IsFinished = !it.IsFinished + it.UpdatedAt = time.Now().Format("2006-01-02 15:04:05") + if err = gueststore.Update(uint64(claims.UserID), guestModuleSchedule, id, it); err != nil { + c.jsonErr(500, 500, "操作失败") + return + } + c.ok(map[string]interface{}{"is_finished": it.IsFinished}) +} diff --git a/go/controllers/backend_menu.go b/go/controllers/backend_menu.go index 4ce90c8..89b1871 100644 --- a/go/controllers/backend_menu.go +++ b/go/controllers/backend_menu.go @@ -76,6 +76,7 @@ type menuPayload struct { Sort *int64 `json:"sort"` Status *int8 `json:"status"` IsVisible *int8 `json:"is_visible"` + RequireLogin *int8 `json:"require_login"` // 1=必须登录(默认) 0=可免登录(游客可用) Views []int `json:"views"` Platforms []string `json:"platforms"` // 展示端:["web"] ["app"] ["web","app"];空=继承所属模块 Type *int8 `json:"type"` @@ -417,6 +418,7 @@ type menuNode struct { Sort int64 `json:"sort"` Status int8 `json:"status"` IsVisible *int8 `json:"is_visible,omitempty"` + RequireLogin int8 `json:"require_login"` // 1=必须登录 0=可免登录(游客) Views []int `json:"views,omitempty"` Platforms []string `json:"platforms,omitempty"` // 展示端,空=继承所属模块 Type int8 `json:"type"` @@ -430,15 +432,16 @@ func buildMenuTree(menus []models.SystemMenu, pid int64) []*menuNode { for _, m := range menus { if m.Pid == pid { node := &menuNode{ - ID: m.ID, - Pid: m.Pid, - Title: m.Title, - Sort: m.Sort, - Status: m.Status, - IsVisible: m.IsVisible, - Views: parseViews(m.Views), - Platforms: services.ParsePlatforms(m.Platforms), - Type: m.Type, + ID: m.ID, + Pid: m.Pid, + Title: m.Title, + Sort: m.Sort, + Status: m.Status, + IsVisible: m.IsVisible, + RequireLogin: m.RequireLogin, + Views: parseViews(m.Views), + Platforms: services.ParsePlatforms(m.Platforms), + Type: m.Type, } if m.Path != nil { node.Path = *m.Path @@ -514,6 +517,7 @@ func (c *BackendMenuController) CreateMenu() { Sort: valueInt64(payload.Sort, 0), Status: valueInt8(payload.Status, 1), IsVisible: ptrInt8(valueInt8(payload.IsVisible, 1)), + RequireLogin: valueInt8(payload.RequireLogin, 1), Views: &viewsStr, Type: valueInt8(payload.Type, 1), Path: ptrString(valueString(payload.Path, "")), @@ -565,6 +569,7 @@ func (c *BackendMenuController) UpdateMenu() { "sort": valueInt64(payload.Sort, 0), "status": valueInt8(payload.Status, 1), "is_visible": valueInt8(payload.IsVisible, 1), + "require_login": valueInt8(payload.RequireLogin, 1), "views": string(viewsBytes), "platforms": services.NormalizePlatforms(payload.Platforms), "type": valueInt8(payload.Type, 1), diff --git a/go/controllers/platform_modules.go b/go/controllers/platform_modules.go index a7942d5..e158f92 100644 --- a/go/controllers/platform_modules.go +++ b/go/controllers/platform_modules.go @@ -52,7 +52,6 @@ func (c *PlatformModulesController) modulesClaims() (*jwtutil.Claims, error) { return identity.ClaimsByPath(c.Ctx.Request.Header.Get("Authorization"), c.Ctx.Request.URL.Path) } - func (c *PlatformModulesController) jsonErr(httpStatus, bizCode int, msg string) { c.Ctx.Output.SetStatus(httpStatus) c.Data["json"] = map[string]interface{}{"code": bizCode, "msg": msg} @@ -79,18 +78,19 @@ func (c *PlatformModulesController) GetList() { list := make([]map[string]interface{}, 0, len(rows)) for i := range rows { list = append(list, map[string]interface{}{ - "id": rows[i].ID, - "mid": rows[i].Mid, - "name": rows[i].Name, - "code": rows[i].Code, - "path": rows[i].Path, - "icon": rows[i].Icon, - "description": rows[i].Description, - "type": rows[i].Type, - "sort": rows[i].Sort, - "status": rows[i].Status, - "is_show": rows[i].IsShow, - "platforms": platformList(&rows[i]), + "id": rows[i].ID, + "mid": rows[i].Mid, + "name": rows[i].Name, + "code": rows[i].Code, + "path": rows[i].Path, + "icon": rows[i].Icon, + "description": rows[i].Description, + "type": rows[i].Type, + "sort": rows[i].Sort, + "status": rows[i].Status, + "is_show": rows[i].IsShow, + "require_login": rows[i].RequireLogin, + "platforms": platformList(&rows[i]), }) } c.Data["json"] = map[string]interface{}{ @@ -156,22 +156,54 @@ func (c *PlatformModulesController) GetDetail() { c.jsonErr(404, 404, "模块不存在") return } - c.Data["json"] = map[string]interface{}{"code": 200, "msg": "获取成功", "data": row} + // 与 GetList 保持一致的规范化输出:platforms 统一为数组、带上 require_login。 + // 否则详情回填时 platforms 是原始 JSON 字符串,编辑提交会因 []string 反序列化失败报 400。 + c.Data["json"] = map[string]interface{}{ + "code": 200, + "msg": "获取成功", + "data": map[string]interface{}{ + "id": row.ID, + "mid": row.Mid, + "name": row.Name, + "code": row.Code, + "path": row.Path, + "icon": row.Icon, + "description": row.Description, + "type": row.Type, + "sort": row.Sort, + "status": row.Status, + "is_show": row.IsShow, + "require_login": row.RequireLogin, + "platforms": platformList(&row), + }, + } _ = c.ServeJSON() } type modulePayload struct { - Mid *uint64 `json:"mid"` - Name string `json:"name"` - Code string `json:"code"` - Path string `json:"path"` - Icon string `json:"icon"` - Description string `json:"description"` - Type int `json:"type"` - Sort int `json:"sort"` - Status int8 `json:"status"` - IsShow int8 `json:"is_show"` - Platforms []string `json:"platforms"` // 展示端:["web"] ["app"] ["web","app"];空=全端 + Mid *uint64 `json:"mid"` + Name string `json:"name"` + Code string `json:"code"` + Path string `json:"path"` + Icon string `json:"icon"` + Description string `json:"description"` + Type int `json:"type"` + Sort int `json:"sort"` + Status int8 `json:"status"` + IsShow int8 `json:"is_show"` + Platforms []string `json:"platforms"` // 展示端:["web"] ["app"] ["web","app"];空=全端 + RequireLogin *int8 `json:"require_login"` // 是否必须登录:1=必须 0=可免登录;指针以区分「未传」(默认 1) +} + +// resolveRequireLogin 未传该字段时回落为 1(必须登录),保持既有行为不变。 +func resolveRequireLogin(v *int8) int8 { + if v == nil { + return 1 + } + if *v != 0 && *v != 1 { + return 1 + } + return *v } // Add POST /platform/modules @@ -213,18 +245,19 @@ func (c *PlatformModulesController) Add() { now := time.Now() platforms := services.NormalizePlatforms(p.Platforms) row := &models.SystemModules{ - Mid: p.Mid, - Name: p.Name, - Code: p.Code, - Path: strings.TrimSpace(p.Path), - Icon: strings.TrimSpace(p.Icon), - Description: strings.TrimSpace(p.Description), - Type: p.Type, - Sort: p.Sort, - Status: p.Status, - IsShow: p.IsShow, - CreateTime: &now, - UpdateTime: &now, + Mid: p.Mid, + Name: p.Name, + Code: p.Code, + Path: strings.TrimSpace(p.Path), + Icon: strings.TrimSpace(p.Icon), + Description: strings.TrimSpace(p.Description), + Type: p.Type, + Sort: p.Sort, + Status: p.Status, + IsShow: p.IsShow, + RequireLogin: resolveRequireLogin(p.RequireLogin), + CreateTime: &now, + UpdateTime: &now, } if platforms != "" { row.Platforms = &platforms @@ -284,18 +317,19 @@ func (c *PlatformModulesController) Edit() { now := time.Now() // 展示端为空串表示「全端」,需显式落库以覆盖历史值 up := map[string]interface{}{ - "mid": p.Mid, - "name": p.Name, - "code": p.Code, - "path": strings.TrimSpace(p.Path), - "icon": strings.TrimSpace(p.Icon), - "description": strings.TrimSpace(p.Description), - "type": p.Type, - "sort": p.Sort, - "status": p.Status, - "is_show": p.IsShow, - "platforms": services.NormalizePlatforms(p.Platforms), - "update_time": now, + "mid": p.Mid, + "name": p.Name, + "code": p.Code, + "path": strings.TrimSpace(p.Path), + "icon": strings.TrimSpace(p.Icon), + "description": strings.TrimSpace(p.Description), + "type": p.Type, + "sort": p.Sort, + "status": p.Status, + "is_show": p.IsShow, + "require_login": resolveRequireLogin(p.RequireLogin), + "platforms": services.NormalizePlatforms(p.Platforms), + "update_time": now, } n, err := models.Orm.QueryTable(new(models.SystemModules)). Filter("id", id). diff --git a/go/docs/sql/alter_system_menu_require_login.sql b/go/docs/sql/alter_system_menu_require_login.sql new file mode 100644 index 0000000..bff48e6 --- /dev/null +++ b/go/docs/sql/alter_system_menu_require_login.sql @@ -0,0 +1,49 @@ +-- ============================================================= +-- yz_system_menu 增加「是否必须登录」字段 require_login +-- +-- 用途:免登录控制从模块级下沉到菜单级——Platform 菜单管理逐条配置 +-- 某菜单(功能入口)是否允许移动端游客(持游客令牌)免登录使用。 +-- 1 = 必须登录(默认,保持既有行为) +-- 0 = 可免登录使用(进入 nav 公开菜单 /api/menus/public) +-- +-- 关联:yz_system_modules.require_login 保留但移动端不再作为判定依据, +-- 以菜单级开关为准(模块宫格是否可点 = 该模块下存在免登录菜单)。 +-- +-- 幂等:MySQL 的 ALTER TABLE ADD COLUMN 不支持 IF NOT EXISTS, +-- 重复执行会报 1060 Duplicate column name。这里用 information_schema +-- 判断列是否存在,缺失才拼 ADD COLUMN,再通过 PREPARE 执行。 +-- 约定:本脚本在 mysql 客户端手动执行(同一会话保持 PREPARE 生效)。 +-- ============================================================= + +SET @sql := CONCAT( + 'ALTER TABLE `yz_system_menu` ', + IF( + (SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'yz_system_menu' + AND COLUMN_NAME = 'require_login') = 0, + 'ADD COLUMN `require_login` TINYINT NOT NULL DEFAULT 1 COMMENT ''1=必须登录 0=可免登录使用'' AFTER `is_visible`', + '' + ) +); + +-- 列已存在时 @sql 仅剩 'ALTER TABLE `yz_system_menu` ',替换为无害语句 +SET @sql := IF(RIGHT(TRIM(@sql), 1) = '`', 'SELECT 1 AS column_already_exists', @sql); + +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- ============================================================= +-- 初始放开「便捷工具」的三个免登录功能(确认菜单标题后再执行): +-- +-- SELECT id, title, path FROM `yz_system_menu` +-- WHERE title IN ('记事本', '日程提醒', '账号管理') AND delete_time IS NULL; +-- +-- UPDATE `yz_system_menu` +-- SET `require_login` = 0 +-- WHERE title IN ('记事本', '日程提醒', '账号管理'); +-- +-- 说明:父级目录菜单如需游客可见,也要一并置 0; +-- 后续新模块按需放开,全部走 Platform 菜单管理界面配置。 +-- ============================================================= diff --git a/go/docs/sql/alter_system_modules_require_login.sql b/go/docs/sql/alter_system_modules_require_login.sql new file mode 100644 index 0000000..d719d9c --- /dev/null +++ b/go/docs/sql/alter_system_modules_require_login.sql @@ -0,0 +1,46 @@ +-- ============================================================= +-- yz_system_modules 增加「是否必须登录」字段 require_login +-- +-- 用途:Platform 模块管理可配置某业务模块是否允许移动端免登录访问。 +-- 1 = 必须登录(默认,保持既有行为) +-- 0 = 可免登录使用 +-- +-- 幂等:MySQL 的 ALTER TABLE ADD COLUMN 不支持 IF NOT EXISTS, +-- 重复执行会报 1060 Duplicate column name。这里用 information_schema +-- 判断列是否存在,缺失才拼 ADD COLUMN,再通过 PREPARE 执行。 +-- 约定:本脚本在 mysql 客户端手动执行(同一会话保持 PREPARE 生效)。 +-- ============================================================= + +SET @sql := CONCAT( + 'ALTER TABLE `yz_system_modules` ', + IF( + (SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'yz_system_modules' + AND COLUMN_NAME = 'require_login') = 0, + 'ADD COLUMN `require_login` TINYINT NOT NULL DEFAULT 1 COMMENT ''1=必须登录 0=可免登录使用'' AFTER `is_show`', + '' + ) +); + +-- 列已存在时 @sql 仅剩 'ALTER TABLE `yz_system_modules` ',替换为无害语句 +SET @sql := IF(RIGHT(TRIM(@sql), 1) = '`', 'SELECT 1 AS column_already_exists', @sql); + +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- ============================================================= +-- 方案 B(兜底):不支持会话变量 / PREPARE 的执行器(工单系统等), +-- 改用下面的纯静态语句。注意:本条不幂等,列已存在会报 +-- 1060 Duplicate column,执行前先用 information_schema 自查: +-- +-- SELECT COUNT(*) FROM information_schema.COLUMNS +-- WHERE TABLE_SCHEMA = DATABASE() +-- AND TABLE_NAME = 'yz_system_modules' +-- AND COLUMN_NAME = 'require_login'; -- 返回 0 才执行 ALTER +-- +-- ALTER TABLE `yz_system_modules` +-- ADD COLUMN `require_login` TINYINT NOT NULL DEFAULT 1 +-- COMMENT '1=必须登录 0=可免登录使用' AFTER `is_show`; +-- ============================================================= diff --git a/go/docs/sql/create_guest_tables.sql b/go/docs/sql/create_guest_tables.sql new file mode 100644 index 0000000..61eddeb --- /dev/null +++ b/go/docs/sql/create_guest_tables.sql @@ -0,0 +1,33 @@ +-- ============================================================= +-- 游客(免登录模块)身份与通用数据表 +-- 对应代码:models.GuestUser / models.GuestData、services/gueststore +-- 幂等:CREATE TABLE IF NOT EXISTS,可重复执行 +-- 用途:小程序用手机号授权换取“游客轻身份”,免登录模块产生的数据 +-- 按 guest_id + module 存入 yz_guest_data(文档式,永久保留不清理); +-- 正式登录后按手机号把数据合并进正式账号(merged_uid 标记,行置 is_deleted=2)。 +-- ============================================================= + +CREATE TABLE IF NOT EXISTS `yz_guest_user` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `phone` VARCHAR(20) NOT NULL DEFAULT '' COMMENT '微信验证手机号(游客唯一标识)', + `openid` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '小程序 openid(可选溯源)', + `unionid` VARCHAR(64) NOT NULL DEFAULT '' COMMENT 'unionid(可为空)', + `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '首次授权时间', + `last_active_time` DATETIME NULL COMMENT '最近一次签发/活动时间', + `merged_uid` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '转正并入的 AuthIdentity.ID,0=未合并', + `merged_time` DATETIME NULL COMMENT '合并时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_phone` (`phone`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='游客轻身份(微信手机号授权签发)'; + +CREATE TABLE IF NOT EXISTS `yz_guest_data` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `guest_id` BIGINT UNSIGNED NOT NULL COMMENT 'yz_guest_user.id', + `module` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '所属业务模块:notebook / schedule / passwordStore / …', + `payload` TEXT NOT NULL COMMENT '业务条目 JSON 序列化(结构由各模块自定义)', + `is_deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '0正常 1已删除(保留不清理) 2已合并进正式账号', + `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `update_time` DATETIME NULL, + PRIMARY KEY (`id`), + KEY `idx_guest_module` (`guest_id`, `module`, `is_deleted`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='游客通用数据(按模块文档式存储,永久保留)'; diff --git a/go/models/init.go b/go/models/init.go index eeae871..d509053 100644 --- a/go/models/init.go +++ b/go/models/init.go @@ -77,6 +77,9 @@ func Init(_ string) { new(SystemDomainPool), new(SystemTenantDomain), new(SystemModules), + // 游客轻身份与通用数据(docs/sql/create_guest_tables.sql):免登录模块的临时存储 + new(GuestUser), + new(GuestData), new(StorageConfig), new(TenantSiteSetting), new(PlatformSiteSetting), diff --git a/go/models/system_menu.go b/go/models/system_menu.go index 3effe89..4d35cfa 100644 --- a/go/models/system_menu.go +++ b/go/models/system_menu.go @@ -14,28 +14,30 @@ const ( // SystemMenu 系统菜单表 yz_system_menu type SystemMenu struct { - ID uint64 `orm:"column(id);pk;auto" json:"id"` // 菜单ID - Pid int64 `orm:"column(pid);default(0)" json:"pid"` // 上级菜单ID - Title string `orm:"column(title);size(50)" json:"title"` // 菜单名称 - Path *string `orm:"column(path);size(200);null" json:"path"` // 路由路径 - ComponentPath *string `orm:"column(component_path);size(255);null" json:"componentPath"` // 组件路径 - Icon *string `orm:"column(icon);size(100);null" json:"icon"` // 菜单图标 - Sort int64 `orm:"column(sort);default(0)" json:"sort"` // 排序号 - Status int8 `orm:"column(status);default(0)" json:"status"` // 状态:1-启用,0-禁用 - IsVisible *int8 `orm:"column(is_visible);null" json:"isVisible"` // 是否显示:1-显示 0-不显示 - Views *string `orm:"column(views);size(255);null" json:"views"` // 菜单显示端(JSON数组字符串):[1]=平台端 [2]=租户端 [1,2]=双端 - Platforms *string `orm:"column(platforms);size(64);null" json:"platforms"` // 展示端(JSON数组字符串):["web"] ["app"] ["web","app"];空/NULL=全端 - Type int8 `orm:"column(type)" json:"type"` // 菜单类型:1-目录,2-页面,3-接口 - Permission *string `orm:"column(permission);size(100);null" json:"permission"` // 权限标识(按钮类型时填写) - Creater *string `orm:"column(creater);size(50);null" json:"creater"` // 创建者 - Remark *string `orm:"column(remark);size(500);null" json:"remark"` // 备注 - CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"createTime"` // 创建时间 - UpdateTime *time.Time `orm:"column(update_time);auto_now;type(datetime);null" json:"updateTime"` // 更新时间 - DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"deleteTime"` // 删除时间 + ID uint64 `orm:"column(id);pk;auto" json:"id"` // 菜单ID + Pid int64 `orm:"column(pid);default(0)" json:"pid"` // 上级菜单ID + Title string `orm:"column(title);size(50)" json:"title"` // 菜单名称 + Path *string `orm:"column(path);size(200);null" json:"path"` // 路由路径 + ComponentPath *string `orm:"column(component_path);size(255);null" json:"componentPath"` // 组件路径 + Icon *string `orm:"column(icon);size(100);null" json:"icon"` // 菜单图标 + Sort int64 `orm:"column(sort);default(0)" json:"sort"` // 排序号 + Status int8 `orm:"column(status);default(0)" json:"status"` // 状态:1-启用,0-禁用 + IsVisible *int8 `orm:"column(is_visible);null" json:"isVisible"` // 是否显示:1-显示 0-不显示 + // 是否必须登录才能使用:1=必须登录(默认) 0=可免登录(游客持游客令牌可用)。 + // 移动端免登录控制从模块级下沉到菜单级:公开菜单接口 /api/menus/public 只下发 0 的菜单。 + RequireLogin int8 `orm:"column(require_login);default(1)" json:"require_login"` + Views *string `orm:"column(views);size(255);null" json:"views"` // 菜单显示端(JSON数组字符串):[1]=平台端 [2]=租户端 [1,2]=双端 + Platforms *string `orm:"column(platforms);size(64);null" json:"platforms"` // 展示端(JSON数组字符串):["web"] ["app"] ["web","app"];空/NULL=全端 + Type int8 `orm:"column(type)" json:"type"` // 菜单类型:1-目录,2-页面,3-接口 + Permission *string `orm:"column(permission);size(100);null" json:"permission"` // 权限标识(按钮类型时填写) + Creater *string `orm:"column(creater);size(50);null" json:"creater"` // 创建者 + Remark *string `orm:"column(remark);size(500);null" json:"remark"` // 备注 + CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"createTime"` // 创建时间 + UpdateTime *time.Time `orm:"column(update_time);auto_now;type(datetime);null" json:"updateTime"` // 更新时间 + DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"deleteTime"` // 删除时间 } // TableName 自定义表名 func (m *SystemMenu) TableName() string { return "yz_system_menu" } - diff --git a/go/models/system_modules.go b/go/models/system_modules.go index 92c3759..4b39ce5 100644 --- a/go/models/system_modules.go +++ b/go/models/system_modules.go @@ -1,28 +1,31 @@ -package models - -import "time" - -// SystemModules 系统模块表 yz_system_modules -type SystemModules struct { - ID uint64 `orm:"column(id);pk;auto" json:"id"` - Mid *uint64 `orm:"column(mid);null" json:"mid"` - Name string `orm:"column(name);size(50)" json:"name"` - Code string `orm:"column(code);size(50)" json:"code"` - Path string `orm:"column(path);size(100)" json:"path"` - Icon string `orm:"column(icon);size(50)" json:"icon"` - Description string `orm:"column(description);size(255)" json:"description"` - Type int `orm:"column(type);default(0)" json:"type"` // 0未分类 1功能模块 2系统配置 - Sort int `orm:"column(sort);default(0)" json:"sort"` - Status int8 `orm:"column(status);default(1)" json:"status"` // 0禁用 1启用 - IsShow int8 `orm:"column(is_show);default(1)" json:"is_show"` // 0否 1是 - // 展示端(JSON 数组字符串):["web"] ["app"] ["web","app"];空/NULL=全端显示。 - // 取值见 PlatformWeb / PlatformApp。菜单未单独配置时继承所属模块的展示端。 - Platforms *string `orm:"column(platforms);size(64);null" json:"platforms"` - CreateTime *time.Time `orm:"column(create_time);type(datetime);null" json:"create_time"` - UpdateTime *time.Time `orm:"column(update_time);type(datetime);null" json:"update_time"` - DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"` -} - -func (m *SystemModules) TableName() string { - return "yz_system_modules" -} +package models + +import "time" + +// SystemModules 系统模块表 yz_system_modules +type SystemModules struct { + ID uint64 `orm:"column(id);pk;auto" json:"id"` + Mid *uint64 `orm:"column(mid);null" json:"mid"` + Name string `orm:"column(name);size(50)" json:"name"` + Code string `orm:"column(code);size(50)" json:"code"` + Path string `orm:"column(path);size(100)" json:"path"` + Icon string `orm:"column(icon);size(50)" json:"icon"` + Description string `orm:"column(description);size(255)" json:"description"` + Type int `orm:"column(type);default(0)" json:"type"` // 0未分类 1功能模块 2系统配置 + Sort int `orm:"column(sort);default(0)" json:"sort"` + Status int8 `orm:"column(status);default(1)" json:"status"` // 0禁用 1启用 + IsShow int8 `orm:"column(is_show);default(1)" json:"is_show"` // 0否 1是 + // 是否必须登录才能进入该模块:1=必须登录(默认,保持既有行为) 0=可免登录使用。 + // 移动端 nav 未登录时据此决定宫格卡片点击是「进登录引导」还是「直接进入」。 + RequireLogin int8 `orm:"column(require_login);default(1)" json:"require_login"` + // 展示端(JSON 数组字符串):["web"] ["app"] ["web","app"];空/NULL=全端显示。 + // 取值见 PlatformWeb / PlatformApp。菜单未单独配置时继承所属模块的展示端。 + Platforms *string `orm:"column(platforms);size(64);null" json:"platforms"` + CreateTime *time.Time `orm:"column(create_time);type(datetime);null" json:"create_time"` + UpdateTime *time.Time `orm:"column(update_time);type(datetime);null" json:"update_time"` + DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"` +} + +func (m *SystemModules) TableName() string { + return "yz_system_modules" +} diff --git a/go/pkg/identity/identity.go b/go/pkg/identity/identity.go index df07564..5c36b89 100644 --- a/go/pkg/identity/identity.go +++ b/go/pkg/identity/identity.go @@ -24,6 +24,9 @@ const ( EndPlatform End = "platform" // 平台端 /platform/** EndBackend End = "backend" // 租户(用户)端 /backend/** EndApp End = "app" // 移动端与小程序 /api/** + // EndGuest 游客轻身份:小程序手机号授权签发的临时令牌(user_type=guest), + // 仅免登录业务控制器显式放行,数据落在 yz_guest_data,转正后按手机号合并。 + EndGuest End = "guest" ) // 错误分类直接决定 HTTP 状态码,因此必须与前端拦截器行为对齐: @@ -93,6 +96,12 @@ func Claims(auth string, allowed ...End) (*jwtutil.Claims, error) { return claims, nil } +// IsGuest 判断解析结果是否为游客令牌(user_type=guest)。 +// 控制器据此分流:游客请求不进业务表,走 gueststore 通用存储。 +func IsGuest(claims *jwtutil.Claims) bool { + return claims != nil && claims.UserType == string(EndGuest) +} + // PathEnd 按路由前缀判定接口属于哪个端;前缀不属于任何端时 ok=false。 func PathEnd(path string) (End, bool) { p := strings.ToLower(path) diff --git a/go/routers/api/api.go b/go/routers/api/api.go index b445197..35d9cc9 100644 --- a/go/routers/api/api.go +++ b/go/routers/api/api.go @@ -1,175 +1,188 @@ -package api - -import ( - "server/controllers" - - beego "github.com/beego/beego/v2/server/web" -) - -// Register 注册移动端 / 客户端开放 API(api)路由。 -// 说明:/api 统一承载「所有移动端接口」(uniapp)与「登录器等客户端开放接口」, -// 与 /platform、/backend、/index、/auth 各端完全隔离,不复用其它端的功能接口。 -// -// 注意:字面量路由必须注册在 :id 通配之前(beego 按注册顺序匹配)。 -func Register() { - RegisterErpRoutes() - - // ===== 客户端登录器 / 开放接口(无需登录)===== - // 客户端检查更新 - beego.Router("/api/softwareupgrade/check", &controllers.ApiSoftwareUpgradeController{}, "get:Check") - - // 登录器上报 Cursor 设备信息 - beego.Router("/api/cursor/equipment/report", &controllers.ApiCursorEquipmentController{}, "post:Report") - // 登录器使用激活码激活/续期 Cursor 设备 - beego.Router("/api/cursor/equipment/activateByCode", &controllers.ApiCursorEquipmentController{}, "post:ActivateByCode") - // 登录器心跳接口,用于更新在线状态 - beego.Router("/api/cursor/equipment/heartbeat", &controllers.ApiCursorEquipmentController{}, "post:Heartbeat") - - // Cursor Token 顺序读取/检测接口(peek 不改变号池状态) - // GET /api/cursor/token/peek?id=11&data_type=tk - beego.Router("/api/cursor/token/peek", &controllers.ApiCursorDetectController{}, "get:PeekToken") - // Cursor Token 可用状态标记接口 - // POST /api/cursor/token/available?id=11 - // POST /api/cursor/token/unavailable?id=11 - beego.Router("/api/cursor/token/available", &controllers.ApiCursorDetectController{}, "post:MarkTokenAvailable") - beego.Router("/api/cursor/token/unavailable", &controllers.ApiCursorDetectController{}, "post:MarkTokenUnavailable") - - // 对外提卡接口 - // GET /api/getcard?type=xianyu&module=cursor&data_type=tk - beego.Router("/api/getcard", &controllers.ApiGetCardController{}, "get:GetCard") - - // 日程提醒确认接口 - beego.Router("/api/schedule/reminder/ack", &controllers.ApiReminderController{}, "get:AckReminder") - - // 小程序订阅消息:取模板 / 上报授权 / 测试发送 - beego.Router("/api/mini/subscribe/templates", &controllers.AppMiniSubscribeController{}, "get:Templates") - beego.Router("/api/mini/subscribe/authorize", &controllers.AppMiniSubscribeController{}, "post:Authorize") - beego.Router("/api/mini/subscribe/test", &controllers.AppMiniSubscribeController{}, "post:TestNotice") - - // ===== 移动端(uniapp)登录相关(AppAuthController)===== - beego.Router("/api/login", &controllers.AppAuthController{}, "post:LoginBackend") - beego.Router("/api/sendLoginCode", &controllers.AppAuthController{}, "post:SendLoginCode") - beego.Router("/api/loginBySms", &controllers.AppAuthController{}, "post:LoginBySms") - beego.Router("/api/logout", &controllers.AppAuthController{}, "post:Logout") - - // 当前用户信息 - beego.Router("/api/currentUser", &controllers.AppAuthController{}, "get:GetCurrentUser") - - // 极验与登录验证配置 - beego.Router("/api/login/getGeetest3Infos", &controllers.AppAuthController{}, "get:GetGeetest3Infos") - beego.Router("/api/login/getGeetest4Infos", &controllers.AppAuthController{}, "get:GetGeetest4Infos") - beego.Router("/api/login/getOpenVerify", &controllers.AppAuthController{}, "get:GetOpenVerify") - - // 找回密码(三步流程) - beego.Router("/api/verifyAccount", &controllers.AppAuthController{}, "post:VerifyAccount") - beego.Router("/api/sendResetCode", &controllers.AppAuthController{}, "post:SendResetCode") - beego.Router("/api/resetPassword", &controllers.AppAuthController{}, "post:ResetPassword") - - // 注册 - beego.Router("/api/register", &controllers.AppAuthController{}, "post:Register") - beego.Router("/api/sendRegisterCode", &controllers.AppAuthController{}, "post:SendRegisterCode") - - // 日程提醒 - beego.Router("/api/schedule/list", &controllers.AppReminderController{}, "get:GetList") - beego.Router("/api/schedule", &controllers.AppReminderController{}, "post:Create") - beego.Router("/api/schedule/:id", &controllers.AppReminderController{}, "get:GetDetail;put:Update;delete:Delete") - beego.Router("/api/schedule/:id/toggle", &controllers.AppReminderController{}, "post:ToggleComplete") - - // 账号管理(密码库,与 /backend/passwordStore/* 同一套数据;字面量路由在 :id 通配之前) - beego.Router("/api/passwordStore/list", &controllers.AppPasswordStoreController{}, "get:GetList") - beego.Router("/api/passwordStore", &controllers.AppPasswordStoreController{}, "post:Create") - beego.Router("/api/passwordStore/:id", &controllers.AppPasswordStoreController{}, "get:GetDetail;post:Update;delete:Delete") - - // 记事本 - beego.Router("/api/notebook/list", &controllers.AppNotebookController{}, "get:GetList") - beego.Router("/api/notebook", &controllers.AppNotebookController{}, "post:Create") - beego.Router("/api/notebook/:id", &controllers.AppNotebookController{}, "get:GetDetail;put:Update;delete:Delete") - beego.Router("/api/notebook/:id/togglePin", &controllers.AppNotebookController{}, "post:TogglePin") - - // OA 协同办公(移动端只读:通知公告 / 文档库 / 薪酬工资单 / 员工档案) - beego.Router("/api/oa/notice/list", &controllers.AppOaController{}, "get:NoticeList") - beego.Router("/api/oa/notice/:id", &controllers.AppOaController{}, "get:NoticeDetail") - - beego.Router("/api/oa/document/categories", &controllers.AppOaController{}, "get:DocCategories") - beego.Router("/api/oa/document/list", &controllers.AppOaController{}, "get:DocList") - beego.Router("/api/oa/document/download/:id", &controllers.AppOaController{}, "get:DocDownload") - beego.Router("/api/oa/document/:id/star", &controllers.AppOaController{}, "post:DocStar") - beego.Router("/api/oa/document/:id", &controllers.AppOaController{}, "get:DocDetail") - - beego.Router("/api/oa/compensation/payrolls", &controllers.AppOaController{}, "get:PayrollList") - beego.Router("/api/oa/compensation/payrolls/:id", &controllers.AppOaController{}, "get:PayrollDetail") - - beego.Router("/api/oa/employee-file", &controllers.AppOaController{}, "get:MyEmployeeFile") - // 员工档案 HR 视角:access 判定是否可看全公司档案,list 全公司档案列表,detail 档案详情 - beego.Router("/api/oa/employee-file/access", &controllers.AppOaController{}, "get:EmployeeFileAccess") - beego.Router("/api/oa/employee-file/list", &controllers.AppOaController{}, "get:EmployeeFileList") - beego.Router("/api/oa/employee-file/detail/:id", &controllers.AppOaController{}, "get:EmployeeFileDetail") - - // OA 工作台仪表盘(移动端首屏顶部聚合:待办/报销统计 + 通知公告 + 今日日程) - beego.Router("/api/oa/dashboard", &controllers.AppOaController{}, "get:Dashboard") - - // OA 工作日程(日历待办,与 /backend/oa/schedule/* 同一套数据;小程序「工作日程」页面使用) - beego.Router("/api/oa/schedule/list", &controllers.AppOaController{}, "get:ScheduleList") - beego.Router("/api/oa/schedule/detail/:id", &controllers.AppOaController{}, "get:ScheduleDetail") - beego.Router("/api/oa/schedule/create", &controllers.AppOaController{}, "post:ScheduleCreate") - beego.Router("/api/oa/schedule/update/:id", &controllers.AppOaController{}, "post:ScheduleUpdate") - beego.Router("/api/oa/schedule/delete/:id", &controllers.AppOaController{}, "delete:ScheduleDelete") - beego.Router("/api/oa/schedule/finish/:id", &controllers.AppOaController{}, "post:ScheduleFinish") - beego.Router("/api/oa/schedule/upload", &controllers.AppOaController{}, "post:ScheduleUploadImage") - beego.Router("/api/oa/schedule/images/:id", &controllers.AppOaController{}, "post:ScheduleImages") - - // OA 工作日报(与 /backend/oa/dailyreport/* 同一套数据;小程序「工作日报」页面使用) - beego.Router("/api/oa/dailyreport/profile", &controllers.AppOaController{}, "get:DailyReportProfile") - beego.Router("/api/oa/dailyreport/mine/list", &controllers.AppOaController{}, "get:DailyReportMineList") - beego.Router("/api/oa/dailyreport/calendar", &controllers.AppOaController{}, "get:DailyReportCalendar") - beego.Router("/api/oa/dailyreport/save", &controllers.AppOaController{}, "post:DailyReportSave") - beego.Router("/api/oa/dailyreport/withdraw/:id", &controllers.AppOaController{}, "post:DailyReportWithdraw") - beego.Router("/api/oa/dailyreport/detail/:id", &controllers.AppOaController{}, "get:DailyReportDetail") - beego.Router("/api/oa/dailyreport/delete/:id", &controllers.AppOaController{}, "delete:DailyReportDelete") - beego.Router("/api/oa/dailyreport/team/status", &controllers.AppOaController{}, "get:DailyReportTeamStatus") - beego.Router("/api/oa/dailyreport/team/orgs", &controllers.AppOaController{}, "get:DailyReportTeamOrgs") - beego.Router("/api/oa/dailyreport/comment", &controllers.AppOaController{}, "post:DailyReportComment") - beego.Router("/api/oa/dailyreport/comment/:id", &controllers.AppOaController{}, "delete:DailyReportCommentDelete") - beego.Router("/api/oa/dailyreport/upload", &controllers.AppOaController{}, "post:DailyReportUpload") - - // OA 报销管理(与 /backend/reimbursements* 同一套数据;小程序「报销管理」页面使用) - beego.Router("/api/oa/expense/list", &controllers.AppOaController{}, "get:ExpenseList") - beego.Router("/api/oa/expense/types", &controllers.AppOaController{}, "get:ExpenseTypes") - beego.Router("/api/oa/expense/upload", &controllers.AppOaController{}, "post:ExpenseUploadImage") - beego.Router("/api/oa/expense/detail/:id", &controllers.AppOaController{}, "get:ExpenseDetail") - beego.Router("/api/oa/expense/create", &controllers.AppOaController{}, "post:ExpenseCreate") - beego.Router("/api/oa/expense/update/:id", &controllers.AppOaController{}, "post:ExpenseUpdate") - beego.Router("/api/oa/expense/delete/:id", &controllers.AppOaController{}, "delete:ExpenseDelete") - beego.Router("/api/oa/expense/item/add/:id", &controllers.AppOaController{}, "post:ExpenseAddItem") - beego.Router("/api/oa/expense/item/update/:id/:item_id", &controllers.AppOaController{}, "post:ExpenseUpdateItem") - beego.Router("/api/oa/expense/item/delete/:id/:item_id", &controllers.AppOaController{}, "delete:ExpenseDeleteItem") - beego.Router("/api/oa/expense/submit/:id", &controllers.AppOaController{}, "post:ExpenseSubmit") - beego.Router("/api/oa/expense/approve/:id", &controllers.AppOaController{}, "post:ExpenseApprove") - beego.Router("/api/oa/expense/reject/:id", &controllers.AppOaController{}, "post:ExpenseReject") - beego.Router("/api/oa/expense/my-approve-task/:id", &controllers.AppOaController{}, "get:ExpenseMyApproveTask") - beego.Router("/api/oa/expense/status/:id", &controllers.AppOaController{}, "post:ExpenseStatus") - beego.Router("/api/oa/expense/records/:id", &controllers.AppOaController{}, "get:ExpenseRecords") - - // 通讯录(移动端只读:列表 / 组织树 / 详情) - beego.Router("/api/oa/contact/list", &controllers.AppOaController{}, "get:ContactList") - beego.Router("/api/oa/contact/org-tree", &controllers.AppOaController{}, "get:ContactOrgTree") - beego.Router("/api/oa/contact/:id", &controllers.AppOaController{}, "get:ContactDetail") - - // 自定义审批流(移动端:我发起的 / 待我审批 / 详情 / 提交 / 通过驳回 / 撤回) - beego.Router("/api/oa/approval/categories", &controllers.AppOaController{}, "get:ApprovalCategories") - beego.Router("/api/oa/approval/flows", &controllers.AppOaController{}, "get:ApprovalFlows") - beego.Router("/api/oa/approval/list", &controllers.AppOaController{}, "get:ApprovalList") - beego.Router("/api/oa/approval/todo", &controllers.AppOaController{}, "get:ApprovalTodo") - beego.Router("/api/oa/approval/submit", &controllers.AppOaController{}, "post:ApprovalSubmit") - beego.Router("/api/oa/approval/approve", &controllers.AppOaController{}, "post:ApprovalApprove") - beego.Router("/api/oa/approval/withdraw/:id", &controllers.AppOaController{}, "post:ApprovalWithdraw") - beego.Router("/api/oa/approval/detail/:id", &controllers.AppOaController{}, "get:ApprovalDetail") - - // 活动日志 - beego.Router("/api/activity/list", &controllers.AppActivityController{}, "get:GetList") - - // 工作台聚合(ERP/OA/CRM/CMS 应用门户首屏数据) - beego.Router("/api/home", &controllers.AppAggregateController{}, "get:Home") - // 移动端用户自定义「常用功能」保存(读取随 /api/home 下发 quick_menu_ids) - beego.Router("/api/quick-menus", &controllers.AppAggregateController{}, "post:SaveQuickMenus") -} +package api + +import ( + "server/controllers" + + beego "github.com/beego/beego/v2/server/web" +) + +// Register 注册移动端 / 客户端开放 API(api)路由。 +// 说明:/api 统一承载「所有移动端接口」(uniapp)与「登录器等客户端开放接口」, +// 与 /platform、/backend、/index、/auth 各端完全隔离,不复用其它端的功能接口。 +// +// 注意:字面量路由必须注册在 :id 通配之前(beego 按注册顺序匹配)。 +func Register() { + RegisterErpRoutes() + + // ===== 客户端登录器 / 开放接口(无需登录)===== + // 客户端检查更新 + beego.Router("/api/softwareupgrade/check", &controllers.ApiSoftwareUpgradeController{}, "get:Check") + + // 登录器上报 Cursor 设备信息 + beego.Router("/api/cursor/equipment/report", &controllers.ApiCursorEquipmentController{}, "post:Report") + // 登录器使用激活码激活/续期 Cursor 设备 + beego.Router("/api/cursor/equipment/activateByCode", &controllers.ApiCursorEquipmentController{}, "post:ActivateByCode") + // 登录器心跳接口,用于更新在线状态 + beego.Router("/api/cursor/equipment/heartbeat", &controllers.ApiCursorEquipmentController{}, "post:Heartbeat") + + // Cursor Token 顺序读取/检测接口(peek 不改变号池状态) + // GET /api/cursor/token/peek?id=11&data_type=tk + beego.Router("/api/cursor/token/peek", &controllers.ApiCursorDetectController{}, "get:PeekToken") + // Cursor Token 可用状态标记接口 + // POST /api/cursor/token/available?id=11 + // POST /api/cursor/token/unavailable?id=11 + beego.Router("/api/cursor/token/available", &controllers.ApiCursorDetectController{}, "post:MarkTokenAvailable") + beego.Router("/api/cursor/token/unavailable", &controllers.ApiCursorDetectController{}, "post:MarkTokenUnavailable") + + // 对外提卡接口 + // GET /api/getcard?type=xianyu&module=cursor&data_type=tk + beego.Router("/api/getcard", &controllers.ApiGetCardController{}, "get:GetCard") + + // 日程提醒确认接口 + beego.Router("/api/schedule/reminder/ack", &controllers.ApiReminderController{}, "get:AckReminder") + + // 小程序订阅消息:取模板 / 上报授权 / 测试发送 + beego.Router("/api/mini/subscribe/templates", &controllers.AppMiniSubscribeController{}, "get:Templates") + beego.Router("/api/mini/subscribe/authorize", &controllers.AppMiniSubscribeController{}, "post:Authorize") + beego.Router("/api/mini/subscribe/test", &controllers.AppMiniSubscribeController{}, "post:TestNotice") + + // ===== 移动端(uniapp)登录相关(AppAuthController)===== + beego.Router("/api/login", &controllers.AppAuthController{}, "post:LoginBackend") + beego.Router("/api/sendLoginCode", &controllers.AppAuthController{}, "post:SendLoginCode") + beego.Router("/api/loginBySms", &controllers.AppAuthController{}, "post:LoginBySms") + beego.Router("/api/logout", &controllers.AppAuthController{}, "post:Logout") + + // 当前用户信息 + beego.Router("/api/currentUser", &controllers.AppAuthController{}, "get:GetCurrentUser") + + // 极验与登录验证配置 + beego.Router("/api/login/getGeetest3Infos", &controllers.AppAuthController{}, "get:GetGeetest3Infos") + beego.Router("/api/login/getGeetest4Infos", &controllers.AppAuthController{}, "get:GetGeetest4Infos") + beego.Router("/api/login/getOpenVerify", &controllers.AppAuthController{}, "get:GetOpenVerify") + + // 找回密码(三步流程) + beego.Router("/api/verifyAccount", &controllers.AppAuthController{}, "post:VerifyAccount") + beego.Router("/api/sendResetCode", &controllers.AppAuthController{}, "post:SendResetCode") + beego.Router("/api/resetPassword", &controllers.AppAuthController{}, "post:ResetPassword") + + // 注册 + beego.Router("/api/register", &controllers.AppAuthController{}, "post:Register") + beego.Router("/api/sendRegisterCode", &controllers.AppAuthController{}, "post:SendRegisterCode") + + // 日程提醒 + beego.Router("/api/schedule/list", &controllers.AppReminderController{}, "get:GetList") + beego.Router("/api/schedule", &controllers.AppReminderController{}, "post:Create") + beego.Router("/api/schedule/:id", &controllers.AppReminderController{}, "get:GetDetail;put:Update;delete:Delete") + beego.Router("/api/schedule/:id/toggle", &controllers.AppReminderController{}, "post:ToggleComplete") + + // 账号管理(密码库,与 /backend/passwordStore/* 同一套数据;字面量路由在 :id 通配之前) + beego.Router("/api/passwordStore/list", &controllers.AppPasswordStoreController{}, "get:GetList") + beego.Router("/api/passwordStore", &controllers.AppPasswordStoreController{}, "post:Create") + beego.Router("/api/passwordStore/:id", &controllers.AppPasswordStoreController{}, "get:GetDetail;post:Update;delete:Delete") + + // 记事本 + beego.Router("/api/notebook/list", &controllers.AppNotebookController{}, "get:GetList") + beego.Router("/api/notebook", &controllers.AppNotebookController{}, "post:Create") + beego.Router("/api/notebook/:id", &controllers.AppNotebookController{}, "get:GetDetail;put:Update;delete:Delete") + beego.Router("/api/notebook/:id/togglePin", &controllers.AppNotebookController{}, "post:TogglePin") + + // OA 协同办公(移动端只读:通知公告 / 文档库 / 薪酬工资单 / 员工档案) + beego.Router("/api/oa/notice/list", &controllers.AppOaController{}, "get:NoticeList") + beego.Router("/api/oa/notice/:id", &controllers.AppOaController{}, "get:NoticeDetail") + + beego.Router("/api/oa/document/categories", &controllers.AppOaController{}, "get:DocCategories") + beego.Router("/api/oa/document/list", &controllers.AppOaController{}, "get:DocList") + beego.Router("/api/oa/document/download/:id", &controllers.AppOaController{}, "get:DocDownload") + beego.Router("/api/oa/document/:id/star", &controllers.AppOaController{}, "post:DocStar") + beego.Router("/api/oa/document/:id", &controllers.AppOaController{}, "get:DocDetail") + + beego.Router("/api/oa/compensation/payrolls", &controllers.AppOaController{}, "get:PayrollList") + beego.Router("/api/oa/compensation/payrolls/:id", &controllers.AppOaController{}, "get:PayrollDetail") + + beego.Router("/api/oa/employee-file", &controllers.AppOaController{}, "get:MyEmployeeFile") + // 员工档案 HR 视角:access 判定是否可看全公司档案,list 全公司档案列表,detail 档案详情 + beego.Router("/api/oa/employee-file/access", &controllers.AppOaController{}, "get:EmployeeFileAccess") + beego.Router("/api/oa/employee-file/list", &controllers.AppOaController{}, "get:EmployeeFileList") + beego.Router("/api/oa/employee-file/detail/:id", &controllers.AppOaController{}, "get:EmployeeFileDetail") + + // OA 工作台仪表盘(移动端首屏顶部聚合:待办/报销统计 + 通知公告 + 今日日程) + beego.Router("/api/oa/dashboard", &controllers.AppOaController{}, "get:Dashboard") + + // OA 工作日程(日历待办,与 /backend/oa/schedule/* 同一套数据;小程序「工作日程」页面使用) + beego.Router("/api/oa/schedule/list", &controllers.AppOaController{}, "get:ScheduleList") + beego.Router("/api/oa/schedule/detail/:id", &controllers.AppOaController{}, "get:ScheduleDetail") + beego.Router("/api/oa/schedule/create", &controllers.AppOaController{}, "post:ScheduleCreate") + beego.Router("/api/oa/schedule/update/:id", &controllers.AppOaController{}, "post:ScheduleUpdate") + beego.Router("/api/oa/schedule/delete/:id", &controllers.AppOaController{}, "delete:ScheduleDelete") + beego.Router("/api/oa/schedule/finish/:id", &controllers.AppOaController{}, "post:ScheduleFinish") + beego.Router("/api/oa/schedule/upload", &controllers.AppOaController{}, "post:ScheduleUploadImage") + beego.Router("/api/oa/schedule/images/:id", &controllers.AppOaController{}, "post:ScheduleImages") + + // OA 工作日报(与 /backend/oa/dailyreport/* 同一套数据;小程序「工作日报」页面使用) + beego.Router("/api/oa/dailyreport/profile", &controllers.AppOaController{}, "get:DailyReportProfile") + beego.Router("/api/oa/dailyreport/mine/list", &controllers.AppOaController{}, "get:DailyReportMineList") + beego.Router("/api/oa/dailyreport/calendar", &controllers.AppOaController{}, "get:DailyReportCalendar") + beego.Router("/api/oa/dailyreport/save", &controllers.AppOaController{}, "post:DailyReportSave") + beego.Router("/api/oa/dailyreport/withdraw/:id", &controllers.AppOaController{}, "post:DailyReportWithdraw") + beego.Router("/api/oa/dailyreport/detail/:id", &controllers.AppOaController{}, "get:DailyReportDetail") + beego.Router("/api/oa/dailyreport/delete/:id", &controllers.AppOaController{}, "delete:DailyReportDelete") + beego.Router("/api/oa/dailyreport/team/status", &controllers.AppOaController{}, "get:DailyReportTeamStatus") + beego.Router("/api/oa/dailyreport/team/orgs", &controllers.AppOaController{}, "get:DailyReportTeamOrgs") + beego.Router("/api/oa/dailyreport/comment", &controllers.AppOaController{}, "post:DailyReportComment") + beego.Router("/api/oa/dailyreport/comment/:id", &controllers.AppOaController{}, "delete:DailyReportCommentDelete") + beego.Router("/api/oa/dailyreport/upload", &controllers.AppOaController{}, "post:DailyReportUpload") + + // OA 报销管理(与 /backend/reimbursements* 同一套数据;小程序「报销管理」页面使用) + beego.Router("/api/oa/expense/list", &controllers.AppOaController{}, "get:ExpenseList") + beego.Router("/api/oa/expense/types", &controllers.AppOaController{}, "get:ExpenseTypes") + beego.Router("/api/oa/expense/upload", &controllers.AppOaController{}, "post:ExpenseUploadImage") + beego.Router("/api/oa/expense/detail/:id", &controllers.AppOaController{}, "get:ExpenseDetail") + beego.Router("/api/oa/expense/create", &controllers.AppOaController{}, "post:ExpenseCreate") + beego.Router("/api/oa/expense/update/:id", &controllers.AppOaController{}, "post:ExpenseUpdate") + beego.Router("/api/oa/expense/delete/:id", &controllers.AppOaController{}, "delete:ExpenseDelete") + beego.Router("/api/oa/expense/item/add/:id", &controllers.AppOaController{}, "post:ExpenseAddItem") + beego.Router("/api/oa/expense/item/update/:id/:item_id", &controllers.AppOaController{}, "post:ExpenseUpdateItem") + beego.Router("/api/oa/expense/item/delete/:id/:item_id", &controllers.AppOaController{}, "delete:ExpenseDeleteItem") + beego.Router("/api/oa/expense/submit/:id", &controllers.AppOaController{}, "post:ExpenseSubmit") + beego.Router("/api/oa/expense/approve/:id", &controllers.AppOaController{}, "post:ExpenseApprove") + beego.Router("/api/oa/expense/reject/:id", &controllers.AppOaController{}, "post:ExpenseReject") + beego.Router("/api/oa/expense/my-approve-task/:id", &controllers.AppOaController{}, "get:ExpenseMyApproveTask") + beego.Router("/api/oa/expense/status/:id", &controllers.AppOaController{}, "post:ExpenseStatus") + beego.Router("/api/oa/expense/records/:id", &controllers.AppOaController{}, "get:ExpenseRecords") + + // 通讯录(移动端只读:列表 / 组织树 / 详情) + beego.Router("/api/oa/contact/list", &controllers.AppOaController{}, "get:ContactList") + beego.Router("/api/oa/contact/org-tree", &controllers.AppOaController{}, "get:ContactOrgTree") + beego.Router("/api/oa/contact/:id", &controllers.AppOaController{}, "get:ContactDetail") + + // 自定义审批流(移动端:我发起的 / 待我审批 / 详情 / 提交 / 通过驳回 / 撤回) + beego.Router("/api/oa/approval/categories", &controllers.AppOaController{}, "get:ApprovalCategories") + beego.Router("/api/oa/approval/flows", &controllers.AppOaController{}, "get:ApprovalFlows") + beego.Router("/api/oa/approval/list", &controllers.AppOaController{}, "get:ApprovalList") + beego.Router("/api/oa/approval/todo", &controllers.AppOaController{}, "get:ApprovalTodo") + beego.Router("/api/oa/approval/submit", &controllers.AppOaController{}, "post:ApprovalSubmit") + beego.Router("/api/oa/approval/approve", &controllers.AppOaController{}, "post:ApprovalApprove") + beego.Router("/api/oa/approval/withdraw/:id", &controllers.AppOaController{}, "post:ApprovalWithdraw") + beego.Router("/api/oa/approval/detail/:id", &controllers.AppOaController{}, "get:ApprovalDetail") + + // 活动日志 + beego.Router("/api/activity/list", &controllers.AppActivityController{}, "get:GetList") + + // 工作台聚合(ERP/OA/CRM/CMS 应用门户首屏数据) + beego.Router("/api/home", &controllers.AppAggregateController{}, "get:Home") + // 移动端未登录时的模块宫格(公开:返回模块列表 + require_login 标记,路径不在鉴权前缀内) + beego.Router("/api/modules/public", &controllers.AppAggregateController{}, "get:PublicModules") + // 移动端免登录菜单(公开:菜单级 require_login=0 的集合,游客可用功能的唯一入口清单) + beego.Router("/api/menus/public", &controllers.AppAggregateController{}, "get:PublicMenus") + + // ===== 游客(免登录模块)===== + // 微信手机号授权签发游客令牌(公开接口,不在鉴权前缀内,凭证由控制器校验) + beego.Router("/api/guest/mini-login", &controllers.GuestController{}, "post:MiniLogin") + // 正式登录后把游客数据并入正式账号(需正式令牌) + beego.Router("/api/guest/merge", &controllers.GuestController{}, "post:Merge") + // 通用游客数据接口(需游客令牌,供后续免登录模块直接复用) + beego.Router("/api/guest/data", &controllers.GuestController{}, "get:DataList;post:DataCreate") + beego.Router("/api/guest/data/:id", &controllers.GuestController{}, "put:DataUpdate;delete:DataDelete") + // 移动端用户自定义「常用功能」保存(读取随 /api/home 下发 quick_menu_ids) + beego.Router("/api/quick-menus", &controllers.AppAggregateController{}, "post:SaveQuickMenus") +} diff --git a/platform/src/views/system/menus/components/edit.vue b/platform/src/views/system/menus/components/edit.vue index 74f1559..b9e0971 100644 --- a/platform/src/views/system/menus/components/edit.vue +++ b/platform/src/views/system/menus/components/edit.vue @@ -98,6 +98,17 @@ /> + + + + 开启后移动端游客(微信手机号授权)可免登录使用此功能;登录后游客数据自动并入账号。 + + + @@ -137,6 +148,7 @@ interface Menu { sort: number; status: 0 | 1; is_visible: 0 | 1; + require_login?: 0 | 1; // 1=必须登录(默认) 0=可免登录(移动端游客) views: number[]; type: 1 | 2 | 3; permission: string; @@ -182,6 +194,7 @@ const currentMenu = ref>({ sort: 0, status: 1, is_visible: 1, + require_login: 1, views: [1], type: 1, permission: '', @@ -252,6 +265,7 @@ watch(() => props.menu, (newMenu) => { sort: 0, status: 1, is_visible: 1, + require_login: 1, views: [1], type: 1, permission: '', @@ -290,6 +304,7 @@ watch(() => props.visible, (newVisible) => { sort: 0, status: 1, is_visible: 1, + require_login: 1, views: [1], type: 1, permission: '', diff --git a/platform/src/views/system/menus/manager.vue b/platform/src/views/system/menus/manager.vue index a7b5e0c..1619597 100644 --- a/platform/src/views/system/menus/manager.vue +++ b/platform/src/views/system/menus/manager.vue @@ -95,6 +95,13 @@ + + + 游客可用 + 需登录 + + + diff --git a/platform/src/views/system/modules/index.vue b/platform/src/views/system/modules/index.vue index 15b3e23..7d405f6 100644 --- a/platform/src/views/system/modules/index.vue +++ b/platform/src/views/system/modules/index.vue @@ -198,6 +198,7 @@ :inactive-value="0" /> + 启用 @@ -246,6 +247,7 @@ const formData = ref({ sort: 0, is_show: 1, status: 1, + require_login: 1, }); const rules = { @@ -292,6 +294,7 @@ function handleAdd() { sort: 0, is_show: 1, status: 1, + require_login: 1, }; dialogVisible.value = true; } diff --git a/uniapp/App.vue b/uniapp/App.vue index 5217962..8a537c8 100644 --- a/uniapp/App.vue +++ b/uniapp/App.vue @@ -1,10 +1,12 @@ diff --git a/uniapp/components/sub/SubHome.vue b/uniapp/components/sub/SubHome.vue index 5af5b4b..55431e9 100644 --- a/uniapp/components/sub/SubHome.vue +++ b/uniapp/components/sub/SubHome.vue @@ -225,6 +225,7 @@ import { mpHeadPad } from '@/utils/mpHead.js' const headPad = mpHeadPad() import { onShow } from '@dcloudio/uni-app' import { isLoggedIn } from '@/utils/auth.js' +import { getGuestToken } from '@/utils/guest.js' import { request } from '@/api/request.js' import { clearHomeCache } from '@/api/workspace.js' import { openMenu } from '@/utils/nav.js' @@ -406,18 +407,17 @@ function goNoticeDetail(id) { } onMounted(() => { - if (!isLoggedIn()) { - uni.reLaunch({ url: '/pages/login/login' }) - return - } + // 菜单:登录走 /api/home,游客走公开免登录菜单(菜单级 require_login 控制); + // 待办等受保护数据需登录或持游客令牌(手机号授权)才拉,失败不阻断外壳 load() + if (!isLoggedIn() && !getGuestToken()) return loadTodo() loadNotices() loadDashboard() }) onShow(() => { - if (isLoggedIn() && !loading.value) { + if ((isLoggedIn() || getGuestToken()) && !loading.value) { loadTodo() loadNotices() loadDashboard() diff --git a/uniapp/components/sub/SubMine.vue b/uniapp/components/sub/SubMine.vue index ace2501..166912d 100644 --- a/uniapp/components/sub/SubMine.vue +++ b/uniapp/components/sub/SubMine.vue @@ -206,10 +206,8 @@ function handleLogout() { } onMounted(() => { - if (!isLoggedIn()) { - uni.reLaunch({ url: '/pages/login/login' }) - return - } + // 游客进入免登录模块:停留外壳,不拉受保护数据,也不强制跳登录 + if (!isLoggedIn()) return load() loadStats() }) diff --git a/uniapp/components/sub/SubTodo.vue b/uniapp/components/sub/SubTodo.vue index e237ede..5c58f24 100644 --- a/uniapp/components/sub/SubTodo.vue +++ b/uniapp/components/sub/SubTodo.vue @@ -116,10 +116,8 @@ function goSchedules() { } onMounted(() => { - if (!isLoggedIn()) { - uni.reLaunch({ url: '/pages/login/login' }) - return - } + // 游客进入免登录模块:停留外壳,不拉受保护数据,也不强制跳登录 + if (!isLoggedIn()) return load() loadTodo() }) diff --git a/uniapp/pages/login/login.vue b/uniapp/pages/login/login.vue index bbb472c..b8a7614 100644 --- a/uniapp/pages/login/login.vue +++ b/uniapp/pages/login/login.vue @@ -64,6 +64,12 @@ + + + + + 返回门户,先使用免登录功能 + @@ -133,6 +139,12 @@ 没有账号?请联系企业管理员开通 + + + + + 返回门户,先使用免登录功能 + @@ -221,6 +233,8 @@ import { getToken } from '@/utils/auth.js' import { showGeetest4 } from '@/utils/geetest.js' +import { getGuestToken, clearGuestToken } from '@/utils/guest.js' +import { guestMerge } from '@/api/guest.js' import FaIcon from '@/components/FaIcon.vue' /** 运行端检测:h5 / apk(app-plus) / mp-weixin,决定登录方式优先级 */ @@ -286,10 +300,10 @@ const brandPadTop = computed(() => { return `${statusBarHeight.value + uni.upx2px(88)}px` }) -/** 是否存在可返回的上级视图(账号密码视图仅在小程序端可退回主视图) */ +/** 是否显示可返回的上级箭头(账号视图无论能否退回主视图都可见:退不了就回门户) */ const canGoBack = computed(() => { if (step.value === 'tenant') return true - if (step.value === 'account') return showMiniLogin.value + if (step.value === 'account') return true return false }) @@ -308,13 +322,30 @@ function goMain() { errorMsg.value = '' } -/** 顶部导航返回 */ +/** 顶部导航返回:登录内部层级优先逐级退;无可退上级时返回门户(免登录页) */ function onNavBack() { if (step.value === 'tenant') { backToAccount() return } - goMain() + if (step.value === 'account' && showMiniLogin.value) { + goMain() + return + } + // 无登录内部上级 → 返回门户,继续用免登录功能 + goPortal() +} + +/** + * 返回门户首页(nav)。 + * 多数从 nav 点「需登录」模块进来,直接 navigateBack 回上一页(保留游客宫格); + * 若是被 401 兑回登录页等无栈场景,回落到 reLaunch nav 以游客态重载。 + */ +function goPortal() { + if (submitting.value) return + const pages = (typeof getCurrentPages === 'function' && getCurrentPages()) || [] + if (pages.length > 1) uni.navigateBack() + else uni.reLaunch({ url: '/pages/nav/nav' }) } onMounted(async () => { @@ -508,6 +539,17 @@ async function finishLogin(tokens, tenantList, tid, fallbackAccount = '') { // 微信端:登录成功后静默绑定 openid,认证中/无手机号能力时一键登录靠它认人 await silentBindMiniapp() + // 游客数据转正:持游客令牌(手机号授权)时静默并入账号; + // 失败不阻断跳转(令牌保留,下次登录自动重试;令牌失效则由 401 自动清理) + if (getGuestToken()) { + try { + await guestMerge() + clearGuestToken() + } catch (e) { + console.warn('[login] guest merge failed:', e) + } + } + uni.hideLoading() uni.showToast({ title: '登录成功', icon: 'success' }) setTimeout(() => { @@ -1082,6 +1124,25 @@ function goForget() { margin-top: 32rpx; } +/* 返回门户:不登录也能先浏览免登录模块 */ +.back-portal { + display: flex; + align-items: center; + justify-content: center; + gap: 6rpx; + margin-top: 28rpx; + padding: 8rpx 0; +} + +.back-portal:active { + opacity: 0.6; +} + +.back-portal-text { + font-size: 24rpx; + color: $color-text-muted; +} + .link { font-size: 26rpx; color: $color-primary; diff --git a/uniapp/pages/nav/nav.vue b/uniapp/pages/nav/nav.vue index 0f35da1..5169f48 100644 --- a/uniapp/pages/nav/nav.vue +++ b/uniapp/pages/nav/nav.vue @@ -7,14 +7,15 @@ YUNZE WORKSPACE 工作台 - {{ user.account || '我的账号' }} + {{ user.account || '我的账号' }} + 登录 - + 当前租户{{ user.tenant || '云泽平台' }} 切换 - 选择一个业务系统,开始高效工作 + {{ guest ? '免登录可逛部分功能,登录后使用完整工作台' : '选择一个业务系统,开始高效工作' }} @@ -46,12 +47,27 @@ {{ card.name }} + {{ card.needLogin ? '需登录' : '免登录' }} + + + + + + 开启免登录保存 + 授权手机号后,记事本、日程提醒、密码库等免登录功能的数据即可保存;正式登录后会自动并入账号,不会丢失。 + + {{ guestAuthLoading ? '授权中…' : '立即授权' }} + + 暂不授权(使用将无法保存数据) + + + @@ -63,9 +79,80 @@ const headPad = mpHeadPad() import { onShow } from '@dcloudio/uni-app' import { isLoggedIn, getTenants, getTid, setAuthTokens, setTid, setTenantName, getUser, setUser } from '@/utils/auth.js' import { switchTenant } from '@/api/auth.js' -import { fetchHome } from '@/api/workspace.js' +import { fetchHome, fetchPublicModules, fetchPublicMenus } from '@/api/workspace.js' import { openMenu } from '@/utils/nav.js' import { getSubsystemByModule, goSubsystem } from '@/config/subsystems.js' +import { getGuestToken, setGuestToken } from '@/utils/guest.js' +import { guestMiniLogin } from '@/api/guest.js' + +/* ==================== 游客手机号授权(免登录保存) ==================== */ + +const guestAuthVisible = ref(false) +const guestAuthLoading = ref(false) +// 本次停留内跳过:关闭后不在 nav 反复弹窗,下次冷启动再引导 +const guestAuthDismissed = ref(false) + +/** 仅微信小程序可拿手机号动态凭证 */ +function canRequestGuestPhone() { + // #ifdef MP-WEIXIN + return true + // #endif + // eslint-disable-next-line no-unreachable + return false +} + +function maybeAskGuestAuth() { + if (!guest.value || getGuestToken() || guestAuthDismissed.value || !canRequestGuestPhone()) { + guestAuthVisible.value = false + return + } + guestAuthVisible.value = true +} + +/** 取 uni.login 临时登录凭证(配合手机号动态 code 换游客令牌) */ +function navUniLoginCode() { + return new Promise((resolve, reject) => { + uni.login({ + provider: 'weixin', + success: (res) => (res && res.code ? resolve(res.code) : reject(new Error('未获取到微信登录凭证'))), + fail: () => reject(new Error('获取微信登录凭证失败')) + }) + }) +} + +async function onGuestPhone(e) { + const detail = (e && e.detail) || {} + if (String(detail.errMsg || '').indexOf('ok') === -1 || !detail.code) { + uni.showToast({ title: '未获取到手机号授权', icon: 'none' }) + return + } + if (guestAuthLoading.value) return + guestAuthLoading.value = true + try { + const code = await navUniLoginCode() + let appid = '' + try { + appid = (uni.getAccountInfoSync().miniProgram || {}).appId || '' + } catch (err) { + appid = '' + } + const res = await guestMiniLogin({ code, phoneCode: detail.code, appid }) + if (!res || !res.token) throw new Error('未返回游客令牌') + setGuestToken(res.token) + guestAuthVisible.value = false + uni.showToast({ title: '授权成功,数据可保存了', icon: 'none' }) + } catch (err) { + console.error('[nav] guest mini-login failed:', err) + uni.showToast({ title: (err && err.message) || '授权失败,请重试', icon: 'none' }) + } finally { + guestAuthLoading.value = false + } +} + +function skipGuestAuth() { + guestAuthDismissed.value = true + guestAuthVisible.value = false +} /** 模块 code → 子系统 slug → 进入其概览页;未注册返回 false */ function goSubsystemByModule(code) { @@ -91,12 +178,43 @@ const modules = ref([]) const menus = ref([]) const tenants = ref(getTenants()) const switchingTenant = ref(false) +// 游客态:未登录时默认落在 nav,渲染公开模块宫格,按菜单级免登录配置分流。 +const guest = ref(!isLoggedIn()) +const publicModules = ref([]) +// 免登录菜单(/api/menus/public,require_login=0):模块宫格是否可免登录进入的唯一依据 +const publicMenus = ref([]) + +/** 含免登录菜单的模块 code 集合(小写) */ +const guestMenuModuleSet = computed( + () => new Set(publicMenus.value.map((m) => String(m.module || '').toLowerCase()).filter(Boolean)) +) /** 叶子菜单(可直接跳转的功能页),已锁定的过滤掉 */ const leafMenus = computed(() => menus.value.filter((m) => m.leaf !== false && !m.locked)) /** 子系统卡片:每个业务模块一张卡 */ const cards = computed(() => { + // 游客:模块可免登录 = 该模块下存在免登录菜单(菜单级 require_login 控制) + if (guest.value) { + return publicModules.value.map((mod, index) => { + const code = String(mod.code || '').toLowerCase() + const needLogin = !guestMenuModuleSet.value.has(code) + return { + id: mod.id, + code: mod.code, + path: mod.path || '', + name: mod.name || '未命名系统', + desc: mod.description || (needLogin ? '需登录使用' : '免登录'), + icon: MODULE_ICONS[code] || 'cube', + tint: 'tint-' + (index % 8), + locked: false, + needLogin, + items: [], + total: 0 + } + }) + } + const byCode = {} leafMenus.value.forEach((m) => { if (!m.module) return @@ -132,6 +250,25 @@ const cards = computed(() => { async function load(force = false) { loading.value = true + guest.value = !isLoggedIn() + // 游客:公开模块宫格 + 免登录菜单;模块可点的判定下沉到菜单级 + // (存在 require_login=0 的菜单即免登录,模块级 require_login 不再作为依据) + if (guest.value) { + try { + const [list, menuList] = await Promise.all([fetchPublicModules(), fetchPublicMenus()]) + publicModules.value = Array.isArray(list) ? list : [] + publicMenus.value = Array.isArray(menuList) ? menuList : [] + loadError.value = '' + } catch (e) { + console.error('[nav] public modules load failed:', e) + loadError.value = (e && e.message) || '接口请求失败' + } finally { + loading.value = false + } + // 进小程序即引导授权:游客无令牌时弹手机号授权窗 + maybeAskGuestAuth() + return + } try { const data = await fetchHome(force) user.value = data.user || {} @@ -188,12 +325,17 @@ function chooseTenant() { /** * 点击子系统卡片 → 进入该子系统独立页面组(带自己的 tabbar)。 * 未在 config/subsystems.js 注册的模块,降级为直接打开 Web 端该模块首页。 + * 游客:命中「必须登录」模块时不直接进,先引导登录(登录后回 nav)。 */ function enter(card) { if (card.locked) { openMenu({ locked: true, title: card.name }) return } + if (guest.value && card.needLogin) { + goLogin(card.name) + return + } if (goSubsystemByModule(card.code)) return if (card.items.length) { openMenu(card.items[0], card.name) @@ -206,6 +348,14 @@ function enter(card) { uni.showToast({ title: card.name + '暂未开放', icon: 'none' }) } +/** 引导登录:登录页登录成功后会回 nav(见 login.vue finishLogin) */ +function goLogin(name = '') { + if (name) { + uni.showToast({ title: '「' + name + '」需登录后使用', icon: 'none' }) + } + uni.navigateTo({ url: '/pages/login/login' }) +} + function openTenantPicker() { if (tenants.value.length < 2) return tenantPickerVisible.value = true @@ -246,19 +396,12 @@ async function changeTenant(item) { /* ==================== 生命周期 ==================== */ onMounted(() => { - if (!isLoggedIn()) { - uni.reLaunch({ url: '/pages/login/login' }) - return - } + // 默认落地页:未登录不再强制回登录页,而是以游客态渲染公开模块宫格 load() }) onShow(() => { - // 未登录(含后台切回来时令牌被清)直接回登录页,不再停留在首页 - if (!isLoggedIn()) { - uni.reLaunch({ url: '/pages/login/login' }) - return - } + // 登录态可能在登录页/其他入口发生变化,每次展示重新判定并加载 if (!loading.value) load() }) @@ -458,7 +601,92 @@ onShow(() => { color: $color-text-muted; } +/* 游客态:卡片左上角标出免登录 / 需登录 */ +.portal-card { + position: relative; +} +.portal-tag { + position: absolute; + top: 14rpx; + right: 14rpx; + padding: 2rpx 12rpx; + border-radius: 20rpx; + font-size: 18rpx; + line-height: 1.6; +} +.portal-tag.free { + color: #16a34a; + background: rgba(22, 163, 74, 0.12); +} +.portal-tag.need { + color: #94a3b8; + background: rgba(148, 163, 184, 0.16); +} + .bottom-space { height: 48rpx; } + +/* ============ 游客授权引导弹窗 ============ */ +.guest-auth-mask { + position: fixed; + left: 0; + right: 0; + top: 0; + bottom: 0; + z-index: 99; + background: rgba(15, 23, 42, 0.55); + display: flex; + align-items: center; + justify-content: center; + padding: 0 56rpx; +} + +.guest-auth-card { + width: 100%; + max-width: 560rpx; + background: #ffffff; + border-radius: 28rpx; + padding: 44rpx 36rpx 32rpx; + display: flex; + flex-direction: column; + align-items: center; + gap: 20rpx; + box-shadow: 0 24rpx 64rpx rgba(15, 23, 42, 0.22); +} + +.guest-auth-title { + font-size: 34rpx; + font-weight: 700; + color: #17345f; +} + +.guest-auth-desc { + font-size: 24rpx; + line-height: 1.65; + color: #64748b; + text-align: left; +} + +.guest-auth-btn { + width: 100%; + margin-top: 8rpx; + background: linear-gradient(145deg, #69a5ff, #3478f6); + color: #ffffff; + font-size: 28rpx; + font-weight: 600; + border-radius: 44rpx; + line-height: 2.6; + border: none; + + &[disabled] { + opacity: 0.6; + } +} + +.guest-auth-skip { + font-size: 22rpx; + color: #94a3b8; + padding: 8rpx 16rpx; +} diff --git a/uniapp/pages/tools/notepad/index.vue b/uniapp/pages/tools/notepad/index.vue index b8e6fcc..dfcc1d9 100644 --- a/uniapp/pages/tools/notepad/index.vue +++ b/uniapp/pages/tools/notepad/index.vue @@ -69,6 +69,7 @@ import { onShow } from '@dcloudio/uni-app' import { fetchNotes, deleteNote, toggleNotePin } from '@/api/note.js' import { formatRelativeTime } from '@/utils/date.js' import { isLoggedIn } from '@/utils/auth.js' +import { getGuestToken } from '@/utils/guest.js' const keyword = ref('') const notes = ref([]) @@ -78,15 +79,16 @@ const loading = ref(false) const pinnedCount = computed(() => notes.value.filter(item => item.pinned).length) onMounted(() => { - if (!isLoggedIn()) { - uni.reLaunch({ url: '/pages/login/login' }) + // 免登录页面:登录态或游客令牌均可用;两者皆无回门户触发授权/登录 + if (!isLoggedIn() && !getGuestToken()) { + uni.reLaunch({ url: '/pages/nav/nav' }) return } loadNotes() }) onShow(() => { - if (isLoggedIn()) loadNotes() + if (isLoggedIn() || getGuestToken()) loadNotes() }) async function loadNotes() { diff --git a/uniapp/pages/tools/schedule/reminder.vue b/uniapp/pages/tools/schedule/reminder.vue index 5bbbd6d..819d092 100644 --- a/uniapp/pages/tools/schedule/reminder.vue +++ b/uniapp/pages/tools/schedule/reminder.vue @@ -115,6 +115,7 @@ import { onShow } from '@dcloudio/uni-app' import { fetchSchedules, getRemindChannelLabel, getRemindMinutesLabel } from '@/api/schedule.js' import { pad, startOfDay, isToday } from '@/utils/date.js' import { isLoggedIn } from '@/utils/auth.js' +import { getGuestToken } from '@/utils/guest.js' /** 周一为一周第一天 */ const WEEK_LABELS = ['一', '二', '三', '四', '五', '六', '日'] @@ -282,8 +283,9 @@ function openCreate() { } onMounted(() => { - if (!isLoggedIn()) { - uni.reLaunch({ url: '/pages/login/login' }) + // 免登录页面:登录态或游客令牌均可用;两者皆无回门户触发授权/登录 + if (!isLoggedIn() && !getGuestToken()) { + uni.reLaunch({ url: '/pages/nav/nav' }) return } loadReminders() @@ -291,8 +293,7 @@ onMounted(() => { // 新增/编辑返回后刷新列表 onShow(() => { - if (!isLoggedIn()) return - if (schedules.value.length || !loading.value) loadReminders() + if (isLoggedIn() || getGuestToken()) loadReminders() }) async function loadReminders() { diff --git a/uniapp/project.config.json b/uniapp/project.config.json index c6cb5bf..2a79dd1 100644 --- a/uniapp/project.config.json +++ b/uniapp/project.config.json @@ -12,7 +12,15 @@ "disablePlugins": [], "outputPath": "" }, - "useCompilerPlugins": false + "useCompilerPlugins": false, + "compileWorklet": false, + "uploadWithSourceMap": true, + "packNpmManually": false, + "minifyWXSS": true, + "minifyWXML": true, + "localPlugins": false, + "disableUseStrict": false, + "condition": false }, "compileType": "miniprogram", "simulatorPluginLibVersion": {}, diff --git a/uniapp/project.private.config.json b/uniapp/project.private.config.json index 738d62c..87fe0cb 100644 --- a/uniapp/project.private.config.json +++ b/uniapp/project.private.config.json @@ -10,6 +10,13 @@ "autoAudits": false, "showShadowRootInWxmlPanel": true, "compileHotReLoad": true, - "useGlassEaselForWxml": false + "useGlassEaselForWxml": false, + "useApiHook": true, + "useStaticServer": false, + "useLanDebug": false, + "showES6CompileOption": false, + "checkInvalidKey": true, + "ignoreDevUnusedFiles": true, + "bigPackageSizeSupport": false } } \ No newline at end of file diff --git a/uniapp/utils/guest.js b/uniapp/utils/guest.js new file mode 100644 index 0000000..19ba84a --- /dev/null +++ b/uniapp/utils/guest.js @@ -0,0 +1,28 @@ +/** + * 游客轻身份(免登录模块的数据临时存储) + * + * 小程序冷启动进入 nav 游客态时,引导用户授权微信手机号, + * 由 /api/guest/mini-login 签发游客令牌(30 天有效)。 + * 携带该令牌可直接使用记事本 / 日程提醒 / 密码库等免登录功能, + * 数据落在后端 yz_guest_data;正式登录后调 /api/guest/merge 并入账号。 + */ +import { getToken } from '@/utils/auth.js' + +const GUEST_TOKEN_KEY = 'app_guest_token' + +export function setGuestToken(token) { + uni.setStorageSync(GUEST_TOKEN_KEY, token || '') +} + +export function getGuestToken() { + return uni.getStorageSync(GUEST_TOKEN_KEY) || '' +} + +export function clearGuestToken() { + uni.removeStorageSync(GUEST_TOKEN_KEY) +} + +/** 游客模式:未登录但持有手机号授权签发的游客令牌 */ +export function isGuestMode() { + return !getToken() && !!getGuestToken() +} diff --git a/uniapp/utils/subsystem.js b/uniapp/utils/subsystem.js index 4ce567b..cf9d786 100644 --- a/uniapp/utils/subsystem.js +++ b/uniapp/utils/subsystem.js @@ -7,9 +7,29 @@ * 这里统一封装,避免三个页面各写一遍。 */ import { ref, computed } from 'vue' -import { fetchHome } from '@/api/workspace.js' +import { fetchHome, fetchPublicMenus } from '@/api/workspace.js' +import { isLoggedIn } from '@/utils/auth.js' import { getSubsystem } from '@/config/subsystems.js' +/** + * 游客(未登录)菜单源:公开接口 /api/menus/public(菜单级 require_login=0)。 + * 免登录控制按菜单粒度在 Platform 菜单管理配置,后端下发什么游客就能用什么; + * 多个 Sub 组件共享一次拉取(会话级缓存,失败不缓存以便重试)。 + */ +let guestMenusPromise = null +function loadGuestMenus() { + if (!guestMenusPromise) { + guestMenusPromise = fetchPublicMenus() + .then((list) => (Array.isArray(list) ? list : [])) + .catch((e) => { + guestMenusPromise = null + console.error('[subsystem] public menus failed:', e) + return [] + }) + } + return guestMenusPromise +} + /** * @param {string} slug 子系统目录名(crm / erp / oa / cms / tools) * @param {import('vue').Ref} currentTab 当前 tab,用于按需拉数据 @@ -47,6 +67,17 @@ export function useSubsystem(slug) { async function load(force = false) { loading.value = true + // 游客(未登录)不拉需鉴权的 /api/home,避免 401 触发登出回环; + // 改用公开菜单接口,按 module 归组后渲染免登录入口(菜单级 require_login 控制)。 + if (!isLoggedIn()) { + try { + menus.value = await loadGuestMenus() + } catch (e) { + menus.value = [] + } + loading.value = false + return + } try { const data = await fetchHome(force) user.value = data.user || {}