package service import ( "sort" "sync" "xk-of-api/internal/model" ) // AdminService 负责后台登录、菜单、权限和官网 CMS 管理等后台业务逻辑。 type AdminService struct{} // Admin 暴露后台服务单例,控制器只调用服务层,不直接处理业务细节。 var Admin = AdminService{} var adminStore = struct { sync.RWMutex settings model.SiteSettings pages []model.SitePage sections []model.SiteSection media []model.SiteMedia jobs []model.RecruitJob }{ settings: defaultSiteSettings(), pages: defaultSitePages(), sections: defaultSiteSections(), media: defaultSiteMedia(), jobs: defaultRecruitJobs(), } // Login 返回后台登录占位结果,后续接入 nl_admin 密码校验和 token 签发。 func (s AdminService) Login(account string) map[string]interface{} { if account == "" { account = "admin" } return map[string]interface{}{"token": "dev-token", "user": map[string]interface{}{"name": account, "role": "super_admin"}} } // Menus 返回支持单列和双列布局的后台多级菜单树。 func (s AdminService) Menus() []model.Menu { return []model.Menu{ {Id: 1, ParentId: 0, Title: "控制台", Path: "/dashboard", Component: "Dashboard", Icon: "dashboard", Type: "menu", Permission: "dashboard:view", MenuArea: 1}, {Id: 2, ParentId: 0, Title: "官网运营", Path: "/site", Component: "Layout", Icon: "global", Type: "catalog", Permission: "site:view", MenuArea: 1, Children: []model.Menu{ {Id: 21, ParentId: 2, Title: "页面模块", Path: "/site/sections", Component: "SiteSections", Icon: "blocks", Type: "menu", Permission: "site:section:view", MenuArea: 2}, {Id: 22, ParentId: 2, Title: "咨询线索", Path: "/site/leads", Component: "SiteLeads", Icon: "message", Type: "menu", Permission: "site:lead:view", MenuArea: 2}, {Id: 23, ParentId: 2, Title: "图片管理", Path: "/site/media", Component: "SiteMedia", Icon: "image", Type: "menu", Permission: "site:media:view", MenuArea: 2}, {Id: 24, ParentId: 2, Title: "招聘管理", Path: "/site/jobs", Component: "RecruitJobs", Icon: "briefcase", Type: "menu", Permission: "site:job:view", MenuArea: 2}, {Id: 25, ParentId: 2, Title: "站点设置", Path: "/site/settings", Component: "SiteSettings", Icon: "setting", Type: "menu", Permission: "site:settings:view", MenuArea: 2}, }}, {Id: 3, ParentId: 0, Title: "商业化", Path: "/commerce", Component: "Layout", Icon: "wallet", Type: "catalog", Permission: "commerce:view", MenuArea: 1, Children: []model.Menu{ {Id: 31, ParentId: 3, Title: "套餐管理", Path: "/commerce/plans", Component: "PlanList", Icon: "package", Type: "menu", Permission: "plan:view", MenuArea: 2}, {Id: 32, ParentId: 3, Title: "订单管理", Path: "/commerce/orders", Component: "OrderList", Icon: "orders", Type: "menu", Permission: "order:view", MenuArea: 2}, }}, {Id: 4, ParentId: 0, Title: "系统管理", Path: "/system", Component: "Layout", Icon: "setting", Type: "catalog", Permission: "system:view", MenuArea: 1, Children: []model.Menu{ {Id: 41, ParentId: 4, Title: "管理员", Path: "/system/admins", Component: "AdminList", Icon: "user", Type: "menu", Permission: "admin:view", MenuArea: 2}, {Id: 42, ParentId: 4, Title: "角色权限", Path: "/system/roles", Component: "RoleList", Icon: "safety", Type: "menu", Permission: "role:view", MenuArea: 2}, {Id: 43, ParentId: 4, Title: "菜单管理", Path: "/system/menus", Component: "MenuList", Icon: "menu", Type: "menu", Permission: "menu:view", MenuArea: 2}, }}, } } // MenuLayout 返回全局默认布局和用户最终布局。 func (s AdminService) MenuLayout() model.MenuLayout { return model.MenuLayout{GlobalLayout: "double", UserLayout: "", FinalLayout: "double"} } // UpdateMenuLayout 保存管理员个人菜单布局偏好。 func (s AdminService) UpdateMenuLayout(layout string) model.MenuLayout { if layout != "single" && layout != "double" { layout = "double" } return model.MenuLayout{GlobalLayout: "double", UserLayout: layout, FinalLayout: layout} } // Roles 返回后台角色占位数据。 func (s AdminService) Roles() []map[string]interface{} { return []map[string]interface{}{{"id": 1, "name": "超级管理员", "code": "super_admin"}, {"id": 2, "name": "运营人员", "code": "operator"}} } // SaveRoleMenus 保存角色菜单授权占位结果。 func (s AdminService) SaveRoleMenus(roleId int, menuIds []int) map[string]interface{} { return map[string]interface{}{"role_id": roleId, "menu_ids": menuIds, "saved": true} } // SitePages 返回官网页面配置,支持每个页面独立维护 SEO 和 GSO/GEO 内容。 func (s AdminService) SitePages() []model.SitePage { adminStore.RLock() defer adminStore.RUnlock() return copyPages(adminStore.pages) } // SaveSitePage 保存页面 SEO/GSO 配置;一期先写入内存,后续替换为 nl_site_page 持久化。 func (s AdminService) SaveSitePage(page model.SitePage) model.SitePage { adminStore.Lock() defer adminStore.Unlock() if page.Id == 0 { page.Id = nextPageId(adminStore.pages) } if page.GsoContent == nil { page.GsoContent = map[string]interface{}{} } adminStore.pages = upsertPage(adminStore.pages, page) return page } // SiteSections 返回官网页面模块配置,可按页面编码过滤。 func (s AdminService) SiteSections(pageCode string) []model.SiteSection { adminStore.RLock() defer adminStore.RUnlock() sections := make([]model.SiteSection, 0) for _, section := range adminStore.sections { if pageCode == "" || section.PageCode == pageCode { sections = append(sections, section) } } sortSections(sections) return sections } // SaveSiteSection 保存官网页面模块;内容字段保持 JSON 结构,便于后台控制标题、按钮、列表和图片。 func (s AdminService) SaveSiteSection(section model.SiteSection) model.SiteSection { adminStore.Lock() defer adminStore.Unlock() if section.Id == 0 { section.Id = nextSectionId(adminStore.sections) } if section.Content == nil { section.Content = map[string]interface{}{} } adminStore.sections = upsertSection(adminStore.sections, section) return section } // SiteMedia 返回官网素材,覆盖 LOGO、favicon、页面图片、分享图和招聘图。 func (s AdminService) SiteMedia(mediaType string) []model.SiteMedia { adminStore.RLock() defer adminStore.RUnlock() items := make([]model.SiteMedia, 0) for _, item := range adminStore.media { if mediaType == "" || item.MediaType == mediaType { items = append(items, item) } } sortMedia(items) return items } // SaveSiteMedia 保存官网素材 URL;真实上传和对象存储可在后续扩展。 func (s AdminService) SaveSiteMedia(media model.SiteMedia) model.SiteMedia { adminStore.Lock() defer adminStore.Unlock() if media.Id == 0 { media.Id = nextMediaId(adminStore.media) } adminStore.media = upsertMedia(adminStore.media, media) return media } // RecruitJobs 返回后台招聘职位列表。 func (s AdminService) RecruitJobs() []model.RecruitJob { adminStore.RLock() defer adminStore.RUnlock() jobs := append([]model.RecruitJob(nil), adminStore.jobs...) sortJobs(jobs) return jobs } // SaveRecruitJob 保存招聘职位;一期先写入内存,后续接入 nl_recruit_job。 func (s AdminService) SaveRecruitJob(job model.RecruitJob) model.RecruitJob { adminStore.Lock() defer adminStore.Unlock() if job.Id == 0 { job.Id = nextJobId(adminStore.jobs) } if job.Requirements == nil { job.Requirements = []string{} } if job.PublishedDate == "" { job.PublishedDate = "2026-07-05" } adminStore.jobs = upsertJob(adminStore.jobs, job) return job } // SiteSettings 返回站点全局配置,页面未配置时使用这里的默认值。 func (s AdminService) SiteSettings() model.SiteSettings { adminStore.RLock() defer adminStore.RUnlock() return copySettings(adminStore.settings) } // SaveSiteSettings 保存站点全局配置,包含标题、LOGO、ico、SEO、GSO/GEO 和高德地图配置。 func (s AdminService) SaveSiteSettings(settings model.SiteSettings) model.SiteSettings { adminStore.Lock() defer adminStore.Unlock() if settings.DefaultSeo == nil { settings.DefaultSeo = map[string]interface{}{} } if settings.DefaultGso == nil { settings.DefaultGso = map[string]interface{}{} } adminStore.settings = copySettings(settings) return copySettings(adminStore.settings) } func defaultSiteSettings() model.SiteSettings { return model.SiteSettings{ SiteTitle: "萧康云医官网", SiteLogo: "/src/assets/images/logo.png", SiteLogoDark: "/src/assets/images/logo-white.png", SiteFavicon: "/favicon.ico", IcpText: "浙ICP备0000000号", Copyright: "Copyright © 萧康云医", ContactPhone: "0571-00000000", ContactEmail: "contact@xiaokang.example", ContactAddress: "杭州市萧山区鸿盛路与高新六路交叉口东200米", DefaultSeo: map[string]interface{}{ "title": "萧康云医官网", "keywords": "诊所SaaS,云医,诊所管理", "description": "萧康云医为诊所提供获客、预约、接诊、复诊和经营分析能力。", }, DefaultGso: map[string]interface{}{ "brand": "萧康云医", "summary": "面向诊所赋能的 SaaS 平台", "services": []string{"患者端", "医生端", "老板端", "推广员端", "PC后台"}, }, AmapKey: "", AmapSecurityCode: "", } } func defaultSitePages() []model.SitePage { return []model.SitePage{ {Id: 1, Code: "home", Title: "首页", SeoTitle: "萧康云医官网", SeoKeywords: "诊所SaaS,诊所管理,云医", SeoDescription: "萧康云医为诊所提供获客、预约、接诊、复诊和经营分析能力。", GsoContent: map[string]interface{}{"summary": "面向诊所赋能的 SaaS 平台", "entities": []string{"萧康云医", "诊所SaaS"}}, Status: 1, Sort: 1}, {Id: 2, Code: "features", Title: "产品能力", SeoTitle: "萧康云医产品能力", SeoKeywords: "患者端,医生端,推广员端,PC后台", SeoDescription: "了解萧康云医患者端、医生端、老板端、推广员端和后台能力。", GsoContent: map[string]interface{}{"summary": "多端协同产品能力"}, Status: 1, Sort: 2}, {Id: 3, Code: "pricing", Title: "价格方案", SeoTitle: "萧康云医价格", SeoKeywords: "SaaS定价,诊所套餐", SeoDescription: "查看萧康云医基础版、增长版、连锁版套餐。", GsoContent: map[string]interface{}{"summary": "诊所 SaaS 套餐价格"}, Status: 1, Sort: 3}, {Id: 4, Code: "careers", Title: "加入我们", SeoTitle: "加入萧康云医", SeoKeywords: "招聘,医疗SaaS招聘", SeoDescription: "加入萧康云医团队,共建诊所赋能平台。", GsoContent: map[string]interface{}{"summary": "萧康云医招聘职位"}, Status: 1, Sort: 4}, } } func defaultSiteSections() []model.SiteSection { return []model.SiteSection{ {Id: 1, PageCode: "features", SectionKey: "hero", Title: "能力地图", Subtitle: "多端协同与经营后台", Content: map[string]interface{}{"layout": "capability_map", "image": "/src/assets/images/features0001.jpeg"}, Status: 1, Sort: 1}, {Id: 2, PageCode: "features", SectionKey: "workflow", Title: "流程切片", Subtitle: "获客到复诊", Content: map[string]interface{}{"steps": []string{"获客", "预约", "接诊", "复诊", "复盘"}}, Status: 1, Sort: 2}, {Id: 3, PageCode: "pricing", SectionKey: "plans", Title: "套餐矩阵", Subtitle: "月付/年付与权益对比", Content: map[string]interface{}{"billing": []string{"month", "year"}}, Status: 1, Sort: 1}, } } func defaultSiteMedia() []model.SiteMedia { return []model.SiteMedia{ {Id: 1, MediaType: "logo", GroupCode: "brand", Title: "站点LOGO", Url: "/src/assets/images/logo.png", Alt: "萧康云医LOGO", Status: 1, Sort: 1}, {Id: 2, MediaType: "favicon", GroupCode: "brand", Title: "favicon", Url: "/favicon.ico", Alt: "萧康云医图标", Status: 1, Sort: 2}, {Id: 3, MediaType: "page_image", GroupCode: "features", Title: "产品能力首图", Url: "/src/assets/images/features0001.jpeg", Alt: "萧康云医产品能力", Status: 1, Sort: 3}, {Id: 4, MediaType: "share_image", GroupCode: "seo", Title: "默认分享图", Url: "/src/assets/images/home0001.jpeg", Alt: "萧康云医官网分享图", Status: 1, Sort: 4}, } } func defaultRecruitJobs() []model.RecruitJob { return []model.RecruitJob{ {Id: 1, Title: "高级前端开发工程师", Department: "技术部", JobType: "全职", City: "杭州", Salary: "25K-45K", Experience: "5年以上", Description: "负责萧康云医官网、后台和小程序端前端架构与体验优化。", Requirements: []string{"熟悉 Vue 3 与工程化", "有中后台或 SaaS 项目经验", "关注性能、可访问性和组件化"}, Manager: "张经理", ManagerTitle: "技术负责人", Email: "tech@xiaokang.example", Phone: "0571-00000000", Featured: true, Status: 1, Sort: 1, PublishedDate: "2026-07-05"}, {Id: 2, Title: "诊所运营顾问", Department: "运营部", JobType: "全职", City: "杭州", Salary: "12K-22K", Experience: "3年以上", Description: "面向诊所客户完成系统演示、上线辅导和经营复盘。", Requirements: []string{"熟悉医疗或本地生活服务行业", "具备客户培训和项目推进能力", "表达清晰,能沉淀方法论"}, Manager: "王经理", ManagerTitle: "运营负责人", Email: "ops@xiaokang.example", Phone: "0571-00000001", Featured: false, Status: 1, Sort: 2, PublishedDate: "2026-07-05"}, } } func copyMap(source map[string]interface{}) map[string]interface{} { if source == nil { return map[string]interface{}{} } result := make(map[string]interface{}, len(source)) for key, value := range source { result[key] = value } return result } func copySettings(settings model.SiteSettings) model.SiteSettings { settings.DefaultSeo = copyMap(settings.DefaultSeo) settings.DefaultGso = copyMap(settings.DefaultGso) return settings } func copyPages(pages []model.SitePage) []model.SitePage { items := append([]model.SitePage(nil), pages...) sort.SliceStable(items, func(i, j int) bool { return items[i].Sort < items[j].Sort || items[i].Sort == items[j].Sort && items[i].Id < items[j].Id }) return items } func upsertPage(items []model.SitePage, item model.SitePage) []model.SitePage { for index := range items { if items[index].Id == item.Id || item.Code != "" && items[index].Code == item.Code { items[index] = item return items } } return append(items, item) } func upsertSection(items []model.SiteSection, item model.SiteSection) []model.SiteSection { for index := range items { if items[index].Id == item.Id || item.SectionKey != "" && items[index].PageCode == item.PageCode && items[index].SectionKey == item.SectionKey { items[index] = item return items } } return append(items, item) } func upsertMedia(items []model.SiteMedia, item model.SiteMedia) []model.SiteMedia { for index := range items { if items[index].Id == item.Id { items[index] = item return items } } return append(items, item) } func upsertJob(items []model.RecruitJob, item model.RecruitJob) []model.RecruitJob { for index := range items { if items[index].Id == item.Id { items[index] = item return items } } return append(items, item) } func sortSections(items []model.SiteSection) { sort.SliceStable(items, func(i, j int) bool { return items[i].Sort < items[j].Sort || items[i].Sort == items[j].Sort && items[i].Id < items[j].Id }) } func sortMedia(items []model.SiteMedia) { sort.SliceStable(items, func(i, j int) bool { return items[i].Sort < items[j].Sort || items[i].Sort == items[j].Sort && items[i].Id < items[j].Id }) } func sortJobs(items []model.RecruitJob) { sort.SliceStable(items, func(i, j int) bool { return items[i].Sort < items[j].Sort || items[i].Sort == items[j].Sort && items[i].Id < items[j].Id }) } func nextPageId(items []model.SitePage) int { maxId := 0 for _, item := range items { if item.Id > maxId { maxId = item.Id } } return maxId + 1 } func nextSectionId(items []model.SiteSection) int { maxId := 0 for _, item := range items { if item.Id > maxId { maxId = item.Id } } return maxId + 1 } func nextMediaId(items []model.SiteMedia) int { maxId := 0 for _, item := range items { if item.Id > maxId { maxId = item.Id } } return maxId + 1 } func nextJobId(items []model.RecruitJob) int { maxId := 0 for _, item := range items { if item.Id > maxId { maxId = item.Id } } return maxId + 1 }