初始化

This commit is contained in:
2026-07-18 05:58:41 +08:00
commit e4b95eb05e
69 changed files with 2289 additions and 0 deletions

386
internal/service/admin.go Normal file
View File

@@ -0,0 +1,386 @@
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
}