From aa92ca40a27bd87bdbb51acfd3b7b2fad4af7920 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E6=89=AB=E5=9C=B0=E5=83=A7?= <357099073@qq.com>
Date: Sat, 3 Oct 2026 23:46:55 +0800
Subject: [PATCH] =?UTF-8?q?=E6=89=B9=E9=87=8F=E4=BC=98=E5=8C=96?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
backend/src/router/index.js | 15 +-
backend/src/views/auth/appLogin.vue | 232 ++++
go/conf/app.conf | 2 +-
go/controllers/app_aggregate.go | 416 ++++++
go/controllers/auth/app_ticket.go | 243 ++++
go/controllers/auth/login.go | 12 +-
go/controllers/auth/wechat_miniapp.go | 309 +++++
go/controllers/backend_menu.go | 33 +-
go/controllers/backend_modules.go | 2 +
go/controllers/platform_modules.go | 86 +-
go/controllers/platform_wechat_mini.go | 131 ++
go/docs/sql/add_module_platforms.sql | 89 ++
go/docs/sql/create_auth_app_login_ticket.sql | 13 +
go/docs/sql/create_auth_tables.sql | 1 +
go/docs/sql/create_wechat_mini_config.sql | 42 +
go/models/auth_app_ticket.go | 26 +
go/models/init.go | 4 +
go/models/system_menu.go | 11 +
go/models/system_modules.go | 3 +
go/models/wechat_mini.go | 24 +
go/routers/app/app.go | 3 +
go/routers/auth/auth.go | 8 +
go/routers/platform/wechat_mp.go | 3 +
go/services/auth/app_ticket.go | 97 ++
go/services/auth/token.go | 2 +-
go/services/display_scope.go | 154 +++
go/services/display_scope_test.go | 91 ++
go/services/wechatmini/api.go | 234 ++++
go/services/wechatmini/config.go | 126 ++
go/views/auth/login.tpl | 2 +
platform/components.d.ts | 2 -
uniapp/App.vue | 5 +-
uniapp/api/auth.js | 81 +-
uniapp/api/config.js | 54 +
uniapp/api/request.js | 6 +-
uniapp/api/workspace.js | 35 +
uniapp/components/AppTabbar.vue | 52 +-
uniapp/components/MainTabbar.vue | 93 ++
uniapp/components/sub/SubFunctions.vue | 315 +++++
uniapp/components/sub/SubHome.vue | 427 ++++++
uniapp/components/sub/SubMine.vue | 485 +++++++
uniapp/components/sub/SubTodo.vue | 297 ++++
uniapp/config/subsystems.js | 85 ++
uniapp/manifest.json | 2 +
uniapp/pages.json | 157 ++-
uniapp/pages/apps/webview.vue | 149 ++
uniapp/pages/cms/functions/functions.vue | 12 +
uniapp/pages/cms/home/home.vue | 12 +
uniapp/pages/cms/mine/mine.vue | 12 +
uniapp/pages/cms/todo/todo.vue | 12 +
uniapp/pages/crm/functions/functions.vue | 12 +
uniapp/pages/crm/home/home.vue | 12 +
uniapp/pages/crm/mine/mine.vue | 12 +
uniapp/pages/crm/todo/todo.vue | 12 +
uniapp/pages/dashboard/dashboard.vue | 1111 +++++++++++----
uniapp/pages/erp/functions/functions.vue | 12 +
uniapp/pages/erp/home/home.vue | 12 +
uniapp/pages/erp/mine/mine.vue | 12 +
uniapp/pages/erp/todo/todo.vue | 12 +
uniapp/pages/features/features.vue | 544 +++++---
uniapp/pages/login/login.vue | 1296 ++++++++++--------
uniapp/pages/nav/nav.vue | 369 +++++
uniapp/pages/oa/functions/functions.vue | 12 +
uniapp/pages/oa/home/home.vue | 12 +
uniapp/pages/oa/mine/mine.vue | 12 +
uniapp/pages/oa/todo/todo.vue | 12 +
uniapp/pages/profile/profile.vue | 26 +-
uniapp/pages/toolbox/functions/functions.vue | 12 +
uniapp/pages/toolbox/home/home.vue | 12 +
uniapp/pages/toolbox/mine/mine.vue | 12 +
uniapp/pages/toolbox/todo/todo.vue | 12 +
uniapp/src/styles/page-common.scss | 31 +
uniapp/src/styles/theme.scss | 53 +-
uniapp/src/styles/tint.scss | 19 +
uniapp/utils/auth.js | 50 +-
uniapp/utils/geetest.js | 5 +-
uniapp/utils/nav.js | 131 ++
uniapp/utils/subsystem.js | 60 +
uniapp/vite.config.js | 5 +-
79 files changed, 7495 insertions(+), 1104 deletions(-)
create mode 100644 backend/src/views/auth/appLogin.vue
create mode 100644 go/controllers/app_aggregate.go
create mode 100644 go/controllers/auth/app_ticket.go
create mode 100644 go/controllers/auth/wechat_miniapp.go
create mode 100644 go/controllers/platform_wechat_mini.go
create mode 100644 go/docs/sql/add_module_platforms.sql
create mode 100644 go/docs/sql/create_auth_app_login_ticket.sql
create mode 100644 go/docs/sql/create_wechat_mini_config.sql
create mode 100644 go/models/auth_app_ticket.go
create mode 100644 go/models/wechat_mini.go
create mode 100644 go/services/auth/app_ticket.go
create mode 100644 go/services/display_scope.go
create mode 100644 go/services/display_scope_test.go
create mode 100644 go/services/wechatmini/api.go
create mode 100644 go/services/wechatmini/config.go
create mode 100644 uniapp/api/workspace.js
create mode 100644 uniapp/components/MainTabbar.vue
create mode 100644 uniapp/components/sub/SubFunctions.vue
create mode 100644 uniapp/components/sub/SubHome.vue
create mode 100644 uniapp/components/sub/SubMine.vue
create mode 100644 uniapp/components/sub/SubTodo.vue
create mode 100644 uniapp/config/subsystems.js
create mode 100644 uniapp/pages/apps/webview.vue
create mode 100644 uniapp/pages/cms/functions/functions.vue
create mode 100644 uniapp/pages/cms/home/home.vue
create mode 100644 uniapp/pages/cms/mine/mine.vue
create mode 100644 uniapp/pages/cms/todo/todo.vue
create mode 100644 uniapp/pages/crm/functions/functions.vue
create mode 100644 uniapp/pages/crm/home/home.vue
create mode 100644 uniapp/pages/crm/mine/mine.vue
create mode 100644 uniapp/pages/crm/todo/todo.vue
create mode 100644 uniapp/pages/erp/functions/functions.vue
create mode 100644 uniapp/pages/erp/home/home.vue
create mode 100644 uniapp/pages/erp/mine/mine.vue
create mode 100644 uniapp/pages/erp/todo/todo.vue
create mode 100644 uniapp/pages/nav/nav.vue
create mode 100644 uniapp/pages/oa/functions/functions.vue
create mode 100644 uniapp/pages/oa/home/home.vue
create mode 100644 uniapp/pages/oa/mine/mine.vue
create mode 100644 uniapp/pages/oa/todo/todo.vue
create mode 100644 uniapp/pages/toolbox/functions/functions.vue
create mode 100644 uniapp/pages/toolbox/home/home.vue
create mode 100644 uniapp/pages/toolbox/mine/mine.vue
create mode 100644 uniapp/pages/toolbox/todo/todo.vue
create mode 100644 uniapp/src/styles/tint.scss
create mode 100644 uniapp/utils/nav.js
create mode 100644 uniapp/utils/subsystem.js
diff --git a/backend/src/router/index.js b/backend/src/router/index.js
index 16c50b9..f2b8ec4 100644
--- a/backend/src/router/index.js
+++ b/backend/src/router/index.js
@@ -148,6 +148,13 @@ const staticRoutes = [
component: () => import("@/views/auth/mpLogin.vue"),
meta: { requiresAuth: false }
},
+ // 移动端免登录页(uniapp APP / 小程序带一次性票据 → 换发 Web 端令牌,避免二次登录)
+ {
+ path: "/app-login",
+ name: "AppLogin",
+ component: () => import("@/views/auth/appLogin.vue"),
+ meta: { requiresAuth: false }
+ },
{
path: "/home",
name: "Home",
@@ -317,8 +324,12 @@ async function gotoAuthorize() {
router.beforeEach(async (to, from, next) => {
const token = localStorage.getItem("token");
- // 认证中心回跳页 / 微信登录兑换页:始终放行,由页面自身完成令牌兑换
- if (to.path === "/auth/callback" || to.path === "/mp-login") {
+ // 认证中心回跳页 / 微信登录兑换页 / 移动端免登录页:始终放行,由页面自身完成令牌兑换
+ if (
+ to.path === "/auth/callback" ||
+ to.path === "/mp-login" ||
+ to.path === "/app-login"
+ ) {
next();
return;
}
diff --git a/backend/src/views/auth/appLogin.vue b/backend/src/views/auth/appLogin.vue
new file mode 100644
index 0000000..a0609e7
--- /dev/null
+++ b/backend/src/views/auth/appLogin.vue
@@ -0,0 +1,232 @@
+
+
+
+
+
+ 选择要进入的企业
+
+ -
+ {{ t.tenant_name }}
+ {{ t.name }}
+
+
+ {{ chooseErr }}
+
+
+
+
+
免登录失败
+
{{ error }}
+
+
+
+
+
+
+
+
+
+
+
diff --git a/go/conf/app.conf b/go/conf/app.conf
index 494eaf8..bd96477 100644
--- a/go/conf/app.conf
+++ b/go/conf/app.conf
@@ -77,7 +77,7 @@ jwt_secrets =
# 注意:不要在这里启用与上面公钥不配对的私钥,否则本地签发的 RS256 token 会验签失败。
# 仅当需要在本地自建认证中心并签发 RS256 时才配置私钥,且公钥必须与它配对。
jwt_rsa_private_key_file =
-jwt_rsa_public_key_file = E:\Demos\DemoOwns\Go\yunzerwebsiteallinone\go\conf\jwt_rsa_public.pem
+jwt_rsa_public_key_file = conf/jwt_rsa_public.pem
jwt_rsa_kid = rsa-1
# ==================== 微信公众号(服务号) ====================
diff --git a/go/controllers/app_aggregate.go b/go/controllers/app_aggregate.go
new file mode 100644
index 0000000..822cbff
--- /dev/null
+++ b/go/controllers/app_aggregate.go
@@ -0,0 +1,416 @@
+package controllers
+
+import (
+ "errors"
+ "sort"
+ "strings"
+ "time"
+
+ "server/models"
+ "server/pkg/jwtutil"
+ "server/services"
+
+ beego "github.com/beego/beego/v2/server/web"
+)
+
+// =============================================================
+// 移动端「工作台」聚合接口
+//
+// uniapp 首页改造成 ERP/OA/CRM/CMS 聚合平台后,首屏需要一次性拿到三组数据:
+// 1. 应用模块卡片(yz_system_modules + 租户套餐开通状态)
+// 2. 当前登录用户可见的菜单(yz_system_menu,按角色过滤 + 套餐锁定标记)
+// 3. 首页 KPI(待办日程 / 逾期 / 笔记 / 站内信未读 / 报销在途)
+//
+// Web 端分别由 /backend/modules/getTenantList、/backend/menu/:id、各 dashboard
+// 提供,但都要求 user_type=backend,移动端令牌调不到。这里复用同一套 service
+// 逻辑,仅把 user_type 放宽到 app,并聚合成一次请求以减少移动端首屏往返。
+//
+// ID 口径(与既有 /app/* 接口保持一致,勿混用):
+// - 业务表的 user_id = 令牌里的 identity_id(claims.UserID)
+// - 老表 yz_system_tenant_user.uid = services.ResolveTenantUID(tid, claims.UserID)
+// =============================================================
+
+var (
+ errNotLogin = errors.New("未登录")
+ errAuthFormat = errors.New("认证信息格式错误")
+ errTokenInvalid = errors.New("无效的token")
+ errNoPermission = errors.New("无权访问")
+)
+
+// AppAggregateController 移动端工作台聚合
+type AppAggregateController struct {
+ beego.Controller
+}
+
+// appMenuItem 菜单扁平项。
+// 移动端要按 path 前缀把菜单归到各应用模块下(与套餐鉴权 ownerModuleCode 同源逻辑),
+// 树形在前端分组不便,扁平 + parent_id 更适合「应用详情」页渲染二级列表。
+type appMenuItem struct {
+ ID uint64 `json:"id"`
+ Pid int64 `json:"pid"`
+ Title string `json:"title"`
+ Path string `json:"path,omitempty"`
+ Icon string `json:"icon,omitempty"`
+ Type int8 `json:"type"`
+ Sort int64 `json:"sort"`
+ Module string `json:"module"` // 归属模块 code(crm/erp/oa/cms/ai),公共菜单为空
+ Locked bool `json:"locked"` // true = 不在租户套餐内,前端置灰并引导升级
+ Leaf bool `json:"leaf"` // true = 叶子菜单(无下级),可直接跳转
+ Docs int `json:"docs,omitempty"` // 该节点下可见的子菜单数量
+}
+
+func (c *AppAggregateController) serveJSON(data map[string]interface{}) {
+ c.Data["json"] = data
+ _ = c.ServeJSON()
+}
+
+func (c *AppAggregateController) fail(msg string) {
+ c.Ctx.Output.SetStatus(401)
+ c.serveJSON(map[string]interface{}{"code": 401, "msg": msg})
+}
+
+// appClaims 校验 Bearer 令牌,允许 backend / app 两种身份
+func (c *AppAggregateController) appClaims() (*jwtutil.Claims, error) {
+ auth := c.Ctx.Request.Header.Get("Authorization")
+ if auth == "" {
+ return nil, errNotLogin
+ }
+ parts := strings.SplitN(auth, " ", 2)
+ if len(parts) != 2 || parts[0] != "Bearer" {
+ return nil, errAuthFormat
+ }
+ claims, err := jwtutil.ParseToken(parts[1])
+ if err != nil {
+ return nil, errTokenInvalid
+ }
+ if claims.UserType != "backend" && claims.UserType != "app" {
+ return nil, errNoPermission
+ }
+ return claims, nil
+}
+
+// appModuleItem 模块卡片:在模块基础信息上标注当前租户是否已开通
+type appModuleItem struct {
+ models.SystemModules
+ Enabled bool `json:"enabled"`
+ Locked bool `json:"locked"`
+ Menus int `json:"menus"` // 该模块下当前用户可见的菜单数
+}
+
+// Home GET /app/home
+//
+// 一次返回工作台首屏所需的全部数据:
+//
+// { user:{...}, modules:[...], menus:[...], stats:{todo,overdue,note,message,reimburse} }
+func (c *AppAggregateController) Home() {
+ claims, err := c.appClaims()
+ if err != nil {
+ c.fail(err.Error())
+ return
+ }
+ tid := uint64(claims.TenantId)
+ uid := services.ResolveTenantUID(tid, uint64(claims.UserID))
+
+ modules := c.appModules(tid)
+ menus := c.appMenus(uid, tid)
+ // 把每个模块下可见的菜单数回填到卡片上,端上决定是否显示「N 个功能」
+ menuCount := map[string]int{}
+ for _, m := range menus {
+ if m.Module != "" {
+ menuCount[m.Module]++
+ }
+ }
+ for i := range modules {
+ modules[i].Menus = menuCount[services.NormalizeModuleCode(modules[i].Code)]
+ }
+
+ c.serveJSON(map[string]interface{}{
+ "code": 200,
+ "msg": "success",
+ "data": map[string]interface{}{
+ "user": c.appUser(claims, uid, tid),
+ "modules": modules,
+ "menus": menus,
+ "stats": c.appStats(claims, tid),
+ },
+ })
+}
+
+// appUser 当前登录用户的展示信息(含企业名,供首页问候语使用)
+func (c *AppAggregateController) appUser(claims *jwtutil.Claims, uid, tid uint64) map[string]interface{} {
+ out := map[string]interface{}{
+ "uid": uid,
+ "tid": tid,
+ "name": "用户",
+ "account": "",
+ "role_name": "",
+ "tenant": "",
+ "tenant_id": tid,
+ "avatar": "",
+ }
+ if claims.Username != "" {
+ out["name"] = claims.Username
+ }
+ var tu models.SystemTenantUser
+ if models.Orm.QueryTable(new(models.SystemTenantUser)).
+ Filter("uid", uid).Filter("tid", tid).One(&tu) == nil {
+ if tu.Name != nil && strings.TrimSpace(*tu.Name) != "" {
+ out["name"] = strings.TrimSpace(*tu.Name)
+ }
+ if tu.Account != nil && strings.TrimSpace(*tu.Account) != "" {
+ out["account"] = strings.TrimSpace(*tu.Account)
+ }
+ if tu.GroupID > 0 {
+ var role models.AdminRole
+ if models.Orm.QueryTable(new(models.AdminRole)).
+ Filter("id", tu.GroupID).Filter("cid", 2).One(&role) == nil {
+ if strings.TrimSpace(role.Name) != "" {
+ out["role_name"] = strings.TrimSpace(role.Name)
+ }
+ }
+ }
+ }
+ var tenant models.SystemTenant
+ if models.Orm.QueryTable(new(models.SystemTenant)).Filter("id", tid).One(&tenant) == nil {
+ if strings.TrimSpace(tenant.TenantName) != "" {
+ out["tenant"] = strings.TrimSpace(tenant.TenantName)
+ }
+ }
+ return out
+}
+
+// appModules 模块卡片列表(status=1 且 is_show=1),并标注套餐开通状态与可见菜单数
+func (c *AppAggregateController) appModules(tid uint64) []appModuleItem {
+ 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 {
+ return []appModuleItem{}
+ }
+
+ // 移动端只展示 app 可见的模块(未配置 platforms 视为全端)
+ rows = services.FilterModulesByPlatform(rows, models.PlatformApp)
+
+ allowed := services.GetTenantModuleCodes(tid)
+ out := make([]appModuleItem, 0, len(rows))
+ for _, m := range rows {
+ enabled := true
+ if len(allowed) > 0 {
+ enabled = allowed[services.NormalizeModuleCode(m.Code)]
+ }
+ out = append(out, appModuleItem{
+ SystemModules: m,
+ Enabled: enabled,
+ Locked: !enabled,
+ })
+ }
+ return out
+}
+
+// appMenus 当前用户可见的租户端菜单(扁平 + 模块归属 + 套餐锁定标记)
+func (c *AppAggregateController) appMenus(uid, tid uint64) []appMenuItem {
+ var rows []models.SystemMenu
+ if _, err := models.Orm.QueryTable(new(models.SystemMenu)).
+ Filter("status", 1).All(&rows); err != nil {
+ return []appMenuItem{}
+ }
+ menus := filterMenusByView(rows, 2)
+
+ // 角色权限过滤:rights 为空视为全权限(与 Web 端口径一致)
+ if uid > 0 {
+ var tu models.SystemTenantUser
+ if models.Orm.QueryTable(new(models.SystemTenantUser)).
+ Filter("uid", uid).Filter("tid", tid).One(&tu) == nil && tu.GroupID > 0 {
+ var role models.AdminRole
+ if models.Orm.QueryTable(new(models.AdminRole)).
+ Filter("id", tu.GroupID).Filter("cid", 2).One(&role) == nil {
+ menus = filterMenusByRights(menus, role.Rights)
+ }
+ }
+ }
+
+ // 套餐未开通的菜单保留但标记 locked,前端置灰并引导升级
+ menus, lockedIDs := filterMenusByTenantPackageLocked("", menus, tid, true)
+
+ // 归属计算必须用「全部模块」而非仅启用的模块:
+ // 系统设置类模块(basicSettings/systemManager/moduleshop,type=2)默认不启用,
+ // 若只用启用模块,它们的菜单会全部落到"无归属"被误吸进协作工具区;
+ // 同理已停用模块的残留菜单也应当归属回自己的模块,由前端按 enabled 决定是否展示。
+ allModules := c.allModules()
+
+ // 第一遍:按展示端筛出本端可见菜单(is_visible=0 的也不下发)。
+ // 必须先筛再统计子节点,否则会出现「父节点非叶子但零个可见子项」的死路。
+ // 按展示端筛选:菜单自身未配置时继承所属模块
+ sortedModules := services.SortedModulesForScope(allModules)
+ visible := make([]models.SystemMenu, 0, len(menus))
+ for _, m := range menus {
+ if m.IsVisible != nil && *m.IsVisible == 0 {
+ continue
+ }
+ if !services.MenuVisibleOn(m, sortedModules, allModules, models.PlatformApp) {
+ continue
+ }
+ visible = append(visible, m)
+ }
+
+ // 第二遍:统计本端可见子节点数量,用于区分「目录」与「可跳转页面」
+ 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)
+ }
+ if lockedIDs != nil {
+ item.Locked = lockedIDs[m.ID]
+ }
+ item.Module = appModuleCodeOfPath(item.Path, allModules)
+ item.Leaf = childCount[int64(m.ID)] == 0
+ item.Docs = childCount[int64(m.ID)]
+ out = append(out, item)
+ }
+ sort.SliceStable(out, func(i, j int) bool { return out[i].Sort < out[j].Sort })
+ return out
+}
+
+// pathOf 取菜单路径(空指针安全)
+func pathOf(m models.SystemMenu) string {
+ if m.Path == nil {
+ return ""
+ }
+ return strings.TrimSpace(*m.Path)
+}
+
+// appModuleCodeOfPath 判断菜单归属哪个功能模块。
+// 与 services.ownerModuleCode 同规则(长路径优先,避免 /apps 吞掉 /apps/erp),
+// 区别是自行排序以避免依赖 services 包的内部实现。
+func appModuleCodeOfPath(path string, allModules []models.SystemModules) string {
+ p := strings.TrimSuffix(strings.TrimSpace(path), "/")
+ if p == "" {
+ return ""
+ }
+ sorted := make([]models.SystemModules, len(allModules))
+ copy(sorted, allModules)
+ sort.SliceStable(sorted, func(i, j int) bool {
+ return len(sorted[i].Path) > len(sorted[j].Path)
+ })
+ for _, m := range sorted {
+ mp := strings.TrimSuffix(strings.TrimSpace(m.Path), "/")
+ if mp == "" {
+ continue
+ }
+ if p == mp || strings.HasPrefix(p, mp+"/") {
+ return services.NormalizeModuleCode(m.Code)
+ }
+ }
+ return ""
+}
+
+// allModules 读取全部未删除模块(含停用 / type=2 系统设置),仅用于菜单归属计算。
+func (c *AppAggregateController) allModules() []models.SystemModules {
+ var rows []models.SystemModules
+ if _, err := models.Orm.QueryTable(new(models.SystemModules)).
+ Filter("delete_time__isnull", true).
+ OrderBy("sort", "id").
+ All(&rows); err != nil {
+ return nil
+ }
+ return rows
+}
+
+// appStats 首页 KPI。任一子查询失败都降级为 0,不影响其它指标。
+func (c *AppAggregateController) appStats(claims *jwtutil.Claims, tid uint64) map[string]interface{} {
+ uid := claims.UserID
+ now := time.Now()
+ today := now.Format("2006-01-02")
+
+ // 待办日程 / 逾期:日程是否完成由提醒任务的 remind_status 决定(与 /app/schedule/list 同口径)
+ todo, overdue := 0, 0
+ {
+ var rows []models.BackendSchedule
+ if _, err := models.Orm.QueryTable(new(models.BackendSchedule)).
+ Filter("user_id", uid).Filter("tid", claims.TenantId).
+ OrderBy("-id").Limit(500).All(&rows); err == nil {
+ for _, s := range rows {
+ var reminders []models.BackendScheduleReminder
+ if _, e := models.Orm.QueryTable(new(models.BackendScheduleReminder)).
+ Filter("schedule_id", s.ID).Filter("is_deleted", 0).
+ All(&reminders); e != nil {
+ continue
+ }
+ if len(reminders) == 0 {
+ todo++
+ if s.ScheduleTime.Format("2006-01-02") < today {
+ overdue++
+ }
+ continue
+ }
+ done := true
+ for _, r := range reminders {
+ if r.RemindStatus != 2 {
+ done = false
+ break
+ }
+ }
+ if !done {
+ todo++
+ if s.ScheduleTime.Format("2006-01-02") < today {
+ overdue++
+ }
+ }
+ }
+ }
+ }
+
+ // 笔记
+ note := 0
+ {
+ cnt, _ := models.Orm.QueryTable(new(models.BackendNotebook)).
+ Filter("user_id", uid).Filter("is_deleted", 0).Count()
+ note = int(cnt)
+ }
+
+ // 站内信未读(receiver_type=tenant,与 /backend/sitereminder/myList 同口径)
+ message := 0
+ {
+ cnt, _ := models.Orm.QueryTable(new(models.SystemReminderList)).
+ Filter("receiver_id", uid).Filter("receiver_type", "tenant").
+ Filter("is_read", 0).Filter("delete_time__isnull", true).Count()
+ message = int(cnt)
+ }
+
+ // 报销在途(status=1 审批中)
+ reimburse := 0
+ {
+ cnt, _ := models.Orm.QueryTable(new(models.BackendReimbursement)).
+ Filter("user_id", uid).Filter("tid", tid).
+ Filter("is_deleted", 0).Filter("status", 1).Count()
+ reimburse = int(cnt)
+ }
+
+ return map[string]interface{}{
+ "todo": todo,
+ "overdue": overdue,
+ "note": note,
+ "message": message,
+ "reimburse": reimburse,
+ }
+}
diff --git a/go/controllers/auth/app_ticket.go b/go/controllers/auth/app_ticket.go
new file mode 100644
index 0000000..7e97a2d
--- /dev/null
+++ b/go/controllers/auth/app_ticket.go
@@ -0,0 +1,243 @@
+package auth
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "strings"
+ "time"
+
+ "server/models"
+ "server/pkg/jwtutil"
+ authsvc "server/services/auth"
+
+ beego "github.com/beego/beego/v2/server/web"
+)
+
+// =============================================================
+// 移动端免登录票据(把移动端登录态桥接到 Web 端)
+//
+// POST /auth/app-ticket 移动端带 Bearer 令牌 → 签发一次性票据
+// POST /auth/app-ticket/exchange Web 端带票据 → 核销并换发 Web 端令牌
+//
+// 仅用于「移动端已登录 → 打开 Web 端功能页」这一条链路,
+// 票据一次性 + 60 秒有效,泄露也无法二次使用。
+// =============================================================
+
+// AuthAppTicketController 移动端免登录票据
+type AuthAppTicketController struct {
+ beego.Controller
+}
+
+func (c *AuthAppTicketController) serveJSON(data map[string]interface{}) {
+ c.Data["json"] = data
+ _ = c.ServeJSON()
+}
+
+func (c *AuthAppTicketController) fail(msg string) {
+ c.Ctx.Output.SetStatus(400)
+ c.serveJSON(map[string]interface{}{"code": 400, "msg": msg})
+}
+
+// ticketClaims 解析 Bearer 令牌;移动端是 user_type=app,Web 端是 backend
+func (c *AuthAppTicketController) ticketClaims() (*jwtutil.Claims, error) {
+ header := c.Ctx.Request.Header.Get("Authorization")
+ if header == "" {
+ return nil, fmt.Errorf("未登录")
+ }
+ parts := strings.SplitN(header, " ", 2)
+ if len(parts) != 2 || parts[0] != "Bearer" {
+ return nil, fmt.Errorf("认证信息格式错误")
+ }
+ claims, err := jwtutil.ParseToken(parts[1])
+ if err != nil {
+ return nil, fmt.Errorf("无效的token")
+ }
+ if claims.UserType != "app" && claims.UserType != "backend" {
+ return nil, fmt.Errorf("无权访问")
+ }
+ return claims, nil
+}
+
+// Create POST /auth/app-ticket
+// 移动端用当前登录态换取一次性票据,Web 端拿票据换自己的令牌。
+func (c *AuthAppTicketController) Create() {
+ claims, err := c.ticketClaims()
+ if err != nil {
+ c.Ctx.Output.SetStatus(401)
+ c.serveJSON(map[string]interface{}{"code": 401, "msg": err.Error()})
+ return
+ }
+
+ var req struct {
+ ClientID string `json:"client_id"`
+ }
+ if body := c.Ctx.Input.RequestBody; len(body) > 0 {
+ _ = json.Unmarshal(body, &req)
+ }
+ // 票据是给 Web 端兑换用的,默认按 Web 端应用签发
+ clientID := strings.TrimSpace(req.ClientID)
+ if clientID == "" {
+ clientID = "yz-backend"
+ }
+
+ tid := uint64(0)
+ if claims.TenantId > 0 {
+ tid = uint64(claims.TenantId)
+ }
+
+ ticket, err := authsvc.CreateAppTicket(uint64(claims.UserID), tid, clientID)
+ if err != nil {
+ c.fail("签发票据失败:" + err.Error())
+ return
+ }
+
+ c.serveJSON(map[string]interface{}{
+ "code": 200,
+ "msg": "success",
+ "data": map[string]interface{}{
+ "ticket": ticket,
+ "expire_in": int(authsvc.AppTicketTTL / time.Second),
+ },
+ })
+}
+
+// Exchange POST /auth/app-ticket/exchange
+// Web 端核销票据并换发本端令牌(无鉴权:票据本身即凭证,一次性 + 短时效)。
+func (c *AuthAppTicketController) Exchange() {
+ body := c.Ctx.Input.RequestBody
+ if len(body) == 0 {
+ body, _ = io.ReadAll(c.Ctx.Request.Body)
+ }
+ var req struct {
+ Ticket string `json:"ticket"`
+ Token string `json:"token"` // 兼容 mp-login 的命名
+ ClientID string `json:"client_id"`
+ }
+ if err := json.Unmarshal(body, &req); err != nil {
+ c.fail("参数错误")
+ return
+ }
+ ticket := strings.TrimSpace(req.Ticket)
+ if ticket == "" {
+ ticket = strings.TrimSpace(req.Token)
+ }
+
+ row, err := authsvc.ConsumeAppTicket(ticket)
+ if err != nil {
+ c.fail(err.Error())
+ return
+ }
+
+ // 身份校验:禁用/锁定的账号不签发
+ var identity models.AuthIdentity
+ if err := models.Orm.QueryTable(new(models.AuthIdentity)).
+ Filter("id", row.IdentityID).
+ Filter("delete_time__isnull", true).
+ One(&identity); err != nil {
+ c.fail("账号不存在或已注销")
+ return
+ }
+ if identity.Status != models.AuthIdentityStatusEnabled {
+ c.fail("账号已被禁用或锁定,无法登录")
+ return
+ }
+
+ username := "云泽用户"
+ if identity.Nickname != nil && strings.TrimSpace(*identity.Nickname) != "" {
+ username = strings.TrimSpace(*identity.Nickname)
+ }
+
+ clientID := strings.TrimSpace(req.ClientID)
+ if clientID == "" {
+ clientID = row.ClientID
+ }
+ if clientID == "" {
+ clientID = "yz-backend"
+ }
+
+ // 未确定企业(多企业)时签发待选令牌,由 Web 端展示企业选择
+ tid := row.Tid
+ needChoose := false
+ if tid == 0 {
+ tenants, lerr := authsvc.ListTenantOptions(row.IdentityID)
+ if lerr != nil || len(tenants) == 0 {
+ c.fail("该账号未加入任何企业,无法登录")
+ return
+ }
+ if len(tenants) == 1 {
+ tid = tenants[0].Tid
+ } else {
+ tid = authsvc.PendingTenantID
+ needChoose = true
+ }
+ }
+
+ tokens, terr := c.issueTicketTokens(row.IdentityID, tid, clientID, username, needChoose)
+ if terr != nil {
+ c.fail(terr.Error())
+ return
+ }
+
+ data := map[string]interface{}{
+ "type": "backend",
+ "tokens": tokens,
+ "tid": tid,
+ }
+ if needChoose {
+ tenants, _ := authsvc.ListTenantOptions(row.IdentityID)
+ data["need_choose_tenant"] = true
+ data["tenants"] = tenants
+ }
+ c.serveJSON(map[string]interface{}{"code": 200, "msg": "登录成功", "data": data})
+}
+
+// issueTicketTokens 建会话 + 签发令牌 + 登录日志(失败不影响主流程)
+func (c *AuthAppTicketController) issueTicketTokens(identityID, tid uint64, clientID, username string, needChoose bool) (*authsvc.TokenPair, error) {
+ accessTTL := 0
+ if needChoose {
+ accessTTL = 600 // 待选状态令牌只够用户完成企业选择
+ }
+ sess, serr := authsvc.CreateSession(authsvc.SessionInfo{
+ IdentityID: identityID,
+ Tid: tid,
+ ClientID: clientID,
+ IP: c.Ctx.Input.IP(),
+ UserAgent: c.Ctx.Request.UserAgent(),
+ LoginType: "app_ticket",
+ Amr: "app_ticket",
+ })
+ if serr != nil {
+ return nil, fmt.Errorf("登录失败:%s", serr.Error())
+ }
+ tokens, terr := authsvc.IssueTokens(authsvc.TokenIssue{
+ IdentityID: identityID,
+ Tid: tid,
+ ClientID: clientID,
+ Sid: sess.Sid,
+ Username: username,
+ UserType: "backend", // Web 端业务接口普遍要求 backend
+ Amr: "app_ticket",
+ AccessTTL: accessTTL,
+ })
+ if terr != nil {
+ return nil, fmt.Errorf("签发令牌失败:%s", terr.Error())
+ }
+
+ identityIDCopy := identityID
+ tidCopy := tid
+ amr := "app_ticket"
+ _, _ = models.Orm.Insert(&models.AuthLoginLog{
+ Tid: &tidCopy,
+ IdentityID: &identityIDCopy,
+ UserName: username,
+ ClientID: clientID,
+ LoginType: "app_ticket",
+ Amr: &amr,
+ Status: 1,
+ Message: "移动端票据免登录",
+ IP: c.Ctx.Input.IP(),
+ UserAgent: c.Ctx.Request.UserAgent(),
+ })
+ return tokens, nil
+}
diff --git a/go/controllers/auth/login.go b/go/controllers/auth/login.go
index 6ce15d5..f2e0b3c 100644
--- a/go/controllers/auth/login.go
+++ b/go/controllers/auth/login.go
@@ -132,11 +132,14 @@ func (c *AuthLoginController) LoginSubmit() {
setSessionCookieForCtx(c.Ctx, result.Tokens.Sid)
if result.NeedChooseTenant {
+ // 待选令牌必须一并下发:APP / 小程序等无 Cookie 环境的客户端
+ // 只能靠 Bearer 令牌调 /auth/switch-tenant,浏览器端则继续用 yz_sid Cookie。
c.serveJSON(map[string]interface{}{
"code": 200,
"msg": "请选择要进入的企业",
"need_choose_tenant": true,
"tenants": result.Tenants,
+ "tokens": result.Tokens,
"sid": result.Tokens.Sid,
})
return
@@ -170,12 +173,16 @@ func (c *AuthLoginController) Tenants() {
}
// SwitchTenant 免密切换企业
-// POST /auth/switch-tenant { tid, client_id, redirect }
+// POST /auth/switch-tenant { tid, client_id, redirect, sid }
+//
+// sid 来源优先级:yz_sid Cookie → Authorization Bearer 令牌中的 sid → 请求体 sid。
+// 请求体 sid 供无 Cookie 能力(且尚未拿到 Bearer 令牌)的客户端兜底。
func (c *AuthLoginController) SwitchTenant() {
var req struct {
Tid uint64 `json:"tid"`
ClientID string `json:"client_id"`
Redirect string `json:"redirect"`
+ Sid string `json:"sid"`
}
body := c.Ctx.Input.RequestBody
if len(body) == 0 {
@@ -189,6 +196,9 @@ func (c *AuthLoginController) SwitchTenant() {
sid = claims.Sid
}
}
+ if sid == "" {
+ sid = strings.TrimSpace(req.Sid)
+ }
if sid == "" || req.Tid == 0 {
c.serveJSON(map[string]interface{}{"code": 400, "msg": "参数错误"})
return
diff --git a/go/controllers/auth/wechat_miniapp.go b/go/controllers/auth/wechat_miniapp.go
new file mode 100644
index 0000000..c000ecc
--- /dev/null
+++ b/go/controllers/auth/wechat_miniapp.go
@@ -0,0 +1,309 @@
+package auth
+
+import (
+ "encoding/json"
+ "io"
+ "strings"
+
+ "server/models"
+ authsvc "server/services/auth"
+ "server/services/wechatmini"
+
+ "github.com/beego/beego/v2/client/orm"
+ beego "github.com/beego/beego/v2/server/web"
+ "github.com/beego/beego/v2/core/logs"
+)
+
+// 小程序登录失败原因(前端按此分支给出不同引导)
+const (
+ MiniReasonNotConfigured = "not_configured" // 服务端未配置小程序
+ MiniReasonIdentityNotFound = "identity_not_found" // 微信未匹配到云泽账号
+ MiniReasonNoTenant = "no_tenant" // 账号未加入任何企业
+ MiniReasonWechatAPI = "wechat_api_error" // 微信接口报错
+)
+
+// idPWechatMini 第三方身份源标识(写入 yz_auth_identity_third.provider)
+const idPWechatMini = "wechat_mini"
+
+// AuthWechatMiniController 微信小程序一键登录(统一认证中心)
+//
+// 与公众号确认链接登录(/api/wechat/mp/login)不同,小程序端由前端直接
+// 携带 wx.login 的 code 与 getPhoneNumber 的动态 code 兑换登录态:
+//
+// POST /auth/wechat/miniapp/login
+// { "code": "wx.login的code", "phone_code": "getPhoneNumber的动态code", "client_id": "yz-miniapp" }
+//
+// 返回结构与 POST /auth/login 完全一致(tokens / need_choose_tenant / tenants 均在顶层),
+// 前端可复用同一套「写入令牌 → 选企业 → switch-tenant」流程。
+type AuthWechatMiniController struct {
+ beego.Controller
+}
+
+func (c *AuthWechatMiniController) serveJSON(data map[string]interface{}) {
+ c.Data["json"] = data
+ _ = c.ServeJSON()
+}
+
+func (c *AuthWechatMiniController) fail(reason string, bizCode int, msg string) {
+ c.Ctx.Output.SetStatus(400)
+ c.serveJSON(map[string]interface{}{"code": bizCode, "msg": msg, "reason": reason})
+}
+
+// Config 小程序登录是否可用
+// GET /auth/wechat/miniapp/config
+func (c *AuthWechatMiniController) Config() {
+ cfg, err := wechatmini.LoadEnabledConfig()
+ if err != nil {
+ c.serveJSON(map[string]interface{}{
+ "code": 200,
+ "data": map[string]interface{}{"enabled": false, "auto_bind": false},
+ })
+ return
+ }
+ c.serveJSON(map[string]interface{}{
+ "code": 200,
+ "data": map[string]interface{}{"enabled": true, "auto_bind": cfg.AutoBind},
+ })
+}
+
+// Login 小程序一键登录
+func (c *AuthWechatMiniController) Login() {
+ var req struct {
+ Code string `json:"code"`
+ PhoneCode string `json:"phone_code"`
+ ClientID string `json:"client_id"`
+ }
+ body := c.Ctx.Input.RequestBody
+ if len(body) == 0 {
+ body, _ = io.ReadAll(c.Ctx.Request.Body)
+ }
+ if err := json.Unmarshal(body, &req); err != nil {
+ c.fail("", 400, "参数错误")
+ return
+ }
+ req.Code = strings.TrimSpace(req.Code)
+ req.PhoneCode = strings.TrimSpace(req.PhoneCode)
+ req.ClientID = strings.TrimSpace(req.ClientID)
+ if req.ClientID == "" {
+ req.ClientID = "yz-miniapp"
+ }
+ if req.Code == "" {
+ c.fail("", 400, "缺少登录凭证,请重新进入小程序")
+ return
+ }
+
+ cfg, err := wechatmini.LoadEnabledConfig()
+ if err != nil {
+ c.fail(MiniReasonNotConfigured, 400, "小程序登录未开通,请使用账号密码登录")
+ return
+ }
+
+ // code → openid / unionid
+ session, err := wechatmini.Code2Session(cfg, req.Code)
+ if err != nil {
+ msg := err.Error()
+ if hint, ok := wechatmini.ExplainError(err); ok {
+ msg = hint
+ }
+ logs.Warn("[wechat-mini] code 换取 openid 失败: %v", err)
+ c.fail(MiniReasonWechatAPI, 400, msg)
+ return
+ }
+
+ // 定位云泽身份:手机号优先(已通过微信验证),其次查已绑定的小程序 openid
+ var mobile string
+ if req.PhoneCode != "" {
+ phone, perr := wechatmini.GetPhoneNumber(cfg, req.PhoneCode)
+ if perr != nil {
+ // 手机号能力未开通 / code 失效时不阻断:仍尝试按已绑定 openid 登录
+ logs.Warn("[wechat-mini] 获取手机号失败: %v", perr)
+ } else {
+ mobile = strings.TrimSpace(phone.PurePhoneNumber)
+ if mobile == "" {
+ mobile = strings.TrimSpace(phone.PhoneNumber)
+ }
+ }
+ }
+
+ identity, err := c.resolveIdentity(cfg, mobile, session)
+ if err != nil {
+ c.fail(MiniReasonIdentityNotFound, 404, "该微信未绑定云泽账号,请使用手机号一键登录或先注册")
+ return
+ }
+ if identity.Status != models.AuthIdentityStatusEnabled {
+ c.fail("", 403, "账号已被禁用或锁定,无法登录")
+ return
+ }
+
+ // 企业选择:单企业直接进入,多企业签发待选令牌由前端展示选择界面
+ tenants, lerr := authsvc.ListTenantOptions(identity.ID)
+ if lerr != nil || len(tenants) == 0 {
+ c.fail(MiniReasonNoTenant, 403, "该账号未加入任何企业,请联系管理员")
+ return
+ }
+ tid := uint64(0)
+ needChoose := false
+ if len(tenants) == 1 {
+ tid = tenants[0].Tid
+ } else {
+ tid = authsvc.PendingTenantID
+ needChoose = true
+ }
+
+ username := "微信用户"
+ if identity.Nickname != nil && strings.TrimSpace(*identity.Nickname) != "" {
+ username = strings.TrimSpace(*identity.Nickname)
+ } else if identity.Mobile != nil && *identity.Mobile != "" {
+ username = *identity.Mobile
+ }
+
+ tokens, terr := c.issueTokens(identity.ID, tid, req.ClientID, username, needChoose)
+ if terr != nil {
+ c.fail("", 400, terr.Error())
+ return
+ }
+
+ resp := map[string]interface{}{
+ "code": 200,
+ "msg": "登录成功",
+ "tokens": tokens,
+ "tenants": tenants,
+ "identity": map[string]interface{}{"id": identity.ID, "nickname": username, "mobile": mobileOr(identity)},
+ }
+ if needChoose {
+ resp["need_choose_tenant"] = true
+ resp["sid"] = tokens.Sid
+ }
+ c.serveJSON(resp)
+}
+
+// resolveIdentity 定位身份并维护小程序绑定关系。
+// mobile 非空表示本次已通过微信手机号验证,是可信的强证据,优先采用。
+func (c *AuthWechatMiniController) resolveIdentity(cfg *wechatmini.Config, mobile string, session *wechatmini.Session) (*models.AuthIdentity, error) {
+ // 1) 手机号命中身份(手机号已由微信验证,属于当前用户的强凭证)
+ if mobile != "" {
+ if identity, err := authsvc.FindIdentityByAccount(mobile); err == nil {
+ c.syncBinding(cfg, identity.ID, session, true)
+ return identity, nil
+ }
+ }
+
+ // 2) 已绑定过该小程序 openid / unionid
+ bind := findMiniBinding(session.OpenID, session.UnionID)
+ if bind == nil {
+ return nil, orm.ErrNoRows
+ }
+ var identity models.AuthIdentity
+ if err := models.Orm.QueryTable(new(models.AuthIdentity)).
+ Filter("id", bind.IdentityID).
+ Filter("delete_time__isnull", true).
+ One(&identity); err != nil {
+ return nil, err
+ }
+ return &identity, nil
+}
+
+// syncBinding 把当前小程序 openid 绑定到该身份。
+// verified 表示本次带通过了微信手机号验证:此时手机号即身份凭证,
+// 允许覆盖历史绑定(用户换绑手机号 / 换微信号场景);
+// 未验证时只允许「新增绑定」,不抢占已绑定到其他身份的 openid。
+func (c *AuthWechatMiniController) syncBinding(cfg *wechatmini.Config, identityID uint64, session *wechatmini.Session, verified bool) {
+ bind := findMiniBinding(session.OpenID, session.UnionID)
+ if bind != nil && bind.IdentityID == identityID {
+ return
+ }
+ if bind != nil {
+ if !verified {
+ return
+ }
+ _, _ = models.Orm.QueryTable(new(models.AuthIdentityThird)).Filter("id", bind.ID).Delete()
+ }
+ if !verified && !cfg.AutoBind {
+ return
+ }
+ item := &models.AuthIdentityThird{
+ IdentityID: identityID,
+ Provider: idPWechatMini,
+ OpenID: session.OpenID,
+ }
+ if session.UnionID != "" {
+ uid := session.UnionID
+ item.UnionID = &uid
+ }
+ _, _ = models.Orm.Insert(item)
+}
+
+// findMiniBinding 按 openid 查绑定,其次按 unionid 查
+func findMiniBinding(openID, unionID string) *models.AuthIdentityThird {
+ qs := models.Orm.QueryTable(new(models.AuthIdentityThird)).Filter("provider", idPWechatMini)
+ if openID != "" {
+ var bind models.AuthIdentityThird
+ if err := qs.Filter("open_id", openID).One(&bind); err == nil {
+ return &bind
+ }
+ }
+ if unionID != "" {
+ var bind models.AuthIdentityThird
+ if err := qs.Filter("union_id", unionID).One(&bind); err == nil {
+ return &bind
+ }
+ }
+ return nil
+}
+
+// issueTokens 建会话并签发 UAC 令牌(含登录日志,失败不影响主流程)
+func (c *AuthWechatMiniController) issueTokens(identityID, tid uint64, clientID, username string, needChoose bool) (*authsvc.TokenPair, error) {
+ accessTTL := 0
+ if needChoose {
+ accessTTL = 600 // 待选状态令牌只够用户完成企业选择
+ }
+ sess, serr := authsvc.CreateSession(authsvc.SessionInfo{
+ IdentityID: identityID,
+ Tid: tid,
+ ClientID: clientID,
+ IP: c.Ctx.Input.IP(),
+ UserAgent: c.Ctx.Request.UserAgent(),
+ LoginType: "wechat_mini",
+ Amr: "wechat_mini",
+ })
+ if serr != nil {
+ return nil, serr
+ }
+ tokens, terr := authsvc.IssueTokens(authsvc.TokenIssue{
+ IdentityID: identityID,
+ Tid: tid,
+ ClientID: clientID,
+ Sid: sess.Sid,
+ Username: username,
+ UserType: "app",
+ Amr: "wechat_mini",
+ AccessTTL: accessTTL,
+ })
+ if terr != nil {
+ return nil, terr
+ }
+
+ identityIDCopy := identityID
+ tidCopy := tid
+ amr := "wechat_mini"
+ _, _ = models.Orm.Insert(&models.AuthLoginLog{
+ Tid: &tidCopy,
+ IdentityID: &identityIDCopy,
+ UserName: username,
+ ClientID: clientID,
+ LoginType: "wechat_mini",
+ Amr: &amr,
+ Status: 1,
+ Message: "微信小程序一键登录成功",
+ IP: c.Ctx.Input.IP(),
+ UserAgent: c.Ctx.Request.UserAgent(),
+ })
+ return tokens, nil
+}
+
+func mobileOr(identity *models.AuthIdentity) string {
+ if identity != nil && identity.Mobile != nil {
+ return *identity.Mobile
+ }
+ return ""
+}
diff --git a/go/controllers/backend_menu.go b/go/controllers/backend_menu.go
index fbe912a..6659321 100644
--- a/go/controllers/backend_menu.go
+++ b/go/controllers/backend_menu.go
@@ -18,17 +18,18 @@ type BackendMenuController struct {
type AdminMenuController = BackendMenuController
type menuPayload struct {
- Pid *int64 `json:"pid"`
- Title *string `json:"title"`
- Path *string `json:"path"`
- ComponentPath *string `json:"component_path"`
- Icon *string `json:"icon"`
- Sort *int64 `json:"sort"`
- Status *int8 `json:"status"`
- IsVisible *int8 `json:"is_visible"`
- Views []int `json:"views"`
- Type *int8 `json:"type"`
- Permission *string `json:"permission"`
+ Pid *int64 `json:"pid"`
+ Title *string `json:"title"`
+ Path *string `json:"path"`
+ ComponentPath *string `json:"component_path"`
+ Icon *string `json:"icon"`
+ Sort *int64 `json:"sort"`
+ Status *int8 `json:"status"`
+ IsVisible *int8 `json:"is_visible"`
+ Views []int `json:"views"`
+ Platforms []string `json:"platforms"` // 展示端:["web"] ["app"] ["web","app"];空=继承所属模块
+ Type *int8 `json:"type"`
+ Permission *string `json:"permission"`
}
func parseViews(raw *string) []int {
@@ -86,7 +87,10 @@ func (c *BackendMenuController) GetBackendMenu() {
_ = c.ServeJSON()
return
}
- backendMenus := filterMenusByView(menus, 2)
+ // 先按 views 过滤租户端,再按展示端过滤出 Web 端可见菜单。
+ // 菜单未单独配置 platforms 时继承所属模块,因此要传入全部模块。
+ allModules, _ := services.ListAllModulesForScope()
+ backendMenus := services.FilterMenusByPlatformWithModules(filterMenusByView(menus, 2), allModules, models.PlatformWeb)
// 按当前登录用户角色过滤可见功能菜单:
// 角色 rights 为空(或未分配角色)→ 全权限,返回全部后端菜单;
@@ -375,6 +379,7 @@ type menuNode struct {
Status int8 `json:"status"`
IsVisible *int8 `json:"is_visible,omitempty"`
Views []int `json:"views,omitempty"`
+ Platforms []string `json:"platforms,omitempty"` // 展示端,空=继承所属模块
Type int8 `json:"type"`
Permission string `json:"permission,omitempty"`
Locked bool `json:"locked,omitempty"`
@@ -393,6 +398,7 @@ func buildMenuTree(menus []models.SystemMenu, pid int64) []*menuNode {
Status: m.Status,
IsVisible: m.IsVisible,
Views: parseViews(m.Views),
+ Platforms: services.ParsePlatforms(m.Platforms),
Type: m.Type,
}
if m.Path != nil {
@@ -469,6 +475,8 @@ func (c *BackendMenuController) CreateMenu() {
ComponentPath: ptrString(valueString(payload.ComponentPath, "")),
Icon: ptrString(valueString(payload.Icon, "")),
Permission: ptrString(valueString(payload.Permission, "")),
+ // 空串表示「继承所属模块的展示端」(模块也未配置则全端)
+ Platforms: ptrString(services.NormalizePlatforms(payload.Platforms)),
}
id, err := models.Orm.Insert(&menu)
@@ -510,6 +518,7 @@ func (c *BackendMenuController) UpdateMenu() {
"status": valueInt8(payload.Status, 1),
"is_visible": valueInt8(payload.IsVisible, 1),
"views": string(viewsBytes),
+ "platforms": services.NormalizePlatforms(payload.Platforms),
"type": valueInt8(payload.Type, 1),
"permission": valueString(payload.Permission, ""),
}
diff --git a/go/controllers/backend_modules.go b/go/controllers/backend_modules.go
index 54c6cf7..a423a17 100644
--- a/go/controllers/backend_modules.go
+++ b/go/controllers/backend_modules.go
@@ -75,6 +75,8 @@ func (c *BackendModulesController) GetTenantList() {
c.jsonErr(500, 500, "获取失败:"+err.Error())
return
}
+ // 租户后台是 Web 端:只保留 web 可见的模块(未配置视为全端)
+ rows = services.FilterModulesByPlatform(rows, models.PlatformWeb)
// 查询当前租户已开通的模块 code 集合(套餐模块 + 已开通产品订单关联模块,编码已规范化)
tid := uint64(claims.TenantId)
diff --git a/go/controllers/platform_modules.go b/go/controllers/platform_modules.go
index 6b5c684..f837a12 100644
--- a/go/controllers/platform_modules.go
+++ b/go/controllers/platform_modules.go
@@ -10,10 +10,39 @@ import (
"server/models"
"server/pkg/jwtutil"
+ "server/services"
beego "github.com/beego/beego/v2/server/web"
)
+// validatePlatforms 校验展示端取值,返回空串表示合法。
+// 只允许 web / app,避免出现拼错的值导致菜单在某端永远不可见。
+func validatePlatforms(list []string) string {
+ for _, s := range list {
+ switch strings.ToLower(strings.TrimSpace(s)) {
+ case models.PlatformWeb, models.PlatformApp:
+ default:
+ if strings.TrimSpace(s) == "" {
+ continue
+ }
+ return "展示端取值非法,仅支持 web(Web端)与 app(移动端)"
+ }
+ }
+ return ""
+}
+
+// platformList 把模块展示端配置输出给管理端:统一返回数组,空配置回落为全端
+func platformList(m *models.SystemModules) []string {
+ if m == nil {
+ return []string{models.PlatformWeb, models.PlatformApp}
+ }
+ list := services.ParsePlatforms(m.Platforms)
+ if len(list) == 0 {
+ return []string{models.PlatformWeb, models.PlatformApp}
+ }
+ return list
+}
+
// PlatformModulesController 模块管理(yz_system_modules)
type PlatformModulesController struct {
beego.Controller
@@ -70,12 +99,30 @@ func (c *PlatformModulesController) GetList() {
c.jsonErr(500, 500, "获取失败:"+err.Error())
return
}
+ // 展示端统一以数组形式下发(空配置回落为全端),方便管理端回填多选框
+ 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]),
+ })
+ }
c.Data["json"] = map[string]interface{}{
"code": 200,
"msg": "获取成功",
"data": map[string]interface{}{
- "list": rows,
- "total": len(rows),
+ "list": list,
+ "total": len(list),
},
}
_ = c.ServeJSON()
@@ -136,16 +183,17 @@ func (c *PlatformModulesController) GetDetail() {
}
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"`
+ 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"];空=全端
}
// Add POST /platform/modules
@@ -164,6 +212,10 @@ func (c *PlatformModulesController) Add() {
c.jsonErr(400, 400, "参数错误")
return
}
+ if errMsg := validatePlatforms(p.Platforms); errMsg != "" {
+ c.jsonErr(400, 400, errMsg)
+ return
+ }
p.Name = strings.TrimSpace(p.Name)
p.Code = strings.TrimSpace(p.Code)
if p.Name == "" || p.Code == "" {
@@ -180,6 +232,7 @@ func (c *PlatformModulesController) Add() {
return
}
now := time.Now()
+ platforms := services.NormalizePlatforms(p.Platforms)
row := &models.SystemModules{
Mid: p.Mid,
Name: p.Name,
@@ -194,6 +247,9 @@ func (c *PlatformModulesController) Add() {
CreateTime: &now,
UpdateTime: &now,
}
+ if platforms != "" {
+ row.Platforms = &platforms
+ }
id, err := models.Orm.Insert(row)
if err != nil {
c.jsonErr(500, 500, "添加失败:"+err.Error())
@@ -231,6 +287,10 @@ func (c *PlatformModulesController) Edit() {
c.jsonErr(400, 400, "模块名称和编码不能为空")
return
}
+ if errMsg := validatePlatforms(p.Platforms); errMsg != "" {
+ c.jsonErr(400, 400, errMsg)
+ return
+ }
// code 唯一(排除自身与软删)
cnt, _ := models.Orm.QueryTable(new(models.SystemModules)).
Filter("code", p.Code).
@@ -242,6 +302,7 @@ func (c *PlatformModulesController) Edit() {
return
}
now := time.Now()
+ // 展示端为空串表示「全端」,需显式落库以覆盖历史值
up := map[string]interface{}{
"mid": p.Mid,
"name": p.Name,
@@ -253,6 +314,7 @@ func (c *PlatformModulesController) Edit() {
"sort": p.Sort,
"status": p.Status,
"is_show": p.IsShow,
+ "platforms": services.NormalizePlatforms(p.Platforms),
"update_time": now,
}
n, err := models.Orm.QueryTable(new(models.SystemModules)).
diff --git a/go/controllers/platform_wechat_mini.go b/go/controllers/platform_wechat_mini.go
new file mode 100644
index 0000000..293030f
--- /dev/null
+++ b/go/controllers/platform_wechat_mini.go
@@ -0,0 +1,131 @@
+package controllers
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "strings"
+
+ "server/pkg/jwtutil"
+ "server/services/wechatmp"
+ "server/services/wechatmini"
+
+ beego "github.com/beego/beego/v2/server/web"
+)
+
+// =============================================================
+// 平台端「微信小程序」配置:AppID / AppSecret / 启用开关 / 自动绑定
+//
+// 配置就绪后,uniapp 小程序端的登录页才会展示「微信手机号一键登录」入口
+// (GET /auth/wechat/miniapp/config 返回 enabled)。
+// AppSecret 掩码回显,提交空串或掩码表示不修改。
+// =============================================================
+
+// PlatformWechatMiniController 平台端微信小程序配置
+type PlatformWechatMiniController struct {
+ beego.Controller
+}
+
+func (c *PlatformWechatMiniController) claims() (*jwtutil.Claims, error) {
+ auth := c.Ctx.Request.Header.Get("Authorization")
+ if auth == "" {
+ return nil, fmt.Errorf("未登录")
+ }
+ parts := strings.SplitN(auth, " ", 2)
+ if len(parts) != 2 || parts[0] != "Bearer" {
+ return nil, fmt.Errorf("认证信息格式错误")
+ }
+ claims, err := jwtutil.ParseToken(parts[1])
+ if err != nil {
+ return nil, fmt.Errorf("无效的token")
+ }
+ if claims.UserType != "platform" {
+ return nil, fmt.Errorf("无权访问")
+ }
+ return claims, nil
+}
+
+func (c *PlatformWechatMiniController) jsonErr(httpStatus, bizCode int, msg string) {
+ c.Ctx.Output.SetStatus(httpStatus)
+ c.Data["json"] = map[string]interface{}{"code": bizCode, "msg": msg}
+ _ = c.ServeJSON()
+}
+
+func (c *PlatformWechatMiniController) ok(data interface{}, msg string) {
+ if msg == "" {
+ msg = "success"
+ }
+ resp := map[string]interface{}{"code": 200, "msg": msg}
+ if data != nil {
+ resp["data"] = data
+ }
+ c.Data["json"] = resp
+ _ = c.ServeJSON()
+}
+
+// GetConfig GET /platform/wechatMini/config
+func (c *PlatformWechatMiniController) GetConfig() {
+ if _, err := c.claims(); err != nil {
+ c.jsonErr(401, 401, err.Error())
+ return
+ }
+ data := map[string]interface{}{
+ "app_id": "",
+ "app_secret": "",
+ "enabled": 0,
+ "auto_bind": 1,
+ "remark": "",
+ "configured": false,
+ }
+ if cfg, err := wechatmini.LoadConfig(); err == nil {
+ data["app_id"] = cfg.AppID
+ data["app_secret"] = wechatmp.MaskSecret(cfg.AppSecret)
+ data["remark"] = cfg.Remark
+ data["configured"] = cfg.AppID != "" && cfg.AppSecret != ""
+ if cfg.Enabled {
+ data["enabled"] = 1
+ }
+ if cfg.AutoBind {
+ data["auto_bind"] = 1
+ } else {
+ data["auto_bind"] = 0
+ }
+ }
+ c.ok(data, "获取成功")
+}
+
+// SaveConfig POST /platform/wechatMini/config
+func (c *PlatformWechatMiniController) SaveConfig() {
+ if _, err := c.claims(); err != nil {
+ c.jsonErr(401, 401, err.Error())
+ return
+ }
+ var p struct {
+ AppID string `json:"app_id"`
+ AppSecret string `json:"app_secret"`
+ Enabled int `json:"enabled"`
+ AutoBind int `json:"auto_bind"`
+ Remark string `json:"remark"`
+ }
+ raw, err := io.ReadAll(c.Ctx.Request.Body)
+ if err != nil {
+ c.jsonErr(400, 400, "参数错误")
+ return
+ }
+ if err := json.Unmarshal(raw, &p); err != nil {
+ c.jsonErr(400, 400, "参数错误")
+ return
+ }
+
+ if err := wechatmini.SaveConfig(wechatmini.SaveInput{
+ AppID: p.AppID,
+ AppSecret: p.AppSecret,
+ Enabled: p.Enabled == 1,
+ AutoBind: p.AutoBind == 1,
+ Remark: p.Remark,
+ }); err != nil {
+ c.jsonErr(400, 400, err.Error())
+ return
+ }
+ c.ok(nil, "保存成功")
+}
diff --git a/go/docs/sql/add_module_platforms.sql b/go/docs/sql/add_module_platforms.sql
new file mode 100644
index 0000000..4cd55a0
--- /dev/null
+++ b/go/docs/sql/add_module_platforms.sql
@@ -0,0 +1,89 @@
+-- =============================================================
+-- 模块 / 菜单「展示端(platforms)」
+--
+-- 需求:后台管理模块层面需要能判断一个模块(及其菜单)
+-- 是 Web 端显示、小程序显示,还是两端都显示。
+--
+-- 与已有的 views 字段是两个独立维度,不要混淆:
+-- views —— [1]平台端 / [2]租户端(决定出现在哪个后台)
+-- platforms —— ["web"] / ["app"] / ["web","app"](决定 Web 还是移动端可见)
+--
+-- 取值约定:
+-- ["web"] 仅 Web 端(租户管理后台)可见
+-- ["app"] 仅移动端(uniapp APP / 微信小程序)可见
+-- ["web","app"] 两端都可见
+-- '' 或 NULL 全端可见(默认,保证历史数据行为不变)
+--
+-- 继承规则:
+-- 菜单自身配置了 platforms → 以自身为准
+-- 菜单未配置 → 继承所属模块的 platforms(按 path 前缀归属)
+-- 两者都未配置 → 全端可见
+--
+-- 本脚本可重复执行:MySQL 不支持 ADD COLUMN IF NOT EXISTS,
+-- 故先用 information_schema 判断,再由动态 SQL 决定加不加列。
+-- 若已存在则只更新注释,不会报 1060 Duplicate column name。
+-- =============================================================
+
+-- ---------- yz_system_modules ----------
+SET @db := DATABASE();
+SET @tbl := 'yz_system_modules';
+SET @col := 'platforms';
+SET @exists := (
+ SELECT COUNT(*) FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = @db AND TABLE_NAME = @tbl AND COLUMN_NAME = @col
+);
+SET @sql := IF(@exists = 0,
+ CONCAT('ALTER TABLE `', @tbl, '` ADD COLUMN `', @col,
+ '` VARCHAR(64) NULL DEFAULT NULL COMMENT ''展示端:web/app JSON数组;空或NULL=全端'' AFTER `is_show`'),
+ CONCAT('ALTER TABLE `', @tbl, '` MODIFY COLUMN `', @col,
+ '` VARCHAR(64) NULL DEFAULT NULL COMMENT ''展示端:web/app JSON数组;空或NULL=全端''')
+);
+PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+-- ---------- yz_system_menu ----------
+SET @tbl := 'yz_system_menu';
+SET @exists := (
+ SELECT COUNT(*) FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = @db AND TABLE_NAME = @tbl AND COLUMN_NAME = @col
+);
+SET @sql := IF(@exists = 0,
+ CONCAT('ALTER TABLE `', @tbl, '` ADD COLUMN `', @col,
+ '` VARCHAR(64) NULL DEFAULT NULL COMMENT ''展示端:web/app JSON数组;空或NULL=继承所属模块'' AFTER `views`'),
+ CONCAT('ALTER TABLE `', @tbl, '` MODIFY COLUMN `', @col,
+ '` VARCHAR(64) NULL DEFAULT NULL COMMENT ''展示端:web/app JSON数组;空或NULL=继承所属模块''')
+);
+PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+-- ---------- 校验 ----------
+SELECT TABLE_NAME, COLUMN_NAME, COLUMN_TYPE, IS_NULLABLE, COLUMN_COMMENT
+FROM information_schema.COLUMNS
+WHERE TABLE_SCHEMA = DATABASE() AND COLUMN_NAME = 'platforms'
+ AND TABLE_NAME IN ('yz_system_modules', 'yz_system_menu');
+
+-- =============================================================
+-- 使用示例(按需执行)
+-- 注意:MySQL 5.7+ 可直接用 JSON_ARRAY('web') 生成 '["web"]',
+-- 比手写转义双引号更省事。
+-- =============================================================
+
+-- 例1:把「客户关系管理」设为仅 Web 端可见(移动端不显示该子系统卡片)
+-- UPDATE yz_system_modules SET platforms = JSON_ARRAY('web') WHERE code = 'crm';
+
+-- 例2:把「便捷工具」设为仅移动端可见(Web 端后台不再展示)
+-- UPDATE yz_system_modules SET platforms = JSON_ARRAY('app') WHERE code = 'bianjiegongju';
+
+-- 例3:某功能两端都要
+-- UPDATE yz_system_modules SET platforms = JSON_ARRAY('web','app') WHERE code = 'cms';
+
+-- 例4:某个具体功能只在小程序上用(如扫码打卡),单独给菜单配置
+-- UPDATE yz_system_menu SET platforms = JSON_ARRAY('app') WHERE path = '/tools/notebook';
+
+-- 例5:恢复全端可见(清空即可,NULL 与 '' 都表示全端)
+-- UPDATE yz_system_modules SET platforms = NULL WHERE code = 'crm';
+-- UPDATE yz_system_modules SET platforms = NULL; -- 全部恢复
+
+-- =============================================================
+-- 回滚
+-- =============================================================
+-- ALTER TABLE yz_system_modules DROP COLUMN platforms;
+-- ALTER TABLE yz_system_menu DROP COLUMN platforms;
diff --git a/go/docs/sql/create_auth_app_login_ticket.sql b/go/docs/sql/create_auth_app_login_ticket.sql
new file mode 100644
index 0000000..50a4733
--- /dev/null
+++ b/go/docs/sql/create_auth_app_login_ticket.sql
@@ -0,0 +1,13 @@
+CREATE TABLE IF NOT EXISTS `yz_auth_app_login_ticket` (
+ `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键',
+ `ticket` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '一次性登录票据',
+ `identity_id` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '认证中心身份ID',
+ `tid` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '租户ID(0=待选企业)',
+ `client_id` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '兑换方应用ID,默认 yz-backend',
+ `expire_at` DATETIME NOT NULL COMMENT '过期时间',
+ `used_at` DATETIME NULL DEFAULT NULL COMMENT '核销时间(一次性)',
+ `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `uk_ticket` (`ticket`),
+ KEY `idx_expire_at` (`expire_at`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='统一认证-移动端免登录票据';
diff --git a/go/docs/sql/create_auth_tables.sql b/go/docs/sql/create_auth_tables.sql
index c7b0c01..0eea0f9 100644
--- a/go/docs/sql/create_auth_tables.sql
+++ b/go/docs/sql/create_auth_tables.sql
@@ -254,5 +254,6 @@ INSERT INTO `yz_auth_client`
VALUES
('yz-backend', 'backend', '租户管理后台', 2, 'authorization_code,refresh_token', 'openid,profile,tenant', 1800, 2592000, 'tenant'),
('yz-uniapp', 'uniapp', '移动端APP', 3, 'authorization_code,refresh_token', 'openid,profile,tenant', 1800, 2592000, 'tenant'),
+ ('yz-miniapp', 'miniapp', '微信小程序', 4, 'authorization_code,refresh_token', 'openid,profile,tenant', 1800, 2592000, 'tenant'),
('yz-website', 'website', '企业官网', 2, 'authorization_code,refresh_token', 'openid,profile,tenant', 1800, 2592000, 'tenant')
ON DUPLICATE KEY UPDATE `name` = VALUES(`name`);
diff --git a/go/docs/sql/create_wechat_mini_config.sql b/go/docs/sql/create_wechat_mini_config.sql
new file mode 100644
index 0000000..fdc8e3e
--- /dev/null
+++ b/go/docs/sql/create_wechat_mini_config.sql
@@ -0,0 +1,42 @@
+-- =============================================================
+-- 微信小程序配置(统一认证 · 小程序一键登录)
+--
+-- 小程序与公众号是两套独立应用,AppID / AppSecret 不可混用,
+-- 因此单独建表。app_secret 以 AES-256-GCM 密文存储(YZW1: 前缀)。
+--
+-- 关联链路:
+-- uniapp 小程序 → wx.login / getPhoneNumber
+-- → POST /auth/wechat/miniapp/login
+-- → jscode2session 换 openid → 按手机号定位 yz_auth_identity
+-- → 写入 yz_auth_identity_third(provider='wechat_mini')
+-- → 复用统一认证的会话与令牌(yz_auth_session / yz_auth_refresh_token)
+-- =============================================================
+
+CREATE TABLE IF NOT EXISTS `yz_platform_wechat_mini_config` (
+ `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键',
+ `app_id` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '小程序 AppID',
+ `app_secret` VARCHAR(512) NOT NULL DEFAULT '' COMMENT '小程序 AppSecret(AES 密文,不回显)',
+ `enabled` TINYINT NOT NULL DEFAULT 0 COMMENT '是否启用小程序登录:0否 1是',
+ `auto_bind` TINYINT NOT NULL DEFAULT 1 COMMENT '手机号验证通过后是否自动绑定小程序:0否 1是',
+ `remark` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '备注',
+ `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+ `update_time` DATETIME NULL DEFAULT NULL COMMENT '更新时间',
+ PRIMARY KEY (`id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='微信小程序配置';
+
+-- -------------------------------------------------------------
+-- 接入应用:微信小程序(app_type=4)
+-- 小程序不走浏览器跳转,只用 POST /auth/wechat/miniapp/login 换令牌,
+-- 因此无需登记 redirect_uris。
+-- -------------------------------------------------------------
+INSERT INTO `yz_auth_client`
+ (`client_id`, `app_code`, `name`, `app_type`, `grant_types`, `scope`, `access_ttl`, `refresh_ttl`, `realm`)
+VALUES
+ ('yz-miniapp', 'miniapp', '微信小程序', 4, 'authorization_code,refresh_token', 'openid,profile,tenant', 1800, 2592000, 'tenant')
+ ON DUPLICATE KEY UPDATE `name` = VALUES(`name`);
+
+-- -------------------------------------------------------------
+-- 第三方身份源:允许 yz_auth_identity_third.provider 出现 'wechat_mini'
+-- (表结构本身为 varchar,无需 DDL;此处仅作说明)
+-- provider = 'wechat_mini',open_id = 小程序 openid,union_id = 小程序 unionid
+-- -------------------------------------------------------------
diff --git a/go/models/auth_app_ticket.go b/go/models/auth_app_ticket.go
new file mode 100644
index 0000000..80d799a
--- /dev/null
+++ b/go/models/auth_app_ticket.go
@@ -0,0 +1,26 @@
+package models
+
+import "time"
+
+// AuthAppLoginTicket 移动端免登录票据:yz_auth_app_login_ticket
+//
+// 用途:移动端(uniapp APP / 小程序)已用统一认证登录后,打开 Web 端业务页面时
+// 通过本票据把登录态传递过去,避免 webview 里再次弹出统一登录页。
+//
+// 流程(与公众号一次性登录令牌 yz_platform_wechat_mp_login_token 同构):
+// POST /auth/app-ticket 移动端带 Bearer 令牌 → 签发一次性票据
+// POST /auth/app-ticket/exchange Web 端带票据 → 核销并换发 Web 端令牌
+//
+// 票据一次性、短时效(默认 60 秒),核销用 CAS 防重放。
+type AuthAppLoginTicket struct {
+ ID uint64 `orm:"column(id);pk;auto" json:"id"`
+ Ticket string `orm:"column(ticket);size(64)" json:"ticket"`
+ IdentityID uint64 `orm:"column(identity_id)" json:"identity_id"`
+ Tid uint64 `orm:"column(tid)" json:"tid"`
+ ClientID string `orm:"column(client_id);size(64);default('')" json:"client_id"`
+ ExpireAt time.Time `orm:"column(expire_at);type(datetime)" json:"expire_at"`
+ UsedAt *time.Time `orm:"column(used_at);type(datetime);null" json:"used_at"`
+ CreateTime time.Time `orm:"column(create_time);type(datetime);auto_now_add" json:"create_time"`
+}
+
+func (m *AuthAppLoginTicket) TableName() string { return "yz_auth_app_login_ticket" }
diff --git a/go/models/init.go b/go/models/init.go
index 84ae0a2..6300f75 100644
--- a/go/models/init.go
+++ b/go/models/init.go
@@ -200,6 +200,9 @@ func Init(_ string) {
new(WechatMpLoginToken),
new(WechatMpScanLogin),
+ // 微信小程序配置(docs/sql/create_wechat_mini_config.sql)
+ new(WechatMiniConfig),
+
// 统一认证中心(UAC):docs/sql/create_auth_tables.sql,人工执行建表
new(AuthIdentity),
new(AuthIdentityThird),
@@ -212,6 +215,7 @@ func Init(_ string) {
new(AuthTenantIdp),
new(AuthTenantAuthConfig),
new(AuthLoginLog),
+ new(AuthAppLoginTicket),
// 数据字典(docs/sql/create_dict.sql):平台端 / 租户端各一套
new(PlatformDict),
diff --git a/go/models/system_menu.go b/go/models/system_menu.go
index 2902cda..3effe89 100644
--- a/go/models/system_menu.go
+++ b/go/models/system_menu.go
@@ -2,6 +2,16 @@ package models
import "time"
+// 展示端(platforms 字段取值)
+//
+// 与 views(平台端 / 租户端)是两个独立维度:
+// views —— 决定菜单出现在「平台后台」还是「租户后台」
+// platforms —— 决定菜单/模块出现在 Web 端还是移动端(APP / 小程序)
+const (
+ PlatformWeb = "web" // Web 端:租户管理后台(PC 浏览器)
+ PlatformApp = "app" // 移动端:uniapp APP / 微信小程序
+)
+
// SystemMenu 系统菜单表 yz_system_menu
type SystemMenu struct {
ID uint64 `orm:"column(id);pk;auto" json:"id"` // 菜单ID
@@ -14,6 +24,7 @@ type SystemMenu struct {
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"` // 创建者
diff --git a/go/models/system_modules.go b/go/models/system_modules.go
index 1f067c0..92c3759 100644
--- a/go/models/system_modules.go
+++ b/go/models/system_modules.go
@@ -15,6 +15,9 @@ type SystemModules struct {
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"`
diff --git a/go/models/wechat_mini.go b/go/models/wechat_mini.go
new file mode 100644
index 0000000..047ca2e
--- /dev/null
+++ b/go/models/wechat_mini.go
@@ -0,0 +1,24 @@
+package models
+
+import "time"
+
+// =============================================================
+// 微信小程序配置(表结构见 docs/sql/create_wechat_mini_config.sql)
+//
+// 小程序与公众号是两套独立应用,AppID / AppSecret 不可混用,
+// 因此单独建表;AppSecret 以 AES-256-GCM 加密存储(复用 wechatmp 的密钥)。
+// =============================================================
+
+// WechatMiniConfig 微信小程序配置 yz_platform_wechat_mini_config(单行)
+type WechatMiniConfig struct {
+ ID uint64 `orm:"column(id);pk;auto" json:"id"`
+ AppID string `orm:"column(app_id);size(64)" json:"app_id"`
+ AppSecret string `orm:"column(app_secret);size(512)" json:"-"` // 加密存储,不回显
+ Enabled int8 `orm:"column(enabled);default(0)" json:"enabled"`
+ AutoBind int8 `orm:"column(auto_bind);default(1)" json:"auto_bind"` // 1:手机号命中身份后自动绑定小程序
+ Remark string `orm:"column(remark);size(255)" json:"remark"`
+ CreateTime time.Time `orm:"column(create_time);type(datetime);auto_now_add" json:"create_time"`
+ UpdateTime *time.Time `orm:"column(update_time);type(datetime);null" json:"update_time"`
+}
+
+func (m *WechatMiniConfig) TableName() string { return "yz_platform_wechat_mini_config" }
diff --git a/go/routers/app/app.go b/go/routers/app/app.go
index 2a8048e..46e56d9 100644
--- a/go/routers/app/app.go
+++ b/go/routers/app/app.go
@@ -45,4 +45,7 @@ func Register() {
// 活动日志
beego.Router("/app/activity/list", &controllers.AppActivityController{}, "get:GetList")
+
+ // 工作台聚合(ERP/OA/CRM/CMS 应用门户首屏数据)
+ beego.Router("/app/home", &controllers.AppAggregateController{}, "get:Home")
}
diff --git a/go/routers/auth/auth.go b/go/routers/auth/auth.go
index c051a6a..6bf0478 100644
--- a/go/routers/auth/auth.go
+++ b/go/routers/auth/auth.go
@@ -48,4 +48,12 @@ func Register() {
// 微信扫码登录(未认证公众号:服务号二维码 + 登录码 + 公众号内确认链接)
beego.Router("/auth/wechat/scan/start", &authctl.AuthLoginController{}, "post:WechatScanStart")
beego.Router("/auth/wechat/scan/status", &authctl.AuthLoginController{}, "get:WechatScanStatus")
+
+ // 微信小程序一键登录(wx.login code + getPhoneNumber 动态 code 换登录态)
+ beego.Router("/auth/wechat/miniapp/config", &authctl.AuthWechatMiniController{}, "get:Config")
+ beego.Router("/auth/wechat/miniapp/login", &authctl.AuthWechatMiniController{}, "post:Login")
+
+ // 移动端免登录票据:把移动端登录态桥接到 Web 端,避免 webview 内二次登录
+ beego.Router("/auth/app-ticket", &authctl.AuthAppTicketController{}, "post:Create")
+ beego.Router("/auth/app-ticket/exchange", &authctl.AuthAppTicketController{}, "post:Exchange")
}
diff --git a/go/routers/platform/wechat_mp.go b/go/routers/platform/wechat_mp.go
index a542c1f..21326c4 100644
--- a/go/routers/platform/wechat_mp.go
+++ b/go/routers/platform/wechat_mp.go
@@ -38,4 +38,7 @@ func RegisterWechatMpRoutes() {
// 消息推送(认证后可用:提醒 / 公告)
beego.Router("/platform/wechatMp/push", &controllers.PlatformWechatMpController{}, "post:Push")
beego.Router("/platform/wechatMp/push/test", &controllers.PlatformWechatMpController{}, "post:PushTest")
+
+ // 小程序配置(一键登录开关;登录端点在认证中心 /auth/wechat/miniapp/*)
+ beego.Router("/platform/wechatMini/config", &controllers.PlatformWechatMiniController{}, "get:GetConfig;post:SaveConfig")
}
diff --git a/go/services/auth/app_ticket.go b/go/services/auth/app_ticket.go
new file mode 100644
index 0000000..eff00c4
--- /dev/null
+++ b/go/services/auth/app_ticket.go
@@ -0,0 +1,97 @@
+package auth
+
+import (
+ "crypto/rand"
+ "encoding/hex"
+ "errors"
+ "strings"
+ "time"
+
+ "server/models"
+)
+
+// =============================================================
+// 移动端免登录票据
+//
+// 背景:移动端用「密码直连」拿到的是 client_id=yz-uniapp、user_type=app 的令牌,
+// Web 端(管理后台)只认自己 localStorage 里 client_id=yz-backend 的令牌,
+// 两者登录态不共享,导致移动端打开 webview 时又要走一遍统一认证登录。
+//
+// 方案:移动端令牌 → 一次性票据 → Web 端令牌。
+// 票据本身是一次性的短时效凭证,即使泄露也无法二次使用。
+// =============================================================
+
+// AppTicketTTL 票据有效期。移动端拿到后应立即跳转,不需要长时效。
+const AppTicketTTL = 60 * time.Second
+
+var (
+ ErrAppTicketInvalid = errors.New("登录票据无效")
+ ErrAppTicketUsed = errors.New("登录票据已被使用")
+ ErrAppTicketExpired = errors.New("登录票据已过期")
+)
+
+// CreateAppTicket 签发一次性票据
+func CreateAppTicket(identityID, tid uint64, clientID string) (string, error) {
+ if identityID == 0 {
+ return "", errors.New("身份无效")
+ }
+ b := make([]byte, 32)
+ if _, err := rand.Read(b); err != nil {
+ return "", err
+ }
+ ticket := hex.EncodeToString(b)
+ if clientID == "" {
+ clientID = "yz-backend"
+ }
+ row := &models.AuthAppLoginTicket{
+ Ticket: ticket,
+ IdentityID: identityID,
+ Tid: tid,
+ ClientID: clientID,
+ ExpireAt: time.Now().Add(AppTicketTTL),
+ }
+ if _, err := models.Orm.Insert(row); err != nil {
+ return "", err
+ }
+ return ticket, nil
+}
+
+// ConsumeAppTicket 核销一次性票据(CAS 防重放),返回票据携带的身份与租户。
+func ConsumeAppTicket(ticket string) (*models.AuthAppLoginTicket, error) {
+ ticket = strings.TrimSpace(ticket)
+ if ticket == "" {
+ return nil, ErrAppTicketInvalid
+ }
+ var row models.AuthAppLoginTicket
+ if err := models.Orm.QueryTable(new(models.AuthAppLoginTicket)).
+ Filter("ticket", ticket).One(&row); err != nil {
+ return nil, ErrAppTicketInvalid
+ }
+ if row.UsedAt != nil {
+ return nil, ErrAppTicketUsed
+ }
+ if row.ExpireAt.Before(time.Now()) {
+ return nil, ErrAppTicketExpired
+ }
+
+ now := time.Now()
+ affected, err := models.Orm.QueryTable(new(models.AuthAppLoginTicket)).
+ Filter("id", row.ID).
+ Filter("used_at__isnull", true).
+ Update(map[string]interface{}{"used_at": now})
+ if err != nil {
+ return nil, err
+ }
+ if affected == 0 {
+ return nil, ErrAppTicketUsed
+ }
+ row.UsedAt = &now
+ return &row, nil
+}
+
+// PurgeExpiredAppTickets 清理过期票据(防止表无限增长,由调用方定期触发)
+func PurgeExpiredAppTickets() (int64, error) {
+ return models.Orm.QueryTable(new(models.AuthAppLoginTicket)).
+ Filter("expire_at__lt", time.Now().Add(-24*time.Hour)).
+ Delete()
+}
diff --git a/go/services/auth/token.go b/go/services/auth/token.go
index 884086e..ca64fb9 100644
--- a/go/services/auth/token.go
+++ b/go/services/auth/token.go
@@ -70,7 +70,7 @@ func IssueTokens(opt TokenIssue) (*TokenPair, error) {
userType := opt.UserType
if userType == "" {
userType = "backend"
- if opt.ClientID == "yz-uniapp" {
+ if opt.ClientID == "yz-uniapp" || opt.ClientID == "yz-miniapp" {
userType = "app"
}
}
diff --git a/go/services/display_scope.go b/go/services/display_scope.go
new file mode 100644
index 0000000..867a151
--- /dev/null
+++ b/go/services/display_scope.go
@@ -0,0 +1,154 @@
+// Package services 中本文件负责「展示端(platforms)」维度的读写与过滤。
+//
+// 背景:模块与菜单原先只有 views([1]平台端 / [2]租户端)一个维度,
+// 无法表达「这个子系统/功能只在 Web 端出现,还是只在小程序出现,还是两端都出现」。
+// 这里新增 platforms 维度:
+// ["web"] —— 仅在 Web 端(租户管理后台)展示
+// ["app"] —— 仅在移动端(uniapp APP / 微信小程序)展示
+// ["web","app"] —— 两端都展示
+// 空串 / NULL —— 视为全端展示(保证历史数据行为不变)
+package services
+
+import (
+ "encoding/json"
+ "strings"
+
+ "server/models"
+)
+
+// ParsePlatforms 解析展示端配置(JSON 数组字符串)。
+// 兼容非数组的旧值(如单个字符串),解析失败一律返回空切片。
+func ParsePlatforms(raw *string) []string {
+ if raw == nil || strings.TrimSpace(*raw) == "" {
+ return nil
+ }
+ var arr []string
+ if err := json.Unmarshal([]byte(*raw), &arr); err == nil {
+ out := make([]string, 0, len(arr))
+ for _, s := range arr {
+ s = strings.ToLower(strings.TrimSpace(s))
+ if s != "" {
+ out = append(out, s)
+ }
+ }
+ return out
+ }
+ // 非数组格式:按单个值处理
+ v := strings.ToLower(strings.TrimSpace(*raw))
+ if v == "" {
+ return nil
+ }
+ return []string{v}
+}
+
+// HasPlatform 判断是否在指定端展示。
+// 未配置(空)视为全端展示,保证历史数据与新建记录默认两端可见。
+func HasPlatform(list []string, platform string) bool {
+ if len(list) == 0 {
+ return true
+ }
+ p := strings.ToLower(strings.TrimSpace(platform))
+ for _, s := range list {
+ if strings.ToLower(strings.TrimSpace(s)) == p {
+ return true
+ }
+ }
+ return false
+}
+
+// NormalizePlatforms 规范化并去重展示端配置,返回待落库的 JSON 字符串。
+// 空输入返回空串(表示全端),便于前端「未配置 = 全端」的语义保持一致。
+func NormalizePlatforms(list []string) string {
+ set := map[string]bool{}
+ out := make([]string, 0, len(list))
+ for _, s := range list {
+ v := strings.ToLower(strings.TrimSpace(s))
+ if v == "" || set[v] {
+ continue
+ }
+ set[v] = true
+ out = append(out, v)
+ }
+ if len(out) == 0 {
+ return ""
+ }
+ if b, err := json.Marshal(out); err == nil {
+ return string(b)
+ }
+ return ""
+}
+
+// ListAllModulesForScope 读取全部未删除模块(含停用与 type=2 系统设置)。
+// 展示端继承判定必须用全量模块:系统设置类模块默认不启用,
+// 若只用启用模块,其菜单会落到"无归属"被误判为全端可见。
+func ListAllModulesForScope() ([]models.SystemModules, error) {
+ var rows []models.SystemModules
+ _, err := models.Orm.QueryTable(new(models.SystemModules)).
+ Filter("delete_time__isnull", true).
+ OrderBy("sort", "id").
+ All(&rows)
+ return rows, err
+}
+
+// FilterModulesByPlatform 按展示端过滤模块
+func FilterModulesByPlatform(rows []models.SystemModules, platform string) []models.SystemModules {
+ out := make([]models.SystemModules, 0, len(rows))
+ for _, m := range rows {
+ if HasPlatform(ParsePlatforms(m.Platforms), platform) {
+ out = append(out, m)
+ }
+ }
+ return out
+}
+
+// FilterMenusByPlatform 按展示端过滤菜单(只看菜单自身的 platforms,不做模块继承)
+func FilterMenusByPlatform(rows []models.SystemMenu, platform string) []models.SystemMenu {
+ out := make([]models.SystemMenu, 0, len(rows))
+ for _, m := range rows {
+ if HasPlatform(ParsePlatforms(m.Platforms), platform) {
+ out = append(out, m)
+ }
+ }
+ return out
+}
+
+// SortedModulesForScope 把模块按 path 长度倒序,供 MenuVisibleOn 的归属判定复用。
+func SortedModulesForScope(modules []models.SystemModules) []models.SystemModules {
+ return sortModulesByPathDesc(modules)
+}
+
+// MenuVisibleOn 判断单条菜单是否在指定端展示。
+// 判定顺序:菜单自身 platforms → 继承所属模块 platforms(按 path 前缀归属)→ 全端可见。
+// 这样后台只需在模块上配一次「仅 Web / 仅小程序」,其下未单独配置的菜单自动跟随。
+func MenuVisibleOn(m models.SystemMenu, sortedModules []models.SystemModules, allModules []models.SystemModules, platform string) bool {
+ if own := ParsePlatforms(m.Platforms); len(own) > 0 {
+ return HasPlatform(own, platform)
+ }
+ path := ""
+ if m.Path != nil {
+ path = strings.TrimSpace(*m.Path)
+ }
+ code, ok := ownerModuleCode(sortedModules, path)
+ if !ok {
+ return true // 不属于任何模块 → 全端
+ }
+ for _, mod := range allModules {
+ if NormalizeModuleCode(mod.Code) == NormalizeModuleCode(code) {
+ return HasPlatform(ParsePlatforms(mod.Platforms), platform)
+ }
+ }
+ return true
+}
+
+// FilterMenusByPlatformWithModules 按展示端过滤菜单,并支持继承所属模块的展示端。
+// modules 应传入全部模块(含停用与 type=2 系统设置),否则系统设置类菜单会误判为全端。
+func FilterMenusByPlatformWithModules(rows []models.SystemMenu, modules []models.SystemModules, platform string) []models.SystemMenu {
+ sorted := sortModulesByPathDesc(modules)
+ out := make([]models.SystemMenu, 0, len(rows))
+ for _, m := range rows {
+ if MenuVisibleOn(m, sorted, modules, platform) {
+ out = append(out, m)
+ }
+ }
+ return out
+}
diff --git a/go/services/display_scope_test.go b/go/services/display_scope_test.go
new file mode 100644
index 0000000..e1be1c8
--- /dev/null
+++ b/go/services/display_scope_test.go
@@ -0,0 +1,91 @@
+package services
+
+import (
+ "testing"
+
+ "server/models"
+)
+
+func strPtr(s string) *string { return &s }
+
+func TestParsePlatforms(t *testing.T) {
+ cases := []struct {
+ name string
+ raw *string
+ want int
+ }{
+ {"nil 视为全端", nil, 0},
+ {"空串视为全端", strPtr(""), 0},
+ {"空格视为全端", strPtr(" "), 0},
+ {"单值 web", strPtr(`["web"]`), 1},
+ {"双值", strPtr(`["web","app"]`), 2},
+ {"大小写归一化", strPtr(`["WEB"]`), 1},
+ {"非法 JSON 退化为单值", strPtr("web"), 1},
+ }
+ for _, c := range cases {
+ got := ParsePlatforms(c.raw)
+ if len(got) != c.want {
+ t.Errorf("%s: got %d items (%v), want %d", c.name, len(got), got, c.want)
+ }
+ }
+}
+
+func TestHasPlatform(t *testing.T) {
+ if !HasPlatform(nil, models.PlatformWeb) || !HasPlatform(nil, models.PlatformApp) {
+ t.Error("未配置时应当全端可见")
+ }
+ web := []string{"web"}
+ if !HasPlatform(web, "web") {
+ t.Error("web 配置应在 web 端可见")
+ }
+ if HasPlatform(web, "app") {
+ t.Error("web 配置不应在 app 端可见")
+ }
+ both := []string{"web", "app"}
+ if !HasPlatform(both, "web") || !HasPlatform(both, "app") {
+ t.Error("双端配置应两端都可见")
+ }
+}
+
+func TestNormalizePlatforms(t *testing.T) {
+ if got := NormalizePlatforms(nil); got != "" {
+ t.Errorf("空输入应返回空串(表示全端),got %q", got)
+ }
+ if got := NormalizePlatforms([]string{"WEB", " app ", "web"}); got != `["web","app"]` {
+ t.Errorf("应去重并小写归一化,got %q", got)
+ }
+}
+
+// 模块级过滤:覆盖三种业务场景
+func TestFilterModulesByPlatform(t *testing.T) {
+ web := `["web"]`
+ app := `["app"]`
+ both := `["web","app"]`
+ rows := []models.SystemModules{
+ {Code: "crm", Platforms: &web}, // 仅 Web
+ {Code: "oa", Platforms: &app}, // 仅小程序
+ {Code: "cms", Platforms: &both}, // 双端
+ {Code: "erp", Platforms: nil}, // 未配置 → 全端
+ }
+
+ got := FilterModulesByPlatform(rows, models.PlatformWeb)
+ if len(got) != 3 {
+ t.Errorf("Web 端应看到 crm/cms/erp 共 3 个,got %d", len(got))
+ }
+ gotApp := FilterModulesByPlatform(rows, models.PlatformApp)
+ if len(gotApp) != 3 {
+ t.Errorf("移动端应看到 oa/cms/erp 共 3 个,got %d", len(gotApp))
+ }
+}
+
+func TestFilterMenusByPlatform(t *testing.T) {
+ app := `["app"]`
+ rows := []models.SystemMenu{
+ {Title: "线索", Platforms: strPtr(`["web"]`)},
+ {Title: "日程", Platforms: &app},
+ {Title: "客户", Platforms: nil},
+ }
+ if got := FilterMenusByPlatform(rows, models.PlatformApp); len(got) != 2 {
+ t.Errorf("移动端应看到 日程/客户 共 2 条,got %d", len(got))
+ }
+}
diff --git a/go/services/wechatmini/api.go b/go/services/wechatmini/api.go
new file mode 100644
index 0000000..7050e7e
--- /dev/null
+++ b/go/services/wechatmini/api.go
@@ -0,0 +1,234 @@
+package wechatmini
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "sync"
+ "time"
+)
+
+// =============================================================
+// 微信小程序服务端 API
+// 1. GET /sns/jscode2session code → openid / unionid / session_key
+// 2. POST /wxa/business/getuserphonenumber phone_code → 手机号(需 access_token)
+// =============================================================
+
+const apiBase = "https://api.weixin.qq.com"
+
+var httpClient = &http.Client{Timeout: 10 * time.Second}
+
+// APIError 微信接口业务错误(HTTP 200 但 errcode 非 0)
+type APIError struct {
+ API string
+ Code int
+ Msg string
+}
+
+func (e *APIError) Error() string {
+ return fmt.Sprintf("%s 失败:errcode=%d errmsg=%s", e.API, e.Code, e.Msg)
+}
+
+// Explain 返回常见 errcode 的中文排查建议;未知错误码返回空串
+func (e *APIError) Explain() string {
+ switch e.Code {
+ case 40029:
+ return "code 无效或已过期:请重新调用 wx.login / uni.login 获取 code 后立即重试"
+ case 40163:
+ return "code 已被使用:同一次 code 只能兑换一次,请重新获取"
+ case 40013:
+ return "AppID 无效:请核对小程序后台「开发 → 开发管理」中的 AppID"
+ case 40125:
+ return "AppSecret 错误:请在小程序后台重置后复制新的 AppSecret 填入本页"
+ case 40226:
+ return "该小程序未开通手机号快速验证能力:请在「开发管理 → 接口设置」中开通"
+ case 45009:
+ return "接口调用频次超限:请稍后重试"
+ }
+ return ""
+}
+
+// ExplainError 提取中文排查建议;无可用建议时返回 ("", false)
+func ExplainError(err error) (string, bool) {
+ if ae, ok := err.(*APIError); ok {
+ if hint := ae.Explain(); hint != "" {
+ return hint, true
+ }
+ }
+ return "", false
+}
+
+// ============================ code 换 openid ============================
+
+// Session jscode2session 返回结果
+type Session struct {
+ OpenID string `json:"openid"`
+ UnionID string `json:"unionid"`
+ SessionKey string `json:"session_key"`
+}
+
+// Code2Session 用 wx.login / uni.login 拿到的 code 换 openid(+ unionid)
+func Code2Session(cfg *Config, code string) (*Session, error) {
+ if cfg == nil || cfg.AppID == "" || cfg.AppSecret == "" {
+ return nil, ErrNotConfigured
+ }
+ api := fmt.Sprintf("%s/sns/jscode2session?appid=%s&secret=%s&js_code=%s&grant_type=authorization_code",
+ apiBase, url.QueryEscape(cfg.AppID), url.QueryEscape(cfg.AppSecret), url.QueryEscape(code))
+
+ var resp struct {
+ OpenID string `json:"openid"`
+ UnionID string `json:"unionid"`
+ SessionKey string `json:"session_key"`
+ ErrCode int `json:"errcode"`
+ ErrMsg string `json:"errmsg"`
+ }
+ if err := httpGetJSON(api, &resp); err != nil {
+ return nil, err
+ }
+ if resp.OpenID == "" {
+ return nil, &APIError{API: "code 换取 openid", Code: resp.ErrCode, Msg: resp.ErrMsg}
+ }
+ return &Session{OpenID: resp.OpenID, UnionID: resp.UnionID, SessionKey: resp.SessionKey}, nil
+}
+
+// ============================ 手机号快速验证 ============================
+
+// PhoneNumber getuserphonenumber 返回结果
+type PhoneNumber struct {
+ PhoneNumber string `json:"phoneNumber"`
+ PurePhoneNumber string `json:"purePhoneNumber"`
+ CountryCode string `json:"countryCode"`
+}
+
+// GetPhoneNumber 用 getPhoneNumber 事件返回的动态 code 换真实手机号。
+// 仅企业主体小程序可用;未开通该能力时微信返回 40226。
+func GetPhoneNumber(cfg *Config, phoneCode string) (*PhoneNumber, error) {
+ if cfg == nil || cfg.AppID == "" || cfg.AppSecret == "" {
+ return nil, ErrNotConfigured
+ }
+ token, err := getAccessToken(cfg)
+ if err != nil {
+ return nil, err
+ }
+ api := fmt.Sprintf("%s/wxa/business/getuserphonenumber?access_token=%s", apiBase, url.QueryEscape(token))
+ payload := map[string]interface{}{"code": phoneCode}
+
+ var resp struct {
+ PhoneInfo struct {
+ PhoneNumber string `json:"phoneNumber"`
+ PurePhoneNumber string `json:"purePhoneNumber"`
+ CountryCode string `json:"countryCode"`
+ } `json:"phone_info"`
+ ErrCode int `json:"errcode"`
+ ErrMsg string `json:"errmsg"`
+ }
+ if err := httpPostJSON(api, payload, &resp); err != nil {
+ return nil, err
+ }
+ if resp.ErrCode != 0 || resp.PhoneInfo.PhoneNumber == "" {
+ return nil, &APIError{API: "获取手机号", Code: resp.ErrCode, Msg: resp.ErrMsg}
+ }
+ return &PhoneNumber{
+ PhoneNumber: resp.PhoneInfo.PhoneNumber,
+ PurePhoneNumber: resp.PhoneInfo.PurePhoneNumber,
+ CountryCode: resp.PhoneInfo.CountryCode,
+ }, nil
+}
+
+// ============================ access_token 缓存 ============================
+
+type tokenEntry struct {
+ appID string
+ token string
+ expiresAt time.Time
+}
+
+var (
+ tokenMu sync.Mutex
+ tokenCache tokenEntry
+ tokenMinTTL = 5 * time.Minute
+)
+
+// GetAccessToken 获取小程序 access_token(内存缓存,提前 5 分钟刷新)
+func GetAccessToken(cfg *Config) (string, error) { return getAccessToken(cfg) }
+
+func getAccessToken(cfg *Config) (string, error) {
+ tokenMu.Lock()
+ if tokenCache.appID == cfg.AppID && tokenCache.token != "" && time.Now().Before(tokenCache.expiresAt) {
+ t := tokenCache.token
+ tokenMu.Unlock()
+ return t, nil
+ }
+ tokenMu.Unlock()
+
+ api := fmt.Sprintf("%s/cgi-bin/token?grant_type=client_credential&appid=%s&secret=%s",
+ apiBase, url.QueryEscape(cfg.AppID), url.QueryEscape(cfg.AppSecret))
+ var resp struct {
+ AccessToken string `json:"access_token"`
+ ExpiresIn int `json:"expires_in"`
+ ErrCode int `json:"errcode"`
+ ErrMsg string `json:"errmsg"`
+ }
+ if err := httpGetJSON(api, &resp); err != nil {
+ return "", err
+ }
+ if resp.AccessToken == "" {
+ return "", &APIError{API: "获取 access_token", Code: resp.ErrCode, Msg: resp.ErrMsg}
+ }
+ ttl := time.Duration(resp.ExpiresIn) * time.Second
+ if ttl <= tokenMinTTL {
+ ttl = time.Hour
+ }
+ tokenMu.Lock()
+ tokenCache = tokenEntry{appID: cfg.AppID, token: resp.AccessToken, expiresAt: time.Now().Add(ttl - tokenMinTTL)}
+ tokenMu.Unlock()
+ return resp.AccessToken, nil
+}
+
+// ============================ HTTP 辅助 ============================
+
+func httpGetJSON(api string, out interface{}) error {
+ resp, err := httpClient.Get(api)
+ if err != nil {
+ return fmt.Errorf("请求微信接口失败: %w", err)
+ }
+ defer resp.Body.Close()
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return fmt.Errorf("读取微信响应失败: %w", err)
+ }
+ if err := json.Unmarshal(body, out); err != nil {
+ return fmt.Errorf("解析微信响应失败: %w(body: %s)", err, truncate(string(body), 200))
+ }
+ return nil
+}
+
+func httpPostJSON(api string, payload interface{}, out interface{}) error {
+ bs, err := json.Marshal(payload)
+ if err != nil {
+ return err
+ }
+ resp, err := httpClient.Post(api, "application/json; charset=utf-8", bytes.NewReader(bs))
+ if err != nil {
+ return fmt.Errorf("请求微信接口失败: %w", err)
+ }
+ defer resp.Body.Close()
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return fmt.Errorf("读取微信响应失败: %w", err)
+ }
+ if err := json.Unmarshal(body, out); err != nil {
+ return fmt.Errorf("解析微信响应失败: %w(body: %s)", err, truncate(string(body), 200))
+ }
+ return nil
+}
+
+func truncate(s string, n int) string {
+ if len(s) <= n {
+ return s
+ }
+ return s[:n] + "..."
+}
diff --git a/go/services/wechatmini/config.go b/go/services/wechatmini/config.go
new file mode 100644
index 0000000..e80e21c
--- /dev/null
+++ b/go/services/wechatmini/config.go
@@ -0,0 +1,126 @@
+// Package wechatmini 微信小程序服务端能力:配置、code 换 openid、手机号快速验证。
+//
+// 与 services/wechatmp(公众号)分开:小程序走 sns/jscode2session 与
+// wxa/business/getuserphonenumber,两者的 AppID / AppSecret 互不相通。
+package wechatmini
+
+import (
+ "errors"
+ "strings"
+ "time"
+
+ "github.com/beego/beego/v2/client/orm"
+ "server/models"
+ "server/services/wechatmp"
+)
+
+// Config 小程序运行配置(敏感字段已解密)
+type Config struct {
+ ID uint64
+ AppID string
+ AppSecret string // 明文
+ Enabled bool
+ AutoBind bool
+ Remark string
+}
+
+// ErrNotConfigured 未配置或未启用
+var ErrNotConfigured = errors.New("微信小程序未配置或未启用")
+
+// LoadConfig 读取小程序配置(单行,敏感字段解密);未配置返回 ErrNotConfigured
+func LoadConfig() (*Config, error) {
+ var row models.WechatMiniConfig
+ err := models.Orm.QueryTable(new(models.WechatMiniConfig)).OrderBy("id").One(&row)
+ if err != nil {
+ if err == orm.ErrNoRows {
+ return nil, ErrNotConfigured
+ }
+ return nil, err
+ }
+ cfg := &Config{
+ ID: row.ID,
+ AppID: strings.TrimSpace(row.AppID),
+ Enabled: row.Enabled == 1,
+ AutoBind: row.AutoBind != 0,
+ Remark: row.Remark,
+ }
+ if row.AppSecret != "" {
+ plain, derr := wechatmp.DecryptSecret(row.AppSecret)
+ if derr != nil {
+ return nil, derr
+ }
+ cfg.AppSecret = plain
+ }
+ return cfg, nil
+}
+
+// LoadEnabledConfig 读取并校验启用状态
+func LoadEnabledConfig() (*Config, error) {
+ cfg, err := LoadConfig()
+ if err != nil {
+ return nil, err
+ }
+ if !cfg.Enabled || cfg.AppID == "" || cfg.AppSecret == "" {
+ return nil, ErrNotConfigured
+ }
+ return cfg, nil
+}
+
+// SaveInput 保存配置入参(AppSecret 支持掩码表示不修改)
+type SaveInput struct {
+ AppID string
+ AppSecret string // 掩码 / 空串 = 保持不变
+ Enabled bool
+ AutoBind bool
+ Remark string
+}
+
+// SaveConfig 保存小程序配置(不存在则创建)
+func SaveConfig(in SaveInput) error {
+ if strings.TrimSpace(in.AppID) == "" {
+ return errors.New("AppID 不能为空")
+ }
+
+ var row models.WechatMiniConfig
+ err := models.Orm.QueryTable(new(models.WechatMiniConfig)).OrderBy("id").One(&row)
+ now := time.Now()
+ isNew := false
+ if err != nil {
+ if err != orm.ErrNoRows {
+ return err
+ }
+ isNew = true
+ row = models.WechatMiniConfig{}
+ }
+
+ secret := strings.TrimSpace(in.AppSecret)
+ if secret != "" && !wechatmp.IsMasked(secret) {
+ enc, eerr := wechatmp.EncryptSecret(secret)
+ if eerr != nil {
+ return eerr
+ }
+ row.AppSecret = enc
+ }
+
+ row.AppID = strings.TrimSpace(in.AppID)
+ row.Remark = strings.TrimSpace(in.Remark)
+ if in.Enabled {
+ row.Enabled = 1
+ } else {
+ row.Enabled = 0
+ }
+ if in.AutoBind {
+ row.AutoBind = 1
+ } else {
+ row.AutoBind = 0
+ }
+ row.UpdateTime = &now
+
+ if isNew {
+ row.CreateTime = now
+ _, err = models.Orm.Insert(&row)
+ return err
+ }
+ _, err = models.Orm.Update(&row)
+ return err
+}
diff --git a/go/views/auth/login.tpl b/go/views/auth/login.tpl
index e64d221..9eae823 100644
--- a/go/views/auth/login.tpl
+++ b/go/views/auth/login.tpl
@@ -535,7 +535,9 @@
+
diff --git a/platform/components.d.ts b/platform/components.d.ts
index 4cc7537..5d2371a 100644
--- a/platform/components.d.ts
+++ b/platform/components.d.ts
@@ -62,8 +62,6 @@ declare module 'vue' {
ElScrollbar: typeof import('element-plus/es')['ElScrollbar']
ElSelect: typeof import('element-plus/es')['ElSelect']
ElSkeleton: typeof import('element-plus/es')['ElSkeleton']
- ElStep: typeof import('element-plus/es')['ElStep']
- ElSteps: typeof import('element-plus/es')['ElSteps']
ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
ElSwitch: typeof import('element-plus/es')['ElSwitch']
ElTable: typeof import('element-plus/es')['ElTable']
diff --git a/uniapp/App.vue b/uniapp/App.vue
index 01e2406..b8262da 100644
--- a/uniapp/App.vue
+++ b/uniapp/App.vue
@@ -17,14 +17,15 @@
diff --git a/uniapp/components/sub/SubFunctions.vue b/uniapp/components/sub/SubFunctions.vue
new file mode 100644
index 0000000..22e2e3c
--- /dev/null
+++ b/uniapp/components/sub/SubFunctions.vue
@@ -0,0 +1,315 @@
+
+
+
+
+
+ 返回导航
+
+
+ {{ sub.title }}
+ {{ filteredMenus.length }} 个功能
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 加载失败
+ {{ loadError }},点击重试
+
+
+
+
+
+
+ {{ keyword.trim() ? '没有匹配的功能' : '该子系统下暂无你可见的功能' }}
+
+
+
+
+
+
+
+
+ {{ item.title }}
+ {{ item.leaf === false ? (item.docs || 0) + ' 个下级' : shortPath(item.path) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/uniapp/components/sub/SubHome.vue b/uniapp/components/sub/SubHome.vue
new file mode 100644
index 0000000..7ee684c
--- /dev/null
+++ b/uniapp/components/sub/SubHome.vue
@@ -0,0 +1,427 @@
+
+
+
+
+
+
+ 返回导航
+
+
+
+
+
+
+ {{ sub.title }}
+ {{ sub.desc }}
+
+
+
+
+
+
+
+
+ 该子系统未开通
+ 当前租户套餐未包含「{{ sub.name }}」,请联系管理员开通后使用。
+
+
+
+
+
+
+
+ 子系统加载失败
+ {{ loadError }},点击重试
+
+
+
+
+
+
+
+
+ 常用功能
+ {{ leafMenus.length }} 个功能
+
+
+ 该子系统下暂无你可见的功能
+
+
+
+
+
+
+ {{ item.title }}
+
+
+
+
+
+
+
+ 功能分组
+
+
+
+
+
+ {{ item.title }}
+ {{ item.docs || 0 }} 个功能
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 进入完整{{ sub.name }}系统
+ 在浏览器中打开,支持全部功能
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/uniapp/components/sub/SubMine.vue b/uniapp/components/sub/SubMine.vue
new file mode 100644
index 0000000..6d3251a
--- /dev/null
+++ b/uniapp/components/sub/SubMine.vue
@@ -0,0 +1,485 @@
+
+
+
+
+
+ 返回导航
+
+
+
+ {{ avatarText }}
+
+
+ {{ user.name || '云泽用户' }}
+ {{ user.tenant || '云泽平台' }} · {{ user.account }}
+
+
+
+
+
+
+
+
+ 加载失败
+ {{ loadError }},点击重试
+
+
+
+
+
+ 当前子系统
+
+
+
+
+
+ {{ sub.title }}
+ {{ allMenus.length }} 个功能 · {{ enabled ? '已开通' : '未开通' }}
+
+ 未开通
+
+
+
+
+
+ 我的数据
+
+
+ {{ pendingCount }}
+ 待办
+
+
+ {{ overdue }}
+ 已逾期
+
+
+ {{ doneCount }}
+ 已完成
+
+
+ {{ allMenus.length }}
+ 可用功能
+
+
+
+
+
+
+ 操作
+
+
+
+
+ 进入完整系统(Web 端)
+
+
+
+
+ 全部功能
+
+
+
+
+ 账号与安全
+
+
+
+
+ 切换子系统
+
+
+
+
+
+
+ 退出登录
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/uniapp/components/sub/SubTodo.vue b/uniapp/components/sub/SubTodo.vue
new file mode 100644
index 0000000..39be80e
--- /dev/null
+++ b/uniapp/components/sub/SubTodo.vue
@@ -0,0 +1,297 @@
+
+
+
+
+
+ 返回导航
+
+ {{ sub.title }} · 待办
+
+
+ {{ pending.length }}
+ 待处理
+
+
+ {{ overdue }}
+ 已逾期
+
+
+ {{ done }}
+ 已完成
+
+
+
+
+
+
+ 日程待办
+
+ 全部日程
+
+
+
+
+
+
+
+ 暂无待办事项
+
+
+
+
+ {{ item.content || item.title || '日程' }}
+ {{ item.schedule_time }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/uniapp/config/subsystems.js b/uniapp/config/subsystems.js
new file mode 100644
index 0000000..4cbe063
--- /dev/null
+++ b/uniapp/config/subsystems.js
@@ -0,0 +1,85 @@
+/**
+ * 子系统注册表
+ *
+ * nav.vue(导航页,无 tabbar)按这里配置渲染子系统宫格;
+ * 点击卡片进入 pages/{slug}/home.vue,该子系统拥有自己的页面组与 tabbar。
+ *
+ * module 对应后端 yz_system_modules.code(菜单归属靠 path 前缀匹配),
+ * slug 是端上路由目录名,务必与 pages/{slug}/ 保持一致。
+ */
+export const SUBSYSTEMS = [
+ {
+ slug: 'crm',
+ module: 'crm',
+ name: '客户关系',
+ title: '客户关系管理',
+ icon: 'handshake',
+ tint: 'tint-0',
+ desc: '线索 · 客户 · 商机 · 合同 · 回款',
+ // 该子系统在 Web 端有完整系统,概览页展示「进入完整系统」入口
+ webEntry: true
+ },
+ {
+ slug: 'erp',
+ module: 'erp',
+ name: '进销存',
+ title: '进销存管理',
+ icon: 'boxes-stacked',
+ tint: 'tint-1',
+ desc: '客户 · 供应商 · 账套 · 通讯录',
+ webEntry: true
+ },
+ {
+ slug: 'oa',
+ module: 'oa',
+ name: 'OA 办公',
+ title: '协同办公',
+ icon: 'clipboard-check',
+ tint: 'tint-2',
+ desc: '日程 · 通知 · 文档 · 报销 · 人事',
+ webEntry: true
+ },
+ {
+ slug: 'cms',
+ module: 'cms',
+ name: '网站管理',
+ title: '网站内容管理',
+ icon: 'globe',
+ tint: 'tint-3',
+ desc: '文章 · 产品 · 解决方案 · 站点',
+ webEntry: true
+ },
+ {
+ slug: 'toolbox',
+ module: 'bianjiegongju',
+ name: '便捷工具',
+ title: '便捷工具',
+ icon: 'wrench',
+ tint: 'tint-4',
+ desc: '记事本 · 日程 · 密码库',
+ // 便捷工具的记事本 / 日程 / 密码库在端上都有原生页面,
+ // Web 端没有对应系统,因此不展示「进入完整系统」入口
+ webEntry: false
+ }
+]
+
+/** 按 slug 取子系统配置 */
+export function getSubsystem(slug) {
+ return SUBSYSTEMS.find((s) => s.slug === slug) || null
+}
+
+/** 按后端模块 code 取子系统配置(menus[].module 与 yz_system_modules.code 对应) */
+export function getSubsystemByModule(module) {
+ const key = String(module || '').toLowerCase()
+ return SUBSYSTEMS.find((s) => String(s.module).toLowerCase() === key) || null
+}
+
+/**
+ * 进入某子系统的概览页。
+ * 后端模块没有对应端上页面时(未在 SUBSYSTEMS 注册),返回 false 让调用方自行降级。
+ */
+export function goSubsystem(slug) {
+ if (!getSubsystem(slug)) return false
+ uni.redirectTo({ url: `/pages/${slug}/home/home` })
+ return true
+}
diff --git a/uniapp/manifest.json b/uniapp/manifest.json
index 64600f2..3ba9e44 100644
--- a/uniapp/manifest.json
+++ b/uniapp/manifest.json
@@ -55,6 +55,8 @@
"quickapp" : {},
/* 小程序特有相关 */
"mp-weixin" : {
+ /* 微信小程序 AppID:需与平台端「微信小程序配置」(/platform/wechatMini/config)中的 AppID 一致,
+ 否则「微信手机号一键登录」会因 jscode2session 报 40029 而失败 */
"appid" : "",
"setting" : {
"urlCheck" : false,
diff --git a/uniapp/pages.json b/uniapp/pages.json
index 655576c..a3e2662 100644
--- a/uniapp/pages.json
+++ b/uniapp/pages.json
@@ -8,6 +8,153 @@
}
},
"pages": [
+ {
+ "path": "pages/nav/nav",
+ "style": {
+ "navigationStyle": "custom",
+ "navigationBarTitleText": "选择子系统"
+ }
+ },
+ {
+ "path": "pages/crm/home/home",
+ "style": {
+ "navigationStyle": "custom",
+ "navigationBarTitleText": "客户关系管理"
+ }
+ },
+ {
+ "path": "pages/crm/functions/functions",
+ "style": {
+ "navigationStyle": "custom",
+ "navigationBarTitleText": "客户关系 · 功能"
+ }
+ },
+ {
+ "path": "pages/crm/todo/todo",
+ "style": {
+ "navigationStyle": "custom",
+ "navigationBarTitleText": "客户关系 · 待办"
+ }
+ },
+ {
+ "path": "pages/crm/mine/mine",
+ "style": {
+ "navigationStyle": "custom",
+ "navigationBarTitleText": "客户关系 · 我的"
+ }
+ },
+ {
+ "path": "pages/erp/home/home",
+ "style": {
+ "navigationStyle": "custom",
+ "navigationBarTitleText": "进销存管理"
+ }
+ },
+ {
+ "path": "pages/erp/functions/functions",
+ "style": {
+ "navigationStyle": "custom",
+ "navigationBarTitleText": "进销存 · 功能"
+ }
+ },
+ {
+ "path": "pages/erp/todo/todo",
+ "style": {
+ "navigationStyle": "custom",
+ "navigationBarTitleText": "进销存 · 待办"
+ }
+ },
+ {
+ "path": "pages/erp/mine/mine",
+ "style": {
+ "navigationStyle": "custom",
+ "navigationBarTitleText": "进销存 · 我的"
+ }
+ },
+ {
+ "path": "pages/oa/home/home",
+ "style": {
+ "navigationStyle": "custom",
+ "navigationBarTitleText": "协同办公"
+ }
+ },
+ {
+ "path": "pages/oa/functions/functions",
+ "style": {
+ "navigationStyle": "custom",
+ "navigationBarTitleText": "OA · 功能"
+ }
+ },
+ {
+ "path": "pages/oa/todo/todo",
+ "style": {
+ "navigationStyle": "custom",
+ "navigationBarTitleText": "OA · 待办"
+ }
+ },
+ {
+ "path": "pages/oa/mine/mine",
+ "style": {
+ "navigationStyle": "custom",
+ "navigationBarTitleText": "OA · 我的"
+ }
+ },
+ {
+ "path": "pages/cms/home/home",
+ "style": {
+ "navigationStyle": "custom",
+ "navigationBarTitleText": "网站内容管理"
+ }
+ },
+ {
+ "path": "pages/cms/functions/functions",
+ "style": {
+ "navigationStyle": "custom",
+ "navigationBarTitleText": "网站管理 · 功能"
+ }
+ },
+ {
+ "path": "pages/cms/todo/todo",
+ "style": {
+ "navigationStyle": "custom",
+ "navigationBarTitleText": "网站管理 · 待办"
+ }
+ },
+ {
+ "path": "pages/cms/mine/mine",
+ "style": {
+ "navigationStyle": "custom",
+ "navigationBarTitleText": "网站管理 · 我的"
+ }
+ },
+ {
+ "path": "pages/toolbox/home/home",
+ "style": {
+ "navigationStyle": "custom",
+ "navigationBarTitleText": "便捷工具"
+ }
+ },
+ {
+ "path": "pages/toolbox/functions/functions",
+ "style": {
+ "navigationStyle": "custom",
+ "navigationBarTitleText": "便捷工具 · 功能"
+ }
+ },
+ {
+ "path": "pages/toolbox/todo/todo",
+ "style": {
+ "navigationStyle": "custom",
+ "navigationBarTitleText": "便捷工具 · 待办"
+ }
+ },
+ {
+ "path": "pages/toolbox/mine/mine",
+ "style": {
+ "navigationStyle": "custom",
+ "navigationBarTitleText": "便捷工具 · 我的"
+ }
+ },
{
"path": "pages/login/login",
"style": {
@@ -54,7 +201,15 @@
"path": "pages/features/features",
"style": {
"navigationStyle": "custom",
- "navigationBarTitleText": "功能"
+ "navigationBarTitleText": "全部应用"
+ }
+ },
+ {
+ "path": "pages/apps/webview",
+ "style": {
+ "navigationBarTitleText": "功能",
+ "navigationBarBackgroundColor": "#ffffff",
+ "navigationBarTextStyle": "black"
}
},
{
diff --git a/uniapp/pages/apps/webview.vue b/uniapp/pages/apps/webview.vue
new file mode 100644
index 0000000..b80f05a
--- /dev/null
+++ b/uniapp/pages/apps/webview.vue
@@ -0,0 +1,149 @@
+
+
+
+
+
+
+
+
+ {{ title }}
+
+
+
+
+
+
+
+
+
+
+ 没有可打开的页面地址
+
+
+
+
+
+
+
diff --git a/uniapp/pages/cms/functions/functions.vue b/uniapp/pages/cms/functions/functions.vue
new file mode 100644
index 0000000..50450aa
--- /dev/null
+++ b/uniapp/pages/cms/functions/functions.vue
@@ -0,0 +1,12 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/uniapp/pages/cms/home/home.vue b/uniapp/pages/cms/home/home.vue
new file mode 100644
index 0000000..27275f6
--- /dev/null
+++ b/uniapp/pages/cms/home/home.vue
@@ -0,0 +1,12 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/uniapp/pages/cms/mine/mine.vue b/uniapp/pages/cms/mine/mine.vue
new file mode 100644
index 0000000..63accf5
--- /dev/null
+++ b/uniapp/pages/cms/mine/mine.vue
@@ -0,0 +1,12 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/uniapp/pages/cms/todo/todo.vue b/uniapp/pages/cms/todo/todo.vue
new file mode 100644
index 0000000..0e2398f
--- /dev/null
+++ b/uniapp/pages/cms/todo/todo.vue
@@ -0,0 +1,12 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/uniapp/pages/crm/functions/functions.vue b/uniapp/pages/crm/functions/functions.vue
new file mode 100644
index 0000000..11a0445
--- /dev/null
+++ b/uniapp/pages/crm/functions/functions.vue
@@ -0,0 +1,12 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/uniapp/pages/crm/home/home.vue b/uniapp/pages/crm/home/home.vue
new file mode 100644
index 0000000..eb2cf2c
--- /dev/null
+++ b/uniapp/pages/crm/home/home.vue
@@ -0,0 +1,12 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/uniapp/pages/crm/mine/mine.vue b/uniapp/pages/crm/mine/mine.vue
new file mode 100644
index 0000000..8c5ffe3
--- /dev/null
+++ b/uniapp/pages/crm/mine/mine.vue
@@ -0,0 +1,12 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/uniapp/pages/crm/todo/todo.vue b/uniapp/pages/crm/todo/todo.vue
new file mode 100644
index 0000000..e592cf7
--- /dev/null
+++ b/uniapp/pages/crm/todo/todo.vue
@@ -0,0 +1,12 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/uniapp/pages/dashboard/dashboard.vue b/uniapp/pages/dashboard/dashboard.vue
index 2bdd7c6..16a824a 100644
--- a/uniapp/pages/dashboard/dashboard.vue
+++ b/uniapp/pages/dashboard/dashboard.vue
@@ -1,58 +1,157 @@
-