更新
This commit is contained in:
+19
-4
@@ -1,6 +1,7 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"flag"
|
||||||
"log"
|
"log"
|
||||||
"photowall/internal/config"
|
"photowall/internal/config"
|
||||||
"photowall/internal/database"
|
"photowall/internal/database"
|
||||||
@@ -16,11 +17,17 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
migrateFlag := flag.Bool("migrate", false, "执行数据库迁移与种子数据初始化 (AutoMigrate)")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
// 1. 加载配置
|
// 1. 加载配置
|
||||||
cfg := config.Load()
|
cfg := config.Load()
|
||||||
log.Printf("[config] port=%s db=%s@%s:%s/%s", cfg.Port, cfg.DBUser, cfg.DBHost, cfg.DBPort, cfg.DBName)
|
if *migrateFlag {
|
||||||
|
cfg.DBAutoMigrate = true
|
||||||
|
}
|
||||||
|
log.Printf("[config] port=%s db=%s@%s:%s/%s auto_migrate=%v", cfg.Port, cfg.DBUser, cfg.DBHost, cfg.DBPort, cfg.DBName, cfg.DBAutoMigrate)
|
||||||
|
|
||||||
// 2. 初始化数据库(连接 + 自动迁移 + 管理员 + 种子数据)
|
// 2. 初始化数据库(连接 MySQL;仅在开启 auto_migrate 时迁移和播种)
|
||||||
if err := database.Init(); err != nil {
|
if err := database.Init(); err != nil {
|
||||||
log.Fatalf("[database] init failed: %v", err)
|
log.Fatalf("[database] init failed: %v", err)
|
||||||
}
|
}
|
||||||
@@ -38,10 +45,15 @@ func main() {
|
|||||||
|
|
||||||
// 4. 初始化 Service
|
// 4. 初始化 Service
|
||||||
configSvc := service.NewConfigService(database.DB)
|
configSvc := service.NewConfigService(database.DB)
|
||||||
// 初始化系统默认配置
|
if cfg.DBAutoMigrate {
|
||||||
|
// 初始化系统默认配置(逐条检测并补齐)
|
||||||
if err := configSvc.InitDefaults(); err != nil {
|
if err := configSvc.InitDefaults(); err != nil {
|
||||||
log.Printf("[config] init defaults failed: %v", err)
|
log.Printf("[config] init defaults failed: %v", err)
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// 快速预热配置缓存(单次全量查询,跳过逐条校验)
|
||||||
|
configSvc.WarmUpCache()
|
||||||
|
}
|
||||||
|
|
||||||
authSvc := service.NewAuthService(database.DB, jm, configSvc)
|
authSvc := service.NewAuthService(database.DB, jm, configSvc)
|
||||||
userSvc := service.NewUserService(database.DB)
|
userSvc := service.NewUserService(database.DB)
|
||||||
@@ -50,10 +62,13 @@ func main() {
|
|||||||
classSvc := service.NewClassService(database.DB, configSvc)
|
classSvc := service.NewClassService(database.DB, configSvc)
|
||||||
adminSvc := service.NewAdminService(database.DB)
|
adminSvc := service.NewAdminService(database.DB)
|
||||||
menuSvc := service.NewMenuService(database.DB)
|
menuSvc := service.NewMenuService(database.DB)
|
||||||
// 初始化默认菜单
|
|
||||||
|
if cfg.DBAutoMigrate {
|
||||||
|
// 初始化默认菜单(逐条检测并补齐)
|
||||||
if err := menuSvc.InitDefaults(); err != nil {
|
if err := menuSvc.InitDefaults(); err != nil {
|
||||||
log.Printf("[menu] init defaults failed: %v", err)
|
log.Printf("[menu] init defaults failed: %v", err)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
// 初始化敏感词过滤器
|
// 初始化敏感词过滤器
|
||||||
sensitive.Init(database.DB)
|
sensitive.Init(database.DB)
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ type Config struct {
|
|||||||
DBPassword string
|
DBPassword string
|
||||||
DBName string
|
DBName string
|
||||||
DBCharset string
|
DBCharset string
|
||||||
|
DBAutoMigrate bool
|
||||||
|
|
||||||
// 平台管理员账号(首次启动自动创建)
|
// 平台管理员账号(首次启动自动创建)
|
||||||
AdminUsername string
|
AdminUsername string
|
||||||
@@ -48,6 +49,7 @@ func Load() *Config {
|
|||||||
DBPassword: getEnv("DB_PASSWORD", "Dfn47yeKpyJfwz8n"),
|
DBPassword: getEnv("DB_PASSWORD", "Dfn47yeKpyJfwz8n"),
|
||||||
DBName: getEnv("DB_NAME", "photowall"),
|
DBName: getEnv("DB_NAME", "photowall"),
|
||||||
DBCharset: getEnv("DB_CHARSET", "utf8mb4"),
|
DBCharset: getEnv("DB_CHARSET", "utf8mb4"),
|
||||||
|
DBAutoMigrate: getEnvBool("DB_AUTO_MIGRATE", false),
|
||||||
AdminUsername: getEnv("ADMIN_USERNAME", "hero920103"),
|
AdminUsername: getEnv("ADMIN_USERNAME", "hero920103"),
|
||||||
AdminPassword: getEnv("ADMIN_PASSWORD", "920103"),
|
AdminPassword: getEnv("ADMIN_PASSWORD", "920103"),
|
||||||
// Redis
|
// Redis
|
||||||
@@ -80,3 +82,12 @@ func getEnvInt(key string, def int) int {
|
|||||||
}
|
}
|
||||||
return def
|
return def
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func getEnvBool(key string, def bool) bool {
|
||||||
|
if v := os.Getenv(key); v != "" {
|
||||||
|
if b, err := strconv.ParseBool(v); err == nil {
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
|||||||
@@ -45,6 +45,19 @@ func Init() error {
|
|||||||
|
|
||||||
DB = db
|
DB = db
|
||||||
|
|
||||||
|
if !cfg.DBAutoMigrate {
|
||||||
|
log.Println("[database] mysql connected (auto migrate skipped)")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Println("[database] running auto migration and seeding...")
|
||||||
|
|
||||||
|
// 增量保护:确保花名册新表及 row_count 字段迁移
|
||||||
|
_ = db.AutoMigrate(&model.ClassRoster{})
|
||||||
|
if !db.Migrator().HasColumn(&model.Class{}, "row_count") {
|
||||||
|
_ = db.Migrator().AddColumn(&model.Class{}, "row_count")
|
||||||
|
}
|
||||||
|
|
||||||
// 数据迁移:旧版 bcrypt 密码(60字符) 超过新版 md5(32字符) 列宽,
|
// 数据迁移:旧版 bcrypt 密码(60字符) 超过新版 md5(32字符) 列宽,
|
||||||
// 在 AutoMigrate 缩列前先清空旧密码,后续 ensureAdmin 会重新设置。
|
// 在 AutoMigrate 缩列前先清空旧密码,后续 ensureAdmin 会重新设置。
|
||||||
// 注意:salt 列此时可能还不存在,只更新 password_hash。
|
// 注意:salt 列此时可能还不存在,只更新 password_hash。
|
||||||
@@ -55,6 +68,9 @@ func Init() error {
|
|||||||
return fmt.Errorf("auto migrate: %w", err)
|
return fmt.Errorf("auto migrate: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 历史数据回填:school_types 多学段字段由原单一 school_type 初始化
|
||||||
|
db.Exec("UPDATE yz_pw_schools SET school_types = school_type WHERE (school_types IS NULL OR school_types = '') AND school_type <> ''")
|
||||||
|
|
||||||
// 创建平台管理员
|
// 创建平台管理员
|
||||||
if err := ensureAdmin(db); err != nil {
|
if err := ensureAdmin(db); err != nil {
|
||||||
return fmt.Errorf("ensure admin: %w", err)
|
return fmt.Errorf("ensure admin: %w", err)
|
||||||
@@ -79,6 +95,7 @@ func autoMigrate(db *gorm.DB) error {
|
|||||||
&model.College{},
|
&model.College{},
|
||||||
&model.Class{},
|
&model.Class{},
|
||||||
&model.ClassMember{},
|
&model.ClassMember{},
|
||||||
|
&model.ClassRoster{},
|
||||||
&model.ClassPhoto{},
|
&model.ClassPhoto{},
|
||||||
&model.Invitation{},
|
&model.Invitation{},
|
||||||
&model.AuditLog{},
|
&model.AuditLog{},
|
||||||
|
|||||||
@@ -10,6 +10,11 @@ import (
|
|||||||
// 全球国家/省/区县数据量巨大,此处仅写入代表性示例,平台管理员可通过
|
// 全球国家/省/区县数据量巨大,此处仅写入代表性示例,平台管理员可通过
|
||||||
// 管理接口批量录入完整数据(或导入民政部/GeoNames 数据源)。
|
// 管理接口批量录入完整数据(或导入民政部/GeoNames 数据源)。
|
||||||
func seedRegions(db *gorm.DB) error {
|
func seedRegions(db *gorm.DB) error {
|
||||||
|
var count int64
|
||||||
|
if err := db.Model(&model.Region{}).Limit(1).Count(&count).Error; err == nil && count > 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// 国家
|
// 国家
|
||||||
china := upsertRegion(db, model.Region{Name: "中国", NameEn: "China", Level: model.LevelCountry, Code: "CN", SortOrder: 1})
|
china := upsertRegion(db, model.Region{Name: "中国", NameEn: "China", Level: model.LevelCountry, Code: "CN", SortOrder: 1})
|
||||||
usa := upsertRegion(db, model.Region{Name: "美国", NameEn: "United States", Level: model.LevelCountry, Code: "US", SortOrder: 2})
|
usa := upsertRegion(db, model.Region{Name: "美国", NameEn: "United States", Level: model.LevelCountry, Code: "US", SortOrder: 2})
|
||||||
@@ -23,22 +28,31 @@ func seedRegions(db *gorm.DB) error {
|
|||||||
shanghai := upsertRegion(db, model.Region{Name: "上海市", NameEn: "Shanghai", Level: model.LevelProvince, ParentID: china.ID, Code: "310000"})
|
shanghai := upsertRegion(db, model.Region{Name: "上海市", NameEn: "Shanghai", Level: model.LevelProvince, ParentID: china.ID, Code: "310000"})
|
||||||
zhejiang := upsertRegion(db, model.Region{Name: "浙江省", NameEn: "Zhejiang", Level: model.LevelProvince, ParentID: china.ID, Code: "330000"})
|
zhejiang := upsertRegion(db, model.Region{Name: "浙江省", NameEn: "Zhejiang", Level: model.LevelProvince, ParentID: china.ID, Code: "330000"})
|
||||||
|
|
||||||
|
// 中国 - 地级市/直辖市市级
|
||||||
|
nanjing := upsertRegion(db, model.Region{Name: "南京市", NameEn: "Nanjing", Level: model.LevelCity, ParentID: jiangsu.ID, Code: "320100"})
|
||||||
|
lianyungang := upsertRegion(db, model.Region{Name: "连云港市", NameEn: "Lianyungang", Level: model.LevelCity, ParentID: jiangsu.ID, Code: "320700"})
|
||||||
|
beijingCity := upsertRegion(db, model.Region{Name: "北京市", NameEn: "Beijing", Level: model.LevelCity, ParentID: beijing.ID, Code: "110100"})
|
||||||
|
guangzhou := upsertRegion(db, model.Region{Name: "广州市", NameEn: "Guangzhou", Level: model.LevelCity, ParentID: guangdong.ID, Code: "440100"})
|
||||||
|
shenzhen := upsertRegion(db, model.Region{Name: "深圳市", NameEn: "Shenzhen", Level: model.LevelCity, ParentID: guangdong.ID, Code: "440300"})
|
||||||
|
shanghaiCity := upsertRegion(db, model.Region{Name: "上海市", NameEn: "Shanghai", Level: model.LevelCity, ParentID: shanghai.ID, Code: "310100"})
|
||||||
|
hangzhou := upsertRegion(db, model.Region{Name: "杭州市", NameEn: "Hangzhou", Level: model.LevelCity, ParentID: zhejiang.ID, Code: "330100"})
|
||||||
|
|
||||||
// 中国 - 区县(最低级)
|
// 中国 - 区县(最低级)
|
||||||
upsertRegion(db, model.Region{Name: "海州区", NameEn: "Haizhou", Level: model.LevelCounty, ParentID: jiangsu.ID, Code: "320706"})
|
upsertRegion(db, model.Region{Name: "连云区", NameEn: "Lianyun", Level: model.LevelCounty, ParentID: lianyungang.ID, Code: "320703"})
|
||||||
upsertRegion(db, model.Region{Name: "连云区", NameEn: "Lianyun", Level: model.LevelCounty, ParentID: jiangsu.ID, Code: "320703"})
|
upsertRegion(db, model.Region{Name: "海州区", NameEn: "Haizhou", Level: model.LevelCounty, ParentID: lianyungang.ID, Code: "320706"})
|
||||||
upsertRegion(db, model.Region{Name: "赣榆区", NameEn: "Ganyu", Level: model.LevelCounty, ParentID: jiangsu.ID, Code: "320707"})
|
upsertRegion(db, model.Region{Name: "赣榆区", NameEn: "Ganyu", Level: model.LevelCounty, ParentID: lianyungang.ID, Code: "320707"})
|
||||||
upsertRegion(db, model.Region{Name: "玄武区", NameEn: "Xuanwu", Level: model.LevelCounty, ParentID: jiangsu.ID, Code: "320102"})
|
upsertRegion(db, model.Region{Name: "玄武区", NameEn: "Xuanwu", Level: model.LevelCounty, ParentID: nanjing.ID, Code: "320102"})
|
||||||
upsertRegion(db, model.Region{Name: "鼓楼区", NameEn: "Gulou", Level: model.LevelCounty, ParentID: jiangsu.ID, Code: "320106"})
|
upsertRegion(db, model.Region{Name: "鼓楼区", NameEn: "Gulou", Level: model.LevelCounty, ParentID: nanjing.ID, Code: "320106"})
|
||||||
upsertRegion(db, model.Region{Name: "海淀区", NameEn: "Haidian", Level: model.LevelCounty, ParentID: beijing.ID, Code: "110108"})
|
upsertRegion(db, model.Region{Name: "海淀区", NameEn: "Haidian", Level: model.LevelCounty, ParentID: beijingCity.ID, Code: "110108"})
|
||||||
upsertRegion(db, model.Region{Name: "朝阳区", NameEn: "Chaoyang", Level: model.LevelCounty, ParentID: beijing.ID, Code: "110105"})
|
upsertRegion(db, model.Region{Name: "朝阳区", NameEn: "Chaoyang", Level: model.LevelCounty, ParentID: beijingCity.ID, Code: "110105"})
|
||||||
upsertRegion(db, model.Region{Name: "西城区", NameEn: "Xicheng", Level: model.LevelCounty, ParentID: beijing.ID, Code: "110102"})
|
upsertRegion(db, model.Region{Name: "西城区", NameEn: "Xicheng", Level: model.LevelCounty, ParentID: beijingCity.ID, Code: "110102"})
|
||||||
upsertRegion(db, model.Region{Name: "天河区", NameEn: "Tianhe", Level: model.LevelCounty, ParentID: guangdong.ID, Code: "440106"})
|
upsertRegion(db, model.Region{Name: "天河区", NameEn: "Tianhe", Level: model.LevelCounty, ParentID: guangzhou.ID, Code: "440106"})
|
||||||
upsertRegion(db, model.Region{Name: "南山区", NameEn: "Nanshan", Level: model.LevelCounty, ParentID: guangdong.ID, Code: "440305"})
|
upsertRegion(db, model.Region{Name: "南山区", NameEn: "Nanshan", Level: model.LevelCounty, ParentID: shenzhen.ID, Code: "440305"})
|
||||||
upsertRegion(db, model.Region{Name: "福田区", NameEn: "Futian", Level: model.LevelCounty, ParentID: guangdong.ID, Code: "440304"})
|
upsertRegion(db, model.Region{Name: "福田区", NameEn: "Futian", Level: model.LevelCounty, ParentID: shenzhen.ID, Code: "440304"})
|
||||||
upsertRegion(db, model.Region{Name: "浦东新区", NameEn: "Pudong", Level: model.LevelCounty, ParentID: shanghai.ID, Code: "310115"})
|
upsertRegion(db, model.Region{Name: "浦东新区", NameEn: "Pudong", Level: model.LevelCounty, ParentID: shanghaiCity.ID, Code: "310115"})
|
||||||
upsertRegion(db, model.Region{Name: "黄浦区", NameEn: "Huangpu", Level: model.LevelCounty, ParentID: shanghai.ID, Code: "310101"})
|
upsertRegion(db, model.Region{Name: "黄浦区", NameEn: "Huangpu", Level: model.LevelCounty, ParentID: shanghaiCity.ID, Code: "310101"})
|
||||||
upsertRegion(db, model.Region{Name: "西湖区", NameEn: "Xihu", Level: model.LevelCounty, ParentID: zhejiang.ID, Code: "330106"})
|
upsertRegion(db, model.Region{Name: "西湖区", NameEn: "Xihu", Level: model.LevelCounty, ParentID: hangzhou.ID, Code: "330106"})
|
||||||
upsertRegion(db, model.Region{Name: "滨江区", NameEn: "Binjiang", Level: model.LevelCounty, ParentID: zhejiang.ID, Code: "330108"})
|
upsertRegion(db, model.Region{Name: "滨江区", NameEn: "Binjiang", Level: model.LevelCounty, ParentID: hangzhou.ID, Code: "330108"})
|
||||||
|
|
||||||
// 美国 - 州
|
// 美国 - 州
|
||||||
california := upsertRegion(db, model.Region{Name: "加利福尼亚州", NameEn: "California", Level: model.LevelProvince, ParentID: usa.ID, Code: "CA"})
|
california := upsertRegion(db, model.Region{Name: "加利福尼亚州", NameEn: "California", Level: model.LevelProvince, ParentID: usa.ID, Code: "CA"})
|
||||||
|
|||||||
@@ -104,7 +104,11 @@ func (h *AdminHandler) Delete(c *gin.Context) {
|
|||||||
|
|
||||||
// AuditLogs GET /api/admin/classes/:id/logs
|
// AuditLogs GET /api/admin/classes/:id/logs
|
||||||
func (h *AdminHandler) AuditLogs(c *gin.Context) {
|
func (h *AdminHandler) AuditLogs(c *gin.Context) {
|
||||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil || id == 0 {
|
||||||
|
response.BadRequest(c, "无效的班级ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
logs, err := h.svc.AuditLogs(uint(id))
|
logs, err := h.svc.AuditLogs(uint(id))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.Internal(c, err.Error())
|
response.Internal(c, err.Error())
|
||||||
@@ -154,6 +158,18 @@ func (h *AdminHandler) UnbanUser(c *gin.Context) {
|
|||||||
response.OK(c, gin.H{"message": "已解封"})
|
response.OK(c, gin.H{"message": "已解封"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListUsers GET /api/admin/users 用户列表(支持状态与搜索)
|
||||||
|
func (h *AdminHandler) ListUsers(c *gin.Context) {
|
||||||
|
status := c.Query("status")
|
||||||
|
keyword := c.Query("keyword")
|
||||||
|
list, err := h.svc.ListUsers(status, keyword)
|
||||||
|
if err != nil {
|
||||||
|
response.Internal(c, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OK(c, list)
|
||||||
|
}
|
||||||
|
|
||||||
// BannedUsers GET /api/admin/users/banned 封禁用户列表
|
// BannedUsers GET /api/admin/users/banned 封禁用户列表
|
||||||
func (h *AdminHandler) BannedUsers(c *gin.Context) {
|
func (h *AdminHandler) BannedUsers(c *gin.Context) {
|
||||||
list, err := h.svc.ListBannedUsers()
|
list, err := h.svc.ListBannedUsers()
|
||||||
|
|||||||
@@ -35,7 +35,11 @@ func (h *ClassHandler) Create(c *gin.Context) {
|
|||||||
|
|
||||||
// Detail GET /api/classes/:id
|
// Detail GET /api/classes/:id
|
||||||
func (h *ClassHandler) Detail(c *gin.Context) {
|
func (h *ClassHandler) Detail(c *gin.Context) {
|
||||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil || id == 0 {
|
||||||
|
response.BadRequest(c, "无效的班级ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
userID := middleware.CurrentUserID(c)
|
userID := middleware.CurrentUserID(c)
|
||||||
isSuperAdmin := middleware.CurrentRole(c) == "admin"
|
isSuperAdmin := middleware.CurrentRole(c) == "admin"
|
||||||
detail, err := h.svc.GetDetail(uint(id), userID, isSuperAdmin)
|
detail, err := h.svc.GetDetail(uint(id), userID, isSuperAdmin)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"photowall/internal/service"
|
"photowall/internal/service"
|
||||||
"photowall/pkg/response"
|
"photowall/pkg/response"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
@@ -29,14 +30,14 @@ func (h *MenuHandler) GetUserMenus(c *gin.Context) {
|
|||||||
response.OK(c, tree)
|
response.OK(c, tree)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAll GET /api/admin/menus 获取所有菜单(管理用)
|
// GetAll GET /api/admin/menus 获取所有菜单树结构(管理用)
|
||||||
func (h *MenuHandler) GetAll(c *gin.Context) {
|
func (h *MenuHandler) GetAll(c *gin.Context) {
|
||||||
list, err := h.menu.GetAll()
|
tree, err := h.menu.GetAllTree()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.Internal(c, "获取菜单失败")
|
response.Internal(c, "获取菜单失败: "+err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
response.OK(c, list)
|
response.OK(c, tree)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create POST /api/admin/menus 创建菜单
|
// Create POST /api/admin/menus 创建菜单
|
||||||
@@ -46,34 +47,59 @@ func (h *MenuHandler) Create(c *gin.Context) {
|
|||||||
response.BadRequest(c, "参数错误:"+err.Error())
|
response.BadRequest(c, "参数错误:"+err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if strings.TrimSpace(m.Name) == "" {
|
||||||
|
response.BadRequest(c, "路由名称(name)不能为空")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(m.Title) == "" {
|
||||||
|
response.BadRequest(c, "菜单标题(title)不能为空")
|
||||||
|
return
|
||||||
|
}
|
||||||
if err := h.menu.Create(&m); err != nil {
|
if err := h.menu.Create(&m); err != nil {
|
||||||
response.Internal(c, "创建菜单失败")
|
response.Internal(c, "创建菜单失败: "+err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
response.Created(c, m)
|
response.Created(c, m)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update PUT /api/admin/menus/:id 更新菜单
|
// Update PUT /api/admin/menus/:id 更新菜单(支持全量或部分字段更新)
|
||||||
func (h *MenuHandler) Update(c *gin.Context) {
|
func (h *MenuHandler) Update(c *gin.Context) {
|
||||||
id, _ := strconv.Atoi(c.Param("id"))
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
var m model.Menu
|
if err != nil || id == 0 {
|
||||||
if err := c.ShouldBindJSON(&m); err != nil {
|
response.BadRequest(c, "无效的菜单ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body map[string]interface{}
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
response.BadRequest(c, "参数错误:"+err.Error())
|
response.BadRequest(c, "参数错误:"+err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
m.ID = uint(id)
|
// 兼容 sort / sort_order
|
||||||
if err := h.menu.Update(&m); err != nil {
|
if v, ok := body["sort_order"]; ok {
|
||||||
response.Internal(c, "更新菜单失败")
|
body["sort"] = v
|
||||||
|
delete(body, "sort_order")
|
||||||
|
}
|
||||||
|
delete(body, "id")
|
||||||
|
delete(body, "children") // 过滤可能携带的子项数组,避免 GORM 尝试保存未知列
|
||||||
|
delete(body, "created_at")
|
||||||
|
delete(body, "updated_at")
|
||||||
|
|
||||||
|
if err := h.menu.UpdateFields(uint(id), body); err != nil {
|
||||||
|
response.Internal(c, "更新菜单失败: "+err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
response.OK(c, m)
|
response.OK(c, gin.H{"message": "更新成功"})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete DELETE /api/admin/menus/:id 删除菜单
|
// Delete DELETE /api/admin/menus/:id 删除菜单
|
||||||
func (h *MenuHandler) Delete(c *gin.Context) {
|
func (h *MenuHandler) Delete(c *gin.Context) {
|
||||||
id, _ := strconv.Atoi(c.Param("id"))
|
id, _ := strconv.Atoi(c.Param("id"))
|
||||||
|
if id == 0 {
|
||||||
|
response.BadRequest(c, "无效的菜单ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
if err := h.menu.Delete(uint(id)); err != nil {
|
if err := h.menu.Delete(uint(id)); err != nil {
|
||||||
response.Internal(c, "删除菜单失败")
|
response.Internal(c, "删除菜单失败: "+err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
response.OK(c, gin.H{"message": "删除成功"})
|
response.OK(c, gin.H{"message": "删除成功"})
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"photowall/internal/model"
|
||||||
"photowall/internal/service"
|
"photowall/internal/service"
|
||||||
"photowall/pkg/response"
|
"photowall/pkg/response"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -16,10 +17,11 @@ func NewRegionHandler(svc *service.RegionService) *RegionHandler {
|
|||||||
return &RegionHandler{svc: svc}
|
return &RegionHandler{svc: svc}
|
||||||
}
|
}
|
||||||
|
|
||||||
// List GET /api/regions?parent_id=0
|
// List GET /api/regions?parent_id=0&level=city
|
||||||
func (h *RegionHandler) List(c *gin.Context) {
|
func (h *RegionHandler) List(c *gin.Context) {
|
||||||
parentID, _ := strconv.ParseUint(c.DefaultQuery("parent_id", "0"), 10, 64)
|
parentID, _ := strconv.ParseUint(c.DefaultQuery("parent_id", "0"), 10, 64)
|
||||||
list, err := h.svc.ListByParent(uint(parentID))
|
level := c.Query("level")
|
||||||
|
list, err := h.svc.ListByParent(uint(parentID), model.RegionLevel(level))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.Internal(c, err.Error())
|
response.Internal(c, err.Error())
|
||||||
return
|
return
|
||||||
@@ -39,7 +41,11 @@ func (h *RegionHandler) Countries(c *gin.Context) {
|
|||||||
|
|
||||||
// Path GET /api/regions/:id/path
|
// Path GET /api/regions/:id/path
|
||||||
func (h *RegionHandler) Path(c *gin.Context) {
|
func (h *RegionHandler) Path(c *gin.Context) {
|
||||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil || id == 0 {
|
||||||
|
response.BadRequest(c, "无效的地区ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
path, err := h.svc.GetPath(uint(id))
|
path, err := h.svc.GetPath(uint(id))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.NotFound(c, "地区不存在")
|
response.NotFound(c, "地区不存在")
|
||||||
@@ -55,10 +61,98 @@ func (h *RegionHandler) Search(c *gin.Context) {
|
|||||||
response.BadRequest(c, "请输入搜索关键词")
|
response.BadRequest(c, "请输入搜索关键词")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
list, err := h.svc.Search(keyword, 20)
|
list, err := h.svc.Search(keyword, 50)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.Internal(c, err.Error())
|
response.Internal(c, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
response.OK(c, list)
|
response.OK(c, list)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tree GET /api/admin/regions/tree
|
||||||
|
func (h *RegionHandler) Tree(c *gin.Context) {
|
||||||
|
parentID, _ := strconv.ParseUint(c.DefaultQuery("parent_id", "0"), 10, 64)
|
||||||
|
tree, err := h.svc.GetTree(uint(parentID))
|
||||||
|
if err != nil {
|
||||||
|
response.Internal(c, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OK(c, tree)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create POST /api/admin/regions
|
||||||
|
func (h *RegionHandler) Create(c *gin.Context) {
|
||||||
|
var r model.Region
|
||||||
|
if err := c.ShouldBindJSON(&r); err != nil {
|
||||||
|
response.BadRequest(c, "参数错误:"+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.svc.Create(&r); err != nil {
|
||||||
|
response.BadRequest(c, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.Created(c, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update PUT /api/admin/regions/:id
|
||||||
|
func (h *RegionHandler) Update(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil || id == 0 {
|
||||||
|
response.BadRequest(c, "无效的地区ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body map[string]interface{}
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
|
response.BadRequest(c, "参数错误:"+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
updates := map[string]interface{}{}
|
||||||
|
if v, ok := body["name"]; ok {
|
||||||
|
updates["name"] = v
|
||||||
|
}
|
||||||
|
if v, ok := body["name_en"]; ok {
|
||||||
|
updates["name_en"] = v
|
||||||
|
}
|
||||||
|
if v, ok := body["level"]; ok {
|
||||||
|
updates["level"] = v
|
||||||
|
}
|
||||||
|
if v, ok := body["code"]; ok {
|
||||||
|
updates["code"] = v
|
||||||
|
}
|
||||||
|
if v, ok := body["sort_order"]; ok {
|
||||||
|
updates["sort_order"] = v
|
||||||
|
}
|
||||||
|
if err := h.svc.Update(uint(id), updates); err != nil {
|
||||||
|
response.BadRequest(c, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OK(c, gin.H{"message": "更新成功"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete DELETE /api/admin/regions/:id
|
||||||
|
func (h *RegionHandler) Delete(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil || id == 0 {
|
||||||
|
response.BadRequest(c, "无效的地区ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.svc.Delete(uint(id)); err != nil {
|
||||||
|
response.BadRequest(c, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OK(c, gin.H{"message": "删除成功"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// InitChina POST /api/admin/regions/init-china
|
||||||
|
func (h *RegionHandler) InitChina(c *gin.Context) {
|
||||||
|
created, total, err := h.svc.InitChinaRegions()
|
||||||
|
if err != nil {
|
||||||
|
response.Internal(c, "初始化失败: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OK(c, gin.H{
|
||||||
|
"message": "初始化中国行政区划成功",
|
||||||
|
"created_count": created,
|
||||||
|
"total_count": total,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"photowall/internal/middleware"
|
||||||
"photowall/internal/model"
|
"photowall/internal/model"
|
||||||
"photowall/internal/service"
|
"photowall/internal/service"
|
||||||
"photowall/pkg/response"
|
"photowall/pkg/response"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
@@ -17,7 +19,7 @@ func NewSchoolHandler(svc *service.SchoolService) *SchoolHandler {
|
|||||||
return &SchoolHandler{svc: svc}
|
return &SchoolHandler{svc: svc}
|
||||||
}
|
}
|
||||||
|
|
||||||
// List GET /api/schools
|
// List GET /api/schools or /api/admin/schools
|
||||||
func (h *SchoolHandler) List(c *gin.Context) {
|
func (h *SchoolHandler) List(c *gin.Context) {
|
||||||
var q service.SchoolQuery
|
var q service.SchoolQuery
|
||||||
if err := c.ShouldBindQuery(&q); err != nil {
|
if err := c.ShouldBindQuery(&q); err != nil {
|
||||||
@@ -32,13 +34,51 @@ func (h *SchoolHandler) List(c *gin.Context) {
|
|||||||
response.OK(c, result)
|
response.OK(c, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create POST /api/schools
|
// Stats GET /api/admin/schools/stats
|
||||||
|
func (h *SchoolHandler) Stats(c *gin.Context) {
|
||||||
|
stats, err := h.svc.Stats()
|
||||||
|
if err != nil {
|
||||||
|
response.Internal(c, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OK(c, stats)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create POST /api/schools or /api/admin/schools
|
||||||
func (h *SchoolHandler) Create(c *gin.Context) {
|
func (h *SchoolHandler) Create(c *gin.Context) {
|
||||||
var school model.School
|
var body struct {
|
||||||
if err := c.ShouldBindJSON(&school); err != nil {
|
RegionID uint `json:"region_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
NameEn string `json:"name_en"`
|
||||||
|
SchoolType model.SchoolType `json:"school_type"` // 兼容旧的单选提交
|
||||||
|
SchoolTypes []string `json:"school_types"` // 新的多选提交
|
||||||
|
Address string `json:"address"`
|
||||||
|
Status model.SchoolStatus `json:"status"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
response.BadRequest(c, "参数错误")
|
response.BadRequest(c, "参数错误")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
types := model.NormalizeSchoolTypes(body.SchoolTypes)
|
||||||
|
if len(types) == 0 && body.SchoolType != "" {
|
||||||
|
types = model.NormalizeSchoolTypes([]string{string(body.SchoolType)})
|
||||||
|
}
|
||||||
|
if len(types) == 0 {
|
||||||
|
response.BadRequest(c, "请选择学校类型")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
school := model.School{
|
||||||
|
RegionID: body.RegionID,
|
||||||
|
Name: strings.TrimSpace(body.Name),
|
||||||
|
NameEn: strings.TrimSpace(body.NameEn),
|
||||||
|
SchoolType: types[0],
|
||||||
|
SchoolTypes: model.JoinSchoolTypes(types),
|
||||||
|
Address: strings.TrimSpace(body.Address),
|
||||||
|
Status: body.Status,
|
||||||
|
}
|
||||||
|
if uid := middleware.CurrentUserID(c); uid > 0 {
|
||||||
|
school.CreatedBy = uid
|
||||||
|
}
|
||||||
if err := h.svc.Create(&school); err != nil {
|
if err := h.svc.Create(&school); err != nil {
|
||||||
response.BadRequest(c, err.Error())
|
response.BadRequest(c, err.Error())
|
||||||
return
|
return
|
||||||
@@ -67,7 +107,6 @@ func (h *SchoolHandler) Update(c *gin.Context) {
|
|||||||
response.BadRequest(c, "参数错误")
|
response.BadRequest(c, "参数错误")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// 只允许更新部分字段
|
|
||||||
updates := map[string]interface{}{}
|
updates := map[string]interface{}{}
|
||||||
if v, ok := body["name"]; ok {
|
if v, ok := body["name"]; ok {
|
||||||
updates["name"] = v
|
updates["name"] = v
|
||||||
@@ -75,12 +114,38 @@ func (h *SchoolHandler) Update(c *gin.Context) {
|
|||||||
if v, ok := body["name_en"]; ok {
|
if v, ok := body["name_en"]; ok {
|
||||||
updates["name_en"] = v
|
updates["name_en"] = v
|
||||||
}
|
}
|
||||||
if v, ok := body["school_type"]; ok {
|
// 多学段:优先取数组形式的 school_types
|
||||||
updates["school_type"] = v
|
if v, ok := body["school_types"]; ok {
|
||||||
|
switch raw := v.(type) {
|
||||||
|
case []interface{}:
|
||||||
|
items := make([]string, 0, len(raw))
|
||||||
|
for _, item := range raw {
|
||||||
|
if s, ok := item.(string); ok {
|
||||||
|
items = append(items, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updates["school_types"] = model.JoinSchoolTypes(model.NormalizeSchoolTypes(items))
|
||||||
|
case string:
|
||||||
|
updates["school_types"] = raw
|
||||||
|
}
|
||||||
|
} else if v, ok := body["school_type"]; ok {
|
||||||
|
// 兼容旧的单选提交:同步写入两个字段
|
||||||
|
if s, ok := v.(string); ok {
|
||||||
|
updates["school_types"] = s
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if v, ok := body["address"]; ok {
|
if v, ok := body["address"]; ok {
|
||||||
updates["address"] = v
|
updates["address"] = v
|
||||||
}
|
}
|
||||||
|
if v, ok := body["region_id"]; ok {
|
||||||
|
updates["region_id"] = v
|
||||||
|
}
|
||||||
|
if v, ok := body["status"]; ok {
|
||||||
|
updates["status"] = v
|
||||||
|
}
|
||||||
|
if v, ok := body["reject_reason"]; ok {
|
||||||
|
updates["reject_reason"] = v
|
||||||
|
}
|
||||||
if err := h.svc.Update(uint(id), updates); err != nil {
|
if err := h.svc.Update(uint(id), updates); err != nil {
|
||||||
response.BadRequest(c, err.Error())
|
response.BadRequest(c, err.Error())
|
||||||
return
|
return
|
||||||
@@ -88,6 +153,41 @@ func (h *SchoolHandler) Update(c *gin.Context) {
|
|||||||
response.OK(c, gin.H{"message": "更新成功"})
|
response.OK(c, gin.H{"message": "更新成功"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Approve POST /api/admin/schools/:id/approve
|
||||||
|
func (h *SchoolHandler) Approve(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil || id == 0 {
|
||||||
|
response.BadRequest(c, "无效的学校ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.svc.Approve(uint(id)); err != nil {
|
||||||
|
response.BadRequest(c, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OK(c, gin.H{"message": "审核通过"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reject POST /api/admin/schools/:id/reject
|
||||||
|
func (h *SchoolHandler) Reject(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil || id == 0 {
|
||||||
|
response.BadRequest(c, "无效的学校ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
Reason string `json:"reason" binding:"required"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil || strings.TrimSpace(body.Reason) == "" {
|
||||||
|
response.BadRequest(c, "请输入打回原因")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.svc.Reject(uint(id), strings.TrimSpace(body.Reason)); err != nil {
|
||||||
|
response.BadRequest(c, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OK(c, gin.H{"message": "打回成功"})
|
||||||
|
}
|
||||||
|
|
||||||
// Delete DELETE /api/schools/:id
|
// Delete DELETE /api/schools/:id
|
||||||
func (h *SchoolHandler) Delete(c *gin.Context) {
|
func (h *SchoolHandler) Delete(c *gin.Context) {
|
||||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
|||||||
@@ -38,10 +38,15 @@ func (h *UserHandler) UpdateProfile(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := h.svc.UpdateProfile(userID, &req); err != nil {
|
if err := h.svc.UpdateProfile(userID, &req); err != nil {
|
||||||
response.Internal(c, err.Error())
|
response.BadRequest(c, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
u, err := h.svc.GetUserByID(userID)
|
||||||
|
if err != nil {
|
||||||
response.OK(c, gin.H{"message": "更新成功"})
|
response.OK(c, gin.H{"message": "更新成功"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OK(c, u)
|
||||||
}
|
}
|
||||||
|
|
||||||
// AddContact POST /api/user/contacts
|
// AddContact POST /api/user/contacts
|
||||||
@@ -149,11 +154,21 @@ func (h *UserHandler) HasEducation(c *gin.Context) {
|
|||||||
// PublicProfile GET /api/user/:id 公开信息(班级成员查看)
|
// PublicProfile GET /api/user/:id 公开信息(班级成员查看)
|
||||||
func (h *UserHandler) PublicProfile(c *gin.Context) {
|
func (h *UserHandler) PublicProfile(c *gin.Context) {
|
||||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
currentUserID := middleware.CurrentUserID(c)
|
||||||
user, err := h.svc.GetUserByID(uint(id))
|
user, err := h.svc.GetUserByID(uint(id))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.NotFound(c, err.Error())
|
response.NotFound(c, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// 隐私脱敏保护:非本人且非管理员,对身份证号码脱敏
|
||||||
|
isAdmin := middleware.CurrentRole(c) == "admin"
|
||||||
|
if currentUserID != user.ID && !isAdmin {
|
||||||
|
if len(user.IdCard) == 18 {
|
||||||
|
user.IdCard = user.IdCard[:6] + "********" + user.IdCard[14:]
|
||||||
|
} else if user.IdCard != "" {
|
||||||
|
user.IdCard = "****************"
|
||||||
|
}
|
||||||
|
}
|
||||||
contacts := h.svc.GetUserContacts(uint(id))
|
contacts := h.svc.GetUserContacts(uint(id))
|
||||||
response.OK(c, gin.H{"user": user, "contacts": contacts})
|
response.OK(c, gin.H{"user": user, "contacts": contacts})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ type Class struct {
|
|||||||
GraduateYear int `gorm:"index:idx_class_unique,priority:5" json:"graduate_year"` // 毕业年份
|
GraduateYear int `gorm:"index:idx_class_unique,priority:5" json:"graduate_year"` // 毕业年份
|
||||||
AdminUserID uint `gorm:"index;not null" json:"admin_user_id"`
|
AdminUserID uint `gorm:"index;not null" json:"admin_user_id"`
|
||||||
GraduationPhoto string `gorm:"size:512" json:"graduation_photo"` // 毕业照URL(每个班级唯一一张)
|
GraduationPhoto string `gorm:"size:512" json:"graduation_photo"` // 毕业照URL(每个班级唯一一张)
|
||||||
|
RowCount int `gorm:"default:1" json:"row_count"` // 毕业照排数
|
||||||
StudentNamesJSON string `gorm:"type:text" json:"-"` // 班级所有同学名字 JSON 数组
|
StudentNamesJSON string `gorm:"type:text" json:"-"` // 班级所有同学名字 JSON 数组
|
||||||
TeacherNamesJSON string `gorm:"type:text" json:"-"` // 老师名字 JSON 数组
|
TeacherNamesJSON string `gorm:"type:text" json:"-"` // 老师名字 JSON 数组
|
||||||
Status ClassStatus `gorm:"size:16;default:pending;index" json:"status"`
|
Status ClassStatus `gorm:"size:16;default:pending;index" json:"status"`
|
||||||
@@ -114,3 +115,18 @@ type Invitation struct {
|
|||||||
Status string `gorm:"size:16;default:active" json:"status"` // active / disabled
|
Status string `gorm:"size:16;default:active" json:"status"` // active / disabled
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ClassRoster 班级毕业照排位花名册(预录入人员,每个同学/老师单独一行存储,支持后续注册后绑定具体账户)
|
||||||
|
type ClassRoster struct {
|
||||||
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
|
ClassID uint `gorm:"index:idx_class_roster;not null" json:"class_id"`
|
||||||
|
RowNo int `gorm:"index:idx_class_roster;not null" json:"row_no"` // 排号(从1开始,第1排为前排)
|
||||||
|
ColNo int `gorm:"not null" json:"col_no"` // 排内序号(从1开始,从左往右)
|
||||||
|
Name string `gorm:"size:64;not null;index" json:"name"` // 姓名
|
||||||
|
Role string `gorm:"size:16;default:student;index" json:"role"` // 身份:student(同学) / teacher(老师)
|
||||||
|
UserID uint `gorm:"index;default:0" json:"user_id"` // 绑定的系统用户ID(0表示未注册/未绑定账户)
|
||||||
|
IsClaimed bool `gorm:"default:false;index" json:"is_claimed"` // 是否已被具体账户绑定认领
|
||||||
|
ClaimedAt *time.Time `json:"claimed_at,omitempty"` // 绑定认领时间
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|||||||
+145
-5
@@ -1,17 +1,23 @@
|
|||||||
package model
|
package model
|
||||||
|
|
||||||
import "time"
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
// RegionLevel 地区层级
|
// RegionLevel 地区层级
|
||||||
type RegionLevel string
|
type RegionLevel string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
LevelCountry RegionLevel = "country"
|
LevelCountry RegionLevel = "country"
|
||||||
LevelProvince RegionLevel = "province" // 省/州/直辖市
|
LevelProvince RegionLevel = "province" // 省/州/直辖市/自治区
|
||||||
LevelCounty RegionLevel = "county" // 县/区(最低级)
|
LevelCity RegionLevel = "city" // 市/地区/自治州/盟
|
||||||
|
LevelCounty RegionLevel = "county" // 县/区/旗/县级市
|
||||||
)
|
)
|
||||||
|
|
||||||
// Region 地区表,自关联三级:国家 -> 省/州 -> 县/区
|
// Region 地区表,自关联多级:国家 -> 省/州 -> 市 -> 县/区
|
||||||
type Region struct {
|
type Region struct {
|
||||||
ID uint `gorm:"primaryKey" json:"id"`
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
Name string `gorm:"size:128;not null;index" json:"name"`
|
Name string `gorm:"size:128;not null;index" json:"name"`
|
||||||
@@ -23,6 +29,21 @@ type Region struct {
|
|||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RegionTree 地区树节点
|
||||||
|
type RegionTree struct {
|
||||||
|
Region
|
||||||
|
Children []*RegionTree `json:"children,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SchoolStatus 学校审核状态
|
||||||
|
type SchoolStatus string
|
||||||
|
|
||||||
|
const (
|
||||||
|
SchoolStatusPending SchoolStatus = "pending" // 待审核
|
||||||
|
SchoolStatusActive SchoolStatus = "active" // 正常
|
||||||
|
SchoolStatusRejected SchoolStatus = "rejected" // 已打回
|
||||||
|
)
|
||||||
|
|
||||||
// SchoolType 学校类型
|
// SchoolType 学校类型
|
||||||
type SchoolType string
|
type SchoolType string
|
||||||
|
|
||||||
@@ -36,6 +57,25 @@ const (
|
|||||||
SchoolOther SchoolType = "other"
|
SchoolOther SchoolType = "other"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// SchoolTypeOrder 学校类型的展示与主类型优先级(数值越小优先级越高)
|
||||||
|
// 一所学校可同时涵盖多个学段(如完全中学 = 初中 + 高中),
|
||||||
|
// 主类型 school_type 取其中优先级最高的一个,用于兼容旧逻辑与统计。
|
||||||
|
var SchoolTypeOrder = map[SchoolType]int{
|
||||||
|
SchoolUniversity: 0,
|
||||||
|
SchoolVocational: 1,
|
||||||
|
SchoolSeniorHigh: 2,
|
||||||
|
SchoolJuniorHigh: 3,
|
||||||
|
SchoolPrimary: 4,
|
||||||
|
SchoolKindergarten: 5,
|
||||||
|
SchoolOther: 6,
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsValid 是否为已定义的学校类型
|
||||||
|
func (s SchoolType) IsValid() bool {
|
||||||
|
_, ok := SchoolTypeOrder[s]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
// HasCollege 该类型学校是否有学院层级
|
// HasCollege 该类型学校是否有学院层级
|
||||||
func (s SchoolType) HasCollege() bool {
|
func (s SchoolType) HasCollege() bool {
|
||||||
return s == SchoolUniversity || s == SchoolVocational
|
return s == SchoolUniversity || s == SchoolVocational
|
||||||
@@ -44,13 +84,113 @@ func (s SchoolType) HasCollege() bool {
|
|||||||
// School 学校
|
// School 学校
|
||||||
type School struct {
|
type School struct {
|
||||||
ID uint `gorm:"primaryKey" json:"id"`
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
RegionID uint `gorm:"index;not null" json:"region_id"` // 必须挂在 county 级地区下
|
RegionID uint `gorm:"index;not null" json:"region_id"` // 挂在 county 或 city 级地区下
|
||||||
Name string `gorm:"size:128;not null;index" json:"name"`
|
Name string `gorm:"size:128;not null;index" json:"name"`
|
||||||
NameEn string `gorm:"size:128" json:"name_en"`
|
NameEn string `gorm:"size:128" json:"name_en"`
|
||||||
|
// SchoolType 主类型:SchoolTypes 中优先级最高的一个,保留用于兼容旧数据与统计
|
||||||
SchoolType SchoolType `gorm:"size:24;index;not null" json:"school_type"`
|
SchoolType SchoolType `gorm:"size:24;index;not null" json:"school_type"`
|
||||||
|
// SchoolTypes 全部涵盖的学段,英文逗号分隔且无空格(如 "junior_high,senior_high"),
|
||||||
|
// 便于使用 MySQL FIND_IN_SET 检索
|
||||||
|
SchoolTypes string `gorm:"size:128;index" json:"school_types_raw"`
|
||||||
Address string `gorm:"size:255" json:"address"`
|
Address string `gorm:"size:255" json:"address"`
|
||||||
|
Status SchoolStatus `gorm:"size:16;default:active;index" json:"status"` // pending, active, rejected
|
||||||
|
RejectReason string `gorm:"size:255" json:"reject_reason"`
|
||||||
|
CreatedBy uint `gorm:"default:0;index" json:"created_by"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
|
||||||
|
// 关联字段(非数据库持久化)
|
||||||
|
RegionPath string `gorm:"-" json:"region_path,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TypeList 返回学校涵盖的全部学段;旧数据未写 school_types 时回退到主类型
|
||||||
|
func (s *School) TypeList() []SchoolType {
|
||||||
|
list := ParseSchoolTypes(s.SchoolTypes)
|
||||||
|
if len(list) == 0 && s.SchoolType != "" {
|
||||||
|
return []SchoolType{s.SchoolType}
|
||||||
|
}
|
||||||
|
return list
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasType 学校是否涵盖指定学段
|
||||||
|
func (s *School) HasType(t SchoolType) bool {
|
||||||
|
for _, item := range s.TypeList() {
|
||||||
|
if item == t {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasCollegeType 学校是否存在支持学院层级的学段(大学/职业院校)
|
||||||
|
func (s *School) HasCollegeType() bool {
|
||||||
|
for _, item := range s.TypeList() {
|
||||||
|
if item.HasCollege() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequiresCollege 建班时是否必须选择学院:仅当所有学段都支持学院层级
|
||||||
|
// (例如"大学 + 高中"的学校,走高中学段建班时无需选学院)
|
||||||
|
func (s *School) RequiresCollege() bool {
|
||||||
|
list := s.TypeList()
|
||||||
|
if len(list) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, item := range list {
|
||||||
|
if !item.HasCollege() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarshalJSON 输出时附带 school_types 数组,前端可直接使用
|
||||||
|
func (s School) MarshalJSON() ([]byte, error) {
|
||||||
|
type schoolAlias School
|
||||||
|
list := (&s).TypeList()
|
||||||
|
strs := make([]string, 0, len(list))
|
||||||
|
for _, item := range list {
|
||||||
|
strs = append(strs, string(item))
|
||||||
|
}
|
||||||
|
return json.Marshal(struct {
|
||||||
|
schoolAlias
|
||||||
|
SchoolTypeList []string `json:"school_types"`
|
||||||
|
}{schoolAlias(s), strs})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseSchoolTypes 解析逗号分隔的学校类型,去重并按优先级排序
|
||||||
|
func ParseSchoolTypes(raw string) []SchoolType {
|
||||||
|
return NormalizeSchoolTypes(strings.Split(raw, ","))
|
||||||
|
}
|
||||||
|
|
||||||
|
// NormalizeSchoolTypes 校验、去重并按优先级排序学校类型集合
|
||||||
|
func NormalizeSchoolTypes(items []string) []SchoolType {
|
||||||
|
seen := map[SchoolType]bool{}
|
||||||
|
res := make([]SchoolType, 0, len(items))
|
||||||
|
for _, item := range items {
|
||||||
|
t := SchoolType(strings.TrimSpace(item))
|
||||||
|
if t == "" || !t.IsValid() || seen[t] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[t] = true
|
||||||
|
res = append(res, t)
|
||||||
|
}
|
||||||
|
sort.SliceStable(res, func(i, j int) bool {
|
||||||
|
return SchoolTypeOrder[res[i]] < SchoolTypeOrder[res[j]]
|
||||||
|
})
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
// JoinSchoolTypes 将类型集合拼为可供 FIND_IN_SET 使用的字符串
|
||||||
|
func JoinSchoolTypes(items []SchoolType) string {
|
||||||
|
strs := make([]string, 0, len(items))
|
||||||
|
for _, item := range items {
|
||||||
|
strs = append(strs, string(item))
|
||||||
|
}
|
||||||
|
return strings.Join(strs, ",")
|
||||||
}
|
}
|
||||||
|
|
||||||
// College 学院(仅大学/职业学校有)
|
// College 学院(仅大学/职业学校有)
|
||||||
|
|||||||
@@ -11,6 +11,13 @@ type User struct {
|
|||||||
PasswordHash string `gorm:"size:32;not null" json:"-"` // MD5 32字符
|
PasswordHash string `gorm:"size:32;not null" json:"-"` // MD5 32字符
|
||||||
Salt string `gorm:"size:32;not null" json:"-"` // 密码盐
|
Salt string `gorm:"size:32;not null" json:"-"` // 密码盐
|
||||||
Email string `gorm:"size:128;index" json:"email"` // 用于找回密码
|
Email string `gorm:"size:128;index" json:"email"` // 用于找回密码
|
||||||
|
Phone string `gorm:"size:32" json:"phone"` // 联系电话
|
||||||
|
RealName string `gorm:"size:64" json:"real_name"` // 真实姓名
|
||||||
|
Gender string `gorm:"size:16" json:"gender"` // 性别:男 / 女
|
||||||
|
Nation string `gorm:"size:32" json:"nation"` // 民族
|
||||||
|
PoliticalStatus string `gorm:"size:32" json:"political_status"` // 政治身份/政治面貌
|
||||||
|
IdCard string `gorm:"size:32" json:"id_card"` // 身份证号
|
||||||
|
Address string `gorm:"size:255" json:"address"` // 家庭住址
|
||||||
Nickname string `gorm:"size:64" json:"nickname"`
|
Nickname string `gorm:"size:64" json:"nickname"`
|
||||||
Avatar string `gorm:"size:512" json:"avatar"`
|
Avatar string `gorm:"size:512" json:"avatar"`
|
||||||
Bio string `gorm:"size:512" json:"bio"`
|
Bio string `gorm:"size:512" json:"bio"`
|
||||||
|
|||||||
@@ -133,6 +133,25 @@ func Register(r *gin.Engine, jm *jwt.Manager, deps *Deps) {
|
|||||||
admin.DELETE("/classes/:id", deps.Admin.Delete)
|
admin.DELETE("/classes/:id", deps.Admin.Delete)
|
||||||
admin.GET("/classes/:id/logs", deps.Admin.AuditLogs)
|
admin.GET("/classes/:id/logs", deps.Admin.AuditLogs)
|
||||||
admin.POST("/cleanup", deps.Admin.TriggerCleanup)
|
admin.POST("/cleanup", deps.Admin.TriggerCleanup)
|
||||||
|
|
||||||
|
// 基础数据 - 行政区划管理
|
||||||
|
admin.GET("/regions/tree", deps.Region.Tree)
|
||||||
|
admin.POST("/regions", deps.Region.Create)
|
||||||
|
admin.PUT("/regions/:id", deps.Region.Update)
|
||||||
|
admin.DELETE("/regions/:id", deps.Region.Delete)
|
||||||
|
admin.POST("/regions/init-china", deps.Region.InitChina)
|
||||||
|
|
||||||
|
// 基础数据 - 学校管理与审核
|
||||||
|
admin.GET("/schools/stats", deps.School.Stats)
|
||||||
|
admin.GET("/schools", deps.School.List)
|
||||||
|
admin.POST("/schools", deps.School.Create)
|
||||||
|
admin.PUT("/schools/:id", deps.School.Update)
|
||||||
|
admin.DELETE("/schools/:id", deps.School.Delete)
|
||||||
|
admin.POST("/schools/:id/approve", deps.School.Approve)
|
||||||
|
admin.POST("/schools/:id/reject", deps.School.Reject)
|
||||||
|
admin.POST("/schools/:id/colleges", deps.School.CreateCollege)
|
||||||
|
admin.PUT("/colleges/:id", deps.School.UpdateCollege)
|
||||||
|
admin.DELETE("/colleges/:id", deps.School.DeleteCollege)
|
||||||
// 系统配置管理
|
// 系统配置管理
|
||||||
admin.GET("/config", deps.Config.GetAll)
|
admin.GET("/config", deps.Config.GetAll)
|
||||||
admin.PUT("/config", deps.Config.Update)
|
admin.PUT("/config", deps.Config.Update)
|
||||||
@@ -141,7 +160,8 @@ func Register(r *gin.Engine, jm *jwt.Manager, deps *Deps) {
|
|||||||
admin.POST("/menus", deps.Menu.Create)
|
admin.POST("/menus", deps.Menu.Create)
|
||||||
admin.PUT("/menus/:id", deps.Menu.Update)
|
admin.PUT("/menus/:id", deps.Menu.Update)
|
||||||
admin.DELETE("/menus/:id", deps.Menu.Delete)
|
admin.DELETE("/menus/:id", deps.Menu.Delete)
|
||||||
// 账号封禁管理
|
// 用户管理与账号封禁
|
||||||
|
admin.GET("/users", deps.Admin.ListUsers)
|
||||||
admin.GET("/users/banned", deps.Admin.BannedUsers)
|
admin.GET("/users/banned", deps.Admin.BannedUsers)
|
||||||
admin.POST("/users/:id/ban", deps.Admin.BanUser)
|
admin.POST("/users/:id/ban", deps.Admin.BanUser)
|
||||||
admin.POST("/users/:id/unban", deps.Admin.UnbanUser)
|
admin.POST("/users/:id/unban", deps.Admin.UnbanUser)
|
||||||
|
|||||||
@@ -226,20 +226,28 @@ func (s *AdminService) DetailedStats() (*DetailedStats, error) {
|
|||||||
{Name: "被打回", Count: core.RejectedClasses},
|
{Name: "被打回", Count: core.RejectedClasses},
|
||||||
}
|
}
|
||||||
|
|
||||||
// 学校类型分布
|
// 学校类型分布(一所学校可涵盖多个学段,按学段分别计数)
|
||||||
type schoolTypeRow struct {
|
typeMap := map[model.SchoolType]string{
|
||||||
SchoolType string `gorm:"column:school_type"`
|
model.SchoolUniversity: "大学",
|
||||||
Cnt int64 `gorm:"column:cnt"`
|
model.SchoolVocational: "职业学校",
|
||||||
|
model.SchoolSeniorHigh: "高中",
|
||||||
|
model.SchoolJuniorHigh: "初中",
|
||||||
|
model.SchoolPrimary: "小学",
|
||||||
|
model.SchoolKindergarten: "幼儿园",
|
||||||
|
model.SchoolOther: "其他",
|
||||||
}
|
}
|
||||||
var stRows []schoolTypeRow
|
orderedTypes := []model.SchoolType{
|
||||||
s.db.Model(&model.School{}).Select("school_type, COUNT(*) as cnt").Group("school_type").Scan(&stRows)
|
model.SchoolUniversity, model.SchoolVocational, model.SchoolSeniorHigh,
|
||||||
typeMap := map[string]string{"university": "大学", "vocational": "职业学校", "senior_high": "高中", "junior_high": "初中", "primary": "小学", "kindergarten": "幼儿园", "other": "其他"}
|
model.SchoolJuniorHigh, model.SchoolPrimary, model.SchoolKindergarten, model.SchoolOther,
|
||||||
for _, r := range stRows {
|
}
|
||||||
name := typeMap[r.SchoolType]
|
for _, t := range orderedTypes {
|
||||||
if name == "" {
|
var cnt int64
|
||||||
name = r.SchoolType
|
s.db.Model(&model.School{}).
|
||||||
|
Where("FIND_IN_SET(?, school_types) > 0 OR ((school_types IS NULL OR school_types = '') AND school_type = ?)", t, t).
|
||||||
|
Count(&cnt)
|
||||||
|
if cnt > 0 {
|
||||||
|
ds.SchoolTypes = append(ds.SchoolTypes, NameCountItem{Name: typeMap[t], Count: cnt})
|
||||||
}
|
}
|
||||||
ds.SchoolTypes = append(ds.SchoolTypes, NameCountItem{Name: name, Count: r.Cnt})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 地区TOP10(按省级行政区统计班级数)
|
// 地区TOP10(按省级行政区统计班级数)
|
||||||
@@ -347,6 +355,21 @@ func (s *AdminService) UnbanUser(userID uint) error {
|
|||||||
return s.db.Save(&user).Error
|
return s.db.Save(&user).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListUsers 获取用户列表(支持状态与关键词检索)
|
||||||
|
func (s *AdminService) ListUsers(status, keyword string) ([]model.User, error) {
|
||||||
|
var list []model.User
|
||||||
|
query := s.db.Model(&model.User{})
|
||||||
|
if status != "" && status != "all" {
|
||||||
|
query = query.Where("status = ?", status)
|
||||||
|
}
|
||||||
|
if keyword != "" {
|
||||||
|
k := "%" + keyword + "%"
|
||||||
|
query = query.Where("username LIKE ? OR real_name LIKE ? OR nickname LIKE ? OR email LIKE ? OR phone LIKE ?", k, k, k, k, k)
|
||||||
|
}
|
||||||
|
err := query.Order("id desc").Find(&list).Error
|
||||||
|
return list, err
|
||||||
|
}
|
||||||
|
|
||||||
// ListBannedUsers 获取封禁用户列表
|
// ListBannedUsers 获取封禁用户列表
|
||||||
func (s *AdminService) ListBannedUsers() ([]model.User, error) {
|
func (s *AdminService) ListBannedUsers() ([]model.User, error) {
|
||||||
var list []model.User
|
var list []model.User
|
||||||
|
|||||||
@@ -0,0 +1,576 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
type ChinaCityData struct {
|
||||||
|
Name string
|
||||||
|
Code string
|
||||||
|
Counties []string
|
||||||
|
}
|
||||||
|
|
||||||
|
type ChinaProvinceData struct {
|
||||||
|
Name string
|
||||||
|
Code string
|
||||||
|
Cities []ChinaCityData
|
||||||
|
}
|
||||||
|
|
||||||
|
var chinaProvincesData = []ChinaProvinceData{
|
||||||
|
// 4 直辖市
|
||||||
|
{
|
||||||
|
Name: "北京市", Code: "110000",
|
||||||
|
Cities: []ChinaCityData{
|
||||||
|
{
|
||||||
|
Name: "北京市", Code: "110100",
|
||||||
|
Counties: []string{"东城区", "西城区", "朝阳区", "丰台区", "石景山区", "海淀区", "门头沟区", "房山区", "通州区", "顺义区", "昌平区", "大兴区", "怀柔区", "平谷区", "密云区", "延庆区"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "天津市", Code: "120000",
|
||||||
|
Cities: []ChinaCityData{
|
||||||
|
{
|
||||||
|
Name: "天津市", Code: "120100",
|
||||||
|
Counties: []string{"和平区", "河东区", "河西区", "南开区", "河北区", "红桥区", "东丽区", "西青区", "津南区", "北辰区", "武清区", "宝坻区", "滨海新区", "宁河区", "静海区", "蓟州区"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "上海市", Code: "310000",
|
||||||
|
Cities: []ChinaCityData{
|
||||||
|
{
|
||||||
|
Name: "上海市", Code: "310100",
|
||||||
|
Counties: []string{"黄浦区", "徐汇区", "长宁区", "静安区", "普陀区", "虹口区", "杨浦区", "闵行区", "宝山区", "嘉定区", "浦东新区", "金山区", "松江区", "青浦区", "奉贤区", "崇明区"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "重庆市", Code: "500000",
|
||||||
|
Cities: []ChinaCityData{
|
||||||
|
{
|
||||||
|
Name: "重庆市", Code: "500100",
|
||||||
|
Counties: []string{
|
||||||
|
"渝中区", "大渡口区", "江北区", "沙坪坝区", "九龙坡区", "南岸区", "北碚区", "渝北区", "巴南区",
|
||||||
|
"万州区", "涪陵区", "永川区", "合川区", "江津区", "璧山区", "铜梁区", "潼南区", "荣昌区",
|
||||||
|
"开州区", "梁平区", "武隆区", "城口县", "丰都县", "垫江县", "忠县", "云阳县", "奉节县", "巫山县", "巫溪县",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// 华北
|
||||||
|
{
|
||||||
|
Name: "河北省", Code: "130000",
|
||||||
|
Cities: []ChinaCityData{
|
||||||
|
{Name: "石家庄市", Code: "130100", Counties: []string{"长安区", "桥西区", "新华区", "井陉矿区", "裕华区", "藁城区", "鹿泉区", "栾城区", "正定县", "辛集市", "晋州市", "新乐市"}},
|
||||||
|
{Name: "唐山市", Code: "130200", Counties: []string{"路南区", "路北区", "古冶区", "开平区", "丰南区", "丰润区", "曹妃甸区", "滦州市", "迁安市", "遵化市"}},
|
||||||
|
{Name: "秦皇岛市", Code: "130300", Counties: []string{"海港区", "山海关区", "北戴河区", "抚宁区", "青龙县", "昌黎县", "卢龙县"}},
|
||||||
|
{Name: "邯郸市", Code: "130400", Counties: []string{"邯山区", "丛台区", "复兴区", "峰峰矿区", "肥乡区", "永年区", "武安市"}},
|
||||||
|
{Name: "邢台市", Code: "130500", Counties: []string{"襄都区", "信都区", "任泽区", "南和区", "南宫市", "沙河市"}},
|
||||||
|
{Name: "保定市", Code: "130600", Counties: []string{"竞秀区", "莲池区", "满城区", "清苑区", "徐水区", "涿州市", "定州市", "安国市", "高碑店市"}},
|
||||||
|
{Name: "张家口市", Code: "130700", Counties: []string{"桥东区", "桥西区", "宣化区", "下花园区", "万全区", "崇礼区"}},
|
||||||
|
{Name: "承德市", Code: "130800", Counties: []string{"双桥区", "双滦区", "鹰手营子矿区", "承德县", "兴隆县", "滦平县", "隆化县", "丰宁县", "宽城县", "围场县", "平泉市"}},
|
||||||
|
{Name: "沧州市", Code: "130900", Counties: []string{"新华区", "运河区", "沧县", "青县", "东光县", "海兴县", "盐山县", "肃宁县", "南皮县", "吴桥县", "献县", "孟村县", "泊头市", "任丘市", "黄骅市", "河间市"}},
|
||||||
|
{Name: "廊坊市", Code: "131000", Counties: []string{"安次区", "广阳区", "固安县", "永清县", "香河县", "大城县", "文安县", "大厂县", "霸州市", "三河市"}},
|
||||||
|
{Name: "衡水市", Code: "131100", Counties: []string{"桃城区", "冀州区", "枣强县", "武邑县", "武强县", "饶阳县", "安平县", "故城县", "景县", "阜城县", "深州市"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "山西省", Code: "140000",
|
||||||
|
Cities: []ChinaCityData{
|
||||||
|
{Name: "太原市", Code: "140100", Counties: []string{"小店区", "迎泽区", "杏花岭区", "尖草坪区", "万柏林区", "晋源区", "清徐县", "阳曲县", "娄烦县", "古交市"}},
|
||||||
|
{Name: "大同市", Code: "140200", Counties: []string{"新荣区", "平城区", "云冈区", "云州区", "阳高县", "天镇县", "广灵县", "灵丘县", "浑源县", "左云县"}},
|
||||||
|
{Name: "阳泉市", Code: "140300", Counties: []string{"城区", "矿区", "郊区", "平定县", "盂县"}},
|
||||||
|
{Name: "长治市", Code: "140400", Counties: []string{"潞州区", "上党区", "屯留区", "潞城区", "襄垣县", "平顺县", "黎城县", "壶关县", "长子县", "武乡县", "沁县", "沁源县"}},
|
||||||
|
{Name: "晋城市", Code: "140500", Counties: []string{"城区", "沁水县", "阳城县", "陵川县", "泽州县", "高平市"}},
|
||||||
|
{Name: "朔州市", Code: "140600", Counties: []string{"朔城区", "平鲁区", "山阴县", "应县", "右玉县", "怀仁市"}},
|
||||||
|
{Name: "晋中市", Code: "140700", Counties: []string{"榆次区", "太谷区", "榆社县", "左权县", "和顺县", "昔阳县", "寿阳县", "祁县", "平遥县", "灵石县", "介休市"}},
|
||||||
|
{Name: "运城市", Code: "140800", Counties: []string{"盐湖区", "临猗县", "万荣县", "闻喜县", "稷山县", "新绛县", "绛县", "垣曲县", "夏县", "平陆县", "芮城县", "永济市", "河津市"}},
|
||||||
|
{Name: "忻州市", Code: "140900", Counties: []string{"忻府区", "定襄县", "五台县", "代县", "繁峙县", "宁武县", "静乐县", "神池县", "五寨县", "岢岚县", "河曲县", "保德县", "偏关县", "原平市"}},
|
||||||
|
{Name: "临汾市", Code: "141000", Counties: []string{"尧都区", "曲沃县", "翼城县", "襄汾县", "洪洞县", "古县", "安泽县", "浮山县", "吉县", "乡宁县", "大宁县", "隰县", "永和县", "蒲县", "汾西县", "侯马市", "霍州市"}},
|
||||||
|
{Name: "吕梁市", Code: "141100", Counties: []string{"离石区", "文水县", "交城县", "兴县", "临县", "柳林县", "石楼县", "岚县", "方山县", "中阳县", "交口县", "孝义市", "汾阳市"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "内蒙古自治区", Code: "150000",
|
||||||
|
Cities: []ChinaCityData{
|
||||||
|
{Name: "呼和浩特市", Code: "150100", Counties: []string{"新城区", "回民区", "玉泉区", "赛罕区", "土默特左旗", "托克托县", "和林格尔县", "清水河县", "武川县"}},
|
||||||
|
{Name: "包头市", Code: "150200", Counties: []string{"东河区", "昆都仑区", "青山区", "石拐区", "白云鄂博矿区", "九原区", "土默特右旗", "固阳县", "达尔罕茂明安联合旗"}},
|
||||||
|
{Name: "乌海市", Code: "150300", Counties: []string{"海勃湾区", "海南区", "乌达区"}},
|
||||||
|
{Name: "赤峰市", Code: "150400", Counties: []string{"红山区", "元宝山区", "松山区", "阿鲁科尔沁旗", "巴林左旗", "巴林右旗", "林西县", "克什克腾旗", "翁牛特旗", "喀喇沁旗", "宁城县", "敖汉旗"}},
|
||||||
|
{Name: "通辽市", Code: "150500", Counties: []string{"科尔沁区", "科尔沁左翼中旗", "科尔沁左翼后旗", "开鲁县", "库伦旗", "奈曼旗", "扎鲁特旗", "霍林郭勒市"}},
|
||||||
|
{Name: "鄂尔多斯市", Code: "150600", Counties: []string{"东胜区", "康巴什区", "达拉特旗", "准格尔旗", "鄂托克前旗", "鄂托克旗", "杭锦旗", "乌审旗", "伊金霍洛旗"}},
|
||||||
|
{Name: "呼伦贝尔市", Code: "150700", Counties: []string{"海拉尔区", "扎赉诺尔区", "阿荣旗", "莫力达瓦达斡尔族自治旗", "鄂伦春自治旗", "鄂温克族自治旗", "陈巴尔虎旗", "新巴尔虎左旗", "新巴尔虎右旗", "满洲里市", "牙克石市", "扎兰屯市", "额尔古纳市", "根河市"}},
|
||||||
|
{Name: "巴彦淖尔市", Code: "150800", Counties: []string{"临河区", "五原县", "磴口县", "乌拉特前旗", "乌拉特中旗", "乌拉特后旗", "杭锦后旗"}},
|
||||||
|
{Name: "乌兰察布市", Code: "150900", Counties: []string{"集宁区", "卓资县", "化德县", "商都县", "兴和县", "凉城县", "察哈尔右翼前旗", "察哈尔右翼中旗", "察哈尔右翼后旗", "四子王旗", "丰镇市"}},
|
||||||
|
{Name: "兴安盟", Code: "152200", Counties: []string{"乌兰浩特市", "阿尔山市", "科尔沁右翼前旗", "科尔沁右翼中旗", "扎赉特旗", "突泉县"}},
|
||||||
|
{Name: "锡林郭勒盟", Code: "152500", Counties: []string{"二连浩特市", "锡林浩特市", "阿巴嘎旗", "苏尼特左旗", "苏尼特右旗", "东乌珠穆沁旗", "西乌珠穆沁旗", "太仆寺旗", "镶黄旗", "正镶白旗", "正蓝旗", "多伦县"}},
|
||||||
|
{Name: "阿拉善盟", Code: "152900", Counties: []string{"阿拉善左旗", "阿拉善右旗", "额济纳旗"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// 东北
|
||||||
|
{
|
||||||
|
Name: "辽宁省", Code: "210000",
|
||||||
|
Cities: []ChinaCityData{
|
||||||
|
{Name: "沈阳市", Code: "210100", Counties: []string{"和平区", "沈河区", "大东区", "皇姑区", "铁西区", "苏家屯区", "浑南区", "沈北新区", "于洪区", "辽中区", "康平县", "法库县", "新民市"}},
|
||||||
|
{Name: "大连市", Code: "210200", Counties: []string{"中山区", "西岗区", "沙河口区", "甘井子区", "旅顺口区", "金州区", "普兰店区", "长海县", "瓦房店市", "庄河市"}},
|
||||||
|
{Name: "鞍山市", Code: "210300", Counties: []string{"铁东区", "铁西区", "立山区", "千山区", "台安县", "岫岩县", "海城市"}},
|
||||||
|
{Name: "抚顺市", Code: "210400", Counties: []string{"新抚区", "东洲区", "望花区", "顺城区", "抚顺县", "新宾县", "清原县"}},
|
||||||
|
{Name: "本溪市", Code: "210500", Counties: []string{"平山区", "溪湖区", "明山区", "南芬区", "本溪县", "桓仁县"}},
|
||||||
|
{Name: "丹东市", Code: "210600", Counties: []string{"元宝区", "振兴区", "振安区", "宽甸县", "东港市", "凤城市"}},
|
||||||
|
{Name: "锦州市", Code: "210700", Counties: []string{"古塔区", "凌河区", "太和区", "黑山县", "义县", "凌海市", "北镇市"}},
|
||||||
|
{Name: "营口市", Code: "210800", Counties: []string{"站前区", "西市区", "鲅鱼圈区", "老边区", "盖州市", "大石桥市"}},
|
||||||
|
{Name: "阜新市", Code: "210900", Counties: []string{"海州区", "太平区", "新邱区", "细河区", "清河门区", "阜新县", "彰武县"}},
|
||||||
|
{Name: "辽阳市", Code: "211000", Counties: []string{"白塔区", "文圣区", "宏伟区", "弓长岭区", "太子河区", "辽阳县", "灯塔市"}},
|
||||||
|
{Name: "盘锦市", Code: "211100", Counties: []string{"双台子区", "兴隆台区", "大洼区", "盘山县"}},
|
||||||
|
{Name: "铁岭市", Code: "211200", Counties: []string{"银州区", "清河区", "铁岭县", "西丰县", "昌图县", "调兵山市", "开原市"}},
|
||||||
|
{Name: "朝阳市", Code: "211300", Counties: []string{"双塔区", "龙城区", "朝阳县", "建平县", "喀喇沁左翼县", "北票市", "凌源市"}},
|
||||||
|
{Name: "葫芦岛市", Code: "211400", Counties: []string{"连山区", "龙港区", "南票区", "绥中县", "建昌县", "兴城市"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "吉林省", Code: "220000",
|
||||||
|
Cities: []ChinaCityData{
|
||||||
|
{Name: "长春市", Code: "220100", Counties: []string{"南关区", "宽城区", "朝阳区", "二道区", "绿园区", "双阳区", "九台区", "农安县", "榆树市", "德惠市", "公主岭市"}},
|
||||||
|
{Name: "吉林市", Code: "220200", Counties: []string{"昌邑区", "龙潭区", "船营区", "丰满区", "永吉县", "蛟河市", "桦甸市", "舒兰市", "磐石市"}},
|
||||||
|
{Name: "四平市", Code: "220300", Counties: []string{"铁西区", "铁东区", "梨树县", "伊通县", "双辽市"}},
|
||||||
|
{Name: "辽源市", Code: "220400", Counties: []string{"龙山区", "西安区", "东丰县", "东辽县"}},
|
||||||
|
{Name: "通化市", Code: "220500", Counties: []string{"东昌区", "二道江区", "通化县", "辉南县", "柳河县", "梅河口市", "集安市"}},
|
||||||
|
{Name: "白山市", Code: "220600", Counties: []string{"浑江区", "江源区", "抚松县", "靖宇县", "长白县", "临江市"}},
|
||||||
|
{Name: "松原市", Code: "220700", Counties: []string{"宁江区", "前郭尔罗斯县", "长岭县", "乾安县", "扶余市"}},
|
||||||
|
{Name: "白城市", Code: "220800", Counties: []string{"洮北区", "镇赉县", "通榆县", "洮南市", "大安市"}},
|
||||||
|
{Name: "延边朝鲜族自治州", Code: "222400", Counties: []string{"延吉市", "图们市", "敦化市", "珲春市", "龙井市", "和龙市", "汪清县", "安图县"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "黑龙江省", Code: "230000",
|
||||||
|
Cities: []ChinaCityData{
|
||||||
|
{Name: "哈尔滨市", Code: "230100", Counties: []string{"道里区", "南岗区", "道外区", "平房区", "松北区", "香坊区", "呼兰区", "阿城区", "双城区", "依兰县", "方正县", "宾县", "巴彦县", "木兰县", "通河县", "延寿县", "尚志市", "五常市"}},
|
||||||
|
{Name: "齐齐哈尔市", Code: "230200", Counties: []string{"龙沙区", "建华区", "铁锋区", "昂昂溪区", "富拉尔基区", "碾子山区", "梅里斯达斡尔族区", "龙江县", "依安县", "泰来县", "甘南县", "富裕县", "克山县", "克东县", "拜泉县", "讷河市"}},
|
||||||
|
{Name: "鸡西市", Code: "230300", Counties: []string{"鸡冠区", "恒山区", "滴道区", "梨树区", "城子河区", "麻山区", "鸡东县", "虎林市", "密山市"}},
|
||||||
|
{Name: "鹤岗市", Code: "230400", Counties: []string{"向阳区", "工农区", "南山区", "兴安区", "东山区", "兴山区", "萝北县", "绥滨县"}},
|
||||||
|
{Name: "双鸭山市", Code: "230500", Counties: []string{"尖山区", "岭东区", "四方台区", "宝山区", "集贤县", "友谊县", "宝清县", "饶河县"}},
|
||||||
|
{Name: "大庆市", Code: "230600", Counties: []string{"萨尔图区", "龙凤区", "让胡路区", "红岗区", "大同区", "肇州县", "肇源县", "林甸县", "杜尔伯特县"}},
|
||||||
|
{Name: "伊春市", Code: "230700", Counties: []string{"伊美区", "乌翠区", "友好区", "金林区", "嘉荫县", "汤旺县", "丰林县", "大箐山县", "南岔县", "铁力市"}},
|
||||||
|
{Name: "佳木斯市", Code: "230800", Counties: []string{"向阳区", "前进区", "东风区", "郊区", "桦南县", "桦川县", "汤原县", "同江市", "富锦市", "抚远市"}},
|
||||||
|
{Name: "七台河市", Code: "230900", Counties: []string{"新兴区", "桃山区", "茄子河区", "勃利县"}},
|
||||||
|
{Name: "牡丹江市", Code: "231000", Counties: []string{"东安区", "阳明区", "爱民区", "西安区", "林口县", "绥芬河市", "海林市", "宁安市", "穆棱市", "东宁市"}},
|
||||||
|
{Name: "黑河市", Code: "231100", Counties: []string{"爱辉区", "逊克县", "孙吴县", "北安市", "五大连池市", "嫩江市"}},
|
||||||
|
{Name: "绥化市", Code: "231200", Counties: []string{"北林区", "望奎县", "兰西县", "青冈县", "庆安县", "明水县", "绥棱县", "安达市", "肇东市", "海伦市"}},
|
||||||
|
{Name: "大兴安岭地区", Code: "232700", Counties: []string{"漠河市", "呼玛县", "塔河县"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// 华东
|
||||||
|
{
|
||||||
|
Name: "江苏省", Code: "320000",
|
||||||
|
Cities: []ChinaCityData{
|
||||||
|
{Name: "南京市", Code: "320100", Counties: []string{"玄武区", "秦淮区", "建邺区", "鼓楼区", "浦口区", "栖霞区", "雨花台区", "江宁区", "六合区", "溧水区", "高淳区"}},
|
||||||
|
{Name: "无锡市", Code: "320200", Counties: []string{"锡山区", "惠山区", "滨湖区", "梁溪区", "新吴区", "江阴市", "宜兴市"}},
|
||||||
|
{Name: "徐州市", Code: "320300", Counties: []string{"鼓楼区", "云龙区", "贾汪区", "泉山区", "铜山区", "丰县", "沛县", "睢宁县", "新沂市", "邳州市"}},
|
||||||
|
{Name: "常州市", Code: "320400", Counties: []string{"天宁区", "钟楼区", "新北区", "武进区", "金坛区", "溧阳市"}},
|
||||||
|
{Name: "苏州市", Code: "320500", Counties: []string{"姑苏区", "虎丘区", "吴中区", "相城区", "吴江区", "常熟市", "张家港市", "昆山市", "太仓市"}},
|
||||||
|
{Name: "南通市", Code: "320600", Counties: []string{"崇川区", "通州区", "海门区", "如东县", "启东市", "如皋市", "海安市"}},
|
||||||
|
{Name: "连云港市", Code: "320700", Counties: []string{"连云区", "海州区", "赣榆区", "东海县", "灌云县", "灌南县"}},
|
||||||
|
{Name: "淮安市", Code: "320800", Counties: []string{"淮安区", "淮阴区", "清江浦区", "洪泽区", "涟水县", "盱眙县", "金湖县"}},
|
||||||
|
{Name: "盐城市", Code: "320900", Counties: []string{"亭湖区", "盐都区", "大丰区", "响水县", "滨海县", "阜宁县", "射阳县", "建湖县", "东台市"}},
|
||||||
|
{Name: "扬州市", Code: "321000", Counties: []string{"广陵区", "邗江区", "江都区", "宝应县", "仪征市", "高邮市"}},
|
||||||
|
{Name: "镇江市", Code: "321100", Counties: []string{"京口区", "润州区", "丹徒区", "丹阳市", "扬中市", "句容市"}},
|
||||||
|
{Name: "泰州市", Code: "321200", Counties: []string{"海陵区", "高港区", "姜堰区", "兴化市", "靖江市", "泰兴市"}},
|
||||||
|
{Name: "宿迁市", Code: "321300", Counties: []string{"宿城区", "宿豫区", "沭阳县", "泗阳县", "泗洪县"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "浙江省", Code: "330000",
|
||||||
|
Cities: []ChinaCityData{
|
||||||
|
{Name: "杭州市", Code: "330100", Counties: []string{"上城区", "拱墅区", "西湖区", "滨江区", "萧山区", "余杭区", "富阳区", "临安区", "临平区", "钱塘区", "桐庐县", "淳安县", "建德市"}},
|
||||||
|
{Name: "宁波市", Code: "330200", Counties: []string{"海曙区", "江北区", "北仑区", "镇海区", "鄞州区", "奉化区", "象山县", "宁海县", "余姚市", "慈溪市"}},
|
||||||
|
{Name: "温州市", Code: "330300", Counties: []string{"鹿城区", "龙湾区", "瓯海区", "洞头区", "永嘉县", "平阳县", "苍南县", "文成县", "泰顺县", "瑞安市", "乐清市", "龙港市"}},
|
||||||
|
{Name: "嘉兴市", Code: "330400", Counties: []string{"南湖区", "秀洲区", "嘉善县", "海盐县", "海宁市", "平湖市", "桐乡市"}},
|
||||||
|
{Name: "湖州市", Code: "330500", Counties: []string{"吴兴区", "南浔区", "德清县", "长兴县", "安吉县"}},
|
||||||
|
{Name: "绍兴市", Code: "330600", Counties: []string{"越城区", "柯桥区", "上虞区", "新昌县", "诸暨市", "嵊州市"}},
|
||||||
|
{Name: "金华市", Code: "330700", Counties: []string{"婺城区", "金东区", "武义县", "浦江县", "磐安县", "兰溪市", "义乌市", "东阳市", "永康市"}},
|
||||||
|
{Name: "衢州市", Code: "330800", Counties: []string{"柯城区", "衢江区", "常山县", "开化县", "龙游县", "江山市"}},
|
||||||
|
{Name: "舟山市", Code: "330900", Counties: []string{"定海区", "普陀区", "岱山县", "嵊泗县"}},
|
||||||
|
{Name: "台州市", Code: "331000", Counties: []string{"椒江区", "黄岩区", "路桥区", "三门县", "天台县", "仙居县", "温岭市", "临海市", "玉环市"}},
|
||||||
|
{Name: "丽水市", Code: "331100", Counties: []string{"莲都区", "青田县", "缙云县", "遂昌县", "松阳县", "云和县", "庆元县", "景宁县", "龙泉市"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "安徽省", Code: "340000",
|
||||||
|
Cities: []ChinaCityData{
|
||||||
|
{Name: "合肥市", Code: "340100", Counties: []string{"瑶海区", "庐阳区", "蜀山区", "包河区", "长丰县", "肥东县", "肥西县", "庐江县", "巢湖市"}},
|
||||||
|
{Name: "芜湖市", Code: "340200", Counties: []string{"镜湖区", "鸠江区", "弋江区", "湾沚区", "繁昌区", "南陵县", "无为市"}},
|
||||||
|
{Name: "蚌埠市", Code: "340300", Counties: []string{"龙子湖区", "蚌山区", "禹会区", "淮上区", "怀远县", "五河县", "固镇县"}},
|
||||||
|
{Name: "淮南市", Code: "340400", Counties: []string{"大通区", "田家庵区", "谢家集区", "八公山区", "潘集区", "凤台县", "寿县"}},
|
||||||
|
{Name: "马鞍山市", Code: "340500", Counties: []string{"花山区", "雨山区", "博望区", "当涂县", "含山县", "和县"}},
|
||||||
|
{Name: "淮北市", Code: "340600", Counties: []string{"杜集区", "相山区", "烈山区", "濉溪县"}},
|
||||||
|
{Name: "铜陵市", Code: "340700", Counties: []string{"铜官区", "义安区", "郊区", "枞阳县"}},
|
||||||
|
{Name: "安庆市", Code: "340800", Counties: []string{"迎江区", "大观区", "宜秀区", "怀宁县", "太湖县", "宿松县", "望江县", "岳西县", "潜山市", "桐城市"}},
|
||||||
|
{Name: "黄山市", Code: "340900", Counties: []string{"屯溪区", "黄山区", "徽州区", "歙县", "休宁县", "黟县", "祁门县"}},
|
||||||
|
{Name: "滁州市", Code: "341000", Counties: []string{"琅琊区", "南谯区", "来安县", "全椒县", "定远县", "凤阳县", "天长市", "明光市"}},
|
||||||
|
{Name: "阜阳市", Code: "341200", Counties: []string{"颍州区", "颍东区", "颍泉区", "临泉县", "太和县", "阜南县", "颍上县", "界首市"}},
|
||||||
|
{Name: "宿州市", Code: "341300", Counties: []string{"埇桥区", "砀山县", "萧县", "灵璧县", "泗县"}},
|
||||||
|
{Name: "六安市", Code: "341500", Counties: []string{"金安区", "裕安区", "叶集区", "霍邱县", "舒城县", "金寨县", "霍山县"}},
|
||||||
|
{Name: "亳州市", Code: "341600", Counties: []string{"谯城区", "涡阳县", "蒙城县", "利辛县"}},
|
||||||
|
{Name: "池州市", Code: "341700", Counties: []string{"贵池区", "东至县", "石台县", "青阳县"}},
|
||||||
|
{Name: "宣城市", Code: "341800", Counties: []string{"宣州区", "郎溪县", "广德市", "泾县", "绩溪县", "旌德县", "宁国市"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "福建省", Code: "350000",
|
||||||
|
Cities: []ChinaCityData{
|
||||||
|
{Name: "福州市", Code: "350100", Counties: []string{"鼓楼区", "台江区", "仓山区", "马尾区", "晋安区", "长乐区", "闽侯县", "连江县", "罗源县", "闽清县", "永泰县", "平潭县", "福清市"}},
|
||||||
|
{Name: "厦门市", Code: "350200", Counties: []string{"思明区", "海沧区", "湖里区", "集美区", "同安区", "翔安区"}},
|
||||||
|
{Name: "莆田市", Code: "350300", Counties: []string{"城厢区", "涵江区", "荔城区", "秀屿区", "仙游县"}},
|
||||||
|
{Name: "三明市", Code: "350400", Counties: []string{"三元区", "沙县区", "明溪县", "清流县", "宁化县", "大田县", "尤溪县", "将乐县", "泰宁县", "建宁县", "永安市"}},
|
||||||
|
{Name: "泉州市", Code: "350500", Counties: []string{"鲤城区", "丰泽区", "洛江区", "泉港区", "惠安县", "安溪县", "永春县", "德化县", "石狮市", "晋江市", "南安市"}},
|
||||||
|
{Name: "漳州市", Code: "350600", Counties: []string{"芗城区", "龙文区", "龙海区", "长泰区", "云霄县", "漳浦县", "诏安县", "东山县", "南靖县", "平和县", "华安县"}},
|
||||||
|
{Name: "南平市", Code: "350700", Counties: []string{"延平区", "建阳区", "顺昌县", "浦城县", "光泽县", "松溪县", "政和县", "邵武市", "武夷山市", "建瓯市"}},
|
||||||
|
{Name: "龙岩市", Code: "350800", Counties: []string{"新罗区", "永定区", "长汀县", "上杭县", "武平县", "连城县", "漳平市"}},
|
||||||
|
{Name: "宁德市", Code: "350900", Counties: []string{"蕉城区", "霞浦县", "古田县", "屏南县", "寿宁县", "周宁县", "柘荣县", "福安市", "福鼎市"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "江西省", Code: "360000",
|
||||||
|
Cities: []ChinaCityData{
|
||||||
|
{Name: "南昌市", Code: "360100", Counties: []string{"东湖区", "西湖区", "青云谱区", "青山湖区", "新建区", "红谷滩区", "南昌县", "安义县", "进贤县"}},
|
||||||
|
{Name: "景德镇市", Code: "360200", Counties: []string{"昌江区", "珠山区", "浮梁县", "乐平市"}},
|
||||||
|
{Name: "萍乡市", Code: "360300", Counties: []string{"安源区", "湘东区", "莲花县", "上栗县", "芦溪县"}},
|
||||||
|
{Name: "九江市", Code: "360400", Counties: []string{"濂溪区", "浔阳区", "柴桑区", "武宁县", "修水县", "永修县", "德安县", "都昌县", "湖口县", "彭泽县", "瑞昌市", "共青城市", "庐山市"}},
|
||||||
|
{Name: "新余市", Code: "360500", Counties: []string{"渝水区", "分宜县"}},
|
||||||
|
{Name: "鹰潭市", Code: "360600", Counties: []string{"月湖区", "余江区", "贵溪市"}},
|
||||||
|
{Name: "赣州市", Code: "360700", Counties: []string{"章贡区", "南康区", "赣县区", "信丰县", "大余县", "上犹县", "崇义县", "安远县", "定南县", "全南县", "宁都县", "于都县", "兴国县", "会昌县", "寻乌县", "石城县", "瑞金市", "龙南市"}},
|
||||||
|
{Name: "吉安市", Code: "360800", Counties: []string{"吉州区", "青原区", "吉安县", "吉水县", "峡江县", "新干县", "永丰县", "泰和县", "遂川县", "万安县", "安福县", "永新县", "井冈山市"}},
|
||||||
|
{Name: "宜春市", Code: "360900", Counties: []string{"袁州区", "奉新县", "万载县", "上高县", "宜丰县", "靖安县", "铜鼓县", "丰城市", "樟树市", "高安市"}},
|
||||||
|
{Name: "抚州市", Code: "361000", Counties: []string{"临川区", "东乡区", "南城县", "黎川县", "南丰县", "崇仁县", "乐安县", "宜黄县", "金溪县", "资溪县", "广昌县"}},
|
||||||
|
{Name: "上饶市", Code: "361100", Counties: []string{"信州区", "广丰区", "广信区", "玉山县", "铅山县", "横峰县", "弋阳县", "余干县", "鄱阳县", "万年县", "婺源县", "德兴市"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "山东省", Code: "370000",
|
||||||
|
Cities: []ChinaCityData{
|
||||||
|
{Name: "济南市", Code: "370100", Counties: []string{"历下区", "市中区", "槐荫区", "天桥区", "历城区", "长清区", "章丘区", "济阳区", "莱芜区", "钢城区", "平阴县", "商河县"}},
|
||||||
|
{Name: "青岛市", Code: "370200", Counties: []string{"市南区", "市北区", "黄岛区", "崂山区", "李沧区", "城阳区", "即墨区", "胶州市", "平度市", "莱西市"}},
|
||||||
|
{Name: "淄博市", Code: "370300", Counties: []string{"淄川区", "张店区", "博山区", "临淄区", "周村区", "桓台县", "高青县", "沂源县"}},
|
||||||
|
{Name: "枣庄市", Code: "370400", Counties: []string{"市中区", "薛城区", "峄城区", "台儿庄区", "山亭区", "滕州市"}},
|
||||||
|
{Name: "东营市", Code: "370500", Counties: []string{"东营区", "河口区", "垦利区", "利津县", "广饶县"}},
|
||||||
|
{Name: "烟台市", Code: "370600", Counties: []string{"芝罘区", "福山区", "牟平区", "莱山区", "蓬莱区", "龙口市", "莱阳市", "莱州市", "招远市", "栖霞市", "海阳市"}},
|
||||||
|
{Name: "潍坊市", Code: "370700", Counties: []string{"潍城区", "寒亭区", "坊子区", "奎文区", "临朐县", "昌乐县", "青州市", "诸城市", "寿光市", "安丘市", "高密市", "昌邑市"}},
|
||||||
|
{Name: "济宁市", Code: "370800", Counties: []string{"任城区", "兖州区", "微山县", "鱼台县", "金乡县", "嘉祥县", "汶上县", "泗水县", "梁山县", "曲阜市", "邹城市"}},
|
||||||
|
{Name: "泰安市", Code: "370900", Counties: []string{"泰山区", "岱岳区", "宁阳县", "东平县", "新泰市", "肥城市"}},
|
||||||
|
{Name: "威海市", Code: "371000", Counties: []string{"环翠区", "文登区", "荣成市", "乳山市"}},
|
||||||
|
{Name: "日照市", Code: "371100", Counties: []string{"东港区", "岚山区", "五莲县", "莒县"}},
|
||||||
|
{Name: "临沂市", Code: "371300", Counties: []string{"兰山区", "罗庄区", "河东区", "沂南县", "郯城县", "沂水县", "兰陵县", "费县", "平邑县", "莒南县", "蒙阴县", "临沭县"}},
|
||||||
|
{Name: "德州市", Code: "371400", Counties: []string{"德城区", "陵城区", "宁津县", "庆云县", "临邑县", "齐河县", "平原县", "夏津县", "武城县", "乐陵市", "禹城市"}},
|
||||||
|
{Name: "聊城市", Code: "371500", Counties: []string{"东昌府区", "茌平区", "阳谷县", "莘县", "东阿县", "冠县", "高唐县", "临清市"}},
|
||||||
|
{Name: "滨州市", Code: "371600", Counties: []string{"滨城区", "沾化区", "惠民县", "阳信县", "无棣县", "博兴县", "邹平市"}},
|
||||||
|
{Name: "菏泽市", Code: "371700", Counties: []string{"牡丹区", "定陶区", "曹县", "单县", "成武县", "巨野县", "郓城县", "鄄城县", "东明县"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// 华中
|
||||||
|
{
|
||||||
|
Name: "河南省", Code: "410000",
|
||||||
|
Cities: []ChinaCityData{
|
||||||
|
{Name: "郑州市", Code: "410100", Counties: []string{"中原区", "二七区", "管城回族区", "金水区", "上街区", "惠济区", "中牟县", "巩义市", "荥阳市", "新密市", "新郑市", "登封市"}},
|
||||||
|
{Name: "开封市", Code: "410200", Counties: []string{"龙亭区", "顺河回族区", "鼓楼区", "禹王台区", "祥符区", "杞县", "通许县", "尉氏县", "兰考县"}},
|
||||||
|
{Name: "洛阳市", Code: "410300", Counties: []string{"老城区", "西工区", "瀍河回族区", "涧西区", "洛龙区", "孟津区", "偃师区", "新安县", "栾川县", "嵩县", "汝阳县", "宜阳县", "洛宁县", "伊川县"}},
|
||||||
|
{Name: "平顶山市", Code: "410400", Counties: []string{"新华区", "卫东区", "石龙区", "湛河区", "宝丰县", "叶县", "鲁山县", "郏县", "舞钢市", "汝州市"}},
|
||||||
|
{Name: "安阳市", Code: "410500", Counties: []string{"文峰区", "北关区", "殷都区", "龙安区", "安阳县", "汤阴县", "滑县", "内黄县", "林州市"}},
|
||||||
|
{Name: "鹤壁市", Code: "410600", Counties: []string{"鹤山区", "山城区", "淇滨区", "浚县", "淇县"}},
|
||||||
|
{Name: "新乡市", Code: "410700", Counties: []string{"红旗区", "卫滨区", "凤泉区", "牧野区", "新乡县", "获嘉县", "原阳县", "延津县", "封丘县", "卫辉市", "辉县市"}},
|
||||||
|
{Name: "焦作市", Code: "410800", Counties: []string{"解放区", "中站区", "马村区", "山阳区", "修武县", "博爱县", "武陟县", "温县", "沁阳市", "孟州市"}},
|
||||||
|
{Name: "濮阳市", Code: "410900", Counties: []string{"华龙区", "清丰县", "南乐县", "范县", "台前县", "濮阳县"}},
|
||||||
|
{Name: "许昌市", Code: "411000", Counties: []string{"魏都区", "建安区", "鄢陵县", "襄城县", "禹州市", "长葛市"}},
|
||||||
|
{Name: "漯河市", Code: "411100", Counties: []string{"源汇区", "郾城区", "召陵区", "舞阳县", "临颍县"}},
|
||||||
|
{Name: "三门峡市", Code: "411200", Counties: []string{"湖滨区", "陕州区", "渑池县", "卢氏县", "义马市", "灵宝市"}},
|
||||||
|
{Name: "南阳市", Code: "411300", Counties: []string{"宛城区", "卧龙区", "南召县", "方城县", "西峡县", "镇平县", "内乡县", "淅川县", "社旗县", "唐河县", "新野县", "桐柏县", "邓州市"}},
|
||||||
|
{Name: "商丘市", Code: "411400", Counties: []string{"梁园区", "睢阳区", "民权县", "睢县", "宁陵县", "柘城县", "虞城县", "夏邑县", "永城市"}},
|
||||||
|
{Name: "信阳市", Code: "411500", Counties: []string{"浉河区", "平桥区", "罗山县", "光山县", "新县", "商城县", "固始县", "潢川县", "淮滨县", "息县"}},
|
||||||
|
{Name: "周口市", Code: "411600", Counties: []string{"川汇区", "淮阳区", "扶沟县", "西华县", "商水县", "沈丘县", "郸城县", "太康县", "鹿邑县", "项城市"}},
|
||||||
|
{Name: "驻马店市", Code: "411700", Counties: []string{"驿城区", "西平县", "上蔡县", "平舆县", "正阳县", "确山县", "泌阳县", "汝南县", "遂平县", "新蔡县"}},
|
||||||
|
{Name: "济源市", Code: "419001", Counties: []string{"济源市"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "湖北省", Code: "420000",
|
||||||
|
Cities: []ChinaCityData{
|
||||||
|
{Name: "武汉市", Code: "420100", Counties: []string{"江岸区", "江汉区", "硚口区", "汉阳区", "武昌区", "青山区", "洪山区", "东西湖区", "汉南区", "蔡甸区", "江夏区", "黄陂区", "新洲区"}},
|
||||||
|
{Name: "黄石市", Code: "420200", Counties: []string{"黄石港区", "西塞山区", "下陆区", "铁山区", "阳新县", "大冶市"}},
|
||||||
|
{Name: "十堰市", Code: "420300", Counties: []string{"茅箭区", "张湾区", "郧阳区", "郧西县", "竹山县", "竹溪县", "房县", "丹江口市"}},
|
||||||
|
{Name: "宜昌市", Code: "420500", Counties: []string{"西陵区", "伍家岗区", "点军区", "猇亭区", "夷陵区", "远安县", "兴山县", "秭归县", "长阳县", "五峰县", "宜都市", "当阳市", "枝江市"}},
|
||||||
|
{Name: "襄阳市", Code: "420600", Counties: []string{"襄城区", "樊城区", "襄州区", "南漳县", "谷城县", "保康县", "老河口市", "枣阳市", "宜城市"}},
|
||||||
|
{Name: "鄂州市", Code: "420700", Counties: []string{"梁子湖区", "华容区", "鄂城区"}},
|
||||||
|
{Name: "荆门市", Code: "420800", Counties: []string{"东宝区", "掇刀区", "京山市", "沙洋县", "钟祥市"}},
|
||||||
|
{Name: "孝感市", Code: "420900", Counties: []string{"孝南区", "孝昌县", "大悟县", "云梦县", "应城市", "安陆市", "汉川市"}},
|
||||||
|
{Name: "荆州市", Code: "421000", Counties: []string{"沙市区", "荆州区", "公安县", "江陵县", "石首市", "洪湖市", "松滋市", "监利市"}},
|
||||||
|
{Name: "黄冈市", Code: "421100", Counties: []string{"黄州区", "团风县", "红安县", "罗田县", "英山县", "浠水县", "蕲春县", "黄梅县", "麻城市", "武穴市"}},
|
||||||
|
{Name: "咸宁市", Code: "421200", Counties: []string{"咸安区", "嘉鱼县", "通城县", "崇阳县", "通山县", "赤壁市"}},
|
||||||
|
{Name: "随州市", Code: "421300", Counties: []string{"曾都区", "随县", "广水市"}},
|
||||||
|
{Name: "恩施土家族苗族自治州", Code: "422800", Counties: []string{"恩施市", "利川市", "建始县", "巴东县", "宣恩县", "咸丰县", "来凤县", "鹤峰县"}},
|
||||||
|
{Name: "省直辖县级", Code: "429000", Counties: []string{"仙桃市", "潜江市", "天门市", "神农架林区"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "湖南省", Code: "430000",
|
||||||
|
Cities: []ChinaCityData{
|
||||||
|
{Name: "长沙市", Code: "430100", Counties: []string{"芙蓉区", "天心区", "岳麓区", "开福区", "雨花区", "望城区", "长沙县", "浏阳市", "宁乡市"}},
|
||||||
|
{Name: "株洲市", Code: "430200", Counties: []string{"荷塘区", "芦淞区", "石峰区", "天元区", "渌口区", "攸县", "茶陵县", "炎陵县", "醴陵市"}},
|
||||||
|
{Name: "湘潭市", Code: "430300", Counties: []string{"雨湖区", "岳塘区", "湘潭县", "湘乡市", "韶山市"}},
|
||||||
|
{Name: "衡阳市", Code: "430400", Counties: []string{"珠晖区", "雁峰区", "石鼓区", "蒸湘区", "南岳区", "衡阳县", "衡南县", "衡山县", "衡东县", "祁东县", "耒阳市", "常宁市"}},
|
||||||
|
{Name: "邵阳市", Code: "430500", Counties: []string{"双清区", "大祥区", "北塔区", "邵东市", "新邵县", "邵阳县", "隆回县", "洞口县", "绥宁县", "新宁县", "城步县", "武冈市"}},
|
||||||
|
{Name: "岳阳市", Code: "430600", Counties: []string{"岳阳楼区", "云溪区", "君山区", "岳阳县", "华容县", "湘阴县", "平江县", "汨罗市", "临湘市"}},
|
||||||
|
{Name: "常德市", Code: "430700", Counties: []string{"武陵区", "鼎城区", "安乡县", "汉寿县", "澧县", "临澧县", "桃源县", "石门县", "津市市"}},
|
||||||
|
{Name: "张家界市", Code: "430800", Counties: []string{"永定区", "武陵源区", "慈利县", "桑植县"}},
|
||||||
|
{Name: "益阳市", Code: "430900", Counties: []string{"资阳区", "赫山区", "南县", "桃江县", "安化县", "沅江市"}},
|
||||||
|
{Name: "郴州市", Code: "431000", Counties: []string{"北湖区", "苏仙区", "桂阳县", "宜章县", "永兴县", "嘉禾县", "临武县", "汝城县", "桂东县", "安仁县", "资兴市"}},
|
||||||
|
{Name: "永州市", Code: "431100", Counties: []string{"零陵区", "冷水滩区", "祁阳市", "东安县", "双牌县", "道县", "江永县", "宁远县", "蓝山县", "新田县", "江华县"}},
|
||||||
|
{Name: "怀化市", Code: "431200", Counties: []string{"鹤城区", "中方县", "沅陵县", "辰溪县", "溆浦县", "会同县", "麻阳县", "新晃县", "芷江县", "靖州县", "通道县", "洪江市"}},
|
||||||
|
{Name: "娄底市", Code: "431300", Counties: []string{"娄星区", "双峰县", "新化县", "冷水江市", "涟源市"}},
|
||||||
|
{Name: "湘西土家族苗族自治州", Code: "433100", Counties: []string{"吉首市", "泸溪县", "凤凰县", "花垣县", "保靖县", "古丈县", "永顺县", "龙山县"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// 华南
|
||||||
|
{
|
||||||
|
Name: "广东省", Code: "440000",
|
||||||
|
Cities: []ChinaCityData{
|
||||||
|
{Name: "广州市", Code: "440100", Counties: []string{"荔湾区", "越秀区", "海珠区", "天河区", "白云区", "黄埔区", "番禺区", "花都区", "南沙区", "从化区", "增城区"}},
|
||||||
|
{Name: "韶关市", Code: "440200", Counties: []string{"武江区", "浈江区", "曲江区", "始兴县", "仁化县", "翁源县", "乳源县", "新丰县", "乐昌市", "南雄市"}},
|
||||||
|
{Name: "深圳市", Code: "440300", Counties: []string{"罗湖区", "福田区", "南山区", "宝安区", "龙岗区", "盐田区", "龙华区", "坪山区", "光明区", "大鹏新区"}},
|
||||||
|
{Name: "珠海市", Code: "440400", Counties: []string{"香洲区", "斗门区", "金湾区"}},
|
||||||
|
{Name: "汕头市", Code: "440500", Counties: []string{"金平区", "龙湖区", "濠江区", "潮阳区", "潮南区", "澄海区", "南澳县"}},
|
||||||
|
{Name: "佛山市", Code: "440600", Counties: []string{"禅城区", "南海区", "顺德区", "三水区", "高明区"}},
|
||||||
|
{Name: "江门市", Code: "440700", Counties: []string{"蓬江区", "江海区", "新会区", "台山市", "开平市", "鹤山市", "恩平市"}},
|
||||||
|
{Name: "湛江市", Code: "440800", Counties: []string{"赤坎区", "霞山区", "坡头区", "麻章区", "遂溪县", "徐闻县", "廉江市", "雷州市", "吴川市"}},
|
||||||
|
{Name: "茂名市", Code: "440900", Counties: []string{"茂南区", "电白区", "高州市", "化州市", "信宜市"}},
|
||||||
|
{Name: "肇庆市", Code: "441200", Counties: []string{"端州区", "鼎湖区", "高要区", "广宁县", "怀集县", "封开县", "德庆县", "四会市"}},
|
||||||
|
{Name: "惠州市", Code: "441300", Counties: []string{"惠城区", "惠阳区", "博罗县", "惠东县", "龙门县"}},
|
||||||
|
{Name: "梅州市", Code: "441400", Counties: []string{"梅江区", "梅县区", "大埔县", "丰顺县", "五华县", "平远县", "蕉岭县", "兴宁市"}},
|
||||||
|
{Name: "汕尾市", Code: "441500", Counties: []string{"城区", "海丰县", "陆河县", "陆丰市"}},
|
||||||
|
{Name: "河源市", Code: "441600", Counties: []string{"源城区", "紫金县", "龙川县", "连平县", "和平县", "东源县"}},
|
||||||
|
{Name: "阳江市", Code: "441700", Counties: []string{"江城区", "阳东区", "阳西县", "阳春市"}},
|
||||||
|
{Name: "清远市", Code: "441800", Counties: []string{"清城区", "清新区", "佛冈县", "阳山县", "连山县", "连南县", "英德市", "连州市"}},
|
||||||
|
{Name: "东莞市", Code: "441900", Counties: []string{"莞城区", "南城区", "东城区", "万江区", "虎门镇", "长安镇", "松山湖"}},
|
||||||
|
{Name: "中山市", Code: "442000", Counties: []string{"石岐区", "东区", "中山港区", "西区", "南区", "五桂山区", "小榄镇", "古镇镇"}},
|
||||||
|
{Name: "潮州市", Code: "445100", Counties: []string{"湘桥区", "潮安区", "饶平县"}},
|
||||||
|
{Name: "揭阳市", Code: "445200", Counties: []string{"榕城区", "揭东区", "揭西县", "惠来县", "普宁市"}},
|
||||||
|
{Name: "云浮市", Code: "445300", Counties: []string{"云城区", "云安区", "新兴县", "郁南县", "罗定市"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "广西壮族自治区", Code: "450000",
|
||||||
|
Cities: []ChinaCityData{
|
||||||
|
{Name: "南宁市", Code: "450100", Counties: []string{"兴宁区", "青秀区", "江南区", "西乡塘区", "良庆区", "邕宁区", "武鸣区", "隆安县", "马山县", "上林县", "宾阳县", "横州市"}},
|
||||||
|
{Name: "柳州市", Code: "450200", Counties: []string{"城中区", "鱼峰区", "柳南区", "柳北区", "柳江区", "柳城县", "鹿寨县", "融安县", "融水县", "三江县"}},
|
||||||
|
{Name: "桂林市", Code: "450300", Counties: []string{"秀峰区", "叠彩区", "象山区", "七星区", "雁山区", "临桂区", "阳朔县", "灵川县", "全州县", "兴安县", "永福县", "灌阳县", "龙胜县", "资源县", "平乐县", "荔浦市", "恭城县"}},
|
||||||
|
{Name: "梧州市", Code: "450400", Counties: []string{"万秀区", "长洲区", "龙圩区", "苍梧县", "藤县", "蒙山县", "岑溪市"}},
|
||||||
|
{Name: "北海市", Code: "450500", Counties: []string{"海城区", "银海区", "铁山港区", "合浦县"}},
|
||||||
|
{Name: "防城港市", Code: "450600", Counties: []string{"港口区", "防城区", "上思县", "东兴市"}},
|
||||||
|
{Name: "钦州市", Code: "450700", Counties: []string{"钦南区", "钦北区", "灵山县", "浦北县"}},
|
||||||
|
{Name: "贵港市", Code: "450800", Counties: []string{"港北区", "港南区", "覃塘区", "平南县", "桂平市"}},
|
||||||
|
{Name: "玉林市", Code: "450900", Counties: []string{"玉州区", "福绵区", "容县", "陆川县", "博白县", "兴业县", "北流市"}},
|
||||||
|
{Name: "百色市", Code: "451000", Counties: []string{"右江区", "田阳区", "田东县", "平果市", "德保县", "那坡县", "凌云县", "乐业县", "田林县", "西林县", "隆林县", "靖西市"}},
|
||||||
|
{Name: "贺州市", Code: "451100", Counties: []string{"八步区", "平桂区", "昭平县", "钟山县", "富川县"}},
|
||||||
|
{Name: "河池市", Code: "451200", Counties: []string{"金城江区", "宜州区", "南丹县", "天峨县", "凤山县", "东兰县", "罗城县", "环江县", "巴马县", "都安县", "大化县"}},
|
||||||
|
{Name: "来宾市", Code: "451300", Counties: []string{"兴宾区", "忻城县", "象州县", "武宣县", "金秀县", "合山市"}},
|
||||||
|
{Name: "崇左市", Code: "451400", Counties: []string{"江州区", "扶绥县", "宁明县", "龙州县", "大新县", "天等县", "凭祥市"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "海南省", Code: "460000",
|
||||||
|
Cities: []ChinaCityData{
|
||||||
|
{Name: "海口市", Code: "460100", Counties: []string{"秀英区", "龙华区", "琼山区", "美兰区"}},
|
||||||
|
{Name: "三亚市", Code: "460200", Counties: []string{"海棠区", "吉阳区", "天涯区", "崖州区"}},
|
||||||
|
{Name: "三沙市", Code: "460300", Counties: []string{"西沙区", "南沙区"}},
|
||||||
|
{Name: "儋州市", Code: "460400", Counties: []string{"那大镇", "白马井镇", "洋浦经济开发区"}},
|
||||||
|
{Name: "省直辖县级", Code: "469000", Counties: []string{"五指山市", "琼海市", "文昌市", "万宁市", "东方市", "定安县", "屯昌县", "澄迈县", "临高县", "白沙县", "昌江县", "乐东县", "陵水县", "保亭县", "琼中县"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// 西南
|
||||||
|
{
|
||||||
|
Name: "四川省", Code: "510000",
|
||||||
|
Cities: []ChinaCityData{
|
||||||
|
{Name: "成都市", Code: "510100", Counties: []string{"锦江区", "青羊区", "金牛区", "武侯区", "成华区", "龙泉驿区", "青白江区", "新都区", "温江区", "双流区", "郫都区", "新津区", "金堂县", "大邑县", "蒲江县", "都江堰市", "彭州市", "邛崃市", "崇州市", "简阳市"}},
|
||||||
|
{Name: "自贡市", Code: "510300", Counties: []string{"自流井区", "贡井区", "大安区", "沿滩区", "荣县", "富顺县"}},
|
||||||
|
{Name: "攀枝花市", Code: "510400", Counties: []string{"东区", "西区", "仁和区", "米易县", "盐边县"}},
|
||||||
|
{Name: "泸州市", Code: "510500", Counties: []string{"江阳区", "纳溪区", "龙马潭区", "泸县", "合江县", "叙永县", "古蔺县"}},
|
||||||
|
{Name: "德阳市", Code: "510600", Counties: []string{"旌阳区", "罗江区", "中江县", "广汉市", "什邡市", "绵竹市"}},
|
||||||
|
{Name: "绵阳市", Code: "510700", Counties: []string{"涪城区", "游仙区", "安州区", "三台县", "盐亭县", "梓潼县", "北川县", "平武县", "江油市"}},
|
||||||
|
{Name: "广元市", Code: "510800", Counties: []string{"利州区", "昭化区", "朝天区", "旺苍县", "青川县", "剑阁县", "苍溪县"}},
|
||||||
|
{Name: "遂宁市", Code: "510900", Counties: []string{"船山区", "安居区", "蓬溪县", "大英县", "射洪市"}},
|
||||||
|
{Name: "内江市", Code: "511000", Counties: []string{"市中区", "东兴区", "威远县", "资中县", "隆昌市"}},
|
||||||
|
{Name: "乐山市", Code: "511100", Counties: []string{"市中区", "沙湾区", "五通桥区", "金口河区", "犍为县", "井研县", "夹江县", "沐川县", "峨边县", "马边县", "峨眉山市"}},
|
||||||
|
{Name: "南充市", Code: "511300", Counties: []string{"顺庆区", "高坪区", "嘉陵区", "南部县", "营山县", "蓬安县", "仪陇县", "西充县", "阆中市"}},
|
||||||
|
{Name: "眉山市", Code: "511400", Counties: []string{"东坡区", "彭山区", "仁寿县", "洪雅县", "丹棱县", "青神县"}},
|
||||||
|
{Name: "宜宾市", Code: "511500", Counties: []string{"翠屏区", "南溪区", "叙州区", "江安县", "长宁县", "高县", "珙县", "筠连县", "兴文县", "屏山县"}},
|
||||||
|
{Name: "广安市", Code: "511600", Counties: []string{"广安区", "前锋区", "岳池县", "武胜县", "邻水县", "华蓥市"}},
|
||||||
|
{Name: "达州市", Code: "511700", Counties: []string{"通川区", "达川区", "宣汉县", "开江县", "大竹县", "渠县", "万源市"}},
|
||||||
|
{Name: "雅安市", Code: "511800", Counties: []string{"雨城区", "名山区", "荥经县", "汉源县", "石棉县", "天全县", "芦山县", "宝兴县"}},
|
||||||
|
{Name: "巴中市", Code: "511900", Counties: []string{"巴州区", "恩阳区", "通江县", "南江县", "平昌县"}},
|
||||||
|
{Name: "资阳市", Code: "512000", Counties: []string{"雁江区", "安岳县", "乐至县"}},
|
||||||
|
{Name: "阿坝藏族羌族自治州", Code: "513200", Counties: []string{"马尔康市", "汶川县", "理县", "茂县", "松潘县", "九寨沟县", "金川县", "小金县", "黑水县", "壤塘县", "阿坝县", "若尔盖县", "红原县"}},
|
||||||
|
{Name: "甘孜藏族自治州", Code: "513300", Counties: []string{"康定市", "泸定县", "丹巴县", "九龙县", "雅江县", "道孚县", "炉霍县", "甘孜县", "新龙县", "德格县", "白玉县", "石渠县", "色达县", "理塘县", "巴塘县", "乡城县", "稻城县", "得荣县"}},
|
||||||
|
{Name: "凉山彝族自治州", Code: "513400", Counties: []string{"西昌市", "会理市", "木里县", "盐源县", "德昌县", "会东县", "宁南县", "普格县", "布拖县", "金阳县", "昭觉县", "喜德县", "冕宁县", "越西县", "甘洛县", "美姑县", "雷波县"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "贵州省", Code: "520000",
|
||||||
|
Cities: []ChinaCityData{
|
||||||
|
{Name: "贵阳市", Code: "520100", Counties: []string{"南明区", "云岩区", "花溪区", "乌当区", "白云区", "观山湖区", "开阳县", "息烽县", "修文县", "清镇市"}},
|
||||||
|
{Name: "六盘水市", Code: "520200", Counties: []string{"钟山区", "六枝特区", "水城区", "盘州市"}},
|
||||||
|
{Name: "遵义市", Code: "520300", Counties: []string{"红花岗区", "汇川区", "播州区", "桐梓县", "绥阳县", "正安县", "道真县", "务川县", "凤冈县", "湄潭县", "余庆县", "习水县", "赤水市", "仁怀市"}},
|
||||||
|
{Name: "安顺市", Code: "520400", Counties: []string{"西秀区", "平坝区", "普定县", "镇宁县", "关岭县", "紫云县"}},
|
||||||
|
{Name: "毕节市", Code: "520500", Counties: []string{"七星关区", "大方县", "黔西市", "金沙县", "织金县", "纳雍县", "威宁县", "赫章县"}},
|
||||||
|
{Name: "铜仁市", Code: "520600", Counties: []string{"碧江区", "万山区", "江口县", "玉屏县", "石阡县", "思南县", "印江县", "德江县", "沿河县", "松桃县"}},
|
||||||
|
{Name: "黔西南布依族苗族自治州", Code: "522300", Counties: []string{"兴义市", "兴仁市", "普安县", "晴隆县", "贞丰县", "望谟县", "册亨县", "安龙县"}},
|
||||||
|
{Name: "黔东南苗族侗族自治州", Code: "522600", Counties: []string{"凯里市", "黄平县", "施秉县", "三穗县", "镇远县", "岑巩县", "天柱县", "锦屏县", "剑河县", "台江县", "黎平县", "榕江县", "从江县", "雷山县", "麻江县", "丹寨县"}},
|
||||||
|
{Name: "黔南布依族苗族自治州", Code: "522700", Counties: []string{"都匀市", "福泉市", "荔波县", "贵定县", "瓮安县", "独山县", "平塘县", "罗甸县", "长顺县", "龙里县", "惠水县", "三都县"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "云南省", Code: "530000",
|
||||||
|
Cities: []ChinaCityData{
|
||||||
|
{Name: "昆明市", Code: "530100", Counties: []string{"五华区", "盘龙区", "官渡区", "西山区", "东川区", "呈贡区", "晋宁区", "富民县", "宜良县", "石林县", "嵩明县", "禄劝县", "寻甸县", "安宁市"}},
|
||||||
|
{Name: "曲靖市", Code: "530300", Counties: []string{"麒麟区", "沾益区", "马龙区", "陆良县", "师宗县", "罗平县", "富源县", "会泽县", "宣威市"}},
|
||||||
|
{Name: "玉溪市", Code: "530400", Counties: []string{"红塔区", "江川区", "澄江市", "通海县", "华宁县", "易门县", "峨山县", "新平县", "元江县"}},
|
||||||
|
{Name: "保山市", Code: "530500", Counties: []string{"隆阳区", "施甸县", "龙陵县", "昌宁县", "腾冲市"}},
|
||||||
|
{Name: "昭通市", Code: "530600", Counties: []string{"昭阳区", "鲁甸县", "巧家县", "盐津县", "大关县", "永善县", "绥江县", "镇雄县", "彝良县", "威信县", "水富市"}},
|
||||||
|
{Name: "丽江市", Code: "530700", Counties: []string{"古城区", "玉龙县", "永胜县", "华坪县", "宁蒗县"}},
|
||||||
|
{Name: "普洱市", Code: "530800", Counties: []string{"思茅区", "宁洱县", "墨江县", "景东县", "景谷县", "镇沅县", "江城县", "孟连县", "澜沧县", "西盟县"}},
|
||||||
|
{Name: "临沧市", Code: "530900", Counties: []string{"临翔区", "凤庆县", "云县", "永德县", "镇康县", "双江县", "耿马县", "沧源县"}},
|
||||||
|
{Name: "楚雄彝族自治州", Code: "532300", Counties: []string{"楚雄市", "双柏县", "牟定县", "南华县", "姚安县", "大姚县", "永仁县", "元谋县", "武定县", "禄丰市"}},
|
||||||
|
{Name: "红河哈尼族彝族自治州", Code: "532500", Counties: []string{"个旧市", "开远市", "蒙自市", "弥勒市", "屏边县", "建水县", "石屏县", "泸西县", "元阳县", "红河县", "金平县", "绿春县", "河口县"}},
|
||||||
|
{Name: "文山壮族苗族自治州", Code: "532600", Counties: []string{"文山市", "砚山县", "西畴县", "麻栗坡县", "马关县", "丘北县", "广南县", "富宁县"}},
|
||||||
|
{Name: "西双版纳傣族自治州", Code: "532800", Counties: []string{"景洪市", "勐海县", "勐腊县"}},
|
||||||
|
{Name: "大理白族自治州", Code: "532900", Counties: []string{"大理市", "漾濞县", "祥云县", "宾川县", "弥渡县", "南涧县", "巍山县", "永平县", "云龙县", "洱源县", "剑川县", "鹤庆县"}},
|
||||||
|
{Name: "德宏傣族景颇族自治州", Code: "533100", Counties: []string{"瑞丽市", "芒市", "梁河县", "盈江县", "陇川县"}},
|
||||||
|
{Name: "怒江傈僳族自治州", Code: "533300", Counties: []string{"泸水市", "福贡县", "贡山县", "兰坪县"}},
|
||||||
|
{Name: "迪庆藏族自治州", Code: "533400", Counties: []string{"香格里拉市", "德钦县", "维西县"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "西藏自治区", Code: "540000",
|
||||||
|
Cities: []ChinaCityData{
|
||||||
|
{Name: "拉萨市", Code: "540100", Counties: []string{"城关区", "堆龙德庆区", "达孜区", "林周县", "当雄县", "尼木县", "曲水县", "墨竹工卡县"}},
|
||||||
|
{Name: "日喀则市", Code: "540200", Counties: []string{"桑珠孜区", "南木林县", "江孜县", "定日县", "萨迦县", "拉孜县", "昂仁县", "谢通门县", "白朗县", "仁布县", "康马县", "定结县", "仲巴县", "亚东县", "吉隆县", "聂拉木县", "萨嘎县", "岗巴县"}},
|
||||||
|
{Name: "昌都市", Code: "540300", Counties: []string{"卡若区", "江达县", "贡觉县", "类乌齐县", "丁青县", "察雅县", "八宿县", "左贡县", "芒康县", "洛隆县", "边坝县"}},
|
||||||
|
{Name: "林芝市", Code: "540400", Counties: []string{"巴宜区", "工布江达县", "米林市", "墨脱县", "波密县", "察隅县", "朗县"}},
|
||||||
|
{Name: "山南市", Code: "540500", Counties: []string{"乃东区", "扎囊县", "贡嘎县", "桑日县", "琼结县", "曲松县", "措美县", "洛扎县", "加查县", "隆子县", "错那市", "浪卡子县"}},
|
||||||
|
{Name: "那曲市", Code: "540600", Counties: []string{"色尼区", "嘉黎县", "比如县", "聂荣县", "安多县", "申扎县", "索县", "班戈县", "巴青县", "尼玛县", "双湖县"}},
|
||||||
|
{Name: "阿里地区", Code: "542500", Counties: []string{"普兰县", "札达县", "噶尔县", "日土县", "革吉县", "改则县", "措勤县"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// 西北
|
||||||
|
{
|
||||||
|
Name: "陕西省", Code: "610000",
|
||||||
|
Cities: []ChinaCityData{
|
||||||
|
{Name: "西安市", Code: "610100", Counties: []string{"新城区", "碑林区", "莲湖区", "雁塔区", "灞桥区", "未央区", "阎良区", "临潼区", "长安区", "高陵区", "鄠邑区", "蓝田县", "周至县"}},
|
||||||
|
{Name: "铜川市", Code: "610200", Counties: []string{"王益区", "印台区", "耀州区", "宜君县"}},
|
||||||
|
{Name: "宝鸡市", Code: "610300", Counties: []string{"渭滨区", "金台区", "陈仓区", "凤翔区", "岐山县", "扶风县", "眉县", "陇县", "千阳县", "麟游县", "凤县", "太白县"}},
|
||||||
|
{Name: "咸阳市", Code: "610400", Counties: []string{"秦都区", "杨陵区", "渭城区", "三原县", "泾阳县", "乾县", "礼泉县", "永寿县", "长武县", "旬邑县", "淳化县", "武功县", "兴平市", "彬州市"}},
|
||||||
|
{Name: "渭南市", Code: "610500", Counties: []string{"临渭区", "华州区", "潼关县", "大荔县", "合阳县", "澄城县", "蒲城县", "白水县", "富平县", "韩城市", "华阴市"}},
|
||||||
|
{Name: "延安市", Code: "610600", Counties: []string{"宝塔区", "安塞区", "延长县", "延川县", "子长市", "志丹县", "吴起县", "甘泉县", "富县", "洛川县", "宜川县", "黄龙县", "黄陵县"}},
|
||||||
|
{Name: "汉中市", Code: "610700", Counties: []string{"汉台区", "南郑区", "城固县", "洋县", "西乡县", "勉县", "宁强县", "略阳县", "镇巴县", "留坝县", "佛坪县"}},
|
||||||
|
{Name: "榆林市", Code: "610800", Counties: []string{"榆阳区", "横山区", "府谷县", "靖边县", "定边县", "绥德县", "米脂县", "佳县", "吴堡县", "清涧县", "子洲县", "神木市"}},
|
||||||
|
{Name: "安康市", Code: "610900", Counties: []string{"汉滨区", "汉阴县", "石泉县", "宁陕县", "紫阳县", "岚皋县", "平利县", "镇坪县", "白河县", "旬阳市"}},
|
||||||
|
{Name: "商洛市", Code: "611000", Counties: []string{"商州区", "洛南县", "丹凤县", "商南县", "山阳县", "镇安县", "柞水县"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "甘肃省", Code: "620000",
|
||||||
|
Cities: []ChinaCityData{
|
||||||
|
{Name: "兰州市", Code: "620100", Counties: []string{"城关区", "七里河区", "西固区", "安宁区", "红古区", "永登县", "皋兰县", "榆中县"}},
|
||||||
|
{Name: "嘉峪关市", Code: "620200", Counties: []string{"雄关街道", "钢城街道", "文殊镇", "新城镇"}},
|
||||||
|
{Name: "金昌市", Code: "620300", Counties: []string{"金川区", "永昌县"}},
|
||||||
|
{Name: "白银市", Code: "620400", Counties: []string{"白银区", "平川区", "靖远县", "会宁县", "景泰县"}},
|
||||||
|
{Name: "天水市", Code: "620500", Counties: []string{"秦州区", "麦积区", "清水县", "秦安县", "甘谷县", "武山县", "张家川县"}},
|
||||||
|
{Name: "武威市", Code: "620600", Counties: []string{"凉州区", "民勤县", "古浪县", "天祝县"}},
|
||||||
|
{Name: "张掖市", Code: "620700", Counties: []string{"甘州区", "肃南县", "民乐县", "临泽县", "高台县", "山丹县"}},
|
||||||
|
{Name: "平凉市", Code: "620800", Counties: []string{"崆峒区", "泾川县", "灵台县", "崇信县", "庄浪县", "静宁县", "华亭市"}},
|
||||||
|
{Name: "酒泉市", Code: "620900", Counties: []string{"肃州区", "金塔县", "瓜州县", "肃北县", "阿克塞县", "玉门市", "敦煌市"}},
|
||||||
|
{Name: "庆阳市", Code: "621000", Counties: []string{"西峰区", "庆城县", "环县", "华池县", "合水县", "正宁县", "宁县", "镇原县"}},
|
||||||
|
{Name: "定西市", Code: "621100", Counties: []string{"安定区", "通渭县", "陇西县", "渭源县", "临洮县", "漳县", "岷县"}},
|
||||||
|
{Name: "陇南市", Code: "621200", Counties: []string{"武都区", "成县", "文县", "宕昌县", "康县", "西和县", "礼县", "徽县", "两当县"}},
|
||||||
|
{Name: "临夏回族自治州", Code: "622900", Counties: []string{"临夏市", "临夏县", "康乐县", "永靖县", "广河县", "和政县", "东乡族自治县", "积石山县"}},
|
||||||
|
{Name: "甘南藏族自治州", Code: "623000", Counties: []string{"合作市", "临潭县", "卓尼县", "舟曲县", "迭部县", "玛曲县", "碌曲县", "夏河县"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "青海省", Code: "630000",
|
||||||
|
Cities: []ChinaCityData{
|
||||||
|
{Name: "西宁市", Code: "630100", Counties: []string{"城东区", "城中区", "城西区", "城北区", "湟中区", "大通县", "湟源县"}},
|
||||||
|
{Name: "海东市", Code: "630200", Counties: []string{"乐都区", "平安区", "民和县", "互助县", "化隆县", "循化县"}},
|
||||||
|
{Name: "海北藏族自治州", Code: "632200", Counties: []string{"门源县", "祁连县", "海晏县", "刚察县"}},
|
||||||
|
{Name: "黄南藏族自治州", Code: "632300", Counties: []string{"同仁市", "尖扎县", "泽库县", "河南蒙古族自治县"}},
|
||||||
|
{Name: "海南藏族自治州", Code: "632500", Counties: []string{"共和县", "同德县", "贵德县", "兴海县", "贵南县"}},
|
||||||
|
{Name: "果洛藏族自治州", Code: "632600", Counties: []string{"玛沁县", "班玛县", "甘德县", "达日县", "久治县", "玛多县"}},
|
||||||
|
{Name: "玉树藏族自治州", Code: "632700", Counties: []string{"玉树市", "杂多县", "称多县", "治多县", "囊谦县", "曲麻莱县"}},
|
||||||
|
{Name: "海西蒙古族藏族自治州", Code: "632800", Counties: []string{"格尔木市", "德令哈市", "茫崖市", "乌兰县", "都兰县", "天峻县"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "宁夏回族自治区", Code: "640000",
|
||||||
|
Cities: []ChinaCityData{
|
||||||
|
{Name: "银川市", Code: "640100", Counties: []string{"兴庆区", "西夏区", "金凤区", "永宁县", "贺兰县", "灵武市"}},
|
||||||
|
{Name: "石嘴山市", Code: "640200", Counties: []string{"大武口区", "惠农区", "平罗县"}},
|
||||||
|
{Name: "吴忠市", Code: "640300", Counties: []string{"利通区", "红寺堡区", "盐池县", "同心县", "青铜峡市"}},
|
||||||
|
{Name: "固原市", Code: "640400", Counties: []string{"原州区", "西吉县", "隆德县", "泾源县", "彭阳县"}},
|
||||||
|
{Name: "中卫市", Code: "640500", Counties: []string{"沙坡头区", "中宁县", "海原县"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "新疆维吾尔自治区", Code: "650000",
|
||||||
|
Cities: []ChinaCityData{
|
||||||
|
{Name: "乌鲁木齐市", Code: "650100", Counties: []string{"天山区", "沙依巴克区", "新市区", "水磨沟区", "头屯河区", "达坂城区", "米东区", "乌鲁木齐县"}},
|
||||||
|
{Name: "克拉玛依市", Code: "650200", Counties: []string{"独山子区", "克拉玛依区", "白碱滩区", "乌尔禾区"}},
|
||||||
|
{Name: "吐鲁番市", Code: "650400", Counties: []string{"高昌区", "鄯善县", "托克逊县"}},
|
||||||
|
{Name: "哈密市", Code: "650500", Counties: []string{"伊州区", "巴里坤县", "伊吾县"}},
|
||||||
|
{Name: "昌吉回族自治州", Code: "652300", Counties: []string{"昌吉市", "阜康市", "呼图壁县", "玛纳斯县", "奇台县", "吉木萨尔县", "木垒县"}},
|
||||||
|
{Name: "博尔塔拉蒙古自治州", Code: "652700", Counties: []string{"博乐市", "阿拉山口市", "精河县", "温泉县"}},
|
||||||
|
{Name: "巴音郭楞蒙古自治州", Code: "652800", Counties: []string{"库尔勒市", "轮台县", "尉犁县", "若羌县", "且末县", "焉耆县", "和静县", "和硕县", "博湖县"}},
|
||||||
|
{Name: "阿克苏地区", Code: "652900", Counties: []string{"阿克苏市", "温宿县", "库车市", "沙雅县", "新和县", "拜城县", "乌什县", "阿瓦提县", "柯坪县"}},
|
||||||
|
{Name: "克孜勒苏柯尔克孜自治州", Code: "653000", Counties: []string{"阿图什市", "阿克陶县", "阿合奇县", "乌恰县"}},
|
||||||
|
{Name: "喀什地区", Code: "653100", Counties: []string{"喀什市", "疏附县", "疏勒县", "英吉沙县", "泽普县", "莎车县", "叶城县", "麦盖提县", "岳普湖县", "伽师县", "巴楚县", "塔什库尔干县"}},
|
||||||
|
{Name: "和田地区", Code: "653200", Counties: []string{"和田市", "和田县", "墨玉县", "皮山县", "洛浦县", "策勒县", "于田县", "民丰县"}},
|
||||||
|
{Name: "伊犁哈萨克自治州", Code: "654000", Counties: []string{"伊宁市", "奎屯市", "霍尔果斯市", "伊宁县", "察布查尔县", "霍城县", "巩留县", "新源县", "昭苏县", "特克斯县", "尼勒克县"}},
|
||||||
|
{Name: "塔城地区", Code: "654200", Counties: []string{"塔城市", "乌苏市", "沙湾市", "额敏县", "托里县", "裕民县", "和布克赛尔县"}},
|
||||||
|
{Name: "阿勒泰地区", Code: "654300", Counties: []string{"阿勒泰市", "布尔津县", "富蕴县", "福海县", "哈巴河县", "青河县", "吉木乃县"}},
|
||||||
|
{Name: "自治区直辖县级", Code: "659000", Counties: []string{"石河子市", "阿拉尔市", "图木舒克市", "五家渠市", "北屯市", "铁门关市", "双河市", "可克达拉市", "昆玉市", "胡杨河市", "新星市"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// 港澳台
|
||||||
|
{
|
||||||
|
Name: "台湾省", Code: "710000",
|
||||||
|
Cities: []ChinaCityData{
|
||||||
|
{Name: "台北市", Code: "710100", Counties: []string{"中正区", "大同区", "中山区", "松山区", "大安区", "万华区", "信义区", "士林区", "北投区", "内湖区", "南港区", "文山区"}},
|
||||||
|
{Name: "新北市", Code: "710200", Counties: []string{"板桥区", "三重区", "中和区", "永和区", "新庄区", "新店区", "树林区", "莺歌区", "三峡区", "淡水区", "汐止区", "瑞芳区"}},
|
||||||
|
{Name: "台中市", Code: "710300", Counties: []string{"中区", "东区", "南区", "西区", "北区", "西屯区", "南屯区", "北屯区", "丰原区"}},
|
||||||
|
{Name: "台南市", Code: "710400", Counties: []string{"东区", "南区", "北区", "安南区", "安平区", "中西区", "永康区", "新营区"}},
|
||||||
|
{Name: "高雄市", Code: "710500", Counties: []string{"盐埕区", "鼓山区", "左营区", "楠梓区", "三民区", "新兴区", "前金区", "苓雅区", "前镇区", "旗津区", "小港区", "凤山区"}},
|
||||||
|
{Name: "基隆市", Code: "710600", Counties: []string{"仁爱区", "信义区", "中正区", "中山区", "安乐区", "暖暖区", "七堵区"}},
|
||||||
|
{Name: "新竹市", Code: "710700", Counties: []string{"东区", "北区", "香山区"}},
|
||||||
|
{Name: "嘉义市", Code: "710800", Counties: []string{"东区", "西区"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "香港特别行政区", Code: "810000",
|
||||||
|
Cities: []ChinaCityData{
|
||||||
|
{
|
||||||
|
Name: "香港特别行政区", Code: "810100",
|
||||||
|
Counties: []string{"中西区", "湾仔区", "东区", "南区", "油尖旺区", "深水埗区", "九龙城区", "黄大仙区", "观塘区", "荃湾区", "屯门区", "元朗区", "北区", "大埔区", "西贡区", "沙田区", "葵青区", "离岛区"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "澳门特别行政区", Code: "820000",
|
||||||
|
Cities: []ChinaCityData{
|
||||||
|
{
|
||||||
|
Name: "澳门特别行政区", Code: "820100",
|
||||||
|
Counties: []string{"花地玛堂区", "花王堂区", "望德堂区", "大堂区", "风顺堂区", "嘉模堂区", "圣方济各堂区", "路氹城"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -21,15 +21,25 @@ func NewClassService(db *gorm.DB, configSvc *ConfigService) *ClassService {
|
|||||||
return &ClassService{db: db, configSvc: configSvc}
|
return &ClassService{db: db, configSvc: configSvc}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RosterItemReq 毕业照人员录入项
|
||||||
|
type RosterItemReq struct {
|
||||||
|
RowNo int `json:"row_no"`
|
||||||
|
ColNo int `json:"col_no"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Role string `json:"role"` // "student" | "teacher"
|
||||||
|
}
|
||||||
|
|
||||||
// CreateClassReq 创建班级请求
|
// CreateClassReq 创建班级请求
|
||||||
type CreateClassReq struct {
|
type CreateClassReq struct {
|
||||||
SchoolID uint `json:"school_id" binding:"required"`
|
SchoolID uint `json:"school_id" binding:"required"`
|
||||||
CollegeID uint `json:"college_id"`
|
CollegeID uint `json:"college_id"`
|
||||||
Name string `json:"name" binding:"required"`
|
Name string `json:"name"`
|
||||||
Grade string `json:"grade" binding:"required"` // 如"2018级"
|
Grade string `json:"grade" binding:"required"` // 如"2018级"
|
||||||
ClassNo string `json:"class_no" binding:"required"`
|
ClassNo string `json:"class_no" binding:"required"`
|
||||||
GraduateYear int `json:"graduate_year" binding:"required"`
|
GraduateYear int `json:"graduate_year"`
|
||||||
GraduationPhoto string `json:"graduation_photo"`
|
GraduationPhoto string `json:"graduation_photo"`
|
||||||
|
RowCount int `json:"row_count"`
|
||||||
|
Roster []RosterItemReq `json:"roster"`
|
||||||
StudentNames []string `json:"student_names"`
|
StudentNames []string `json:"student_names"`
|
||||||
TeacherNames []string `json:"teacher_names"`
|
TeacherNames []string `json:"teacher_names"`
|
||||||
}
|
}
|
||||||
@@ -54,10 +64,6 @@ func cleanNames(names []string) []string {
|
|||||||
|
|
||||||
// Create 创建班级
|
// Create 创建班级
|
||||||
func (s *ClassService) Create(userID uint, req *CreateClassReq) (*model.Class, error) {
|
func (s *ClassService) Create(userID uint, req *CreateClassReq) (*model.Class, error) {
|
||||||
// 0. 敏感词检测
|
|
||||||
if found, word := sensitive.Check(req.Name); found {
|
|
||||||
return nil, fmt.Errorf("班级名称包含敏感词:%s", word)
|
|
||||||
}
|
|
||||||
// 1. 校验用户有学习履历
|
// 1. 校验用户有学习履历
|
||||||
var eduCount int64
|
var eduCount int64
|
||||||
s.db.Model(&model.EducationHistory{}).Where("user_id = ?", userID).Count(&eduCount)
|
s.db.Model(&model.EducationHistory{}).Where("user_id = ?", userID).Count(&eduCount)
|
||||||
@@ -72,19 +78,60 @@ func (s *ClassService) Create(userID uint, req *CreateClassReq) (*model.Class, e
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 3. 校验学院(如果传了)
|
// 3. 校验学院(如果传了)
|
||||||
|
var collegeName string
|
||||||
if req.CollegeID > 0 {
|
if req.CollegeID > 0 {
|
||||||
if !school.SchoolType.HasCollege() {
|
if !school.HasCollegeType() {
|
||||||
return nil, errors.New("该学校类型不支持学院层级")
|
return nil, errors.New("该学校类型不支持学院层级")
|
||||||
}
|
}
|
||||||
var college model.College
|
var college model.College
|
||||||
if err := s.db.Where("id = ? AND school_id = ?", req.CollegeID, req.SchoolID).First(&college).Error; err != nil {
|
if err := s.db.Where("id = ? AND school_id = ?", req.CollegeID, req.SchoolID).First(&college).Error; err != nil {
|
||||||
return nil, errors.New("学院不存在或不属于该学校")
|
return nil, errors.New("学院不存在或不属于该学校")
|
||||||
}
|
}
|
||||||
} else if school.SchoolType.HasCollege() {
|
collegeName = college.Name
|
||||||
// 大学/职业学校必须选学院
|
} else if school.RequiresCollege() {
|
||||||
|
// 纯大学/职业学校必须选学院;同时涵盖中小学学段的学校可不选
|
||||||
return nil, errors.New("该学校需要选择所属学院")
|
return nil, errors.New("该学校需要选择所属学院")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 规范化年级与班级,自动补齐班级名称与推导毕业年份
|
||||||
|
req.Grade = strings.TrimSpace(req.Grade)
|
||||||
|
req.ClassNo = strings.TrimSpace(req.ClassNo)
|
||||||
|
if req.Name == "" {
|
||||||
|
req.Name = school.Name + collegeName + req.Grade + req.ClassNo
|
||||||
|
}
|
||||||
|
|
||||||
|
// 0. 敏感词检测
|
||||||
|
if found, word := sensitive.Check(req.Name); found {
|
||||||
|
return nil, fmt.Errorf("班级名称包含敏感词:%s", word)
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.GraduateYear <= 0 {
|
||||||
|
startYear := 0
|
||||||
|
var numStr string
|
||||||
|
for _, ch := range req.Grade {
|
||||||
|
if ch >= '0' && ch <= '9' {
|
||||||
|
numStr += string(ch)
|
||||||
|
if len(numStr) == 4 {
|
||||||
|
fmt.Sscanf(numStr, "%d", &startYear)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
numStr = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
duration := 3
|
||||||
|
if strings.Contains(school.SchoolTypes, "primary") || school.SchoolType == model.SchoolPrimary {
|
||||||
|
duration = 6
|
||||||
|
} else if strings.Contains(school.SchoolTypes, "university") || school.SchoolType == model.SchoolUniversity {
|
||||||
|
duration = 4
|
||||||
|
}
|
||||||
|
if startYear > 0 {
|
||||||
|
req.GraduateYear = startYear + duration
|
||||||
|
} else {
|
||||||
|
req.GraduateYear = time.Now().Year()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 4. 唯一性校验
|
// 4. 唯一性校验
|
||||||
var existCount int64
|
var existCount int64
|
||||||
q := s.db.Model(&model.Class{}).Where("school_id = ? AND grade = ? AND class_no = ? AND graduate_year = ? AND status != ?",
|
q := s.db.Model(&model.Class{}).Where("school_id = ? AND grade = ? AND class_no = ? AND graduate_year = ? AND status != ?",
|
||||||
@@ -99,9 +146,91 @@ func (s *ClassService) Create(userID uint, req *CreateClassReq) (*model.Class, e
|
|||||||
return nil, errors.New("该班级已存在,请勿重复创建")
|
return nil, errors.New("该班级已存在,请勿重复创建")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. 清洗名单
|
// 5. 清洗名单与构建排位花名册(预录入每个同学与老师,未注册账号预留)
|
||||||
students := cleanNames(req.StudentNames)
|
var rosters []model.ClassRoster
|
||||||
teachers := cleanNames(req.TeacherNames)
|
var students []string
|
||||||
|
var teachers []string
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
var creator model.User
|
||||||
|
s.db.Select("id, username, nickname").First(&creator, userID)
|
||||||
|
|
||||||
|
if len(req.Roster) > 0 {
|
||||||
|
maxRow := 1
|
||||||
|
for _, item := range req.Roster {
|
||||||
|
name := strings.TrimSpace(item.Name)
|
||||||
|
if name == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rowNo := item.RowNo
|
||||||
|
if rowNo <= 0 {
|
||||||
|
rowNo = 1
|
||||||
|
}
|
||||||
|
if rowNo > maxRow {
|
||||||
|
maxRow = rowNo
|
||||||
|
}
|
||||||
|
role := item.Role
|
||||||
|
if role == "" {
|
||||||
|
role = "student"
|
||||||
|
}
|
||||||
|
if role == "teacher" {
|
||||||
|
teachers = append(teachers, name)
|
||||||
|
} else {
|
||||||
|
students = append(students, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
r := model.ClassRoster{
|
||||||
|
RowNo: rowNo,
|
||||||
|
ColNo: item.ColNo,
|
||||||
|
Name: name,
|
||||||
|
Role: role,
|
||||||
|
}
|
||||||
|
// 若当前录入的名字与创建者本人姓名/昵称一致,且尚未绑定任何一条,自动认领绑定
|
||||||
|
if creator.ID > 0 && (name == creator.Nickname || name == creator.Username) {
|
||||||
|
r.UserID = creator.ID
|
||||||
|
r.IsClaimed = true
|
||||||
|
r.ClaimedAt = &now
|
||||||
|
}
|
||||||
|
rosters = append(rosters, r)
|
||||||
|
}
|
||||||
|
if req.RowCount < maxRow {
|
||||||
|
req.RowCount = maxRow
|
||||||
|
}
|
||||||
|
students = cleanNames(students)
|
||||||
|
teachers = cleanNames(teachers)
|
||||||
|
} else {
|
||||||
|
// 兼容旧格式调用
|
||||||
|
students = cleanNames(req.StudentNames)
|
||||||
|
teachers = cleanNames(req.TeacherNames)
|
||||||
|
col := 1
|
||||||
|
for _, name := range students {
|
||||||
|
r := model.ClassRoster{
|
||||||
|
RowNo: 1,
|
||||||
|
ColNo: col,
|
||||||
|
Name: name,
|
||||||
|
Role: "student",
|
||||||
|
}
|
||||||
|
if creator.ID > 0 && (name == creator.Nickname || name == creator.Username) {
|
||||||
|
r.UserID = creator.ID
|
||||||
|
r.IsClaimed = true
|
||||||
|
r.ClaimedAt = &now
|
||||||
|
}
|
||||||
|
rosters = append(rosters, r)
|
||||||
|
col++
|
||||||
|
}
|
||||||
|
for _, name := range teachers {
|
||||||
|
rosters = append(rosters, model.ClassRoster{
|
||||||
|
RowNo: 1,
|
||||||
|
ColNo: col,
|
||||||
|
Name: name,
|
||||||
|
Role: "teacher",
|
||||||
|
})
|
||||||
|
col++
|
||||||
|
}
|
||||||
|
if req.RowCount <= 0 {
|
||||||
|
req.RowCount = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 6. 构建班级
|
// 6. 构建班级
|
||||||
graceDays := 30
|
graceDays := 30
|
||||||
@@ -117,24 +246,35 @@ func (s *ClassService) Create(userID uint, req *CreateClassReq) (*model.Class, e
|
|||||||
GraduateYear: req.GraduateYear,
|
GraduateYear: req.GraduateYear,
|
||||||
AdminUserID: userID,
|
AdminUserID: userID,
|
||||||
GraduationPhoto: req.GraduationPhoto,
|
GraduationPhoto: req.GraduationPhoto,
|
||||||
|
RowCount: req.RowCount,
|
||||||
AuditDeadline: time.Now().AddDate(0, 0, graceDays),
|
AuditDeadline: time.Now().AddDate(0, 0, graceDays),
|
||||||
}
|
}
|
||||||
class.SetStudentNames(students)
|
class.SetStudentNames(students)
|
||||||
class.SetTeacherNames(teachers)
|
class.SetTeacherNames(teachers)
|
||||||
|
|
||||||
// 7. 名字不足8个直接打回
|
// 7. 总人数不足8个直接打回
|
||||||
if len(students) < model.MinStudentCount {
|
totalPeople := len(students) + len(teachers)
|
||||||
|
if totalPeople < model.MinStudentCount {
|
||||||
class.Status = model.ClassRejected
|
class.Status = model.ClassRejected
|
||||||
class.RejectReason = fmt.Sprintf("班级同学名单仅 %d 人,需至少录入 %d 人(毕业照上通常有全部同学姓名,请补齐后重新提交)", len(students), model.MinStudentCount)
|
class.RejectReason = fmt.Sprintf("班级录入人员仅 %d 人,需至少录入 %d 人(毕业照上通常有全部人员姓名,请补齐后重新提交)", totalPeople, model.MinStudentCount)
|
||||||
} else {
|
} else {
|
||||||
class.Status = model.ClassPending
|
class.Status = model.ClassPending
|
||||||
}
|
}
|
||||||
|
|
||||||
// 8. 事务:创建班级 + 创建者成为管理员成员 + 审核日志
|
// 8. 事务:创建班级 + 保存分开的花名册预录入记录 + 创建者成为管理员成员 + 审核日志
|
||||||
err := s.db.Transaction(func(tx *gorm.DB) error {
|
err := s.db.Transaction(func(tx *gorm.DB) error {
|
||||||
if err := tx.Create(class).Error; err != nil {
|
if err := tx.Create(class).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
// 写入花名册:每个同学/老师作为独立记录存储(未绑定具体账号)
|
||||||
|
if len(rosters) > 0 {
|
||||||
|
for i := range rosters {
|
||||||
|
rosters[i].ClassID = class.ID
|
||||||
|
}
|
||||||
|
if err := tx.Create(&rosters).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
member := &model.ClassMember{
|
member := &model.ClassMember{
|
||||||
ClassID: class.ID,
|
ClassID: class.ID,
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
@@ -145,11 +285,28 @@ func (s *ClassService) Create(userID uint, req *CreateClassReq) (*model.Class, e
|
|||||||
if err := tx.Create(member).Error; err != nil {
|
if err := tx.Create(member).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 检查创建者是否在花名册中,若是则自动认领绑定对应站位
|
||||||
|
var creator model.User
|
||||||
|
if tx.Select("id, nickname, username, real_name").First(&creator, userID).Error == nil {
|
||||||
|
for i := range rosters {
|
||||||
|
name := rosters[i].Name
|
||||||
|
if (creator.RealName != "" && name == creator.RealName) || (creator.Nickname != "" && name == creator.Nickname) || name == creator.Username {
|
||||||
|
tx.Model(&model.ClassRoster{}).Where("class_id = ? AND row_no = ? AND col_no = ?", class.ID, rosters[i].RowNo, rosters[i].ColNo).Updates(map[string]interface{}{
|
||||||
|
"user_id": userID,
|
||||||
|
"is_claimed": true,
|
||||||
|
"claimed_at": time.Now(),
|
||||||
|
})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
log := &model.AuditLog{
|
log := &model.AuditLog{
|
||||||
ClassID: class.ID,
|
ClassID: class.ID,
|
||||||
AuditorID: userID,
|
AuditorID: userID,
|
||||||
Action: model.AuditSubmit,
|
Action: model.AuditSubmit,
|
||||||
Reason: fmt.Sprintf("创建班级,录入同学 %d 人、老师 %d 人", len(students), len(teachers)),
|
Reason: fmt.Sprintf("创建班级,按 %d 排录入 %d 位人员(学生 %d 人,老师 %d 人)", class.RowCount, len(rosters), len(students), len(teachers)),
|
||||||
}
|
}
|
||||||
return tx.Create(log).Error
|
return tx.Create(log).Error
|
||||||
})
|
})
|
||||||
@@ -162,6 +319,8 @@ func (s *ClassService) Create(userID uint, req *CreateClassReq) (*model.Class, e
|
|||||||
// ClassDetail 班级详情
|
// ClassDetail 班级详情
|
||||||
type ClassDetail struct {
|
type ClassDetail struct {
|
||||||
Class model.Class `json:"class"`
|
Class model.Class `json:"class"`
|
||||||
|
RowCount int `json:"row_count"`
|
||||||
|
Roster []model.ClassRoster `json:"roster"`
|
||||||
StudentNames []string `json:"student_names"`
|
StudentNames []string `json:"student_names"`
|
||||||
TeacherNames []string `json:"teacher_names"`
|
TeacherNames []string `json:"teacher_names"`
|
||||||
School *model.School `json:"school,omitempty"`
|
School *model.School `json:"school,omitempty"`
|
||||||
@@ -192,13 +351,51 @@ func (s *ClassService) GetDetail(classID, currentUserID uint, isSuperAdmin bool)
|
|||||||
// 数据隔离:只有班级成员或超级管理员可查看完整信息(照片+联系方式)
|
// 数据隔离:只有班级成员或超级管理员可查看完整信息(照片+联系方式)
|
||||||
canViewSensitive := isSuperAdmin || (currentUserID > 0 && s.isMember(classID, currentUserID))
|
canViewSensitive := isSuperAdmin || (currentUserID > 0 && s.isMember(classID, currentUserID))
|
||||||
|
|
||||||
|
var rosters []model.ClassRoster
|
||||||
|
s.db.Where("class_id = ?", classID).Order("row_no asc, col_no asc, id asc").Find(&rosters)
|
||||||
|
|
||||||
detail := &ClassDetail{
|
detail := &ClassDetail{
|
||||||
Class: class,
|
Class: class,
|
||||||
|
RowCount: class.RowCount,
|
||||||
|
Roster: rosters,
|
||||||
StudentNames: class.StudentNames(),
|
StudentNames: class.StudentNames(),
|
||||||
TeacherNames: class.TeacherNames(),
|
TeacherNames: class.TeacherNames(),
|
||||||
CanViewSensitive: canViewSensitive,
|
CanViewSensitive: canViewSensitive,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 兼容老数据:如果花名册表中暂无记录,则按 StudentNames/TeacherNames 构造第1排返回
|
||||||
|
if len(rosters) == 0 && (len(detail.StudentNames) > 0 || len(detail.TeacherNames) > 0) {
|
||||||
|
col := 1
|
||||||
|
for _, name := range detail.StudentNames {
|
||||||
|
detail.Roster = append(detail.Roster, model.ClassRoster{
|
||||||
|
ClassID: classID,
|
||||||
|
RowNo: 1,
|
||||||
|
ColNo: col,
|
||||||
|
Name: name,
|
||||||
|
Role: "student",
|
||||||
|
})
|
||||||
|
col++
|
||||||
|
}
|
||||||
|
for _, name := range detail.TeacherNames {
|
||||||
|
detail.Roster = append(detail.Roster, model.ClassRoster{
|
||||||
|
ClassID: classID,
|
||||||
|
RowNo: 1,
|
||||||
|
ColNo: col,
|
||||||
|
Name: name,
|
||||||
|
Role: "teacher",
|
||||||
|
})
|
||||||
|
col++
|
||||||
|
}
|
||||||
|
detail.RowCount = 1
|
||||||
|
}
|
||||||
|
if detail.RowCount <= 0 && len(detail.Roster) > 0 {
|
||||||
|
for _, r := range detail.Roster {
|
||||||
|
if r.RowNo > detail.RowCount {
|
||||||
|
detail.RowCount = r.RowNo
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 学校
|
// 学校
|
||||||
var school model.School
|
var school model.School
|
||||||
if s.db.First(&school, class.SchoolID).Error == nil {
|
if s.db.First(&school, class.SchoolID).Error == nil {
|
||||||
@@ -223,12 +420,26 @@ func (s *ClassService) GetDetail(classID, currentUserID uint, isSuperAdmin bool)
|
|||||||
for _, m := range members {
|
for _, m := range members {
|
||||||
var u model.User
|
var u model.User
|
||||||
s.db.First(&u, m.UserID)
|
s.db.First(&u, m.UserID)
|
||||||
info := ClassMemberInfo{Member: m, User: u}
|
// 敏感信息脱敏:非本人且非平台管理员时脱敏身份证号
|
||||||
// 数据隔离:同班成员或超级管理员可查看联系方式
|
if !isSuperAdmin && m.UserID != currentUserID {
|
||||||
if canViewSensitive {
|
if len(u.IdCard) == 18 {
|
||||||
info.Contacts = s.getUserContacts(m.UserID)
|
u.IdCard = u.IdCard[:6] + "********" + u.IdCard[14:]
|
||||||
|
} else if u.IdCard != "" {
|
||||||
|
u.IdCard = "****************"
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
// 数据隔离:同班成员或超级管理员可查看联系方式;非成员清空敏感联系信息
|
||||||
|
if canViewSensitive {
|
||||||
|
info := ClassMemberInfo{Member: m, User: u}
|
||||||
|
info.Contacts = s.getUserContacts(m.UserID)
|
||||||
detail.Members = append(detail.Members, info)
|
detail.Members = append(detail.Members, info)
|
||||||
|
} else {
|
||||||
|
u.Phone = ""
|
||||||
|
u.Email = ""
|
||||||
|
u.Address = ""
|
||||||
|
info := ClassMemberInfo{Member: m, User: u}
|
||||||
|
detail.Members = append(detail.Members, info)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 照片墙:数据隔离,只有成员或超级管理员可查看
|
// 照片墙:数据隔离,只有成员或超级管理员可查看
|
||||||
@@ -259,7 +470,33 @@ func (s *ClassService) isMember(classID, userID uint) bool {
|
|||||||
|
|
||||||
func (s *ClassService) getUserContacts(userID uint) []model.UserContact {
|
func (s *ClassService) getUserContacts(userID uint) []model.UserContact {
|
||||||
var list []model.UserContact
|
var list []model.UserContact
|
||||||
s.db.Where("user_id = ?", userID).Order("type, is_primary desc").Find(&list)
|
s.db.Where("user_id = ?", userID).Order("type, is_primary desc, id").Find(&list)
|
||||||
|
var u model.User
|
||||||
|
if s.db.Select("id, phone, email, address").First(&u, userID).Error == nil {
|
||||||
|
hasPhone := false
|
||||||
|
hasEmail := false
|
||||||
|
hasAddress := false
|
||||||
|
for _, c := range list {
|
||||||
|
if c.Type == model.ContactPhone {
|
||||||
|
hasPhone = true
|
||||||
|
}
|
||||||
|
if c.Type == model.ContactEmail {
|
||||||
|
hasEmail = true
|
||||||
|
}
|
||||||
|
if c.Type == model.ContactAddress {
|
||||||
|
hasAddress = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !hasPhone && u.Phone != "" {
|
||||||
|
list = append(list, model.UserContact{UserID: userID, Type: model.ContactPhone, Value: u.Phone, IsPrimary: true})
|
||||||
|
}
|
||||||
|
if !hasEmail && u.Email != "" {
|
||||||
|
list = append(list, model.UserContact{UserID: userID, Type: model.ContactEmail, Value: u.Email, IsPrimary: true})
|
||||||
|
}
|
||||||
|
if !hasAddress && u.Address != "" {
|
||||||
|
list = append(list, model.UserContact{UserID: userID, Type: model.ContactAddress, Value: u.Address, IsPrimary: true})
|
||||||
|
}
|
||||||
|
}
|
||||||
return list
|
return list
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -312,6 +549,13 @@ type JoinBySearchReq struct {
|
|||||||
|
|
||||||
// JoinBySearch 通过搜索+8人验证加入班级
|
// JoinBySearch 通过搜索+8人验证加入班级
|
||||||
func (s *ClassService) JoinBySearch(userID uint, req *JoinBySearchReq) error {
|
func (s *ClassService) JoinBySearch(userID uint, req *JoinBySearchReq) error {
|
||||||
|
// 0. 校验用户有学习履历
|
||||||
|
var eduCount int64
|
||||||
|
s.db.Model(&model.EducationHistory{}).Where("user_id = ?", userID).Count(&eduCount)
|
||||||
|
if eduCount == 0 {
|
||||||
|
return errors.New("请先完善个人学习履历,有学习履历的用户才能加入班级")
|
||||||
|
}
|
||||||
|
|
||||||
var class model.Class
|
var class model.Class
|
||||||
if err := s.db.First(&class, req.ClassID).Error; err != nil {
|
if err := s.db.First(&class, req.ClassID).Error; err != nil {
|
||||||
return errors.New("班级不存在")
|
return errors.New("班级不存在")
|
||||||
@@ -346,7 +590,29 @@ func (s *ClassService) JoinBySearch(userID uint, req *JoinBySearchReq) error {
|
|||||||
JoinMethod: model.JoinBySearch,
|
JoinMethod: model.JoinBySearch,
|
||||||
JoinedAt: time.Now(),
|
JoinedAt: time.Now(),
|
||||||
}
|
}
|
||||||
return s.db.Create(member).Error
|
if err := s.db.Create(member).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 联查班级花名册:若当前用户姓名与花名册中未绑定人员匹配,自动绑定该坑位
|
||||||
|
var user model.User
|
||||||
|
if s.db.Select("id, nickname, username, real_name").First(&user, userID).Error == nil {
|
||||||
|
var targetRoster model.ClassRoster
|
||||||
|
q := s.db.Where("class_id = ? AND is_claimed = ? AND user_id = 0", class.ID, false)
|
||||||
|
if user.RealName != "" {
|
||||||
|
q = q.Where("name = ? OR name = ? OR name = ?", user.RealName, user.Nickname, user.Username)
|
||||||
|
} else {
|
||||||
|
q = q.Where("name = ? OR name = ?", user.Nickname, user.Username)
|
||||||
|
}
|
||||||
|
if err := q.First(&targetRoster).Error; err == nil {
|
||||||
|
s.db.Model(&targetRoster).Updates(map[string]interface{}{
|
||||||
|
"user_id": userID,
|
||||||
|
"is_claimed": true,
|
||||||
|
"claimed_at": time.Now(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- 邀请码 ----
|
// ---- 邀请码 ----
|
||||||
@@ -400,6 +666,13 @@ type JoinByInvitationReq struct {
|
|||||||
|
|
||||||
// JoinByInvitation 通过邀请码加入
|
// JoinByInvitation 通过邀请码加入
|
||||||
func (s *ClassService) JoinByInvitation(userID uint, req *JoinByInvitationReq) (*model.Class, error) {
|
func (s *ClassService) JoinByInvitation(userID uint, req *JoinByInvitationReq) (*model.Class, error) {
|
||||||
|
// 0. 校验用户有学习履历
|
||||||
|
var eduCount int64
|
||||||
|
s.db.Model(&model.EducationHistory{}).Where("user_id = ?", userID).Count(&eduCount)
|
||||||
|
if eduCount == 0 {
|
||||||
|
return nil, errors.New("请先完善个人学习履历,有学习履历的用户才能加入班级")
|
||||||
|
}
|
||||||
|
|
||||||
code := strings.ToUpper(strings.TrimSpace(req.Code))
|
code := strings.ToUpper(strings.TrimSpace(req.Code))
|
||||||
var inv model.Invitation
|
var inv model.Invitation
|
||||||
if err := s.db.Where("code = ?", code).First(&inv).Error; err != nil {
|
if err := s.db.Where("code = ?", code).First(&inv).Error; err != nil {
|
||||||
@@ -436,6 +709,26 @@ func (s *ClassService) JoinByInvitation(userID uint, req *JoinByInvitationReq) (
|
|||||||
if err := tx.Create(member).Error; err != nil {
|
if err := tx.Create(member).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 联查花名册自动绑定坑位
|
||||||
|
var u model.User
|
||||||
|
if tx.Select("id, nickname, username, real_name").First(&u, userID).Error == nil {
|
||||||
|
var targetRoster model.ClassRoster
|
||||||
|
q := tx.Where("class_id = ? AND is_claimed = ? AND user_id = 0", class.ID, false)
|
||||||
|
if u.RealName != "" {
|
||||||
|
q = q.Where("name = ? OR name = ? OR name = ?", u.RealName, u.Nickname, u.Username)
|
||||||
|
} else {
|
||||||
|
q = q.Where("name = ? OR name = ?", u.Nickname, u.Username)
|
||||||
|
}
|
||||||
|
if err := q.First(&targetRoster).Error; err == nil {
|
||||||
|
tx.Model(&targetRoster).Updates(map[string]interface{}{
|
||||||
|
"user_id": userID,
|
||||||
|
"is_claimed": true,
|
||||||
|
"claimed_at": time.Now(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return tx.Model(&model.Invitation{}).Where("id = ?", inv.ID).
|
return tx.Model(&model.Invitation{}).Where("id = ?", inv.ID).
|
||||||
Update("used_count", gorm.Expr("used_count + 1")).Error
|
Update("used_count", gorm.Expr("used_count + 1")).Error
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -52,6 +52,18 @@ var defaultConfigs = []model.SystemConfig{
|
|||||||
{Key: "max_file_size_mb", Value: "10", Label: "单文件大小上限(MB)", Description: "上传图片的最大大小限制", Category: "storage", Placeholder: "10", Sort: 2},
|
{Key: "max_file_size_mb", Value: "10", Label: "单文件大小上限(MB)", Description: "上传图片的最大大小限制", Category: "storage", Placeholder: "10", Sort: 2},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WarmUpCache 从数据库全量加载配置到内存缓存(单次快速查询)
|
||||||
|
func (s *ConfigService) WarmUpCache() {
|
||||||
|
var list []model.SystemConfig
|
||||||
|
if err := s.db.Find(&list).Error; err == nil {
|
||||||
|
s.mu.Lock()
|
||||||
|
for _, item := range list {
|
||||||
|
s.cache[item.Key] = item.Value
|
||||||
|
}
|
||||||
|
s.mu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// InitDefaults 初始化默认配置(不存在则创建,已存在但为空且默认有初始值则补齐)
|
// InitDefaults 初始化默认配置(不存在则创建,已存在但为空且默认有初始值则补齐)
|
||||||
func (s *ConfigService) InitDefaults() error {
|
func (s *ConfigService) InitDefaults() error {
|
||||||
for _, c := range defaultConfigs {
|
for _, c := range defaultConfigs {
|
||||||
@@ -66,15 +78,7 @@ func (s *ConfigService) InitDefaults() error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 预热内存缓存
|
s.WarmUpCache()
|
||||||
var list []model.SystemConfig
|
|
||||||
if err := s.db.Find(&list).Error; err == nil {
|
|
||||||
s.mu.Lock()
|
|
||||||
for _, item := range list {
|
|
||||||
s.cache[item.Key] = item.Value
|
|
||||||
}
|
|
||||||
s.mu.Unlock()
|
|
||||||
}
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -31,10 +31,11 @@ var defaultMenus = []model.Menu{
|
|||||||
{ID: 18, ParentID: 8, Name: "Dashboard", Path: "/admin/dashboard", Component: "admin/dashboard/index", Title: "数据统计", Icon: "fas fa-chart-line", Sort: 1, Visible: true, RequireAuth: true, RequireAdmin: true},
|
{ID: 18, ParentID: 8, Name: "Dashboard", Path: "/admin/dashboard", Component: "admin/dashboard/index", Title: "数据统计", Icon: "fas fa-chart-line", Sort: 1, Visible: true, RequireAuth: true, RequireAdmin: true},
|
||||||
{ID: 9, ParentID: 8, Name: "AdminAudit", Path: "/admin/audit", Component: "admin/audit/list/index", Title: "审核管理", Icon: "fas fa-clipboard-check", Sort: 2, Visible: true, RequireAuth: true, RequireAdmin: true},
|
{ID: 9, ParentID: 8, Name: "AdminAudit", Path: "/admin/audit", Component: "admin/audit/list/index", Title: "审核管理", Icon: "fas fa-clipboard-check", Sort: 2, Visible: true, RequireAuth: true, RequireAdmin: true},
|
||||||
{ID: 10, ParentID: 8, Name: "AdminAuditDetail", Path: "/admin/audit/detail/:id", Component: "admin/audit/detail/index", Title: "审核详情", Icon: "", Sort: 3, Visible: false, RequireAuth: true, RequireAdmin: true},
|
{ID: 10, ParentID: 8, Name: "AdminAuditDetail", Path: "/admin/audit/detail/:id", Component: "admin/audit/detail/index", Title: "审核详情", Icon: "", Sort: 3, Visible: false, RequireAuth: true, RequireAdmin: true},
|
||||||
{ID: 11, ParentID: 8, Name: "AdminConfig", Path: "/admin/config", Component: "admin/config/index", Title: "系统配置", Icon: "fas fa-sliders-h", Sort: 4, Visible: true, RequireAuth: true, RequireAdmin: true},
|
{ID: 21, ParentID: 8, Name: "AdminBaseData", Path: "/admin/basedata", Component: "admin/basedata/index", Title: "基础数据", Icon: "fas fa-database", Sort: 4, Visible: true, RequireAuth: true, RequireAdmin: true},
|
||||||
{ID: 12, ParentID: 8, Name: "AdminMenu", Path: "/admin/menu", Component: "admin/menu/index", Title: "菜单管理", Icon: "fas fa-bars", Sort: 5, Visible: true, RequireAuth: true, RequireAdmin: true},
|
{ID: 11, ParentID: 8, Name: "AdminConfig", Path: "/admin/config", Component: "admin/config/index", Title: "系统配置", Icon: "fas fa-sliders-h", Sort: 5, Visible: true, RequireAuth: true, RequireAdmin: true},
|
||||||
{ID: 19, ParentID: 8, Name: "UserManage", Path: "/admin/users", Component: "admin/users/index", Title: "用户管理", Icon: "fas fa-user-shield", Sort: 6, Visible: true, RequireAuth: true, RequireAdmin: true},
|
{ID: 12, ParentID: 8, Name: "AdminMenu", Path: "/admin/menu", Component: "admin/menu/index", Title: "菜单管理", Icon: "fas fa-bars", Sort: 6, Visible: true, RequireAuth: true, RequireAdmin: true},
|
||||||
{ID: 20, ParentID: 8, Name: "SensitiveWord", Path: "/admin/sensitive", Component: "admin/sensitive/index", Title: "敏感词管理", Icon: "fas fa-shield-alt", Sort: 7, Visible: true, RequireAuth: true, RequireAdmin: true},
|
{ID: 19, ParentID: 8, Name: "UserManage", Path: "/admin/users", Component: "admin/users/index", Title: "用户管理", Icon: "fas fa-user-shield", Sort: 7, Visible: true, RequireAuth: true, RequireAdmin: true},
|
||||||
|
{ID: 20, ParentID: 8, Name: "SensitiveWord", Path: "/admin/sensitive", Component: "admin/sensitive/index", Title: "敏感词管理", Icon: "fas fa-shield-alt", Sort: 8, Visible: true, RequireAuth: true, RequireAdmin: true},
|
||||||
// 基本设置(管理员)
|
// 基本设置(管理员)
|
||||||
{ID: 13, ParentID: 0, Name: "NormalSettings", Path: "/normalsettings", Component: "", Title: "基本设置", Icon: "fas fa-wrench", Sort: 5, Visible: true, RequireAuth: true, RequireAdmin: true, Redirect: "/normalsettings/usersettings"},
|
{ID: 13, ParentID: 0, Name: "NormalSettings", Path: "/normalsettings", Component: "", Title: "基本设置", Icon: "fas fa-wrench", Sort: 5, Visible: true, RequireAuth: true, RequireAdmin: true, Redirect: "/normalsettings/usersettings"},
|
||||||
{ID: 14, ParentID: 13, Name: "UserSettings", Path: "/normalsettings/usersettings", Component: "normalsettings/usersettings/list/index", Title: "用户设置", Icon: "fas fa-user-cog", Sort: 1, Visible: true, RequireAuth: true, RequireAdmin: true},
|
{ID: 14, ParentID: 13, Name: "UserSettings", Path: "/normalsettings/usersettings", Component: "normalsettings/usersettings/list/index", Title: "用户设置", Icon: "fas fa-user-cog", Sort: 1, Visible: true, RequireAuth: true, RequireAdmin: true},
|
||||||
@@ -47,12 +48,15 @@ var defaultMenus = []model.Menu{
|
|||||||
// InitDefaults 初始化默认菜单(不存在则创建)
|
// InitDefaults 初始化默认菜单(不存在则创建)
|
||||||
func (s *MenuService) InitDefaults() error {
|
func (s *MenuService) InitDefaults() error {
|
||||||
for _, m := range defaultMenus {
|
for _, m := range defaultMenus {
|
||||||
var count int64
|
var existing model.Menu
|
||||||
s.db.Model(&model.Menu{}).Where("id = ? OR name = ?", m.ID, m.Name).Count(&count)
|
err := s.db.Where("id = ? OR name = ?", m.ID, m.Name).First(&existing).Error
|
||||||
if count == 0 {
|
if err != nil {
|
||||||
if err := s.db.Create(&m).Error; err != nil {
|
if err := s.db.Select("*").Create(&m).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
} else if !m.Visible && existing.Visible {
|
||||||
|
// 默认隐藏菜单(如详情页带 :id 参数、登录页)在库中若是显示状态,自动校正为隐藏
|
||||||
|
s.db.Model(&existing).Update("visible", false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -74,10 +78,19 @@ func (s *MenuService) GetMenuTree(isAdmin bool) ([]*model.MenuTree, error) {
|
|||||||
// GetAll 获取所有菜单(管理用,含隐藏)
|
// GetAll 获取所有菜单(管理用,含隐藏)
|
||||||
func (s *MenuService) GetAll() ([]model.Menu, error) {
|
func (s *MenuService) GetAll() ([]model.Menu, error) {
|
||||||
var list []model.Menu
|
var list []model.Menu
|
||||||
err := s.db.Order("parent_id, sort").Find(&list).Error
|
err := s.db.Order("sort, id").Find(&list).Error
|
||||||
return list, err
|
return list, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetAllTree 获取所有菜单树结构(管理用,含隐藏)
|
||||||
|
func (s *MenuService) GetAllTree() ([]*model.MenuTree, error) {
|
||||||
|
var all []model.Menu
|
||||||
|
if err := s.db.Order("sort, id").Find(&all).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return buildTree(all, 0), nil
|
||||||
|
}
|
||||||
|
|
||||||
// GetByID 获取单个菜单
|
// GetByID 获取单个菜单
|
||||||
func (s *MenuService) GetByID(id uint) (*model.Menu, error) {
|
func (s *MenuService) GetByID(id uint) (*model.Menu, error) {
|
||||||
var m model.Menu
|
var m model.Menu
|
||||||
@@ -97,6 +110,11 @@ func (s *MenuService) Update(m *model.Menu) error {
|
|||||||
return s.db.Save(m).Error
|
return s.db.Save(m).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UpdateFields 更新菜单指定字段
|
||||||
|
func (s *MenuService) UpdateFields(id uint, updates map[string]interface{}) error {
|
||||||
|
return s.db.Model(&model.Menu{}).Where("id = ?", id).Updates(updates).Error
|
||||||
|
}
|
||||||
|
|
||||||
// Delete 删除菜单(同时删除子菜单)
|
// Delete 删除菜单(同时删除子菜单)
|
||||||
func (s *MenuService) Delete(id uint) error {
|
func (s *MenuService) Delete(id uint) error {
|
||||||
return s.db.Transaction(func(tx *gorm.DB) error {
|
return s.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
"photowall/internal/model"
|
"photowall/internal/model"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
@@ -14,21 +16,34 @@ func NewRegionService(db *gorm.DB) *RegionService {
|
|||||||
return &RegionService{db: db}
|
return &RegionService{db: db}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListByParent 根据父级ID查子地区;parentID=0 查国家
|
// GetByID 根据 ID 查询地区
|
||||||
func (s *RegionService) ListByParent(parentID uint) ([]model.Region, error) {
|
func (s *RegionService) GetByID(id uint) (*model.Region, error) {
|
||||||
|
var r model.Region
|
||||||
|
if err := s.db.First(&r, id).Error; err != nil {
|
||||||
|
return nil, errors.New("地区不存在")
|
||||||
|
}
|
||||||
|
return &r, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListByParent 根据父级ID查子地区;parentID=0 查国家;可选按层级筛选
|
||||||
|
func (s *RegionService) ListByParent(parentID uint, level ...model.RegionLevel) ([]model.Region, error) {
|
||||||
|
query := s.db.Where("parent_id = ?", parentID)
|
||||||
|
if len(level) > 0 && level[0] != "" {
|
||||||
|
query = query.Where("level = ?", level[0])
|
||||||
|
}
|
||||||
var list []model.Region
|
var list []model.Region
|
||||||
err := s.db.Where("parent_id = ?", parentID).Order("sort_order, name").Find(&list).Error
|
err := query.Order("sort_order, id").Find(&list).Error
|
||||||
return list, err
|
return list, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListCountries 查所有国家
|
// ListCountries 查所有国家
|
||||||
func (s *RegionService) ListCountries() ([]model.Region, error) {
|
func (s *RegionService) ListCountries() ([]model.Region, error) {
|
||||||
var list []model.Region
|
var list []model.Region
|
||||||
err := s.db.Where("level = ?", model.LevelCountry).Order("sort_order, name").Find(&list).Error
|
err := s.db.Where("level = ?", model.LevelCountry).Order("sort_order, id").Find(&list).Error
|
||||||
return list, err
|
return list, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetPath 获取某地区的完整路径(国家->省->县)
|
// GetPath 获取某地区的完整路径(国家->省->市->县)
|
||||||
func (s *RegionService) GetPath(regionID uint) ([]model.Region, error) {
|
func (s *RegionService) GetPath(regionID uint) ([]model.Region, error) {
|
||||||
var r model.Region
|
var r model.Region
|
||||||
if err := s.db.First(&r, regionID).Error; err != nil {
|
if err := s.db.First(&r, regionID).Error; err != nil {
|
||||||
@@ -41,28 +56,222 @@ func (s *RegionService) GetPath(regionID uint) ([]model.Region, error) {
|
|||||||
if err := s.db.First(&parent, cur.ParentID).Error; err != nil {
|
if err := s.db.First(&parent, cur.ParentID).Error; err != nil {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
// 插到前面
|
|
||||||
path = append([]model.Region{parent}, path...)
|
path = append([]model.Region{parent}, path...)
|
||||||
cur = parent
|
cur = parent
|
||||||
}
|
}
|
||||||
return path, nil
|
return path, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Search 按名称搜索地区(用于快速定位)
|
// Search 按名称或代码搜索地区
|
||||||
func (s *RegionService) Search(keyword string, limit int) ([]model.Region, error) {
|
func (s *RegionService) Search(keyword string, limit int) ([]model.Region, error) {
|
||||||
if limit <= 0 {
|
if limit <= 0 {
|
||||||
limit = 20
|
limit = 50
|
||||||
}
|
}
|
||||||
var list []model.Region
|
var list []model.Region
|
||||||
err := s.db.Where("name LIKE ? OR name_en LIKE ?", "%"+keyword+"%", "%"+keyword+"%").
|
err := s.db.Where("name LIKE ? OR name_en LIKE ? OR code LIKE ?", "%"+keyword+"%", "%"+keyword+"%", "%"+keyword+"%").
|
||||||
Order("level, sort_order, name").Limit(limit).Find(&list).Error
|
Order("level, sort_order, id").Limit(limit).Find(&list).Error
|
||||||
return list, err
|
return list, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// BatchCreate 平台管理员批量录入地区(导入完整行政区划数据用)
|
// Create 创建地区
|
||||||
|
func (s *RegionService) Create(r *model.Region) error {
|
||||||
|
if r.Name == "" {
|
||||||
|
return errors.New("地区名称不能为空")
|
||||||
|
}
|
||||||
|
if r.Level == "" {
|
||||||
|
return errors.New("请选择地区层级")
|
||||||
|
}
|
||||||
|
if r.ParentID > 0 {
|
||||||
|
var parent model.Region
|
||||||
|
if err := s.db.First(&parent, r.ParentID).Error; err != nil {
|
||||||
|
return errors.New("所选上级地区不存在")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s.db.Create(r).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update 更新地区信息
|
||||||
|
func (s *RegionService) Update(id uint, updates map[string]interface{}) error {
|
||||||
|
res := s.db.Model(&model.Region{}).Where("id = ?", id).Updates(updates)
|
||||||
|
if res.Error != nil {
|
||||||
|
return res.Error
|
||||||
|
}
|
||||||
|
if res.RowsAffected == 0 {
|
||||||
|
return errors.New("地区不存在")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete 删除地区(包含子项/学校引用防误删检查)
|
||||||
|
func (s *RegionService) Delete(id uint) error {
|
||||||
|
// 检查是否有子地区
|
||||||
|
var childCount int64
|
||||||
|
s.db.Model(&model.Region{}).Where("parent_id = ?", id).Count(&childCount)
|
||||||
|
if childCount > 0 {
|
||||||
|
return errors.New("该地区下仍有下级地区,请先处理下级地区")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否有学校关联
|
||||||
|
var schoolCount int64
|
||||||
|
s.db.Model(&model.School{}).Where("region_id = ?", id).Count(&schoolCount)
|
||||||
|
if schoolCount > 0 {
|
||||||
|
return errors.New("该地区下已有关联学校,无法直接删除")
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.db.Delete(&model.Region{}, id).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTree 获取地区层级树(parentID=0 表示从顶级国家开始)
|
||||||
|
func (s *RegionService) GetTree(parentID uint) ([]*model.RegionTree, error) {
|
||||||
|
var all []model.Region
|
||||||
|
if err := s.db.Order("sort_order, id").Find(&all).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return buildRegionTree(all, parentID), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildRegionTree(list []model.Region, parentID uint) []*model.RegionTree {
|
||||||
|
var tree []*model.RegionTree
|
||||||
|
for i := range list {
|
||||||
|
if list[i].ParentID == parentID {
|
||||||
|
node := &model.RegionTree{
|
||||||
|
Region: list[i],
|
||||||
|
Children: buildRegionTree(list, list[i].ID),
|
||||||
|
}
|
||||||
|
tree = append(tree, node)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tree
|
||||||
|
}
|
||||||
|
|
||||||
|
// BatchCreate 批量录入地区
|
||||||
func (s *RegionService) BatchCreate(regions []model.Region) error {
|
func (s *RegionService) BatchCreate(regions []model.Region) error {
|
||||||
if len(regions) == 0 {
|
if len(regions) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return s.db.CreateInBatches(regions, 100).Error
|
return s.db.CreateInBatches(regions, 100).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// InitChinaRegions 生成/初始化整个中国的地域选项(34个省/直辖市/自治区、地级市、区县)
|
||||||
|
func (s *RegionService) InitChinaRegions() (createdCount int, totalCount int, err error) {
|
||||||
|
// 1. 预加载所有已存在的地区,极大提升比对性能(避免数千次单条查询导致超时)
|
||||||
|
var allExisting []model.Region
|
||||||
|
if err := s.db.Select("id, name, level, parent_id, code").Find(&allExisting).Error; err != nil {
|
||||||
|
return 0, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
regMap := make(map[string]uint, len(allExisting)+3500)
|
||||||
|
for _, r := range allExisting {
|
||||||
|
key := fmt.Sprintf("%d:%s:%s", r.ParentID, r.Level, r.Name)
|
||||||
|
regMap[key] = r.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 查找或创建国家:中国
|
||||||
|
chinaKey := fmt.Sprintf("0:%s:中国", model.LevelCountry)
|
||||||
|
chinaID, exists := regMap[chinaKey]
|
||||||
|
if !exists {
|
||||||
|
china := model.Region{
|
||||||
|
Name: "中国",
|
||||||
|
NameEn: "China",
|
||||||
|
Level: model.LevelCountry,
|
||||||
|
ParentID: 0,
|
||||||
|
Code: "CN",
|
||||||
|
SortOrder: 1,
|
||||||
|
}
|
||||||
|
if err := s.db.Create(&china).Error; err != nil {
|
||||||
|
return 0, 0, err
|
||||||
|
}
|
||||||
|
chinaID = china.ID
|
||||||
|
regMap[chinaKey] = chinaID
|
||||||
|
createdCount++
|
||||||
|
}
|
||||||
|
totalCount++
|
||||||
|
|
||||||
|
// 2.1 清理历史残留错误数据:挂在中国省级下的直接 county 记录(如无学校关联)
|
||||||
|
var wrongProvCounties []model.Region
|
||||||
|
s.db.Raw(`
|
||||||
|
SELECT c.id FROM yz_pw_regions c
|
||||||
|
JOIN yz_pw_regions p ON c.parent_id = p.id
|
||||||
|
WHERE c.level = 'county' AND p.level = 'province' AND p.parent_id = ?
|
||||||
|
`, chinaID).Scan(&wrongProvCounties)
|
||||||
|
if len(wrongProvCounties) > 0 {
|
||||||
|
var toDel []uint
|
||||||
|
for _, wc := range wrongProvCounties {
|
||||||
|
var scCount int64
|
||||||
|
s.db.Model(&model.School{}).Where("region_id = ?", wc.ID).Count(&scCount)
|
||||||
|
if scCount == 0 {
|
||||||
|
toDel = append(toDel, wc.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(toDel) > 0 {
|
||||||
|
s.db.Where("id IN ?", toDel).Delete(&model.Region{})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 遍历全国省市县数据并批量写入
|
||||||
|
for pIdx, pData := range chinaProvincesData {
|
||||||
|
provKey := fmt.Sprintf("%d:%s:%s", chinaID, model.LevelProvince, pData.Name)
|
||||||
|
provID, provExists := regMap[provKey]
|
||||||
|
if !provExists {
|
||||||
|
prov := model.Region{
|
||||||
|
Name: pData.Name,
|
||||||
|
Level: model.LevelProvince,
|
||||||
|
ParentID: chinaID,
|
||||||
|
Code: pData.Code,
|
||||||
|
SortOrder: pIdx + 1,
|
||||||
|
}
|
||||||
|
if err := s.db.Create(&prov).Error; err != nil {
|
||||||
|
return createdCount, totalCount, err
|
||||||
|
}
|
||||||
|
provID = prov.ID
|
||||||
|
regMap[provKey] = provID
|
||||||
|
createdCount++
|
||||||
|
}
|
||||||
|
totalCount++
|
||||||
|
|
||||||
|
for cIdx, cData := range pData.Cities {
|
||||||
|
cityKey := fmt.Sprintf("%d:%s:%s", provID, model.LevelCity, cData.Name)
|
||||||
|
cityID, cityExists := regMap[cityKey]
|
||||||
|
if !cityExists {
|
||||||
|
city := model.Region{
|
||||||
|
Name: cData.Name,
|
||||||
|
Level: model.LevelCity,
|
||||||
|
ParentID: provID,
|
||||||
|
Code: cData.Code,
|
||||||
|
SortOrder: cIdx + 1,
|
||||||
|
}
|
||||||
|
if err := s.db.Create(&city).Error; err != nil {
|
||||||
|
return createdCount, totalCount, err
|
||||||
|
}
|
||||||
|
cityID = city.ID
|
||||||
|
regMap[cityKey] = cityID
|
||||||
|
createdCount++
|
||||||
|
}
|
||||||
|
totalCount++
|
||||||
|
|
||||||
|
var missingCounties []model.Region
|
||||||
|
for kIdx, countyName := range cData.Counties {
|
||||||
|
totalCount++
|
||||||
|
countyKey := fmt.Sprintf("%d:%s:%s", cityID, model.LevelCounty, countyName)
|
||||||
|
if _, ctyExists := regMap[countyKey]; !ctyExists {
|
||||||
|
missingCounties = append(missingCounties, model.Region{
|
||||||
|
Name: countyName,
|
||||||
|
Level: model.LevelCounty,
|
||||||
|
ParentID: cityID,
|
||||||
|
SortOrder: kIdx + 1,
|
||||||
|
})
|
||||||
|
regMap[countyKey] = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(missingCounties) > 0 {
|
||||||
|
if err := s.db.CreateInBatches(missingCounties, 100).Error; err != nil {
|
||||||
|
return createdCount, totalCount, err
|
||||||
|
}
|
||||||
|
createdCount += len(missingCounties)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return createdCount, totalCount, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package service
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"photowall/internal/model"
|
"photowall/internal/model"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
@@ -19,6 +20,7 @@ type SchoolQuery struct {
|
|||||||
RegionID uint `form:"region_id"`
|
RegionID uint `form:"region_id"`
|
||||||
SchoolType model.SchoolType `form:"school_type"`
|
SchoolType model.SchoolType `form:"school_type"`
|
||||||
Keyword string `form:"keyword"`
|
Keyword string `form:"keyword"`
|
||||||
|
Status string `form:"status"` // pending / active / rejected / all
|
||||||
Page int `form:"page"`
|
Page int `form:"page"`
|
||||||
PageSize int `form:"page_size"`
|
PageSize int `form:"page_size"`
|
||||||
}
|
}
|
||||||
@@ -32,6 +34,13 @@ type PageResp struct {
|
|||||||
|
|
||||||
type SchoolListResp = PageResp // 别名兼容
|
type SchoolListResp = PageResp // 别名兼容
|
||||||
|
|
||||||
|
type SchoolStatsResp struct {
|
||||||
|
Total int64 `json:"total"`
|
||||||
|
Pending int64 `json:"pending"`
|
||||||
|
Active int64 `json:"active"`
|
||||||
|
Rejected int64 `json:"rejected"`
|
||||||
|
}
|
||||||
|
|
||||||
func (s *SchoolService) List(q *SchoolQuery) (*SchoolListResp, error) {
|
func (s *SchoolService) List(q *SchoolQuery) (*SchoolListResp, error) {
|
||||||
if q.Page <= 0 {
|
if q.Page <= 0 {
|
||||||
q.Page = 1
|
q.Page = 1
|
||||||
@@ -41,10 +50,19 @@ func (s *SchoolService) List(q *SchoolQuery) (*SchoolListResp, error) {
|
|||||||
}
|
}
|
||||||
db := s.db.Model(&model.School{})
|
db := s.db.Model(&model.School{})
|
||||||
if q.RegionID > 0 {
|
if q.RegionID > 0 {
|
||||||
db = db.Where("region_id = ?", q.RegionID)
|
// 支持查该地区自身及所有下级地区下的学校
|
||||||
|
regionIDs := s.getSelfAndDescendantRegionIDs(q.RegionID)
|
||||||
|
db = db.Where("region_id IN ?", regionIDs)
|
||||||
}
|
}
|
||||||
if q.SchoolType != "" {
|
if q.SchoolType != "" {
|
||||||
db = db.Where("school_type = ?", q.SchoolType)
|
// school_types 为逗号分隔的多学段字段;旧数据可能为空,回退比较主类型
|
||||||
|
db = db.Where("FIND_IN_SET(?, school_types) > 0 OR ((school_types IS NULL OR school_types = '') AND school_type = ?)", q.SchoolType, q.SchoolType)
|
||||||
|
}
|
||||||
|
if q.Status != "" && q.Status != "all" {
|
||||||
|
db = db.Where("status = ?", q.Status)
|
||||||
|
} else if q.Status == "" {
|
||||||
|
// 公开默认只查 active
|
||||||
|
db = db.Where("status = ? OR status = ''", model.SchoolStatusActive)
|
||||||
}
|
}
|
||||||
if q.Keyword != "" {
|
if q.Keyword != "" {
|
||||||
db = db.Where("name LIKE ? OR name_en LIKE ?", "%"+q.Keyword+"%", "%"+q.Keyword+"%")
|
db = db.Where("name LIKE ? OR name_en LIKE ?", "%"+q.Keyword+"%", "%"+q.Keyword+"%")
|
||||||
@@ -56,37 +74,161 @@ func (s *SchoolService) List(q *SchoolQuery) (*SchoolListResp, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 填充地区路径
|
||||||
|
for i := range list {
|
||||||
|
list[i].RegionPath = s.getRegionPathString(list[i].RegionID)
|
||||||
|
}
|
||||||
|
|
||||||
return &SchoolListResp{List: list, Total: total, Page: q.Page}, nil
|
return &SchoolListResp{List: list, Total: total, Page: q.Page}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create 创建学校(必须挂在 county 级地区下)
|
// getSelfAndDescendantRegionIDs 获取自身及所有下级地区ID
|
||||||
|
func (s *SchoolService) getSelfAndDescendantRegionIDs(rootID uint) []uint {
|
||||||
|
res := []uint{rootID}
|
||||||
|
var children []uint
|
||||||
|
s.db.Model(&model.Region{}).Where("parent_id = ?", rootID).Pluck("id", &children)
|
||||||
|
for _, cID := range children {
|
||||||
|
res = append(res, s.getSelfAndDescendantRegionIDs(cID)...)
|
||||||
|
}
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SchoolService) getRegionPathString(regionID uint) string {
|
||||||
|
if regionID == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var names []string
|
||||||
|
curID := regionID
|
||||||
|
for curID > 0 {
|
||||||
|
var r model.Region
|
||||||
|
if err := s.db.First(&r, curID).Error; err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
names = append([]string{r.Name}, names...)
|
||||||
|
curID = r.ParentID
|
||||||
|
}
|
||||||
|
return strings.Join(names, " / ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stats 统计学校审核状态数据
|
||||||
|
func (s *SchoolService) Stats() (*SchoolStatsResp, error) {
|
||||||
|
var total, pending, active, rejected int64
|
||||||
|
s.db.Model(&model.School{}).Count(&total)
|
||||||
|
s.db.Model(&model.School{}).Where("status = ?", model.SchoolStatusPending).Count(&pending)
|
||||||
|
s.db.Model(&model.School{}).Where("status = ? OR status = ''", model.SchoolStatusActive).Count(&active)
|
||||||
|
s.db.Model(&model.School{}).Where("status = ?", model.SchoolStatusRejected).Count(&rejected)
|
||||||
|
return &SchoolStatsResp{
|
||||||
|
Total: total,
|
||||||
|
Pending: pending,
|
||||||
|
Active: active,
|
||||||
|
Rejected: rejected,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create 创建学校(挂在 county 或 city 级地区下)
|
||||||
func (s *SchoolService) Create(school *model.School) error {
|
func (s *SchoolService) Create(school *model.School) error {
|
||||||
// 校验 region 必须是 county 级
|
|
||||||
var region model.Region
|
var region model.Region
|
||||||
if err := s.db.First(®ion, school.RegionID).Error; err != nil {
|
if err := s.db.First(®ion, school.RegionID).Error; err != nil {
|
||||||
return errors.New("所属地区不存在")
|
return errors.New("所属地区不存在")
|
||||||
}
|
}
|
||||||
if region.Level != model.LevelCounty {
|
if region.Level != model.LevelCounty && region.Level != model.LevelCity {
|
||||||
return errors.New("学校必须挂载在区/县级地区下")
|
return errors.New("学校必须挂载在市级或区/县级地区下")
|
||||||
}
|
}
|
||||||
if school.Name == "" {
|
if school.Name == "" {
|
||||||
return errors.New("学校名称不能为空")
|
return errors.New("学校名称不能为空")
|
||||||
}
|
}
|
||||||
if school.SchoolType == "" {
|
// 规整多学段:优先取 school_types,其次回退到单一主类型
|
||||||
|
types := model.ParseSchoolTypes(school.SchoolTypes)
|
||||||
|
if len(types) == 0 && school.SchoolType != "" {
|
||||||
|
types = model.NormalizeSchoolTypes([]string{string(school.SchoolType)})
|
||||||
|
}
|
||||||
|
if len(types) == 0 {
|
||||||
return errors.New("请选择学校类型")
|
return errors.New("请选择学校类型")
|
||||||
}
|
}
|
||||||
|
school.SchoolTypes = model.JoinSchoolTypes(types)
|
||||||
|
school.SchoolType = types[0]
|
||||||
|
if school.Status == "" {
|
||||||
|
school.Status = model.SchoolStatusActive
|
||||||
|
}
|
||||||
return s.db.Create(school).Error
|
return s.db.Create(school).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Approve 审核通过学校
|
||||||
|
func (s *SchoolService) Approve(id uint) error {
|
||||||
|
res := s.db.Model(&model.School{}).Where("id = ?", id).Updates(map[string]interface{}{
|
||||||
|
"status": model.SchoolStatusActive,
|
||||||
|
"reject_reason": "",
|
||||||
|
})
|
||||||
|
if res.Error != nil {
|
||||||
|
return res.Error
|
||||||
|
}
|
||||||
|
if res.RowsAffected == 0 {
|
||||||
|
return errors.New("学校不存在")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reject 打回学校并填写原因
|
||||||
|
func (s *SchoolService) Reject(id uint, reason string) error {
|
||||||
|
if reason == "" {
|
||||||
|
return errors.New("请输入打回原因")
|
||||||
|
}
|
||||||
|
res := s.db.Model(&model.School{}).Where("id = ?", id).Updates(map[string]interface{}{
|
||||||
|
"status": model.SchoolStatusRejected,
|
||||||
|
"reject_reason": reason,
|
||||||
|
})
|
||||||
|
if res.Error != nil {
|
||||||
|
return res.Error
|
||||||
|
}
|
||||||
|
if res.RowsAffected == 0 {
|
||||||
|
return errors.New("学校不存在")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *SchoolService) GetByID(id uint) (*model.School, error) {
|
func (s *SchoolService) GetByID(id uint) (*model.School, error) {
|
||||||
var school model.School
|
var school model.School
|
||||||
if err := s.db.First(&school, id).Error; err != nil {
|
if err := s.db.First(&school, id).Error; err != nil {
|
||||||
return nil, errors.New("学校不存在")
|
return nil, errors.New("学校不存在")
|
||||||
}
|
}
|
||||||
|
school.RegionPath = s.getRegionPathString(school.RegionID)
|
||||||
return &school, nil
|
return &school, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *SchoolService) Update(id uint, updates map[string]interface{}) error {
|
func (s *SchoolService) Update(id uint, updates map[string]interface{}) error {
|
||||||
|
if regVal, ok := updates["region_id"]; ok {
|
||||||
|
var regID uint
|
||||||
|
switch v := regVal.(type) {
|
||||||
|
case float64:
|
||||||
|
regID = uint(v)
|
||||||
|
case int:
|
||||||
|
regID = uint(v)
|
||||||
|
case int64:
|
||||||
|
regID = uint(v)
|
||||||
|
case uint:
|
||||||
|
regID = v
|
||||||
|
}
|
||||||
|
if regID > 0 {
|
||||||
|
var region model.Region
|
||||||
|
if err := s.db.First(®ion, regID).Error; err != nil {
|
||||||
|
return errors.New("所属地区不存在")
|
||||||
|
}
|
||||||
|
if region.Level != model.LevelCounty && region.Level != model.LevelCity {
|
||||||
|
return errors.New("学校必须挂载在市级或区/县级地区下")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 多学段:school_types 变更时同步刷新主类型 school_type
|
||||||
|
if raw, ok := updates["school_types"]; ok {
|
||||||
|
typesStr, _ := raw.(string)
|
||||||
|
types := model.ParseSchoolTypes(typesStr)
|
||||||
|
if len(types) == 0 {
|
||||||
|
return errors.New("请选择学校类型")
|
||||||
|
}
|
||||||
|
updates["school_types"] = model.JoinSchoolTypes(types)
|
||||||
|
updates["school_type"] = types[0]
|
||||||
|
}
|
||||||
res := s.db.Model(&model.School{}).Where("id = ?", id).Updates(updates)
|
res := s.db.Model(&model.School{}).Where("id = ?", id).Updates(updates)
|
||||||
if res.Error != nil {
|
if res.Error != nil {
|
||||||
return res.Error
|
return res.Error
|
||||||
@@ -104,6 +246,8 @@ func (s *SchoolService) Delete(id uint) error {
|
|||||||
if count > 0 {
|
if count > 0 {
|
||||||
return errors.New("该学校下已有班级,无法删除")
|
return errors.New("该学校下已有班级,无法删除")
|
||||||
}
|
}
|
||||||
|
// 同时删除所属学院
|
||||||
|
s.db.Where("school_id = ?", id).Delete(&model.College{})
|
||||||
return s.db.Delete(&model.School{}, id).Error
|
return s.db.Delete(&model.School{}, id).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,12 +263,11 @@ func (s *SchoolService) CreateCollege(schoolID uint, name string) (*model.Colleg
|
|||||||
if name == "" {
|
if name == "" {
|
||||||
return nil, errors.New("学院名称不能为空")
|
return nil, errors.New("学院名称不能为空")
|
||||||
}
|
}
|
||||||
// 校验学校是否支持学院
|
|
||||||
var school model.School
|
var school model.School
|
||||||
if err := s.db.First(&school, schoolID).Error; err != nil {
|
if err := s.db.First(&school, schoolID).Error; err != nil {
|
||||||
return nil, errors.New("学校不存在")
|
return nil, errors.New("学校不存在")
|
||||||
}
|
}
|
||||||
if !school.SchoolType.HasCollege() {
|
if !school.HasCollegeType() {
|
||||||
return nil, errors.New("该学校类型不支持学院层级")
|
return nil, errors.New("该学校类型不支持学院层级")
|
||||||
}
|
}
|
||||||
c := &model.College{SchoolID: schoolID, Name: name}
|
c := &model.College{SchoolID: schoolID, Name: name}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"photowall/internal/model"
|
"photowall/internal/model"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
@@ -30,33 +32,177 @@ func (s *UserService) GetProfile(userID uint) (*ProfileResp, error) {
|
|||||||
}
|
}
|
||||||
var contacts []model.UserContact
|
var contacts []model.UserContact
|
||||||
s.db.Where("user_id = ?", userID).Order("type, is_primary desc, id").Find(&contacts)
|
s.db.Where("user_id = ?", userID).Order("type, is_primary desc, id").Find(&contacts)
|
||||||
|
|
||||||
|
// 融合基本资料与联系方式:若 contacts 中缺少基本资料中的 phone/email/address,补齐展示
|
||||||
|
hasPhone := false
|
||||||
|
hasEmail := false
|
||||||
|
hasAddress := false
|
||||||
|
for _, c := range contacts {
|
||||||
|
if c.Type == model.ContactPhone {
|
||||||
|
hasPhone = true
|
||||||
|
}
|
||||||
|
if c.Type == model.ContactEmail {
|
||||||
|
hasEmail = true
|
||||||
|
}
|
||||||
|
if c.Type == model.ContactAddress {
|
||||||
|
hasAddress = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !hasPhone && user.Phone != "" {
|
||||||
|
contacts = append(contacts, model.UserContact{
|
||||||
|
UserID: userID,
|
||||||
|
Type: model.ContactPhone,
|
||||||
|
Value: user.Phone,
|
||||||
|
IsPrimary: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if !hasEmail && user.Email != "" {
|
||||||
|
contacts = append(contacts, model.UserContact{
|
||||||
|
UserID: userID,
|
||||||
|
Type: model.ContactEmail,
|
||||||
|
Value: user.Email,
|
||||||
|
IsPrimary: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if !hasAddress && user.Address != "" {
|
||||||
|
contacts = append(contacts, model.UserContact{
|
||||||
|
UserID: userID,
|
||||||
|
Type: model.ContactAddress,
|
||||||
|
Value: user.Address,
|
||||||
|
IsPrimary: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
var edu []model.EducationHistory
|
var edu []model.EducationHistory
|
||||||
s.db.Where("user_id = ?", userID).Order("start_year desc, id desc").Find(&edu)
|
s.db.Where("user_id = ?", userID).Order("start_year desc, id desc").Find(&edu)
|
||||||
return &ProfileResp{User: user, Contacts: contacts, Education: edu}, nil
|
return &ProfileResp{User: user, Contacts: contacts, Education: edu}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IsValidIDCard 校验中国大陆18位二代居民身份证合法性
|
||||||
|
func IsValidIDCard(idCard string) bool {
|
||||||
|
idCard = strings.ToUpper(strings.TrimSpace(idCard))
|
||||||
|
if len(idCard) != 18 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// 正则粗筛:前17位数字,第18位数字或字母X
|
||||||
|
matched, _ := regexp.MatchString(`^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[0-9X]$`, idCard)
|
||||||
|
if !matched {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// ISO 7064:1983.MOD 11-2 校验码加权
|
||||||
|
weights := []int{7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2}
|
||||||
|
checkCodes := []byte{'1', '0', 'X', '9', '8', '7', '6', '5', '4', '3', '2'}
|
||||||
|
sum := 0
|
||||||
|
for i := 0; i < 17; i++ {
|
||||||
|
sum += int(idCard[i]-'0') * weights[i]
|
||||||
|
}
|
||||||
|
expectedCheck := checkCodes[sum%11]
|
||||||
|
return idCard[17] == expectedCheck
|
||||||
|
}
|
||||||
|
|
||||||
type UpdateProfileReq struct {
|
type UpdateProfileReq struct {
|
||||||
|
RealName string `json:"real_name"`
|
||||||
|
Gender string `json:"gender"`
|
||||||
|
Nation string `json:"nation"`
|
||||||
|
PoliticalStatus string `json:"political_status"`
|
||||||
|
IdCard string `json:"id_card"`
|
||||||
|
Address string `json:"address"`
|
||||||
|
Phone string `json:"phone"`
|
||||||
Nickname string `json:"nickname"`
|
Nickname string `json:"nickname"`
|
||||||
|
Email string `json:"email"`
|
||||||
Bio string `json:"bio"`
|
Bio string `json:"bio"`
|
||||||
Avatar string `json:"avatar"`
|
Avatar string `json:"avatar"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *UserService) UpdateProfile(userID uint, req *UpdateProfileReq) error {
|
func (s *UserService) UpdateProfile(userID uint, req *UpdateProfileReq) error {
|
||||||
updates := map[string]interface{}{}
|
updates := map[string]interface{}{}
|
||||||
|
updates["real_name"] = strings.TrimSpace(req.RealName)
|
||||||
|
updates["gender"] = strings.TrimSpace(req.Gender)
|
||||||
|
updates["nation"] = strings.TrimSpace(req.Nation)
|
||||||
|
updates["political_status"] = strings.TrimSpace(req.PoliticalStatus)
|
||||||
|
|
||||||
|
idCard := strings.ToUpper(strings.TrimSpace(req.IdCard))
|
||||||
|
if idCard != "" && !strings.Contains(idCard, "*") {
|
||||||
|
if !IsValidIDCard(idCard) {
|
||||||
|
return errors.New("身份证号码格式不正确,请输入18位有效居民身份证号")
|
||||||
|
}
|
||||||
|
updates["id_card"] = idCard
|
||||||
|
} else if idCard == "" {
|
||||||
|
updates["id_card"] = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
address := strings.TrimSpace(req.Address)
|
||||||
|
updates["address"] = address
|
||||||
|
phone := strings.TrimSpace(req.Phone)
|
||||||
|
updates["phone"] = phone
|
||||||
|
|
||||||
if req.Nickname != "" {
|
if req.Nickname != "" {
|
||||||
updates["nickname"] = req.Nickname
|
updates["nickname"] = strings.TrimSpace(req.Nickname)
|
||||||
|
}
|
||||||
|
email := strings.TrimSpace(req.Email)
|
||||||
|
if email != "" {
|
||||||
|
updates["email"] = email
|
||||||
}
|
}
|
||||||
if req.Bio != "" {
|
|
||||||
updates["bio"] = req.Bio
|
updates["bio"] = req.Bio
|
||||||
|
updates["avatar"] = strings.TrimSpace(req.Avatar)
|
||||||
|
|
||||||
|
if err := s.db.Model(&model.User{}).Where("id = ?", userID).Updates(updates).Error; err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
if req.Avatar != "" {
|
|
||||||
updates["avatar"] = req.Avatar
|
// 融合基本资料与联系方式:同步手机号、邮箱、住址到联系方式表
|
||||||
|
if phone != "" {
|
||||||
|
var primaryPhone model.UserContact
|
||||||
|
if err := s.db.Where("user_id = ? AND type = ? AND is_primary = ?", userID, model.ContactPhone, true).First(&primaryPhone).Error; err == nil {
|
||||||
|
s.db.Model(&primaryPhone).Update("value", phone)
|
||||||
|
} else {
|
||||||
|
var anyPhone model.UserContact
|
||||||
|
if err := s.db.Where("user_id = ? AND type = ?", userID, model.ContactPhone).First(&anyPhone).Error; err == nil {
|
||||||
|
s.db.Model(&anyPhone).Updates(map[string]interface{}{"value": phone, "is_primary": true})
|
||||||
|
} else {
|
||||||
|
s.db.Create(&model.UserContact{
|
||||||
|
UserID: userID,
|
||||||
|
Type: model.ContactPhone,
|
||||||
|
Value: phone,
|
||||||
|
IsPrimary: true,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
if len(updates) == 0 {
|
}
|
||||||
|
}
|
||||||
|
if email != "" {
|
||||||
|
var primaryEmail model.UserContact
|
||||||
|
if err := s.db.Where("user_id = ? AND type = ? AND is_primary = ?", userID, model.ContactEmail, true).First(&primaryEmail).Error; err == nil {
|
||||||
|
s.db.Model(&primaryEmail).Update("value", email)
|
||||||
|
} else {
|
||||||
|
var anyEmail model.UserContact
|
||||||
|
if err := s.db.Where("user_id = ? AND type = ?", userID, model.ContactEmail).First(&anyEmail).Error; err == nil {
|
||||||
|
s.db.Model(&anyEmail).Updates(map[string]interface{}{"value": email, "is_primary": true})
|
||||||
|
} else {
|
||||||
|
s.db.Create(&model.UserContact{
|
||||||
|
UserID: userID,
|
||||||
|
Type: model.ContactEmail,
|
||||||
|
Value: email,
|
||||||
|
IsPrimary: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if address != "" {
|
||||||
|
var anyAddr model.UserContact
|
||||||
|
if err := s.db.Where("user_id = ? AND type = ?", userID, model.ContactAddress).First(&anyAddr).Error; err == nil {
|
||||||
|
s.db.Model(&anyAddr).Updates(map[string]interface{}{"value": address, "is_primary": true})
|
||||||
|
} else {
|
||||||
|
s.db.Create(&model.UserContact{
|
||||||
|
UserID: userID,
|
||||||
|
Type: model.ContactAddress,
|
||||||
|
Value: address,
|
||||||
|
IsPrimary: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return s.db.Model(&model.User{}).Where("id = ?", userID).Updates(updates).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
// AddContact 添加联系方式,校验各类型数量上限
|
// AddContact 添加联系方式,校验各类型数量上限
|
||||||
func (s *UserService) AddContact(userID uint, contactType model.ContactType, value string, isPrimary bool) (*model.UserContact, error) {
|
func (s *UserService) AddContact(userID uint, contactType model.ContactType, value string, isPrimary bool) (*model.UserContact, error) {
|
||||||
@@ -85,6 +231,19 @@ func (s *UserService) AddContact(userID uint, contactType model.ContactType, val
|
|||||||
if err := s.db.Create(c).Error; err != nil {
|
if err := s.db.Create(c).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 融合基本资料与联系方式:若设为主要联系方式或为首个联系方式,同步回写到 User 表中
|
||||||
|
if isPrimary || count == 0 {
|
||||||
|
switch contactType {
|
||||||
|
case model.ContactPhone:
|
||||||
|
s.db.Model(&model.User{}).Where("id = ?", userID).Update("phone", value)
|
||||||
|
case model.ContactEmail:
|
||||||
|
s.db.Model(&model.User{}).Where("id = ?", userID).Update("email", value)
|
||||||
|
case model.ContactAddress:
|
||||||
|
s.db.Model(&model.User{}).Where("id = ?", userID).Update("address", value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return c, nil
|
return c, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,11 +258,24 @@ func (s *UserService) UpdateContact(userID, contactID uint, value string, isPrim
|
|||||||
if isPrimary != nil && *isPrimary {
|
if isPrimary != nil && *isPrimary {
|
||||||
s.db.Model(&model.UserContact{}).Where("user_id = ? AND type = ?", userID, c.Type).Update("is_primary", false)
|
s.db.Model(&model.UserContact{}).Where("user_id = ? AND type = ?", userID, c.Type).Update("is_primary", false)
|
||||||
c.IsPrimary = true
|
c.IsPrimary = true
|
||||||
|
// 同步回写至 User 表
|
||||||
|
switch c.Type {
|
||||||
|
case model.ContactPhone:
|
||||||
|
s.db.Model(&model.User{}).Where("id = ?", userID).Update("phone", c.Value)
|
||||||
|
case model.ContactEmail:
|
||||||
|
s.db.Model(&model.User{}).Where("id = ?", userID).Update("email", c.Value)
|
||||||
|
case model.ContactAddress:
|
||||||
|
s.db.Model(&model.User{}).Where("id = ?", userID).Update("address", c.Value)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return s.db.Save(&c).Error
|
return s.db.Save(&c).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *UserService) DeleteContact(userID, contactID uint) error {
|
func (s *UserService) DeleteContact(userID, contactID uint) error {
|
||||||
|
var c model.UserContact
|
||||||
|
if err := s.db.Where("id = ? AND user_id = ?", contactID, userID).First(&c).Error; err != nil {
|
||||||
|
return errors.New("联系方式不存在")
|
||||||
|
}
|
||||||
res := s.db.Where("id = ? AND user_id = ?", contactID, userID).Delete(&model.UserContact{})
|
res := s.db.Where("id = ? AND user_id = ?", contactID, userID).Delete(&model.UserContact{})
|
||||||
if res.Error != nil {
|
if res.Error != nil {
|
||||||
return res.Error
|
return res.Error
|
||||||
@@ -111,6 +283,35 @@ func (s *UserService) DeleteContact(userID, contactID uint) error {
|
|||||||
if res.RowsAffected == 0 {
|
if res.RowsAffected == 0 {
|
||||||
return errors.New("联系方式不存在")
|
return errors.New("联系方式不存在")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 若删除的是主联系方式,同步 User 表中对应信息
|
||||||
|
switch c.Type {
|
||||||
|
case model.ContactPhone:
|
||||||
|
var nextPhone model.UserContact
|
||||||
|
if err := s.db.Where("user_id = ? AND type = ?", userID, model.ContactPhone).Order("is_primary desc, id").First(&nextPhone).Error; err == nil {
|
||||||
|
s.db.Model(&nextPhone).Update("is_primary", true)
|
||||||
|
s.db.Model(&model.User{}).Where("id = ?", userID).Update("phone", nextPhone.Value)
|
||||||
|
} else {
|
||||||
|
s.db.Model(&model.User{}).Where("id = ?", userID).Update("phone", "")
|
||||||
|
}
|
||||||
|
case model.ContactEmail:
|
||||||
|
var nextEmail model.UserContact
|
||||||
|
if err := s.db.Where("user_id = ? AND type = ?", userID, model.ContactEmail).Order("is_primary desc, id").First(&nextEmail).Error; err == nil {
|
||||||
|
s.db.Model(&nextEmail).Update("is_primary", true)
|
||||||
|
s.db.Model(&model.User{}).Where("id = ?", userID).Update("email", nextEmail.Value)
|
||||||
|
} else {
|
||||||
|
s.db.Model(&model.User{}).Where("id = ?", userID).Update("email", "")
|
||||||
|
}
|
||||||
|
case model.ContactAddress:
|
||||||
|
var nextAddr model.UserContact
|
||||||
|
if err := s.db.Where("user_id = ? AND type = ?", userID, model.ContactAddress).Order("is_primary desc, id").First(&nextAddr).Error; err == nil {
|
||||||
|
s.db.Model(&nextAddr).Update("is_primary", true)
|
||||||
|
s.db.Model(&model.User{}).Where("id = ?", userID).Update("address", nextAddr.Value)
|
||||||
|
} else {
|
||||||
|
s.db.Model(&model.User{}).Where("id = ?", userID).Update("address", "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,6 +376,32 @@ func (s *UserService) GetUserByID(userID uint) (*model.User, error) {
|
|||||||
// GetUserContacts 公开用户联系方式(同班同学可查看)
|
// GetUserContacts 公开用户联系方式(同班同学可查看)
|
||||||
func (s *UserService) GetUserContacts(userID uint) []model.UserContact {
|
func (s *UserService) GetUserContacts(userID uint) []model.UserContact {
|
||||||
var list []model.UserContact
|
var list []model.UserContact
|
||||||
s.db.Where("user_id = ?", userID).Order("type, is_primary desc").Find(&list)
|
s.db.Where("user_id = ?", userID).Order("type, is_primary desc, id").Find(&list)
|
||||||
|
var u model.User
|
||||||
|
if s.db.Select("id, phone, email, address").First(&u, userID).Error == nil {
|
||||||
|
hasPhone := false
|
||||||
|
hasEmail := false
|
||||||
|
hasAddress := false
|
||||||
|
for _, c := range list {
|
||||||
|
if c.Type == model.ContactPhone {
|
||||||
|
hasPhone = true
|
||||||
|
}
|
||||||
|
if c.Type == model.ContactEmail {
|
||||||
|
hasEmail = true
|
||||||
|
}
|
||||||
|
if c.Type == model.ContactAddress {
|
||||||
|
hasAddress = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !hasPhone && u.Phone != "" {
|
||||||
|
list = append(list, model.UserContact{UserID: userID, Type: model.ContactPhone, Value: u.Phone, IsPrimary: true})
|
||||||
|
}
|
||||||
|
if !hasEmail && u.Email != "" {
|
||||||
|
list = append(list, model.UserContact{UserID: userID, Type: model.ContactEmail, Value: u.Email, IsPrimary: true})
|
||||||
|
}
|
||||||
|
if !hasAddress && u.Address != "" {
|
||||||
|
list = append(list, model.UserContact{UserID: userID, Type: model.ContactAddress, Value: u.Address, IsPrimary: true})
|
||||||
|
}
|
||||||
|
}
|
||||||
return list
|
return list
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-6
@@ -9,14 +9,14 @@
|
|||||||
<div class="menu">
|
<div class="menu">
|
||||||
<template v-for="menu in visibleMenus" :key="menu.id">
|
<template v-for="menu in visibleMenus" :key="menu.id">
|
||||||
<!-- 有子菜单 -->
|
<!-- 有子菜单 -->
|
||||||
<div v-if="menu.children && menu.children.filter(c => c.visible).length > 0">
|
<div v-if="menu.children && menu.children.length > 0">
|
||||||
<div class="submenu-title" :class="{ open: openSubmenus.includes(menu.id) }" @click="toggleSubmenu(menu.id)">
|
<div class="submenu-title" :class="{ open: openSubmenus.includes(menu.id) }" @click="toggleSubmenu(menu.id)">
|
||||||
<i :class="menu.icon || 'fas fa-folder'"></i>
|
<i :class="menu.icon || 'fas fa-folder'"></i>
|
||||||
<span v-if="!siderCollapsed">{{ menu.title }}</span>
|
<span v-if="!siderCollapsed">{{ menu.title }}</span>
|
||||||
<i v-if="!siderCollapsed" class="fas fa-chevron-right arrow"></i>
|
<i v-if="!siderCollapsed" class="fas fa-chevron-right arrow"></i>
|
||||||
</div>
|
</div>
|
||||||
<div class="submenu-items" v-show="openSubmenus.includes(menu.id)">
|
<div class="submenu-items" v-show="openSubmenus.includes(menu.id)">
|
||||||
<div v-for="child in menu.children.filter(c => c.visible)" :key="child.id"
|
<div v-for="child in menu.children" :key="child.id"
|
||||||
class="menu-item" :class="{ active: $route.path === child.path }"
|
class="menu-item" :class="{ active: $route.path === child.path }"
|
||||||
@click="navigate(child.path)">
|
@click="navigate(child.path)">
|
||||||
<i :class="child.icon || 'fas fa-circle'"></i>
|
<i :class="child.icon || 'fas fa-circle'"></i>
|
||||||
@@ -43,8 +43,8 @@
|
|||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
<el-dropdown @command="handleCommand">
|
<el-dropdown @command="handleCommand">
|
||||||
<div class="user-info">
|
<div class="user-info">
|
||||||
<div class="avatar">{{ (user.nickname || user.username || 'U').charAt(0).toUpperCase() }}</div>
|
<div class="avatar">{{ (user.real_name || user.nickname || user.username || 'U').charAt(0).toUpperCase() }}</div>
|
||||||
<span class="username">{{ user.nickname || user.username }}</span>
|
<span class="username">{{ user.real_name || user.nickname || user.username }}</span>
|
||||||
<span v-if="isAdmin" class="role-tag">管理员</span>
|
<span v-if="isAdmin" class="role-tag">管理员</span>
|
||||||
<i class="fas fa-chevron-down" style="color:#8c8c8c;font-size:12px"></i>
|
<i class="fas fa-chevron-down" style="color:#8c8c8c;font-size:12px"></i>
|
||||||
</div>
|
</div>
|
||||||
@@ -91,9 +91,16 @@ onMounted(() => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// 过滤可见菜单
|
// 过滤可见菜单(排除包含动态路径参数如 :id 的页面)
|
||||||
|
const isNavMenu = (m) => !!m && m.visible && (!m.path || !m.path.includes(':'))
|
||||||
|
|
||||||
const visibleMenus = computed(() => {
|
const visibleMenus = computed(() => {
|
||||||
return menus.value.filter(m => m.visible)
|
return (menus.value || [])
|
||||||
|
.filter(isNavMenu)
|
||||||
|
.map(m => ({
|
||||||
|
...m,
|
||||||
|
children: (m.children || []).filter(isNavMenu)
|
||||||
|
}))
|
||||||
})
|
})
|
||||||
|
|
||||||
const currentTitle = computed(() => {
|
const currentTitle = computed(() => {
|
||||||
|
|||||||
+34
-1
@@ -55,9 +55,11 @@ export const userApi = {
|
|||||||
profile: () => http.get('/user/profile'),
|
profile: () => http.get('/user/profile'),
|
||||||
updateProfile: data => http.put('/user/profile', data),
|
updateProfile: data => http.put('/user/profile', data),
|
||||||
hasEducation: () => http.get('/user/has-education'),
|
hasEducation: () => http.get('/user/has-education'),
|
||||||
|
listContacts: () => http.get('/user/profile').then(r => ({ data: r.data?.contacts || [] })),
|
||||||
addContact: data => http.post('/user/contacts', data),
|
addContact: data => http.post('/user/contacts', data),
|
||||||
updateContact: (id, data) => http.put(`/user/contacts/${id}`, data),
|
updateContact: (id, data) => http.put(`/user/contacts/${id}`, data),
|
||||||
deleteContact: id => http.delete(`/user/contacts/${id}`),
|
deleteContact: id => http.delete(`/user/contacts/${id}`),
|
||||||
|
listEducation: () => http.get('/user/profile').then(r => ({ data: r.data?.education || [] })),
|
||||||
addEducation: data => http.post('/user/education', data),
|
addEducation: data => http.post('/user/education', data),
|
||||||
updateEducation: (id, data) => http.put(`/user/education/${id}`, data),
|
updateEducation: (id, data) => http.put(`/user/education/${id}`, data),
|
||||||
deleteEducation: id => http.delete(`/user/education/${id}`),
|
deleteEducation: id => http.delete(`/user/education/${id}`),
|
||||||
@@ -66,7 +68,7 @@ export const userApi = {
|
|||||||
|
|
||||||
// ============ 地区 ============
|
// ============ 地区 ============
|
||||||
export const regionApi = {
|
export const regionApi = {
|
||||||
list: parentId => http.get('/regions', { params: { parent_id: parentId || 0 } }),
|
list: (parentId, params = {}) => http.get('/regions', { params: { parent_id: parentId || 0, ...params } }),
|
||||||
countries: () => http.get('/regions/countries'),
|
countries: () => http.get('/regions/countries'),
|
||||||
search: keyword => http.get('/regions/search', { params: { keyword } }),
|
search: keyword => http.get('/regions/search', { params: { keyword } }),
|
||||||
path: id => http.get(`/regions/${id}/path`)
|
path: id => http.get(`/regions/${id}/path`)
|
||||||
@@ -109,6 +111,7 @@ export const classApi = {
|
|||||||
// ============ 平台管理员 ============
|
// ============ 平台管理员 ============
|
||||||
export const adminApi = {
|
export const adminApi = {
|
||||||
stats: () => http.get('/admin/stats'),
|
stats: () => http.get('/admin/stats'),
|
||||||
|
listUsers: params => http.get('/admin/users', { params }),
|
||||||
auditQueue: params => http.get('/admin/classes', { params }),
|
auditQueue: params => http.get('/admin/classes', { params }),
|
||||||
approve: (id, data) => http.post(`/admin/classes/${id}/approve`, data),
|
approve: (id, data) => http.post(`/admin/classes/${id}/approve`, data),
|
||||||
reject: (id, data) => http.post(`/admin/classes/${id}/reject`, data),
|
reject: (id, data) => http.post(`/admin/classes/${id}/reject`, data),
|
||||||
@@ -181,3 +184,33 @@ export const sensitiveApi = {
|
|||||||
delete: id => http.delete(`/admin/sensitive-words/${id}`),
|
delete: id => http.delete(`/admin/sensitive-words/${id}`),
|
||||||
toggle: (id, enabled) => http.put(`/admin/sensitive-words/${id}/toggle`, { enabled })
|
toggle: (id, enabled) => http.put(`/admin/sensitive-words/${id}/toggle`, { enabled })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============ 基础数据管理(管理员:地区与学校) ============
|
||||||
|
export const adminBaseDataApi = {
|
||||||
|
// 地区接口
|
||||||
|
getRegionTree: parentId => http.get('/admin/regions/tree', { params: { parent_id: parentId || 0 } }),
|
||||||
|
getRegionList: parentId => http.get('/regions', { params: { parent_id: parentId || 0 } }),
|
||||||
|
searchRegions: keyword => http.get('/regions/search', { params: { keyword } }),
|
||||||
|
getRegionPath: id => http.get(`/regions/${id}/path`),
|
||||||
|
createRegion: data => http.post('/admin/regions', data),
|
||||||
|
updateRegion: (id, data) => http.put(`/admin/regions/${id}`, data),
|
||||||
|
deleteRegion: id => http.delete(`/admin/regions/${id}`),
|
||||||
|
initChinaRegions: () => http.post('/admin/regions/init-china'),
|
||||||
|
|
||||||
|
// 学校接口
|
||||||
|
getSchoolStats: () => http.get('/admin/schools/stats'),
|
||||||
|
getSchools: params => http.get('/admin/schools', { params }),
|
||||||
|
getSchool: id => http.get(`/schools/${id}`),
|
||||||
|
createSchool: data => http.post('/admin/schools', data),
|
||||||
|
updateSchool: (id, data) => http.put(`/admin/schools/${id}`, data),
|
||||||
|
deleteSchool: id => http.delete(`/admin/schools/${id}`),
|
||||||
|
approveSchool: id => http.post(`/admin/schools/${id}/approve`),
|
||||||
|
rejectSchool: (id, reason) => http.post(`/admin/schools/${id}/reject`, { reason }),
|
||||||
|
|
||||||
|
// 学院接口
|
||||||
|
getColleges: schoolId => http.get(`/schools/${schoolId}/colleges`),
|
||||||
|
createCollege: (schoolId, name) => http.post(`/admin/schools/${schoolId}/colleges`, { name }),
|
||||||
|
updateCollege: (collegeId, name) => http.put(`/admin/colleges/${collegeId}`, { name }),
|
||||||
|
deleteCollege: collegeId => http.delete(`/admin/colleges/${collegeId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -31,25 +31,44 @@
|
|||||||
<div v-else class="empty"><i class="fas fa-image"></i><p>未上传毕业照</p></div>
|
<div v-else class="empty"><i class="fas fa-image"></i><p>未上传毕业照</p></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 同学名单 -->
|
<!-- 毕业照站位与人员名单 -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h3 style="margin-bottom:12px">
|
<h3 style="margin-bottom:12px">
|
||||||
<i class="fas fa-user-friends" style="color:#1890ff"></i> 同学名单 ({{ detail?.student_names?.length || 0 }}人)
|
<i class="fas fa-user-friends" style="color:#1890ff"></i> 毕业照站位与人员名单 ({{ totalPeople }}人)
|
||||||
<el-tag v-if="(detail?.student_names?.length || 0) < 8" type="danger" style="margin-left:8px">不足8人,需打回补齐</el-tag>
|
<el-tag v-if="totalPeople < 8" type="danger" style="margin-left:8px">不足8人,需打回补齐</el-tag>
|
||||||
|
<span v-if="detail?.row_count > 1" style="font-size:12px;color:#8c8c8c;font-weight:normal;margin-left:8px">
|
||||||
|
共 {{ detail.row_count }} 排,从前至后、从左往右
|
||||||
|
</span>
|
||||||
</h3>
|
</h3>
|
||||||
<div style="display:flex;flex-wrap:wrap;gap:8px">
|
<!-- 按排展示 -->
|
||||||
|
<div v-if="rosterRows.length > 0" class="audit-roster-rows">
|
||||||
|
<div v-for="row in rosterRows" :key="row.rowNo" class="audit-roster-row">
|
||||||
|
<div class="audit-roster-row-header">
|
||||||
|
<span class="audit-row-num">第 {{ row.rowNo }} 排</span>
|
||||||
|
<span class="audit-row-desc">{{ row.rowNo === 1 ? '前排就坐 / 从左往右' : '从左往右' }}</span>
|
||||||
|
<span class="audit-row-count">({{ row.items.length }}人)</span>
|
||||||
|
</div>
|
||||||
|
<div class="audit-roster-tags">
|
||||||
|
<span
|
||||||
|
v-for="person in row.items"
|
||||||
|
:key="person.id || person.name"
|
||||||
|
:class="['roster-tag-item', person.role === 'teacher' ? 'is-teacher' : 'is-student']"
|
||||||
|
>
|
||||||
|
<span class="p-pos">#{{ person.col_no }}</span>
|
||||||
|
<span class="p-name">{{ person.name }}</span>
|
||||||
|
<span v-if="person.role === 'teacher'" class="p-role">老师</span>
|
||||||
|
<span v-if="person.is_claimed" class="p-claim claimed">已绑定</span>
|
||||||
|
<span v-else class="p-claim unclaimed">待认领</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- 兜底旧名单 -->
|
||||||
|
<div v-else style="display:flex;flex-wrap:wrap;gap:8px">
|
||||||
<span v-for="(name, i) in detail?.student_names" :key="i" class="tag primary" style="padding:4px 12px">{{ i+1 }}. {{ name }}</span>
|
<span v-for="(name, i) in detail?.student_names" :key="i" class="tag primary" style="padding:4px 12px">{{ i+1 }}. {{ name }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 老师名单 -->
|
|
||||||
<div v-if="detail?.teacher_names?.length > 0" class="card">
|
|
||||||
<h3 style="margin-bottom:12px"><i class="fas fa-chalkboard-teacher" style="color:#1890ff"></i> 老师名单 ({{ detail.teacher_names.length }}人)</h3>
|
|
||||||
<div style="display:flex;flex-wrap:wrap;gap:8px">
|
|
||||||
<span v-for="(name, i) in detail.teacher_names" :key="i" class="tag success" style="padding:4px 12px">{{ i+1 }}. {{ name }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 审核日志 -->
|
<!-- 审核日志 -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h3 style="margin-bottom:12px"><i class="fas fa-history" style="color:#1890ff"></i> 审核日志</h3>
|
<h3 style="margin-bottom:12px"><i class="fas fa-history" style="color:#1890ff"></i> 审核日志</h3>
|
||||||
@@ -93,7 +112,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { classApi, adminApi } from '../../../../api'
|
import { classApi, adminApi } from '../../../../api'
|
||||||
@@ -105,6 +124,29 @@ const loading = ref(false)
|
|||||||
const showReject = ref(false)
|
const showReject = ref(false)
|
||||||
const rejectReason = ref('')
|
const rejectReason = ref('')
|
||||||
|
|
||||||
|
const rosterRows = computed(() => {
|
||||||
|
const list = detail.value?.roster || []
|
||||||
|
if (!list.length) return []
|
||||||
|
const groupMap = {}
|
||||||
|
list.forEach(item => {
|
||||||
|
const r = item.row_no || 1
|
||||||
|
if (!groupMap[r]) groupMap[r] = []
|
||||||
|
groupMap[r].push(item)
|
||||||
|
})
|
||||||
|
return Object.keys(groupMap)
|
||||||
|
.map(Number)
|
||||||
|
.sort((a, b) => a - b)
|
||||||
|
.map(r => ({
|
||||||
|
rowNo: r,
|
||||||
|
items: groupMap[r].sort((a, b) => (a.col_no || 0) - (b.col_no || 0))
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
const totalPeople = computed(() => {
|
||||||
|
if (detail.value?.roster?.length) return detail.value.roster.length
|
||||||
|
return (detail.value?.student_names?.length || 0) + (detail.value?.teacher_names?.length || 0)
|
||||||
|
})
|
||||||
|
|
||||||
const statusMap = {
|
const statusMap = {
|
||||||
pending: { text: '待审核', cls: 'warning' },
|
pending: { text: '待审核', cls: 'warning' },
|
||||||
active: { text: '已通过', cls: 'success' },
|
active: { text: '已通过', cls: 'success' },
|
||||||
@@ -114,9 +156,15 @@ const statusMap = {
|
|||||||
const actionMap = { approve: '通过', reject: '打回', manual_delete: '删除', resubmit: '重新提交', create: '创建' }
|
const actionMap = { approve: '通过', reject: '打回', manual_delete: '删除', resubmit: '重新提交', create: '创建' }
|
||||||
|
|
||||||
async function loadDetail() {
|
async function loadDetail() {
|
||||||
|
const auditId = route.params.id
|
||||||
|
if (!auditId || auditId === ':id' || isNaN(Number(auditId))) {
|
||||||
|
ElMessage.error('无效的审核记录 ID')
|
||||||
|
router.replace('/admin/audit')
|
||||||
|
return
|
||||||
|
}
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const res = await classApi.detail(route.params.id)
|
const res = await classApi.detail(auditId)
|
||||||
detail.value = res.data
|
detail.value = res.data
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
ElMessage.error('加载失败')
|
ElMessage.error('加载失败')
|
||||||
@@ -160,3 +208,109 @@ onMounted(() => {
|
|||||||
loadLogs()
|
loadLogs()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.audit-roster-rows {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audit-roster-row {
|
||||||
|
background: #fafafa;
|
||||||
|
border: 1px solid #f0f0f0;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audit-roster-row-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audit-row-num {
|
||||||
|
background: #1890ff;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 1px 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audit-row-desc {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #8c8c8c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audit-row-count {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #595959;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audit-roster-tags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.roster-tag-item {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 13px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.roster-tag-item.is-student {
|
||||||
|
background: #e6f7ff;
|
||||||
|
border-color: #bae7ff;
|
||||||
|
color: #0050b3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.roster-tag-item.is-teacher {
|
||||||
|
background: #fff7e6;
|
||||||
|
border-color: #ffd591;
|
||||||
|
color: #d46b08;
|
||||||
|
}
|
||||||
|
|
||||||
|
.p-pos {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
opacity: 0.65;
|
||||||
|
}
|
||||||
|
|
||||||
|
.p-name {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.p-role {
|
||||||
|
font-size: 10px;
|
||||||
|
background: rgba(212, 107, 8, 0.15);
|
||||||
|
color: #d46b08;
|
||||||
|
padding: 0 4px;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.p-claim {
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 1px 5px;
|
||||||
|
border-radius: 2px;
|
||||||
|
margin-left: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.p-claim.claimed {
|
||||||
|
background: #f6ffed;
|
||||||
|
color: #52c41a;
|
||||||
|
border: 1px solid #b7eb8f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.p-claim.unclaimed {
|
||||||
|
background: #f5f5f5;
|
||||||
|
color: #8c8c8c;
|
||||||
|
border: 1px solid #d9d9d9;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,78 +1,258 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div class="menu-admin-container">
|
||||||
<div class="page-title">
|
<div class="page-title">
|
||||||
<i class="fas fa-list-alt"></i>菜单管理
|
<div class="title-left">
|
||||||
<el-button type="primary" style="float:right" @click="showAdd(null)">
|
<i class="fas fa-sitemap"></i> 菜单与动态路由管理
|
||||||
<i class="fas fa-plus" style="margin-right:4px"></i>新增菜单
|
</div>
|
||||||
</el-button>
|
<div class="title-desc">
|
||||||
|
数据库存储的树形菜单与权限路由,登录后根据角色动态挂载前端路由
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<el-alert type="info" :closable="false" style="margin-bottom:16px">
|
<el-alert type="info" :closable="false" style="margin-bottom: 16px" show-icon>
|
||||||
菜单数据存储在数据库中,登录后根据用户角色动态加载路由。修改菜单后需重新登录生效。
|
菜单数据以树形层级结构组织。修改菜单、路由或权限配置后,需重新登录或刷新页面以重新加载动态路由。
|
||||||
</el-alert>
|
</el-alert>
|
||||||
|
|
||||||
<el-table :data="treeData" row-key="id" :tree-props="{ children: 'children' }" default-expand-all v-loading="loading">
|
<!-- 操作工具条 -->
|
||||||
<el-table-column prop="title" label="菜单名称" min-width="180" />
|
<div class="menu-action-bar">
|
||||||
<el-table-column prop="path" label="路由路径" min-width="180" />
|
<div class="action-left">
|
||||||
<el-table-column prop="component" label="组件路径" min-width="200">
|
<el-button type="primary" @click="showAdd(null)">
|
||||||
|
<i class="fas fa-plus" style="margin-right: 6px"></i> 新增顶级菜单
|
||||||
|
</el-button>
|
||||||
|
<el-button @click="toggleExpandAll">
|
||||||
|
<i :class="isAllExpanded ? 'fas fa-compress-alt' : 'fas fa-expand-alt'" style="margin-right: 6px"></i>
|
||||||
|
{{ isAllExpanded ? '全部折叠' : '全部展开' }}
|
||||||
|
</el-button>
|
||||||
|
<el-button @click="load">
|
||||||
|
<i class="fas fa-sync-alt" style="margin-right: 6px"></i> 刷新
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="action-right">
|
||||||
|
<el-input
|
||||||
|
v-model="searchKeyword"
|
||||||
|
placeholder="搜索菜单标题 / 路由标识 / 路径"
|
||||||
|
clearable
|
||||||
|
style="width: 280px"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<i class="fas fa-search" style="color: #8c8c8c"></i>
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 树形表格 -->
|
||||||
|
<el-table
|
||||||
|
:key="tableKey"
|
||||||
|
:data="filteredTreeData"
|
||||||
|
row-key="id"
|
||||||
|
:tree-props="{ children: 'children' }"
|
||||||
|
:default-expand-all="isAllExpanded"
|
||||||
|
v-loading="loading"
|
||||||
|
stripe
|
||||||
|
style="width: 100%"
|
||||||
|
>
|
||||||
|
<el-table-column prop="title" label="菜单标题" min-width="220">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<code style="font-size:12px;color:#1890ff">{{ row.component || '-' }}</code>
|
<span class="menu-title-cell">
|
||||||
|
<i v-if="row.icon" :class="row.icon" class="menu-icon"></i>
|
||||||
|
<i v-else class="fas fa-file-alt menu-icon default-icon"></i>
|
||||||
|
<span class="menu-title-text">{{ row.title }}</span>
|
||||||
|
<el-tag
|
||||||
|
v-if="!row.component && (!row.children || row.children.length === 0)"
|
||||||
|
size="small"
|
||||||
|
type="info"
|
||||||
|
effect="plain"
|
||||||
|
style="margin-left: 6px; transform: scale(0.85)"
|
||||||
|
>
|
||||||
|
空目录
|
||||||
|
</el-tag>
|
||||||
|
<el-tag
|
||||||
|
v-else-if="!row.component"
|
||||||
|
size="small"
|
||||||
|
type="primary"
|
||||||
|
effect="plain"
|
||||||
|
style="margin-left: 6px; transform: scale(0.85)"
|
||||||
|
>
|
||||||
|
目录
|
||||||
|
</el-tag>
|
||||||
|
</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="icon" label="图标" width="100">
|
|
||||||
|
<el-table-column prop="name" label="路由标识 (name)" min-width="150">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<i :class="row.icon" style="color:#1890ff"></i>
|
<code class="code-badge-name">{{ row.name }}</code>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="权限" width="120">
|
|
||||||
|
<el-table-column prop="path" label="路由路径 (path)" min-width="160">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-tag v-if="row.require_admin" type="danger" size="small">管理员</el-tag>
|
<code class="code-badge">{{ row.path }}</code>
|
||||||
<el-tag v-else type="success" size="small">登录用户</el-tag>
|
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="sort_order" label="排序" width="80" />
|
|
||||||
<el-table-column label="显示" width="80">
|
<el-table-column prop="component" label="组件路径 (component)" min-width="180">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-switch v-model="row.visible" @change="toggleVisible(row)" />
|
<span v-if="row.component" class="comp-text">{{ row.component }}</span>
|
||||||
|
<span v-else-if="row.redirect" class="redirect-text">
|
||||||
|
<i class="fas fa-external-link-alt" style="margin-right: 4px; font-size: 11px"></i>{{ row.redirect }}
|
||||||
|
</span>
|
||||||
|
<span v-else class="empty-text">目录节点</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="操作" width="180" fixed="right">
|
|
||||||
|
<el-table-column label="权限配置" width="130" align="center">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-button type="primary" link @click="showAdd(row)">添加子菜单</el-button>
|
<el-tag v-if="row.require_admin" type="danger" size="small" effect="dark">
|
||||||
<el-button type="warning" link @click="edit(row)">编辑</el-button>
|
<i class="fas fa-shield-alt" style="margin-right: 3px"></i>管理员
|
||||||
<el-button type="danger" link @click="del(row)">删除</el-button>
|
</el-tag>
|
||||||
|
<el-tag v-else-if="row.require_auth" type="warning" size="small">
|
||||||
|
<i class="fas fa-lock" style="margin-right: 3px"></i>需登录
|
||||||
|
</el-tag>
|
||||||
|
<el-tag v-else type="success" size="small">
|
||||||
|
<i class="fas fa-globe" style="margin-right: 3px"></i>公开
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
|
||||||
|
<el-table-column label="排序" width="80" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span class="sort-number">{{ row.sort ?? row.sort_order ?? 0 }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
|
||||||
|
<el-table-column label="显示" width="90" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tooltip
|
||||||
|
v-if="row.path?.includes(':')"
|
||||||
|
content="动态参数路由(如 :id)不可在导航栏直接显示"
|
||||||
|
placement="top"
|
||||||
|
>
|
||||||
|
<el-switch :model-value="false" disabled />
|
||||||
|
</el-tooltip>
|
||||||
|
<el-switch
|
||||||
|
v-else
|
||||||
|
v-model="row.visible"
|
||||||
|
:loading="row._switchLoading"
|
||||||
|
@change="toggleVisible(row)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
|
||||||
|
<el-table-column label="缓存" width="75" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag v-if="row.keep_alive" type="success" size="small" effect="plain">是</el-tag>
|
||||||
|
<span v-else style="color: #bfbfbf; font-size: 12px">否</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
|
||||||
|
<el-table-column label="操作" width="220" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button type="primary" link size="small" @click="showAdd(row)">
|
||||||
|
<i class="fas fa-plus" style="margin-right: 2px"></i>子菜单
|
||||||
|
</el-button>
|
||||||
|
<el-button type="primary" link size="small" @click="edit(row)">
|
||||||
|
<i class="fas fa-edit" style="margin-right: 2px"></i>编辑
|
||||||
|
</el-button>
|
||||||
|
<el-button type="danger" link size="small" @click="del(row)">
|
||||||
|
<i class="fas fa-trash-alt" style="margin-right: 2px"></i>删除
|
||||||
|
</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 编辑弹窗 -->
|
<!-- 新增 / 编辑菜单弹窗 -->
|
||||||
<el-dialog v-model="showDialog" :title="form.id ? '编辑菜单' : '新增菜单'" width="550px">
|
<el-dialog
|
||||||
<el-form :model="form" label-width="100px">
|
v-model="showDialog"
|
||||||
<el-form-item label="菜单名称" required><el-input v-model="form.title" /></el-form-item>
|
:title="form.id ? '编辑菜单' : '新增菜单'"
|
||||||
<el-form-item label="路由路径"><el-input v-model="form.path" placeholder="如 /class" /></el-form-item>
|
width="600px"
|
||||||
<el-form-item label="组件路径"><el-input v-model="form.component" placeholder="如 class/list/index" /></el-form-item>
|
destroy-on-close
|
||||||
<el-form-item label="图标"><el-input v-model="form.icon" placeholder="如 fas fa-users" /></el-form-item>
|
>
|
||||||
<el-form-item label="父级菜单">
|
<el-form :model="form" label-width="110px" :rules="formRules" ref="formRef">
|
||||||
<el-select v-model="form.parent_id" clearable style="width:100%">
|
<el-form-item label="父级菜单" prop="parent_id">
|
||||||
<el-option label="顶级菜单" :value="0" />
|
<el-select v-model="form.parent_id" style="width: 100%">
|
||||||
<el-option v-for="m in flatMenus" :key="m.id" :label="m.title" :value="m.id" />
|
<el-option label="[顶级菜单] (根目录)" :value="0" />
|
||||||
|
<el-option
|
||||||
|
v-for="m in parentSelectOptions"
|
||||||
|
:key="m.id"
|
||||||
|
:label="m.indentedTitle"
|
||||||
|
:value="m.id"
|
||||||
|
/>
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="排序"><el-input-number v-model="form.sort_order" :min="0" style="width:100%" /></el-form-item>
|
|
||||||
<el-form-item label="权限">
|
<el-form-item label="菜单标题" prop="title" required>
|
||||||
<el-radio-group v-model="form.require_admin">
|
<el-input v-model="form.title" placeholder="显示在侧边导航栏的标题,例如:基础数据" />
|
||||||
<el-radio :value="false">登录用户</el-radio>
|
</el-form-item>
|
||||||
<el-radio :value="true">管理员</el-radio>
|
|
||||||
|
<el-form-item label="路由标识" prop="name" required>
|
||||||
|
<el-input v-model="form.name" placeholder="唯一英文字符串,例如:AdminBaseData" />
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="路由路径" prop="path" required>
|
||||||
|
<el-input v-model="form.path" placeholder="浏览器访问路径,例如:/admin/basedata" />
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="组件路径" prop="component">
|
||||||
|
<el-input
|
||||||
|
v-model="form.component"
|
||||||
|
placeholder="相对 views/ 目录下的组件,例如:admin/basedata/index(目录留空)"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="重定向路径" prop="redirect">
|
||||||
|
<el-input v-model="form.redirect" placeholder="可选,例如:/admin/dashboard(用于目录点击自动跳转)" />
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="图标类名" prop="icon">
|
||||||
|
<div style="display: flex; gap: 10px; width: 100%; align-items: center">
|
||||||
|
<el-input v-model="form.icon" placeholder="FontAwesome 类名,例如:fas fa-database" style="flex: 1" />
|
||||||
|
<div class="icon-preview-box" :title="form.icon">
|
||||||
|
<i v-if="form.icon" :class="form.icon"></i>
|
||||||
|
<span v-else style="font-size: 12px; color: #bfbfbf">无</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="排序权重" prop="sort">
|
||||||
|
<el-input-number v-model="form.sort" :min="0" :max="9999" style="width: 100%" />
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="访问权限">
|
||||||
|
<el-radio-group v-model="authRadio">
|
||||||
|
<el-radio label="public">公开访问</el-radio>
|
||||||
|
<el-radio label="user">需要登录</el-radio>
|
||||||
|
<el-radio label="admin">仅管理员</el-radio>
|
||||||
</el-radio-group>
|
</el-radio-group>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="显示菜单"><el-switch v-model="form.visible" /></el-form-item>
|
|
||||||
<el-form-item label="缓存"><el-switch v-model="form.keep_alive" /></el-form-item>
|
<el-form-item label="显示状态">
|
||||||
|
<div style="display: flex; align-items: center; gap: 8px">
|
||||||
|
<el-switch v-model="form.visible" :disabled="form.path?.includes(':')" />
|
||||||
|
<span v-if="form.path?.includes(':')" style="font-size: 12px; color: #f5222d">
|
||||||
|
含动态参数路由(如 :id)不可在侧边栏显示
|
||||||
|
</span>
|
||||||
|
<span v-else style="font-size: 12px; color: #8c8c8c">
|
||||||
|
{{ form.visible ? '在导航栏正常展示' : '隐藏(仅可通过路由路径访问)' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="页面缓存">
|
||||||
|
<div style="display: flex; align-items: center; gap: 8px">
|
||||||
|
<el-switch v-model="form.keep_alive" />
|
||||||
|
<span style="font-size: 12px; color: #8c8c8c">开启后路由切换将保持页面组件状态</span>
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
|
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<el-button @click="showDialog = false">取消</el-button>
|
<el-button @click="showDialog = false">取消</el-button>
|
||||||
<el-button type="primary" @click="save">保存</el-button>
|
<el-button type="primary" :loading="saving" @click="save">保存</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
</div>
|
</div>
|
||||||
@@ -85,18 +265,179 @@ import { menuApi } from '../../../api'
|
|||||||
|
|
||||||
const treeData = ref([])
|
const treeData = ref([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
const saving = ref(false)
|
||||||
const showDialog = ref(false)
|
const showDialog = ref(false)
|
||||||
const form = reactive({ id: null, parent_id: 0, title: '', path: '', component: '', icon: '', sort_order: 0, require_admin: false, visible: true, keep_alive: false })
|
const formRef = ref(null)
|
||||||
|
|
||||||
const flatMenus = computed(() => {
|
const isAllExpanded = ref(true)
|
||||||
const result = []
|
const tableKey = ref(1)
|
||||||
function flatten(list) {
|
const searchKeyword = ref('')
|
||||||
list.forEach(m => {
|
|
||||||
result.push(m)
|
const form = reactive({
|
||||||
if (m.children) flatten(m.children)
|
id: null,
|
||||||
|
parent_id: 0,
|
||||||
|
name: '',
|
||||||
|
title: '',
|
||||||
|
path: '',
|
||||||
|
component: '',
|
||||||
|
redirect: '',
|
||||||
|
icon: '',
|
||||||
|
sort: 0,
|
||||||
|
require_auth: true,
|
||||||
|
require_admin: false,
|
||||||
|
visible: true,
|
||||||
|
keep_alive: false
|
||||||
|
})
|
||||||
|
|
||||||
|
const authRadio = ref('user')
|
||||||
|
|
||||||
|
const formRules = {
|
||||||
|
title: [{ required: true, message: '请输入菜单标题', trigger: 'blur' }],
|
||||||
|
name: [{ required: true, message: '请输入路由标识(name)', trigger: 'blur' }],
|
||||||
|
path: [{ required: true, message: '请输入路由路径(path)', trigger: 'blur' }]
|
||||||
|
}
|
||||||
|
|
||||||
|
// 切换展开/折叠全部
|
||||||
|
function toggleExpandAll() {
|
||||||
|
isAllExpanded.value = !isAllExpanded.value
|
||||||
|
tableKey.value++
|
||||||
|
}
|
||||||
|
|
||||||
|
// 将后台数据规格化为干净的树结构
|
||||||
|
function normalizeTree(data) {
|
||||||
|
if (!Array.isArray(data)) return []
|
||||||
|
|
||||||
|
// 判断是否已是嵌套树(根节点含有 children 字段)
|
||||||
|
const hasNestedChildren = data.some(item => Array.isArray(item.children))
|
||||||
|
|
||||||
|
if (hasNestedChildren) {
|
||||||
|
// 深度克隆并清理空 children 数组
|
||||||
|
function cleanTree(nodes) {
|
||||||
|
return nodes.map(node => {
|
||||||
|
const item = {
|
||||||
|
...node,
|
||||||
|
sort: node.sort ?? node.sort_order ?? 0
|
||||||
|
}
|
||||||
|
if (Array.isArray(node.children) && node.children.length > 0) {
|
||||||
|
item.children = cleanTree(node.children)
|
||||||
|
} else {
|
||||||
|
delete item.children
|
||||||
|
}
|
||||||
|
return item
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
flatten(treeData.value)
|
return cleanTree(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 扁平列表转树结构
|
||||||
|
const map = {}
|
||||||
|
const roots = []
|
||||||
|
const list = data.map(item => ({
|
||||||
|
...item,
|
||||||
|
sort: item.sort ?? item.sort_order ?? 0,
|
||||||
|
children: []
|
||||||
|
}))
|
||||||
|
|
||||||
|
list.forEach(item => {
|
||||||
|
map[item.id] = item
|
||||||
|
})
|
||||||
|
|
||||||
|
list.forEach(item => {
|
||||||
|
if (item.parent_id && map[item.parent_id]) {
|
||||||
|
map[item.parent_id].children.push(item)
|
||||||
|
} else {
|
||||||
|
roots.push(item)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function finalize(nodes) {
|
||||||
|
nodes.sort((a, b) => (a.sort || 0) - (b.sort || 0))
|
||||||
|
nodes.forEach(node => {
|
||||||
|
if (node.children && node.children.length > 0) {
|
||||||
|
finalize(node.children)
|
||||||
|
} else {
|
||||||
|
delete node.children
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
finalize(roots)
|
||||||
|
return roots
|
||||||
|
}
|
||||||
|
|
||||||
|
// 过滤搜索树结构
|
||||||
|
const filteredTreeData = computed(() => {
|
||||||
|
const kw = searchKeyword.value.trim().toLowerCase()
|
||||||
|
if (!kw) return treeData.value
|
||||||
|
|
||||||
|
function filterNodes(nodes) {
|
||||||
|
const result = []
|
||||||
|
nodes.forEach(node => {
|
||||||
|
const matchSelf =
|
||||||
|
(node.title && node.title.toLowerCase().includes(kw)) ||
|
||||||
|
(node.name && node.name.toLowerCase().includes(kw)) ||
|
||||||
|
(node.path && node.path.toLowerCase().includes(kw)) ||
|
||||||
|
(node.component && node.component.toLowerCase().includes(kw))
|
||||||
|
|
||||||
|
let matchedChildren = []
|
||||||
|
if (node.children && node.children.length > 0) {
|
||||||
|
matchedChildren = filterNodes(node.children)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matchSelf || matchedChildren.length > 0) {
|
||||||
|
result.push({
|
||||||
|
...node,
|
||||||
|
children: matchedChildren.length > 0 ? matchedChildren : undefined
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
return filterNodes(treeData.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 生成选择父级菜单时带层级缩进的选项(自动排除当前编辑的节点及其所有子节点以防止自循环)
|
||||||
|
const parentSelectOptions = computed(() => {
|
||||||
|
const result = []
|
||||||
|
|
||||||
|
function getDescendantIds(nodes, targetId) {
|
||||||
|
const ids = new Set([targetId])
|
||||||
|
function traverse(list) {
|
||||||
|
list.forEach(n => {
|
||||||
|
if (ids.has(n.parent_id)) {
|
||||||
|
ids.add(n.id)
|
||||||
|
}
|
||||||
|
if (n.children) traverse(n.children)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// 遍历多次直到无新增
|
||||||
|
let prevSize = 0
|
||||||
|
while (ids.size !== prevSize) {
|
||||||
|
prevSize = ids.size
|
||||||
|
traverse(nodes)
|
||||||
|
}
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
|
||||||
|
const excludedIds = form.id ? getDescendantIds(treeData.value, form.id) : new Set()
|
||||||
|
|
||||||
|
function traverse(nodes, depth = 0) {
|
||||||
|
nodes.forEach(node => {
|
||||||
|
if (!excludedIds.has(node.id)) {
|
||||||
|
const indent = ' '.repeat(depth) + (depth > 0 ? '└─ ' : '')
|
||||||
|
result.push({
|
||||||
|
id: node.id,
|
||||||
|
indentedTitle: `${indent}${node.title} (${node.path})`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (node.children && node.children.length > 0) {
|
||||||
|
traverse(node.children, depth + 1)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
traverse(treeData.value, 0)
|
||||||
return result
|
return result
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -104,58 +445,271 @@ async function load() {
|
|||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const res = await menuApi.getAll()
|
const res = await menuApi.getAll()
|
||||||
treeData.value = res.data || []
|
treeData.value = normalizeTree(res.data || [])
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
ElMessage.error('加载失败')
|
ElMessage.error(e.message || '加载菜单列表失败')
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function showAdd(parent) {
|
function showAdd(parent) {
|
||||||
Object.assign(form, { id: null, parent_id: parent ? parent.id : 0, title: '', path: '', component: '', icon: '', sort_order: 0, require_admin: false, visible: true, keep_alive: false })
|
authRadio.value = parent?.require_admin ? 'admin' : parent?.require_auth ? 'user' : 'public'
|
||||||
|
Object.assign(form, {
|
||||||
|
id: null,
|
||||||
|
parent_id: parent ? parent.id : 0,
|
||||||
|
name: '',
|
||||||
|
title: '',
|
||||||
|
path: '',
|
||||||
|
component: '',
|
||||||
|
redirect: '',
|
||||||
|
icon: parent ? '' : 'fas fa-folder',
|
||||||
|
sort: 0,
|
||||||
|
require_auth: true,
|
||||||
|
require_admin: false,
|
||||||
|
visible: true,
|
||||||
|
keep_alive: false
|
||||||
|
})
|
||||||
showDialog.value = true
|
showDialog.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
function edit(row) {
|
function edit(row) {
|
||||||
Object.assign(form, row)
|
if (row.require_admin) {
|
||||||
|
authRadio.value = 'admin'
|
||||||
|
} else if (row.require_auth) {
|
||||||
|
authRadio.value = 'user'
|
||||||
|
} else {
|
||||||
|
authRadio.value = 'public'
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.assign(form, {
|
||||||
|
id: row.id,
|
||||||
|
parent_id: row.parent_id || 0,
|
||||||
|
name: row.name || '',
|
||||||
|
title: row.title || '',
|
||||||
|
path: row.path || '',
|
||||||
|
component: row.component || '',
|
||||||
|
redirect: row.redirect || '',
|
||||||
|
icon: row.icon || '',
|
||||||
|
sort: row.sort ?? row.sort_order ?? 0,
|
||||||
|
require_auth: row.require_auth ?? true,
|
||||||
|
require_admin: row.require_admin ?? false,
|
||||||
|
visible: row.visible ?? true,
|
||||||
|
keep_alive: row.keep_alive ?? false
|
||||||
|
})
|
||||||
showDialog.value = true
|
showDialog.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
async function save() {
|
async function save() {
|
||||||
if (!form.title) { ElMessage.warning('请输入菜单名称'); return }
|
if (!formRef.value) return
|
||||||
try {
|
await formRef.value.validate(async valid => {
|
||||||
if (form.id) {
|
if (!valid) return
|
||||||
await menuApi.update(form.id, form)
|
|
||||||
ElMessage.success('已更新')
|
// 同步单选框权限到字段
|
||||||
|
if (authRadio.value === 'admin') {
|
||||||
|
form.require_auth = true
|
||||||
|
form.require_admin = true
|
||||||
|
} else if (authRadio.value === 'user') {
|
||||||
|
form.require_auth = true
|
||||||
|
form.require_admin = false
|
||||||
} else {
|
} else {
|
||||||
await menuApi.create(form)
|
form.require_auth = false
|
||||||
ElMessage.success('已创建')
|
form.require_admin = false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (form.path?.includes(':')) {
|
||||||
|
form.visible = false
|
||||||
|
}
|
||||||
|
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
parent_id: form.parent_id,
|
||||||
|
name: form.name.trim(),
|
||||||
|
title: form.title.trim(),
|
||||||
|
path: form.path.trim(),
|
||||||
|
component: form.component.trim(),
|
||||||
|
redirect: form.redirect.trim(),
|
||||||
|
icon: form.icon.trim(),
|
||||||
|
sort: form.sort,
|
||||||
|
require_auth: form.require_auth,
|
||||||
|
require_admin: form.require_admin,
|
||||||
|
visible: form.visible,
|
||||||
|
keep_alive: form.keep_alive
|
||||||
|
}
|
||||||
|
|
||||||
|
if (form.id) {
|
||||||
|
await menuApi.update(form.id, payload)
|
||||||
|
ElMessage.success('菜单已更新')
|
||||||
|
} else {
|
||||||
|
await menuApi.create(payload)
|
||||||
|
ElMessage.success('菜单已创建')
|
||||||
}
|
}
|
||||||
showDialog.value = false
|
showDialog.value = false
|
||||||
load()
|
load()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
ElMessage.error(e.message || '保存失败')
|
ElMessage.error(e.message || '保存失败')
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
}
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async function del(row) {
|
async function del(row) {
|
||||||
try {
|
try {
|
||||||
await ElMessageBox.confirm(`确定删除菜单「${row.title}」吗?子菜单也会一并删除!`, '警告', { type: 'danger' })
|
await ElMessageBox.confirm(
|
||||||
|
`确定删除菜单「${row.title}」吗?如果该菜单包含下级子菜单,子菜单也将被一并级联删除!`,
|
||||||
|
'删除确认',
|
||||||
|
{
|
||||||
|
confirmButtonText: '确定删除',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning'
|
||||||
|
}
|
||||||
|
)
|
||||||
await menuApi.delete(row.id)
|
await menuApi.delete(row.id)
|
||||||
ElMessage.success('已删除')
|
ElMessage.success('删除成功')
|
||||||
load()
|
load()
|
||||||
} catch (e) {}
|
} catch (e) {
|
||||||
|
if (e !== 'cancel') {
|
||||||
|
ElMessage.error(e.message || '删除失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function toggleVisible(row) {
|
async function toggleVisible(row) {
|
||||||
|
row._switchLoading = true
|
||||||
try {
|
try {
|
||||||
await menuApi.update(row.id, { visible: row.visible })
|
await menuApi.update(row.id, { visible: row.visible })
|
||||||
|
ElMessage.success(`已${row.visible ? '开启' : '关闭'}菜单显示`)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
row.visible = !row.visible
|
row.visible = !row.visible
|
||||||
ElMessage.error('更新失败')
|
ElMessage.error(e.message || '更新显示状态失败')
|
||||||
|
} finally {
|
||||||
|
row._switchLoading = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => { load() })
|
onMounted(() => {
|
||||||
|
load()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.menu-admin-container {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #262626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title-left i {
|
||||||
|
color: #1890ff;
|
||||||
|
margin-right: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title-desc {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #8c8c8c;
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 20px;
|
||||||
|
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-action-bar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-left {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-title-cell {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-icon {
|
||||||
|
margin-right: 8px;
|
||||||
|
color: #1890ff;
|
||||||
|
font-size: 14px;
|
||||||
|
width: 16px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.default-icon {
|
||||||
|
color: #8c8c8c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-title-text {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #262626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-badge-name {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #722ed1;
|
||||||
|
background: #f9f0ff;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-badge {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #096dd9;
|
||||||
|
background: #e6f7ff;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comp-text {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #595959;
|
||||||
|
}
|
||||||
|
|
||||||
|
.redirect-text {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #fa8c16;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-text {
|
||||||
|
color: #bfbfbf;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-number {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #595959;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-preview-box {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border: 1px solid #d9d9d9;
|
||||||
|
border-radius: 4px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: #fafafa;
|
||||||
|
color: #1890ff;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -3,48 +3,76 @@
|
|||||||
<div class="page-title"><i class="fas fa-user-shield"></i>用户管理</div>
|
<div class="page-title"><i class="fas fa-user-shield"></i>用户管理</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<el-tabs v-model="activeTab" stretch @tab-change="load">
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;flex-wrap:wrap;gap:12px">
|
||||||
<el-tab-pane label="全部用户" name="all" />
|
<el-radio-group v-model="activeTab" @change="load">
|
||||||
<el-tab-pane label="封禁用户" name="banned" />
|
<el-radio-button label="all">全部用户</el-radio-button>
|
||||||
</el-tabs>
|
<el-radio-button label="active">正常用户</el-radio-button>
|
||||||
|
<el-radio-button label="banned">封禁用户</el-radio-button>
|
||||||
|
</el-radio-group>
|
||||||
|
|
||||||
|
<div style="display:flex;gap:8px">
|
||||||
|
<el-input
|
||||||
|
v-model="keyword"
|
||||||
|
placeholder="搜索用户名、姓名、昵称、手机号..."
|
||||||
|
clearable
|
||||||
|
style="width:280px"
|
||||||
|
@keyup.enter="load"
|
||||||
|
>
|
||||||
|
<template #prefix><i class="fas fa-search" style="color:#8c8c8c"></i></template>
|
||||||
|
</el-input>
|
||||||
|
<el-button type="primary" @click="load">搜索</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<el-table :data="list" stripe v-loading="loading">
|
<el-table :data="list" stripe v-loading="loading">
|
||||||
<el-table-column label="用户" min-width="150">
|
<el-table-column label="用户" min-width="170">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<div style="display:flex;align-items:center;gap:8px">
|
<div style="display:flex;align-items:center;gap:8px">
|
||||||
<div style="width:32px;height:32px;border-radius:50%;background:#1890ff;color:#fff;display:flex;align-items:center;justify-content:center;font-weight:600">
|
<div style="width:32px;height:32px;border-radius:50%;background:#1890ff;color:#fff;display:flex;align-items:center;justify-content:center;font-weight:600">
|
||||||
{{ (row.nickname || row.username || 'U').charAt(0).toUpperCase() }}
|
{{ (row.real_name || row.nickname || row.username || 'U').charAt(0).toUpperCase() }}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div>{{ row.nickname || row.username }}</div>
|
<div style="font-weight:600">{{ row.real_name || row.nickname || row.username }}</div>
|
||||||
<div style="font-size:12px;color:#8c8c8c">@{{ row.username }}</div>
|
<div style="font-size:12px;color:#8c8c8c">@{{ row.username }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="email" label="邮箱" width="180" />
|
<el-table-column prop="real_name" label="姓名" width="100">
|
||||||
<el-table-column label="角色" width="100">
|
<template #default="{ row }">{{ row.real_name || '-' }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="gender" label="性别" width="70">
|
||||||
|
<template #default="{ row }">{{ row.gender || '-' }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="nation" label="民族" width="90">
|
||||||
|
<template #default="{ row }">{{ row.nation || '-' }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="political_status" label="政治身份" width="110">
|
||||||
|
<template #default="{ row }">{{ row.political_status || '-' }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="phone" label="手机号" width="130">
|
||||||
|
<template #default="{ row }">{{ row.phone || '-' }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="email" label="邮箱" width="160" show-overflow-tooltip>
|
||||||
|
<template #default="{ row }">{{ row.email || '-' }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="角色" width="90">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-tag :type="row.role === 'admin' ? 'danger' : 'success'" size="small">
|
<el-tag :type="row.role === 'admin' ? 'danger' : 'success'" size="small">
|
||||||
{{ row.role === 'admin' ? '管理员' : '普通用户' }}
|
{{ row.role === 'admin' ? '管理员' : '普通用户' }}
|
||||||
</el-tag>
|
</el-tag>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="状态" width="100">
|
<el-table-column label="状态" width="90">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-tag :type="row.status === 'banned' ? 'danger' : 'success'" size="small">
|
<el-tag :type="row.status === 'banned' ? 'danger' : 'success'" size="small">
|
||||||
{{ row.status === 'banned' ? '已封禁' : '正常' }}
|
{{ row.status === 'banned' ? '已封禁' : '正常' }}
|
||||||
</el-tag>
|
</el-tag>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="封禁原因" min-width="150">
|
<el-table-column label="操作" width="160" fixed="right">
|
||||||
<template #default="{ row }">{{ row.ban_reason || '-' }}</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="注册时间" width="160">
|
|
||||||
<template #default="{ row }">{{ row.created_at }}</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="操作" width="150" fixed="right">
|
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
|
<el-button type="primary" link @click="viewDetail(row)">详情</el-button>
|
||||||
<el-button v-if="row.status !== 'banned' && row.role !== 'admin'" type="danger" link @click="ban(row)">封禁</el-button>
|
<el-button v-if="row.status !== 'banned' && row.role !== 'admin'" type="danger" link @click="ban(row)">封禁</el-button>
|
||||||
<el-button v-if="row.status === 'banned'" type="success" link @click="unban(row)">解封</el-button>
|
<el-button v-if="row.status === 'banned'" type="success" link @click="unban(row)">解封</el-button>
|
||||||
</template>
|
</template>
|
||||||
@@ -52,13 +80,58 @@
|
|||||||
</el-table>
|
</el-table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 用户详情弹窗 -->
|
||||||
|
<el-dialog v-model="showDetail" title="用户详细资料" width="560px">
|
||||||
|
<div v-if="currentUser">
|
||||||
|
<el-descriptions :column="2" border>
|
||||||
|
<el-descriptions-item label="用户名">{{ currentUser.username }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="真实姓名">{{ currentUser.real_name || '未填写' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="个性昵称">{{ currentUser.nickname || '未设置' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="性别">{{ currentUser.gender || '未设置' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="民族">{{ currentUser.nation || '未设置' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="政治面貌">{{ currentUser.political_status || '未设置' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="身份证号" :span="2">
|
||||||
|
<span style="font-family:monospace">{{ currentUser.id_card || '未填写' }}</span>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="手机号码">{{ currentUser.phone || '未填写' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="电子邮箱">{{ currentUser.email || '未填写' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="家庭住址" :span="2">{{ currentUser.address || '未填写' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item v-if="currentUser.contacts && currentUser.contacts.length" label="其他联系方式" :span="2">
|
||||||
|
<div style="display:flex;flex-wrap:wrap;gap:8px">
|
||||||
|
<el-tag v-for="c in currentUser.contacts" :key="c.id || c.value" size="small" type="info">
|
||||||
|
{{ contactTypeText(c.type) }}: {{ c.value }}{{ c.is_primary ? ' (主)' : '' }}
|
||||||
|
</el-tag>
|
||||||
|
</div>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="个人简介" :span="2">{{ currentUser.bio || '无' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="用户角色">
|
||||||
|
<el-tag :type="currentUser.role === 'admin' ? 'danger' : 'success'" size="small">
|
||||||
|
{{ currentUser.role === 'admin' ? '管理员' : '普通用户' }}
|
||||||
|
</el-tag>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="账号状态">
|
||||||
|
<el-tag :type="currentUser.status === 'banned' ? 'danger' : 'success'" size="small">
|
||||||
|
{{ currentUser.status === 'banned' ? '已封禁' : '正常' }}
|
||||||
|
</el-tag>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item v-if="currentUser.status === 'banned'" label="封禁原因" :span="2">
|
||||||
|
<span style="color:#f5222d">{{ currentUser.ban_reason }}</span>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="注册时间" :span="2">{{ currentUser.created_at }}</el-descriptions-item>
|
||||||
|
</el-descriptions>
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="showDetail=false">关闭</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
<!-- 封禁弹窗 -->
|
<!-- 封禁弹窗 -->
|
||||||
<el-dialog v-model="showBan" title="封禁用户" width="400px">
|
<el-dialog v-model="showBan" title="封禁用户" width="400px">
|
||||||
<el-alert type="warning" :closable="false" style="margin-bottom:16px">
|
<el-alert type="warning" :closable="false" style="margin-bottom:16px">
|
||||||
封禁后该用户将无法登录,所有班级操作受限
|
封禁后该用户将无法登录,所有班级操作受限
|
||||||
</el-alert>
|
</el-alert>
|
||||||
<el-form label-width="80px">
|
<el-form label-width="80px">
|
||||||
<el-form-item label="用户">{{ currentUser?.nickname || currentUser?.username }}</el-form-item>
|
<el-form-item label="用户">{{ currentUser?.real_name || currentUser?.nickname || currentUser?.username }}</el-form-item>
|
||||||
<el-form-item label="封禁原因" required>
|
<el-form-item label="封禁原因" required>
|
||||||
<el-input v-model="banReason" type="textarea" :rows="3" placeholder="请输入封禁原因" />
|
<el-input v-model="banReason" type="textarea" :rows="3" placeholder="请输入封禁原因" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
@@ -74,26 +147,25 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { banApi, userApi } from '../../../api'
|
import { banApi, adminApi, userApi } from '../../../api'
|
||||||
|
|
||||||
const activeTab = ref('all')
|
const activeTab = ref('all')
|
||||||
|
const keyword = ref('')
|
||||||
const list = ref([])
|
const list = ref([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const showBan = ref(false)
|
const showBan = ref(false)
|
||||||
|
const showDetail = ref(false)
|
||||||
const banReason = ref('')
|
const banReason = ref('')
|
||||||
const currentUser = ref(null)
|
const currentUser = ref(null)
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
if (activeTab.value === 'banned') {
|
const res = await adminApi.listUsers({
|
||||||
const res = await banApi.list()
|
status: activeTab.value,
|
||||||
|
keyword: keyword.value.trim()
|
||||||
|
})
|
||||||
list.value = res.data || []
|
list.value = res.data || []
|
||||||
} else {
|
|
||||||
// 全部用户 - 暂时用封禁列表+其他方式,后续可加用户列表接口
|
|
||||||
const res = await banApi.list()
|
|
||||||
list.value = res.data || []
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
list.value = []
|
list.value = []
|
||||||
} finally {
|
} finally {
|
||||||
@@ -101,6 +173,26 @@ async function load() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function viewDetail(row) {
|
||||||
|
currentUser.value = { ...row }
|
||||||
|
showDetail.value = true
|
||||||
|
try {
|
||||||
|
const res = await userApi.publicProfile(row.id)
|
||||||
|
if (res.data?.user) {
|
||||||
|
currentUser.value = {
|
||||||
|
...row,
|
||||||
|
...res.data.user,
|
||||||
|
contacts: res.data.contacts || []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function contactTypeText(type) {
|
||||||
|
const map = { email: '邮箱', qq: 'QQ', wechat: '微信', phone: '手机号', address: '住址', workunit: '工作单位' }
|
||||||
|
return map[type] || type
|
||||||
|
}
|
||||||
|
|
||||||
function ban(row) {
|
function ban(row) {
|
||||||
currentUser.value = row
|
currentUser.value = row
|
||||||
banReason.value = ''
|
banReason.value = ''
|
||||||
@@ -121,12 +213,16 @@ async function confirmBan() {
|
|||||||
|
|
||||||
async function unban(row) {
|
async function unban(row) {
|
||||||
try {
|
try {
|
||||||
await ElMessageBox.confirm(`确定解封用户「${row.nickname || row.username}」吗?`, '提示', { type: 'warning' })
|
await ElMessageBox.confirm(`确定解封用户「${row.real_name || row.nickname || row.username}」吗?`, '提示', { type: 'warning' })
|
||||||
await banApi.unban(row.id)
|
await banApi.unban(row.id)
|
||||||
ElMessage.success('已解封')
|
ElMessage.success('已解封')
|
||||||
load()
|
load()
|
||||||
} catch (e) {}
|
} catch (e) {
|
||||||
|
if (e !== 'cancel') ElMessage.error(e.message || '解封失败')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => { load() })
|
onMounted(() => {
|
||||||
|
load()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -28,12 +28,47 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 同学名单 -->
|
<!-- 毕业照与花名册名单 -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h3 style="margin-bottom:12px"><i class="fas fa-user-friends" style="color:#1890ff"></i> 同学名单 ({{ detail.student_names?.length || 0 }}人)</h3>
|
<h3 style="margin-bottom:12px">
|
||||||
<div v-if="detail.can_view_sensitive" style="display:flex;flex-wrap:wrap;gap:8px">
|
<i class="fas fa-user-friends" style="color:#1890ff"></i> 毕业照站位与人员名单 ({{ totalRosterCount }}人)
|
||||||
|
<span v-if="detail?.row_count > 1" style="font-size:12px;color:#8c8c8c;font-weight:normal;margin-left:8px">
|
||||||
|
共 {{ detail.row_count }} 排,从前至后、从左往右
|
||||||
|
</span>
|
||||||
|
</h3>
|
||||||
|
<div v-if="detail.can_view_sensitive">
|
||||||
|
<!-- 按排结构化展示 -->
|
||||||
|
<div v-if="rosterRows.length > 0" class="detail-roster-rows">
|
||||||
|
<div v-for="row in rosterRows" :key="row.rowNo" class="detail-roster-row">
|
||||||
|
<div class="detail-roster-row-header">
|
||||||
|
<span class="detail-row-num">第 {{ row.rowNo }} 排</span>
|
||||||
|
<span class="detail-row-desc">{{ row.rowNo === 1 ? '前排就坐 / 从左往右' : '从左往右' }}</span>
|
||||||
|
<span class="detail-row-count">({{ row.items.length }}人)</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-roster-tags">
|
||||||
|
<span
|
||||||
|
v-for="person in row.items"
|
||||||
|
:key="person.id || person.name"
|
||||||
|
:class="['roster-detail-tag', person.role === 'teacher' ? 'is-teacher' : 'is-student']"
|
||||||
|
>
|
||||||
|
<span class="person-pos">#{{ person.col_no }}</span>
|
||||||
|
<span class="person-name">{{ person.name }}</span>
|
||||||
|
<span v-if="person.role === 'teacher'" class="person-role-tag">老师</span>
|
||||||
|
<span v-if="person.is_claimed" class="person-claim-tag claimed" title="已绑定系统账号">
|
||||||
|
<i class="fas fa-check-circle"></i>已绑定
|
||||||
|
</span>
|
||||||
|
<span v-else class="person-claim-tag unclaimed" title="该同学尚未在系统注册绑定">
|
||||||
|
待认领
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- 兜底旧单列表 -->
|
||||||
|
<div v-else style="display:flex;flex-wrap:wrap;gap:8px">
|
||||||
<span v-for="(name, i) in detail.student_names" :key="i" class="tag primary" style="padding:4px 12px">{{ i+1 }}. {{ name }}</span>
|
<span v-for="(name, i) in detail.student_names" :key="i" class="tag primary" style="padding:4px 12px">{{ i+1 }}. {{ name }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div v-else class="empty" style="padding:20px">
|
<div v-else class="empty" style="padding:20px">
|
||||||
<i class="fas fa-lock"></i>
|
<i class="fas fa-lock"></i>
|
||||||
<p>加入班级后可查看完整同学名单</p>
|
<p>加入班级后可查看完整同学名单</p>
|
||||||
@@ -73,13 +108,20 @@
|
|||||||
<div class="card">
|
<div class="card">
|
||||||
<h3 style="margin-bottom:12px"><i class="fas fa-users" style="color:#1890ff"></i> 班级成员 ({{ detail.members?.length || 0 }}人)</h3>
|
<h3 style="margin-bottom:12px"><i class="fas fa-users" style="color:#1890ff"></i> 班级成员 ({{ detail.members?.length || 0 }}人)</h3>
|
||||||
<el-table :data="detail.members" stripe>
|
<el-table :data="detail.members" stripe>
|
||||||
<el-table-column label="用户" min-width="150">
|
<el-table-column label="用户" min-width="170">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<div style="display:flex;align-items:center;gap:8px">
|
<div style="display:flex;align-items:center;gap:8px">
|
||||||
<div style="width:32px;height:32px;border-radius:50%;background:#1890ff;color:#fff;display:flex;align-items:center;justify-content:center;font-size:14px;font-weight:600">
|
<div style="width:32px;height:32px;border-radius:50%;background:#1890ff;color:#fff;display:flex;align-items:center;justify-content:center;font-size:14px;font-weight:600">
|
||||||
{{ (row.user.nickname || row.user.username || 'U').charAt(0).toUpperCase() }}
|
{{ (row.user.real_name || row.user.nickname || row.user.username || 'U').charAt(0).toUpperCase() }}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div>
|
||||||
|
<span style="font-weight:600">{{ row.user.real_name || row.user.nickname || row.user.username }}</span>
|
||||||
|
<span v-if="row.user.real_name && row.user.nickname" style="font-size:12px;color:#8c8c8c;margin-left:4px">({{ row.user.nickname }})</span>
|
||||||
|
<span v-if="row.user.gender" style="font-size:12px;color:#8c8c8c;margin-left:6px">[{{ row.user.gender }}]</span>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:12px;color:#8c8c8c">@{{ row.user.username }}</div>
|
||||||
</div>
|
</div>
|
||||||
<span>{{ row.user.nickname || row.user.username }}</span>
|
|
||||||
<span v-if="row.member.role === 'admin'" class="tag primary" style="margin-left:4px">管理员</span>
|
<span v-if="row.member.role === 'admin'" class="tag primary" style="margin-left:4px">管理员</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -97,10 +139,21 @@
|
|||||||
<el-table-column v-if="detail.can_view_sensitive" label="联系方式" min-width="200">
|
<el-table-column v-if="detail.can_view_sensitive" label="联系方式" min-width="200">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<div v-if="row.contacts?.length" style="font-size:12px;color:#595959">
|
<div v-if="row.contacts?.length" style="font-size:12px;color:#595959">
|
||||||
<div v-for="c in row.contacts" :key="c.id" style="margin-bottom:2px">
|
<div v-for="c in row.contacts" :key="c.id || c.value" style="margin-bottom:2px">
|
||||||
<i :class="contactIcon(c.type)" style="margin-right:4px;color:#1890ff"></i>{{ c.value }}
|
<i :class="contactIcon(c.type)" style="margin-right:4px;color:#1890ff"></i>{{ c.value }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-else-if="row.user?.phone || row.user?.email || row.user?.address" style="font-size:12px;color:#595959">
|
||||||
|
<div v-if="row.user.phone" style="margin-bottom:2px">
|
||||||
|
<i class="fas fa-phone" style="margin-right:4px;color:#1890ff"></i>{{ row.user.phone }}
|
||||||
|
</div>
|
||||||
|
<div v-if="row.user.email" style="margin-bottom:2px">
|
||||||
|
<i class="fas fa-envelope" style="margin-right:4px;color:#1890ff"></i>{{ row.user.email }}
|
||||||
|
</div>
|
||||||
|
<div v-if="row.user.address" style="margin-bottom:2px">
|
||||||
|
<i class="fas fa-map-marker-alt" style="margin-right:4px;color:#1890ff"></i>{{ row.user.address }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<span v-else style="color:#bfbfbf">未填写</span>
|
<span v-else style="color:#bfbfbf">未填写</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
@@ -220,6 +273,29 @@ const inviteForm = reactive({ max_uses: 10, expire_days: 30 })
|
|||||||
const uploadUrl = '/api/upload'
|
const uploadUrl = '/api/upload'
|
||||||
const uploadHeaders = computed(() => ({ Authorization: 'Bearer ' + token.value }))
|
const uploadHeaders = computed(() => ({ Authorization: 'Bearer ' + token.value }))
|
||||||
|
|
||||||
|
const rosterRows = computed(() => {
|
||||||
|
const list = detail.value?.roster || []
|
||||||
|
if (!list.length) return []
|
||||||
|
const groupMap = {}
|
||||||
|
list.forEach(item => {
|
||||||
|
const r = item.row_no || 1
|
||||||
|
if (!groupMap[r]) groupMap[r] = []
|
||||||
|
groupMap[r].push(item)
|
||||||
|
})
|
||||||
|
return Object.keys(groupMap)
|
||||||
|
.map(Number)
|
||||||
|
.sort((a, b) => a - b)
|
||||||
|
.map(r => ({
|
||||||
|
rowNo: r,
|
||||||
|
items: groupMap[r].sort((a, b) => (a.col_no || 0) - (b.col_no || 0))
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
const totalRosterCount = computed(() => {
|
||||||
|
if (detail.value?.roster?.length) return detail.value.roster.length
|
||||||
|
return (detail.value?.student_names?.length || 0) + (detail.value?.teacher_names?.length || 0)
|
||||||
|
})
|
||||||
|
|
||||||
const statusMap = {
|
const statusMap = {
|
||||||
pending: { text: '待审核', cls: 'warning' },
|
pending: { text: '待审核', cls: 'warning' },
|
||||||
active: { text: '已通过', cls: 'success' },
|
active: { text: '已通过', cls: 'success' },
|
||||||
@@ -242,9 +318,15 @@ function canDeletePhoto(photo) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadDetail() {
|
async function loadDetail() {
|
||||||
|
const classId = route.params.id
|
||||||
|
if (!classId || classId === ':id' || isNaN(Number(classId))) {
|
||||||
|
ElMessage.error('无效的班级 ID')
|
||||||
|
router.replace('/class/list')
|
||||||
|
return
|
||||||
|
}
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const res = await classApi.detail(route.params.id)
|
const res = await classApi.detail(classId)
|
||||||
detail.value = res.data
|
detail.value = res.data
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
ElMessage.error('加载失败')
|
ElMessage.error('加载失败')
|
||||||
@@ -315,3 +397,109 @@ async function resubmit() {
|
|||||||
|
|
||||||
onMounted(() => { loadDetail() })
|
onMounted(() => { loadDetail() })
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.detail-roster-rows {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-roster-row {
|
||||||
|
background: #fafafa;
|
||||||
|
border: 1px solid #f0f0f0;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-roster-row-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-row-num {
|
||||||
|
background: #1890ff;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 1px 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-row-desc {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #8c8c8c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-row-count {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #595959;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-roster-tags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.roster-detail-tag {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 13px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.roster-detail-tag.is-student {
|
||||||
|
background: #e6f7ff;
|
||||||
|
border-color: #bae7ff;
|
||||||
|
color: #0050b3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.roster-detail-tag.is-teacher {
|
||||||
|
background: #fff7e6;
|
||||||
|
border-color: #ffd591;
|
||||||
|
color: #d46b08;
|
||||||
|
}
|
||||||
|
|
||||||
|
.person-pos {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
opacity: 0.65;
|
||||||
|
}
|
||||||
|
|
||||||
|
.person-name {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.person-role-tag {
|
||||||
|
font-size: 10px;
|
||||||
|
background: rgba(212, 107, 8, 0.15);
|
||||||
|
color: #d46b08;
|
||||||
|
padding: 0 4px;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.person-claim-tag {
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 1px 5px;
|
||||||
|
border-radius: 2px;
|
||||||
|
margin-left: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.person-claim-tag.claimed {
|
||||||
|
background: #f6ffed;
|
||||||
|
color: #52c41a;
|
||||||
|
border: 1px solid #b7eb8f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.person-claim-tag.unclaimed {
|
||||||
|
background: #f5f5f5;
|
||||||
|
color: #8c8c8c;
|
||||||
|
border: 1px solid #d9d9d9;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,27 @@
|
|||||||
<div>
|
<div>
|
||||||
<div class="page-title"><i class="fas fa-sign-in-alt"></i>加入班级</div>
|
<div class="page-title"><i class="fas fa-sign-in-alt"></i>加入班级</div>
|
||||||
|
|
||||||
<div class="card">
|
<!-- 检查履历中 -->
|
||||||
|
<div v-if="checkingEdu" class="card" v-loading="true" style="min-height: 260px"></div>
|
||||||
|
|
||||||
|
<!-- 未完善个人学习履历拦截提醒 -->
|
||||||
|
<div v-else-if="!hasEdu" class="card">
|
||||||
|
<el-result
|
||||||
|
icon="warning"
|
||||||
|
title="请先完善个人学习履历"
|
||||||
|
sub-title="根据平台规定,只有完善个人学习履历的用户才能加入班级。请先前往个人中心添加至少一条学习履历。"
|
||||||
|
>
|
||||||
|
<template #extra>
|
||||||
|
<el-button type="primary" @click="$router.push('/profile?tab=education&action=add&from=class_join')">
|
||||||
|
<i class="fas fa-graduation-cap" style="margin-right:4px"></i>前往完善学习履历
|
||||||
|
</el-button>
|
||||||
|
<el-button @click="$router.push('/')">返回首页</el-button>
|
||||||
|
</template>
|
||||||
|
</el-result>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 正常流程:已有履历 -->
|
||||||
|
<div v-else class="card">
|
||||||
<el-tabs v-model="activeTab" stretch>
|
<el-tabs v-model="activeTab" stretch>
|
||||||
<!-- 搜索加入 -->
|
<!-- 搜索加入 -->
|
||||||
<el-tab-pane label="搜索加入" name="search">
|
<el-tab-pane label="搜索加入" name="search">
|
||||||
@@ -83,10 +103,10 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { classApi } from '../../../api'
|
import { classApi, userApi } from '../../../api'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const activeTab = ref('search')
|
const activeTab = ref('search')
|
||||||
@@ -99,11 +119,45 @@ const showVerifyDialog = ref(false)
|
|||||||
const verifyNames = ref('')
|
const verifyNames = ref('')
|
||||||
const currentClass = ref(null)
|
const currentClass = ref(null)
|
||||||
|
|
||||||
|
const checkingEdu = ref(true)
|
||||||
|
const hasEdu = ref(false)
|
||||||
|
|
||||||
const verifyCount = computed(() => {
|
const verifyCount = computed(() => {
|
||||||
return verifyNames.value.split(/[\n,,\s]+/).filter(s => s.trim()).length
|
return verifyNames.value.split(/[\n,,\s]+/).filter(s => s.trim()).length
|
||||||
})
|
})
|
||||||
|
|
||||||
|
async function checkEducation() {
|
||||||
|
checkingEdu.value = true
|
||||||
|
try {
|
||||||
|
const res = await userApi.hasEducation()
|
||||||
|
hasEdu.value = !!res.data?.has_education
|
||||||
|
if (!hasEdu.value) {
|
||||||
|
ElMessageBox.confirm(
|
||||||
|
'根据平台规定,只有完善个人学习履历的用户才能加入班级。是否立即前往添加学习履历?',
|
||||||
|
'提示',
|
||||||
|
{
|
||||||
|
confirmButtonText: '前往完善',
|
||||||
|
cancelButtonText: '暂不完善',
|
||||||
|
type: 'warning',
|
||||||
|
distinguishCancelAndClose: true
|
||||||
|
}
|
||||||
|
).then(() => {
|
||||||
|
router.push('/profile?tab=education&action=add&from=class_join')
|
||||||
|
}).catch(() => {})
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
ElMessage.error(e.message || '检查用户学习履历失败')
|
||||||
|
hasEdu.value = false
|
||||||
|
} finally {
|
||||||
|
checkingEdu.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function search() {
|
async function search() {
|
||||||
|
if (!hasEdu.value) {
|
||||||
|
ElMessage.warning('请先完善个人学习履历,有学习履历的用户才能加入班级')
|
||||||
|
return
|
||||||
|
}
|
||||||
if (!keyword.value.trim()) { ElMessage.warning('请输入搜索关键词'); return }
|
if (!keyword.value.trim()) { ElMessage.warning('请输入搜索关键词'); return }
|
||||||
searched.value = true
|
searched.value = true
|
||||||
try {
|
try {
|
||||||
@@ -115,12 +169,20 @@ async function search() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function showVerify(row) {
|
function showVerify(row) {
|
||||||
|
if (!hasEdu.value) {
|
||||||
|
ElMessage.warning('请先完善个人学习履历,有学习履历的用户才能加入班级')
|
||||||
|
return
|
||||||
|
}
|
||||||
currentClass.value = row
|
currentClass.value = row
|
||||||
verifyNames.value = ''
|
verifyNames.value = ''
|
||||||
showVerifyDialog.value = true
|
showVerifyDialog.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
async function joinBySearch() {
|
async function joinBySearch() {
|
||||||
|
if (!hasEdu.value) {
|
||||||
|
ElMessage.warning('请先完善个人学习履历,有学习履历的用户才能加入班级')
|
||||||
|
return
|
||||||
|
}
|
||||||
const names = verifyNames.value.split(/[\n,,\s]+/).filter(s => s.trim())
|
const names = verifyNames.value.split(/[\n,,\s]+/).filter(s => s.trim())
|
||||||
if (names.length < 8) { ElMessage.error('至少需要8位同学姓名'); return }
|
if (names.length < 8) { ElMessage.error('至少需要8位同学姓名'); return }
|
||||||
joining.value = true
|
joining.value = true
|
||||||
@@ -137,6 +199,10 @@ async function joinBySearch() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function joinByInvite() {
|
async function joinByInvite() {
|
||||||
|
if (!hasEdu.value) {
|
||||||
|
ElMessage.warning('请先完善个人学习履历,有学习履历的用户才能加入班级')
|
||||||
|
return
|
||||||
|
}
|
||||||
if (!inviteCode.value.trim()) { ElMessage.warning('请输入邀请码'); return }
|
if (!inviteCode.value.trim()) { ElMessage.warning('请输入邀请码'); return }
|
||||||
joining.value = true
|
joining.value = true
|
||||||
try {
|
try {
|
||||||
@@ -149,4 +215,8 @@ async function joinByInvite() {
|
|||||||
joining.value = false
|
joining.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
checkEducation()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
+110
-13
@@ -56,18 +56,23 @@
|
|||||||
<div class="card">
|
<div class="card">
|
||||||
<h3 style="margin-bottom:16px"><i class="fas fa-globe-asia" style="color:#1890ff"></i> 按地区浏览学校</h3>
|
<h3 style="margin-bottom:16px"><i class="fas fa-globe-asia" style="color:#1890ff"></i> 按地区浏览学校</h3>
|
||||||
<el-row :gutter="16">
|
<el-row :gutter="16">
|
||||||
<el-col :xs="24" :sm="8" :md="6">
|
<el-col :xs="24" :sm="12" :md="6">
|
||||||
<el-select v-model="selectedCountry" placeholder="选择国家" style="width:100%" @change="loadProvinces">
|
<el-select v-model="selectedCountry" placeholder="选择国家" filterable style="width:100%" @change="loadProvinces">
|
||||||
<el-option v-for="c in countries" :key="c.id" :label="c.name" :value="c.id" />
|
<el-option v-for="c in countries" :key="c.id" :label="c.name" :value="c.id" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-col>
|
</el-col>
|
||||||
<el-col :xs="24" :sm="8" :md="6">
|
<el-col :xs="24" :sm="12" :md="6">
|
||||||
<el-select v-model="selectedProvince" placeholder="选择省/州" style="width:100%" @change="loadCounties" :disabled="!selectedCountry">
|
<el-select v-model="selectedProvince" :placeholder="isChina ? '选择省/直辖市' : '选择省/州'" filterable style="width:100%" @change="loadCities" :disabled="!selectedCountry">
|
||||||
<el-option v-for="p in provinces" :key="p.id" :label="p.name" :value="p.id" />
|
<el-option v-for="p in provinces" :key="p.id" :label="p.name" :value="p.id" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-col>
|
</el-col>
|
||||||
<el-col :xs="24" :sm="8" :md="6">
|
<el-col :xs="24" :sm="12" :md="6">
|
||||||
<el-select v-model="selectedCounty" placeholder="选择区/县" style="width:100%" @change="loadSchools" :disabled="!selectedProvince">
|
<el-select v-model="selectedCity" :placeholder="isChina ? '选择市' : '选择市/地区'" filterable style="width:100%" @change="loadCounties" :disabled="!selectedProvince">
|
||||||
|
<el-option v-for="c in cities" :key="c.id" :label="c.name" :value="c.id" />
|
||||||
|
</el-select>
|
||||||
|
</el-col>
|
||||||
|
<el-col :xs="24" :sm="12" :md="6">
|
||||||
|
<el-select v-model="selectedCounty" placeholder="选择区/县" clearable filterable style="width:100%" @change="loadSchools" :disabled="!selectedCity && cities.length > 0">
|
||||||
<el-option v-for="c in counties" :key="c.id" :label="c.name" :value="c.id" />
|
<el-option v-for="c in counties" :key="c.id" :label="c.name" :value="c.id" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-col>
|
</el-col>
|
||||||
@@ -76,15 +81,22 @@
|
|||||||
<div v-if="schools.length > 0" style="margin-top:16px">
|
<div v-if="schools.length > 0" style="margin-top:16px">
|
||||||
<el-table :data="schools" stripe>
|
<el-table :data="schools" stripe>
|
||||||
<el-table-column prop="name" label="学校名称" />
|
<el-table-column prop="name" label="学校名称" />
|
||||||
<el-table-column prop="school_type" label="类型" width="120">
|
<el-table-column prop="school_type" label="类型" min-width="140">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<span class="tag primary">{{ typeMap[row.school_type] || row.school_type }}</span>
|
<span
|
||||||
|
v-for="t in schoolTypeNames(row)"
|
||||||
|
:key="t"
|
||||||
|
class="tag primary"
|
||||||
|
style="margin-right: 4px; margin-bottom: 2px; display: inline-block"
|
||||||
|
>
|
||||||
|
{{ t }}
|
||||||
|
</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="address" label="地址" />
|
<el-table-column prop="address" label="地址" />
|
||||||
</el-table>
|
</el-table>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="selectedCounty" class="empty">
|
<div v-else-if="selectedCity || selectedCounty" class="empty">
|
||||||
<i class="fas fa-school"></i>
|
<i class="fas fa-school"></i>
|
||||||
<p>该地区暂无学校,快去创建第一个吧</p>
|
<p>该地区暂无学校,快去创建第一个吧</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -118,10 +130,12 @@ const keyword = ref('')
|
|||||||
const searchResults = ref([])
|
const searchResults = ref([])
|
||||||
const countries = ref([])
|
const countries = ref([])
|
||||||
const provinces = ref([])
|
const provinces = ref([])
|
||||||
|
const cities = ref([])
|
||||||
const counties = ref([])
|
const counties = ref([])
|
||||||
const schools = ref([])
|
const schools = ref([])
|
||||||
const selectedCountry = ref()
|
const selectedCountry = ref()
|
||||||
const selectedProvince = ref()
|
const selectedProvince = ref()
|
||||||
|
const selectedCity = ref()
|
||||||
const selectedCounty = ref()
|
const selectedCounty = ref()
|
||||||
|
|
||||||
const typeMap = {
|
const typeMap = {
|
||||||
@@ -129,6 +143,14 @@ const typeMap = {
|
|||||||
junior_high: '初中', primary: '小学', kindergarten: '幼儿园', other: '其他'
|
junior_high: '初中', primary: '小学', kindergarten: '幼儿园', other: '其他'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function schoolTypeNames(row) {
|
||||||
|
if (!row) return []
|
||||||
|
const list = Array.isArray(row.school_types) && row.school_types.length > 0
|
||||||
|
? row.school_types
|
||||||
|
: (row.school_type ? [row.school_type] : [])
|
||||||
|
return list.map(t => typeMap[t] || t)
|
||||||
|
}
|
||||||
|
|
||||||
async function search() {
|
async function search() {
|
||||||
if (!keyword.value.trim()) {
|
if (!keyword.value.trim()) {
|
||||||
ElMessage.warning('请输入搜索关键词')
|
ElMessage.warning('请输入搜索关键词')
|
||||||
@@ -142,30 +164,105 @@ async function search() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 是否为中国行政区划体系(默认 true)
|
||||||
|
const isChina = computed(() => {
|
||||||
|
if (!selectedCountry.value) return true
|
||||||
|
const c = countries.value.find(item => item.id === selectedCountry.value)
|
||||||
|
return !c || c.code === 'CN' || c.name === '中国'
|
||||||
|
})
|
||||||
|
|
||||||
async function loadCountries() {
|
async function loadCountries() {
|
||||||
|
try {
|
||||||
const res = await regionApi.countries()
|
const res = await regionApi.countries()
|
||||||
countries.value = res.data || []
|
countries.value = res.data || []
|
||||||
|
if (!selectedCountry.value && countries.value.length > 0) {
|
||||||
|
const cn = countries.value.find(c => c.code === 'CN' || c.name === '中国')
|
||||||
|
if (cn) {
|
||||||
|
selectedCountry.value = cn.id
|
||||||
|
await loadProvinces()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
ElMessage.error('加载国家列表失败')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadProvinces() {
|
async function loadProvinces() {
|
||||||
selectedProvince.value = null
|
selectedProvince.value = null
|
||||||
|
selectedCity.value = null
|
||||||
selectedCounty.value = null
|
selectedCounty.value = null
|
||||||
|
provinces.value = []
|
||||||
|
cities.value = []
|
||||||
counties.value = []
|
counties.value = []
|
||||||
schools.value = []
|
schools.value = []
|
||||||
|
if (!selectedCountry.value) return
|
||||||
|
try {
|
||||||
const res = await regionApi.list(selectedCountry.value)
|
const res = await regionApi.list(selectedCountry.value)
|
||||||
provinces.value = res.data || []
|
provinces.value = res.data || []
|
||||||
|
} catch (e) {
|
||||||
|
ElMessage.error('加载省/州列表失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadCities() {
|
||||||
|
selectedCity.value = null
|
||||||
|
selectedCounty.value = null
|
||||||
|
cities.value = []
|
||||||
|
counties.value = []
|
||||||
|
schools.value = []
|
||||||
|
if (!selectedProvince.value) return
|
||||||
|
try {
|
||||||
|
const res = await regionApi.list(selectedProvince.value)
|
||||||
|
const list = res.data || []
|
||||||
|
if (isChina.value) {
|
||||||
|
cities.value = list.filter(item => item.level === 'city')
|
||||||
|
counties.value = []
|
||||||
|
} else {
|
||||||
|
if (list.length > 0 && list[0].level === 'county') {
|
||||||
|
counties.value = list
|
||||||
|
cities.value = []
|
||||||
|
} else {
|
||||||
|
cities.value = list.filter(item => item.level === 'city' || !item.level)
|
||||||
|
counties.value = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loadSchools()
|
||||||
|
} catch (e) {
|
||||||
|
ElMessage.error(isChina.value ? '加载城市列表失败' : '加载市/地区列表失败')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadCounties() {
|
async function loadCounties() {
|
||||||
selectedCounty.value = null
|
selectedCounty.value = null
|
||||||
|
counties.value = []
|
||||||
schools.value = []
|
schools.value = []
|
||||||
const res = await regionApi.list(selectedProvince.value)
|
if (!selectedCity.value) {
|
||||||
counties.value = res.data || []
|
loadSchools()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await regionApi.list(selectedCity.value)
|
||||||
|
const list = res.data || []
|
||||||
|
counties.value = list.filter(item => item.level === 'county' || !item.level)
|
||||||
|
loadSchools()
|
||||||
|
} catch (e) {
|
||||||
|
ElMessage.error('加载区/县列表失败')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadSchools() {
|
async function loadSchools() {
|
||||||
const res = await schoolApi.list({ region_id: selectedCounty.value })
|
const targetId = selectedCounty.value || selectedCity.value || selectedProvince.value
|
||||||
schools.value = res.data || []
|
if (!targetId) {
|
||||||
|
schools.value = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await schoolApi.list({ region_id: targetId })
|
||||||
|
const list = res.data?.list || res.data || []
|
||||||
|
schools.value = Array.isArray(list) ? list : []
|
||||||
|
} catch (e) {
|
||||||
|
schools.value = []
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
|||||||
@@ -3,42 +3,256 @@
|
|||||||
<div class="page-title"><i class="fas fa-user-cog"></i>个人中心</div>
|
<div class="page-title"><i class="fas fa-user-cog"></i>个人中心</div>
|
||||||
|
|
||||||
<el-row :gutter="16">
|
<el-row :gutter="16">
|
||||||
<!-- 左侧:基本信息 -->
|
<!-- 左侧:基本信息与概要卡片 -->
|
||||||
<el-col :xs="24" :md="8">
|
<el-col :xs="24" :md="8">
|
||||||
<div class="card" style="text-align:center">
|
<div class="card" style="text-align:center">
|
||||||
<div style="width:80px;height:80px;border-radius:50%;background:linear-gradient(135deg,#1890ff,#40a9ff);color:#fff;display:flex;align-items:center;justify-content:center;font-size:32px;font-weight:700;margin:0 auto 12px">
|
<div style="width:80px;height:80px;border-radius:50%;background:linear-gradient(135deg,#1890ff,#40a9ff);color:#fff;display:flex;align-items:center;justify-content:center;font-size:32px;font-weight:700;margin:0 auto 12px">
|
||||||
{{ (user?.nickname || user?.username || 'U').charAt(0).toUpperCase() }}
|
{{ (profileForm.real_name || profileForm.nickname || user?.username || 'U').charAt(0).toUpperCase() }}
|
||||||
</div>
|
</div>
|
||||||
<h3 style="margin-bottom:4px">{{ user?.nickname || user?.username }}</h3>
|
<h3 style="margin-bottom:4px">
|
||||||
<p style="color:#8c8c8c;font-size:13px;margin-bottom:16px">@{{ user?.username }}</p>
|
{{ profileForm.real_name || profileForm.nickname || user?.username }}
|
||||||
|
<span v-if="profileForm.real_name && profileForm.nickname" style="font-size:13px;color:#8c8c8c;font-weight:normal">
|
||||||
|
({{ profileForm.nickname }})
|
||||||
|
</span>
|
||||||
|
</h3>
|
||||||
|
<p style="color:#8c8c8c;font-size:13px;margin-bottom:12px">@{{ user?.username }}</p>
|
||||||
<el-tag v-if="user?.role === 'admin'" type="primary" size="large">平台管理员</el-tag>
|
<el-tag v-if="user?.role === 'admin'" type="primary" size="large">平台管理员</el-tag>
|
||||||
<el-tag v-else type="success" size="large">普通用户</el-tag>
|
<el-tag v-else type="success" size="large">普通用户</el-tag>
|
||||||
<div style="margin-top:16px;padding-top:16px;border-top:1px solid #f0f0f0;text-align:left">
|
|
||||||
<div style="margin-bottom:8px;color:#595959"><i class="fas fa-envelope" style="color:#1890ff;width:20px"></i>{{ user?.email || '未设置邮箱' }}</div>
|
<!-- 个人概要信息标签卡(融合基本资料与联系方式) -->
|
||||||
<div style="color:#595959"><i class="fas fa-clock" style="color:#1890ff;width:20px"></i>注册于 {{ formatDate(user?.created_at) }}</div>
|
<div style="margin-top:16px;padding-top:16px;border-top:1px solid #f0f0f0;text-align:left;font-size:13px;display:flex;flex-direction:column;gap:10px">
|
||||||
|
<div style="color:#595959">
|
||||||
|
<i class="fas fa-id-badge" style="color:#1890ff;width:20px"></i>
|
||||||
|
<span style="color:#8c8c8c">真实姓名:</span>
|
||||||
|
<span style="font-weight:600;color:#262626">{{ profileForm.real_name || '未填写' }}</span>
|
||||||
|
</div>
|
||||||
|
<div style="color:#595959">
|
||||||
|
<i class="fas fa-venus-mars" style="color:#1890ff;width:20px"></i>
|
||||||
|
<span style="color:#8c8c8c">性 别:</span>
|
||||||
|
<span>{{ profileForm.gender || '未设置' }}</span>
|
||||||
|
</div>
|
||||||
|
<div style="color:#595959">
|
||||||
|
<i class="fas fa-users" style="color:#1890ff;width:20px"></i>
|
||||||
|
<span style="color:#8c8c8c">民 族:</span>
|
||||||
|
<span>{{ profileForm.nation || '未设置' }}</span>
|
||||||
|
</div>
|
||||||
|
<div style="color:#595959">
|
||||||
|
<i class="fas fa-landmark" style="color:#1890ff;width:20px"></i>
|
||||||
|
<span style="color:#8c8c8c">政治面貌:</span>
|
||||||
|
<span>{{ profileForm.political_status || '未设置' }}</span>
|
||||||
|
</div>
|
||||||
|
<div style="color:#595959">
|
||||||
|
<i class="fas fa-id-card" style="color:#1890ff;width:20px"></i>
|
||||||
|
<span style="color:#8c8c8c">身份证号:</span>
|
||||||
|
<span style="font-family:monospace">{{ maskedIdCard }}</span>
|
||||||
|
</div>
|
||||||
|
<div style="color:#595959">
|
||||||
|
<i class="fas fa-phone" style="color:#1890ff;width:20px"></i>
|
||||||
|
<span style="color:#8c8c8c">联系电话:</span>
|
||||||
|
<span>{{ profileForm.phone || '未设置' }}</span>
|
||||||
|
</div>
|
||||||
|
<div style="color:#595959">
|
||||||
|
<i class="fas fa-envelope" style="color:#1890ff;width:20px"></i>
|
||||||
|
<span style="color:#8c8c8c">电子邮箱:</span>
|
||||||
|
<span>{{ profileForm.email || user?.email || '未设置邮箱' }}</span>
|
||||||
|
</div>
|
||||||
|
<div style="color:#595959">
|
||||||
|
<i class="fas fa-home" style="color:#1890ff;width:20px"></i>
|
||||||
|
<span style="color:#8c8c8c">家庭住址:</span>
|
||||||
|
<span :title="profileForm.address" style="word-break:break-all">{{ profileForm.address || '未填写' }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="contacts.some(c => c.type === 'wechat')" style="color:#595959">
|
||||||
|
<i class="fab fa-weixin" style="color:#52c41a;width:20px"></i>
|
||||||
|
<span style="color:#8c8c8c">微 信:</span>
|
||||||
|
<span>{{ contacts.find(c => c.type === 'wechat')?.value }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="contacts.some(c => c.type === 'qq')" style="color:#595959">
|
||||||
|
<i class="fab fa-qq" style="color:#1890ff;width:20px"></i>
|
||||||
|
<span style="color:#8c8c8c">Q Q:</span>
|
||||||
|
<span>{{ contacts.find(c => c.type === 'qq')?.value }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="contacts.some(c => c.type === 'workunit')" style="color:#595959">
|
||||||
|
<i class="fas fa-building" style="color:#1890ff;width:20px"></i>
|
||||||
|
<span style="color:#8c8c8c">工作单位:</span>
|
||||||
|
<span>{{ contacts.find(c => c.type === 'workunit')?.value }}</span>
|
||||||
|
</div>
|
||||||
|
<div style="color:#595959">
|
||||||
|
<i class="fas fa-clock" style="color:#1890ff;width:20px"></i>
|
||||||
|
<span style="color:#8c8c8c">注册时间:</span>
|
||||||
|
<span>{{ formatDate(user?.created_at) }}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</el-col>
|
</el-col>
|
||||||
|
|
||||||
<!-- 右侧:编辑区 -->
|
<!-- 右侧:编辑与管理区 -->
|
||||||
<el-col :xs="24" :md="16">
|
<el-col :xs="24" :md="16">
|
||||||
<el-tabs v-model="activeTab" stretch>
|
<el-tabs v-model="activeTab" stretch>
|
||||||
<!-- 基本资料 -->
|
<!-- 基本资料(已深度融合身份档案与联系方式) -->
|
||||||
<el-tab-pane label="基本资料" name="profile">
|
<el-tab-pane label="基本资料" name="profile">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<el-form :model="profileForm" label-width="100px">
|
<el-form ref="profileFormRef" :model="profileForm" :rules="profileRules" label-width="100px">
|
||||||
<el-form-item label="昵称">
|
<!-- 第一部分:个人身份信息 -->
|
||||||
<el-input v-model="profileForm.nickname" placeholder="请输入昵称" />
|
<div style="font-size:15px;font-weight:600;color:#262626;margin-bottom:16px;display:flex;align-items:center">
|
||||||
|
<i class="fas fa-id-card-alt" style="color:#1890ff;margin-right:8px"></i>
|
||||||
|
<span>个人身份档案</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-row :gutter="16">
|
||||||
|
<el-col :xs="24" :sm="12">
|
||||||
|
<el-form-item label="真实姓名" prop="real_name">
|
||||||
|
<el-input v-model="profileForm.real_name" placeholder="请输入姓名(如:张三)" clearable />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="邮箱">
|
</el-col>
|
||||||
<el-input v-model="profileForm.email" placeholder="请输入邮箱(用于找回密码)" />
|
<el-col :xs="24" :sm="12">
|
||||||
|
<el-form-item label="性别" prop="gender">
|
||||||
|
<el-radio-group v-model="profileForm.gender">
|
||||||
|
<el-radio label="男">男</el-radio>
|
||||||
|
<el-radio label="女">女</el-radio>
|
||||||
|
<el-radio label="保密">保密</el-radio>
|
||||||
|
</el-radio-group>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="个人简介">
|
</el-col>
|
||||||
<el-input v-model="profileForm.bio" type="textarea" :rows="4" placeholder="介绍一下自己吧" />
|
</el-row>
|
||||||
|
|
||||||
|
<el-row :gutter="16">
|
||||||
|
<el-col :xs="24" :sm="12">
|
||||||
|
<el-form-item label="民族" prop="nation">
|
||||||
|
<el-select
|
||||||
|
v-model="profileForm.nation"
|
||||||
|
placeholder="请选择或输入民族"
|
||||||
|
filterable
|
||||||
|
allow-create
|
||||||
|
default-first-option
|
||||||
|
style="width:100%"
|
||||||
|
>
|
||||||
|
<el-option v-for="n in nationOptions" :key="n" :label="n" :value="n" />
|
||||||
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item>
|
</el-col>
|
||||||
<el-button type="primary" @click="saveProfile"><i class="fas fa-save" style="margin-right:4px"></i>保存资料</el-button>
|
<el-col :xs="24" :sm="12">
|
||||||
|
<el-form-item label="政治身份" prop="political_status">
|
||||||
|
<el-select
|
||||||
|
v-model="profileForm.political_status"
|
||||||
|
placeholder="请选择或输入政治身份"
|
||||||
|
filterable
|
||||||
|
allow-create
|
||||||
|
default-first-option
|
||||||
|
style="width:100%"
|
||||||
|
>
|
||||||
|
<el-option v-for="p in politicalStatusOptions" :key="p" :label="p" :value="p" />
|
||||||
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
|
||||||
|
<el-form-item label="身份证号" prop="id_card">
|
||||||
|
<el-input
|
||||||
|
v-model="profileForm.id_card"
|
||||||
|
placeholder="请输入18位二代居民身份证号"
|
||||||
|
maxlength="18"
|
||||||
|
show-word-limit
|
||||||
|
clearable
|
||||||
|
/>
|
||||||
|
<div style="font-size:12px;color:#8c8c8c;margin-top:4px">
|
||||||
|
<i class="fas fa-shield-alt" style="color:#52c41a;margin-right:4px"></i>
|
||||||
|
用于校友实名档案及安全认证,非本人或平台管理员查看时自动掩码脱敏
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="个性昵称" prop="nickname">
|
||||||
|
<el-input v-model="profileForm.nickname" placeholder="请输入个性昵称" clearable />
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="个人简介" prop="bio">
|
||||||
|
<el-input v-model="profileForm.bio" type="textarea" :rows="2" placeholder="介绍一下自己吧" />
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<!-- 第二部分:联系方式(深度融入基本资料) -->
|
||||||
|
<div style="margin:24px 0 16px;padding-top:16px;border-top:1px solid #f0f0f0;display:flex;justify-content:space-between;align-items:center">
|
||||||
|
<div style="font-size:15px;font-weight:600;color:#262626;display:flex;align-items:center">
|
||||||
|
<i class="fas fa-address-book" style="color:#1890ff;margin-right:8px"></i>
|
||||||
|
<span>联系方式与通讯录</span>
|
||||||
|
<span style="font-size:12px;color:#8c8c8c;font-weight:normal;margin-left:8px">(校友录通讯资料,同班同学与班级管理员可互相查看)</span>
|
||||||
|
</div>
|
||||||
|
<el-button type="primary" size="small" plain @click="openAddContactDialog">
|
||||||
|
<i class="fas fa-plus" style="margin-right:4px"></i>添加更多联系方式
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-row :gutter="16">
|
||||||
|
<el-col :xs="24" :sm="12">
|
||||||
|
<el-form-item label="常用手机" prop="phone">
|
||||||
|
<el-input v-model="profileForm.phone" placeholder="请输入常用手机号" clearable>
|
||||||
|
<template #prefix><i class="fas fa-phone" style="color:#8c8c8c"></i></template>
|
||||||
|
</el-input>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :xs="24" :sm="12">
|
||||||
|
<el-form-item label="电子邮箱" prop="email">
|
||||||
|
<el-input v-model="profileForm.email" placeholder="请输入电子邮箱(找回密码与通知)" clearable>
|
||||||
|
<template #prefix><i class="fas fa-envelope" style="color:#8c8c8c"></i></template>
|
||||||
|
</el-input>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
|
||||||
|
<el-form-item label="常住住址" prop="address">
|
||||||
|
<el-input
|
||||||
|
v-model="profileForm.address"
|
||||||
|
type="textarea"
|
||||||
|
:rows="2"
|
||||||
|
placeholder="请输入常住家庭住址(省/市/区县/街道详细住址)"
|
||||||
|
clearable
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item style="margin-bottom:12px">
|
||||||
|
<el-button type="primary" :loading="saving" @click="saveProfile">
|
||||||
|
<i class="fas fa-save" style="margin-right:4px"></i>保存基本资料
|
||||||
|
</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<!-- 第三部分:已登记的多渠道联系方式清单 -->
|
||||||
|
<div style="margin-top:20px;padding-top:16px;border-top:1px dashed #e8e8e8">
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px">
|
||||||
|
<div style="font-size:13px;font-weight:600;color:#595959">
|
||||||
|
已登记的多渠道联系方式(QQ、微信、多手机、工作单位等):
|
||||||
|
</div>
|
||||||
|
<div style="font-size:12px;color:#8c8c8c">
|
||||||
|
上限:邮箱2个、QQ5个、微信2个、手机号5个、住址1个、工作单位1个
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-table v-if="contacts.length > 0" :data="contacts" size="small" stripe border style="width:100%">
|
||||||
|
<el-table-column label="联系类型" width="130">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<i :class="contactIcon(row.type)" style="color:#1890ff;margin-right:6px"></i>
|
||||||
|
<span>{{ contactTypeMap[row.type] || row.type }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="value" label="联系内容 / 账号" min-width="180" />
|
||||||
|
<el-table-column label="属性" width="100">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag v-if="row.is_primary" type="success" size="small">主要</el-tag>
|
||||||
|
<el-button v-else-if="['phone','email','address'].includes(row.type)" type="primary" link size="small" @click="setPrimaryContact(row)">
|
||||||
|
设为主
|
||||||
|
</el-button>
|
||||||
|
<span v-else style="color:#bfbfbf;font-size:12px">普通</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="80" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button type="danger" link size="small" @click="deleteContact(row)">
|
||||||
|
<i class="fas fa-trash"></i>
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
<div v-else style="background:#fafafa;border:1px dashed #d9d9d9;border-radius:6px;padding:16px;text-align:center;color:#8c8c8c;font-size:13px">
|
||||||
|
暂无其他附加联系方式,点击右上角「添加更多联系方式」登记 QQ、微信、备用手机或工作单位
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</el-form>
|
</el-form>
|
||||||
</div>
|
</div>
|
||||||
</el-tab-pane>
|
</el-tab-pane>
|
||||||
@@ -48,7 +262,7 @@
|
|||||||
<div class="card">
|
<div class="card">
|
||||||
<div style="margin-bottom:16px">
|
<div style="margin-bottom:16px">
|
||||||
<el-button type="primary" @click="showEduDialog = true"><i class="fas fa-plus" style="margin-right:4px"></i>添加履历</el-button>
|
<el-button type="primary" @click="showEduDialog = true"><i class="fas fa-plus" style="margin-right:4px"></i>添加履历</el-button>
|
||||||
<el-tag type="info" style="margin-left:12px">有学习履历才能创建班级</el-tag>
|
<el-tag type="info" style="margin-left:12px">有学习履历才能创建或加入班级</el-tag>
|
||||||
</div>
|
</div>
|
||||||
<el-timeline v-if="educationList.length > 0">
|
<el-timeline v-if="educationList.length > 0">
|
||||||
<el-timeline-item v-for="edu in educationList" :key="edu.id" :timestamp="`${edu.start_year} - ${edu.end_year || '至今'}`" placement="top">
|
<el-timeline-item v-for="edu in educationList" :key="edu.id" :timestamp="`${edu.start_year} - ${edu.end_year || '至今'}`" placement="top">
|
||||||
@@ -68,36 +282,11 @@
|
|||||||
</el-timeline>
|
</el-timeline>
|
||||||
<div v-else class="empty">
|
<div v-else class="empty">
|
||||||
<i class="fas fa-graduation-cap"></i>
|
<i class="fas fa-graduation-cap"></i>
|
||||||
<p>暂无学习履历,添加后才能创建班级</p>
|
<p>暂无学习履历,添加后才能创建或加入班级</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</el-tab-pane>
|
</el-tab-pane>
|
||||||
|
|
||||||
<!-- 联系方式 -->
|
|
||||||
<el-tab-pane label="联系方式" name="contacts">
|
|
||||||
<div class="card">
|
|
||||||
<div style="margin-bottom:16px">
|
|
||||||
<el-button type="primary" @click="showContactDialog = true"><i class="fas fa-plus" style="margin-right:4px"></i>添加联系方式</el-button>
|
|
||||||
</div>
|
|
||||||
<el-table :data="contacts" stripe>
|
|
||||||
<el-table-column label="类型" width="120">
|
|
||||||
<template #default="{ row }">
|
|
||||||
<i :class="contactIcon(row.type)" style="color:#1890ff;margin-right:6px"></i>{{ contactTypeMap[row.type] }}
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column prop="value" label="内容" />
|
|
||||||
<el-table-column label="操作" width="100">
|
|
||||||
<template #default="{ row }">
|
|
||||||
<el-button type="danger" link @click="deleteContact(row)"><i class="fas fa-trash"></i></el-button>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
</el-table>
|
|
||||||
<el-alert type="info" :closable="false" style="margin-top:16px">
|
|
||||||
联系方式上限:邮箱2个、QQ5个、微信2个、手机号5个、住址1个、工作单位1个
|
|
||||||
</el-alert>
|
|
||||||
</div>
|
|
||||||
</el-tab-pane>
|
|
||||||
|
|
||||||
<!-- 我的班级 -->
|
<!-- 我的班级 -->
|
||||||
<el-tab-pane label="我的班级" name="classes">
|
<el-tab-pane label="我的班级" name="classes">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
@@ -140,92 +329,287 @@
|
|||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
<!-- 添加联系方式弹窗 -->
|
<!-- 添加联系方式弹窗 -->
|
||||||
<el-dialog v-model="showContactDialog" title="添加联系方式" width="400px">
|
<el-dialog v-model="showContactDialog" title="添加联系方式" width="440px">
|
||||||
<el-form :model="contactForm" label-width="80px">
|
<el-form :model="contactForm" label-width="90px">
|
||||||
<el-form-item label="类型" required>
|
<el-form-item label="类型" required>
|
||||||
<el-select v-model="contactForm.type" style="width:100%">
|
<el-select v-model="contactForm.type" style="width:100%">
|
||||||
<el-option label="邮箱" value="email" />
|
|
||||||
<el-option label="QQ" value="qq" />
|
|
||||||
<el-option label="微信" value="wechat" />
|
|
||||||
<el-option label="手机号" value="phone" />
|
<el-option label="手机号" value="phone" />
|
||||||
|
<el-option label="电子邮箱" value="email" />
|
||||||
|
<el-option label="微信" value="wechat" />
|
||||||
|
<el-option label="QQ" value="qq" />
|
||||||
<el-option label="家庭住址" value="address" />
|
<el-option label="家庭住址" value="address" />
|
||||||
<el-option label="工作单位" value="workunit" />
|
<el-option label="工作单位" value="workunit" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="内容" required><el-input v-model="contactForm.value" /></el-form-item>
|
<el-form-item label="内容" required>
|
||||||
|
<el-input v-model="contactForm.value" :placeholder="contactPlaceholder" clearable />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item v-if="['phone','email','address'].includes(contactForm.type)">
|
||||||
|
<el-checkbox v-model="contactForm.is_primary">设为该类型的首选/主要联系方式</el-checkbox>
|
||||||
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<el-button @click="showContactDialog=false">取消</el-button>
|
<el-button @click="showContactDialog=false">取消</el-button>
|
||||||
<el-button type="primary" @click="addContact">保存</el-button>
|
<el-button type="primary" :loading="contactSaving" @click="addContact">保存</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, reactive, onMounted } from 'vue'
|
import { ref, reactive, computed, onMounted, watch } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { userApi, classApi } from '../../../../api'
|
import { userApi, classApi } from '../../../../api'
|
||||||
import { useAuth } from '../../../../composables/useAuth'
|
import { useAuth } from '../../../../composables/useAuth'
|
||||||
|
|
||||||
const { user, setAuth } = useAuth()
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const { token, user, setAuth } = useAuth()
|
||||||
const activeTab = ref('profile')
|
const activeTab = ref('profile')
|
||||||
const profileForm = reactive({ nickname: '', email: '', bio: '' })
|
const saving = ref(false)
|
||||||
|
const contactSaving = ref(false)
|
||||||
|
const profileFormRef = ref(null)
|
||||||
|
|
||||||
|
const profileForm = reactive({
|
||||||
|
real_name: '',
|
||||||
|
gender: '男',
|
||||||
|
nation: '汉族',
|
||||||
|
political_status: '群众',
|
||||||
|
id_card: '',
|
||||||
|
address: '',
|
||||||
|
phone: '',
|
||||||
|
nickname: '',
|
||||||
|
email: '',
|
||||||
|
bio: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
// 56个民族标准列表
|
||||||
|
const nationOptions = [
|
||||||
|
'汉族', '蒙古族', '回族', '藏族', '维吾尔族', '苗族', '彝族', '壮族', '布依族', '朝鲜族',
|
||||||
|
'满族', '侗族', '瑶族', '白族', '土家族', '哈尼族', '哈萨克族', '傣族', '黎族', '傈僳族',
|
||||||
|
'佤族', '畲族', '高山族', '拉祜族', '水族', '东乡族', '纳西族', '景颇族', '柯尔克孜族', '土族',
|
||||||
|
'达斡尔族', '仫佬族', '羌族', '布朗族', '撒拉族', '毛南族', '仡佬族', '锡伯族', '阿昌族', '普米族',
|
||||||
|
'塔吉克族', '怒族', '乌孜别克族', '俄罗斯族', '鄂温克族', '德昂族', '保安族', '裕固族', '京族', '塔塔尔族',
|
||||||
|
'独龙族', '鄂伦春族', '赫哲族', '门巴族', '珞巴族', '基诺族'
|
||||||
|
]
|
||||||
|
|
||||||
|
// 政治身份/政治面貌常见选项
|
||||||
|
const politicalStatusOptions = [
|
||||||
|
'群众',
|
||||||
|
'共青团员',
|
||||||
|
'中共党员',
|
||||||
|
'中共预备党员',
|
||||||
|
'民革党员',
|
||||||
|
'民盟盟员',
|
||||||
|
'民建会员',
|
||||||
|
'民进会员',
|
||||||
|
'农工党党员',
|
||||||
|
'致公党党员',
|
||||||
|
'九三学社社员',
|
||||||
|
'台盟盟员',
|
||||||
|
'无党派人士'
|
||||||
|
]
|
||||||
|
|
||||||
|
// 身份证号前端校验
|
||||||
|
const validateIdCardRule = (rule, value, callback) => {
|
||||||
|
if (!value) return callback()
|
||||||
|
const val = String(value).trim().toUpperCase()
|
||||||
|
if (val.includes('*')) return callback()
|
||||||
|
const reg = /^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[0-9X]$/
|
||||||
|
if (!reg.test(val)) {
|
||||||
|
return callback(new Error('请输入18位有效中国居民身份证号码'))
|
||||||
|
}
|
||||||
|
const weights = [7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2]
|
||||||
|
const checkCodes = ['1', '0', 'X', '9', '8', '7', '6', '5', '4', '3', '2']
|
||||||
|
let sum = 0
|
||||||
|
for (let i = 0; i < 17; i++) {
|
||||||
|
sum += parseInt(val.charAt(i), 10) * weights[i]
|
||||||
|
}
|
||||||
|
if (val.charAt(17) !== checkCodes[sum % 11]) {
|
||||||
|
return callback(new Error(`身份证校验码错误(末位应为 ${checkCodes[sum % 11]})`))
|
||||||
|
}
|
||||||
|
callback()
|
||||||
|
}
|
||||||
|
|
||||||
|
const profileRules = {
|
||||||
|
real_name: [
|
||||||
|
{ max: 30, message: '真实姓名长度不能超过30个字符', trigger: 'blur' }
|
||||||
|
],
|
||||||
|
id_card: [
|
||||||
|
{ validator: validateIdCardRule, trigger: ['blur', 'change'] }
|
||||||
|
],
|
||||||
|
email: [
|
||||||
|
{ type: 'email', message: '请输入正确的邮箱地址格式', trigger: ['blur', 'change'] }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
const maskedIdCard = computed(() => {
|
||||||
|
const id = profileForm.id_card
|
||||||
|
if (!id) return '未填写'
|
||||||
|
if (id.length === 18) {
|
||||||
|
return id.substring(0, 6) + '********' + id.substring(14)
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
})
|
||||||
|
|
||||||
const educationList = ref([])
|
const educationList = ref([])
|
||||||
const contacts = ref([])
|
const contacts = ref([])
|
||||||
const myClasses = ref([])
|
const myClasses = ref([])
|
||||||
const showEduDialog = ref(false)
|
const showEduDialog = ref(false)
|
||||||
const showContactDialog = ref(false)
|
const showContactDialog = ref(false)
|
||||||
const eduForm = reactive({ school_name: '', college_name: '', major: '', grade: '', class_no: '', start_year: 2015, end_year: 2018 })
|
const eduForm = reactive({ school_name: '', college_name: '', major: '', grade: '', class_no: '', start_year: 2015, end_year: 2018 })
|
||||||
const contactForm = reactive({ type: 'email', value: '' })
|
const contactForm = reactive({ type: 'phone', value: '', is_primary: false })
|
||||||
|
|
||||||
|
const contactTypeMap = {
|
||||||
|
phone: '手机号',
|
||||||
|
email: '电子邮箱',
|
||||||
|
wechat: '微信',
|
||||||
|
qq: 'QQ',
|
||||||
|
address: '家庭住址',
|
||||||
|
workunit: '工作单位'
|
||||||
|
}
|
||||||
|
|
||||||
|
const contactPlaceholder = computed(() => {
|
||||||
|
switch (contactForm.type) {
|
||||||
|
case 'phone': return '请输入手机号码'
|
||||||
|
case 'email': return '请输入邮箱地址,如 abc@example.com'
|
||||||
|
case 'wechat': return '请输入微信号'
|
||||||
|
case 'qq': return '请输入QQ号'
|
||||||
|
case 'address': return '请输入详细住址'
|
||||||
|
case 'workunit': return '请输入工作单位全称'
|
||||||
|
default: return '请输入联系方式内容'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const contactTypeMap = { email: '邮箱', qq: 'QQ', wechat: '微信', phone: '手机号', address: '家庭住址', workunit: '工作单位' }
|
|
||||||
function contactIcon(type) {
|
function contactIcon(type) {
|
||||||
const map = { email: 'fas fa-envelope', phone: 'fas fa-phone', qq: 'fab fa-qq', wechat: 'fab fa-weixin', address: 'fas fa-map-marker-alt', workunit: 'fas fa-building' }
|
const map = {
|
||||||
|
email: 'fas fa-envelope',
|
||||||
|
phone: 'fas fa-phone',
|
||||||
|
qq: 'fab fa-qq',
|
||||||
|
wechat: 'fab fa-weixin',
|
||||||
|
address: 'fas fa-map-marker-alt',
|
||||||
|
workunit: 'fas fa-building'
|
||||||
|
}
|
||||||
return map[type] || 'fas fa-info-circle'
|
return map[type] || 'fas fa-info-circle'
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(d) {
|
function formatDate(d) {
|
||||||
if (!d) return ''
|
if (!d) return ''
|
||||||
return d.substring(0, 10)
|
return d.substring(0, 10)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadProfile() {
|
function openAddContactDialog() {
|
||||||
const res = await userApi.profile()
|
contactForm.type = 'phone'
|
||||||
Object.assign(profileForm, res.data)
|
contactForm.value = ''
|
||||||
|
contactForm.is_primary = false
|
||||||
|
showContactDialog.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadProfile() {
|
||||||
|
try {
|
||||||
|
const res = await userApi.profile()
|
||||||
|
const u = res.data?.user || res.data || {}
|
||||||
|
profileForm.real_name = u.real_name || ''
|
||||||
|
profileForm.gender = u.gender || '男'
|
||||||
|
profileForm.nation = u.nation || '汉族'
|
||||||
|
profileForm.political_status = u.political_status || '群众'
|
||||||
|
profileForm.id_card = u.id_card || ''
|
||||||
|
profileForm.address = u.address || ''
|
||||||
|
profileForm.phone = u.phone || ''
|
||||||
|
profileForm.nickname = u.nickname || ''
|
||||||
|
profileForm.email = u.email || ''
|
||||||
|
profileForm.bio = u.bio || ''
|
||||||
|
|
||||||
|
// 同步提取返回的联系方式列表
|
||||||
|
if (res.data?.contacts) {
|
||||||
|
contacts.value = res.data.contacts
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
ElMessage.error(e.message || '加载个人资料失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function loadEducation() {
|
async function loadEducation() {
|
||||||
|
try {
|
||||||
const res = await userApi.listEducation()
|
const res = await userApi.listEducation()
|
||||||
educationList.value = res.data || []
|
educationList.value = res.data || []
|
||||||
|
} catch (e) {
|
||||||
|
educationList.value = []
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function loadContacts() {
|
async function loadContacts() {
|
||||||
|
try {
|
||||||
const res = await userApi.listContacts()
|
const res = await userApi.listContacts()
|
||||||
contacts.value = res.data || []
|
contacts.value = res.data || []
|
||||||
|
} catch (e) {
|
||||||
|
contacts.value = []
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function loadMyClasses() {
|
async function loadMyClasses() {
|
||||||
try {
|
try {
|
||||||
const res = await classApi.mine()
|
const res = await classApi.mine()
|
||||||
myClasses.value = res.data || []
|
myClasses.value = res.data || []
|
||||||
} catch (e) { myClasses.value = [] }
|
} catch (e) {
|
||||||
|
myClasses.value = []
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveProfile() {
|
async function saveProfile() {
|
||||||
|
if (profileFormRef.value) {
|
||||||
|
const valid = await profileFormRef.value.validate().catch(() => false)
|
||||||
|
if (!valid) {
|
||||||
|
ElMessage.warning('请检查输入信息格式是否正确')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
saving.value = true
|
||||||
try {
|
try {
|
||||||
const res = await userApi.updateProfile(profileForm)
|
const res = await userApi.updateProfile(profileForm)
|
||||||
setAuth(user.value.token, res.data)
|
if (res.data && res.data.id) {
|
||||||
ElMessage.success('资料已保存')
|
setAuth(token.value, res.data)
|
||||||
|
}
|
||||||
|
ElMessage.success('基本资料与联系方式保存成功')
|
||||||
|
await loadProfile()
|
||||||
|
await loadContacts()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
ElMessage.error(e.message || '保存失败')
|
ElMessage.error(e.message || '保存失败')
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function addEdu() {
|
async function addEdu() {
|
||||||
if (!eduForm.school_name) { ElMessage.warning('请输入学校名称'); return }
|
if (!eduForm.school_name) { ElMessage.warning('请输入学校名称'); return }
|
||||||
try {
|
try {
|
||||||
await userApi.addEducation(eduForm)
|
await userApi.addEducation(eduForm)
|
||||||
ElMessage.success('已添加')
|
ElMessage.success('学习履历添加成功')
|
||||||
showEduDialog.value = false
|
showEduDialog.value = false
|
||||||
loadEducation()
|
loadEducation()
|
||||||
|
|
||||||
|
// 若从创建班级或加入班级跳转过来,引导返回
|
||||||
|
if (route.query.from === 'class_create') {
|
||||||
|
ElMessageBox.confirm('学习履历已添加成功!是否立即前往创建班级?', '提示', {
|
||||||
|
confirmButtonText: '前往创建班级',
|
||||||
|
cancelButtonText: '留在本页',
|
||||||
|
type: 'success'
|
||||||
|
}).then(() => {
|
||||||
|
router.push('/class/edit')
|
||||||
|
}).catch(() => {})
|
||||||
|
} else if (route.query.from === 'class_join') {
|
||||||
|
ElMessageBox.confirm('学习履历已添加成功!是否立即前往加入班级?', '提示', {
|
||||||
|
confirmButtonText: '前往加入班级',
|
||||||
|
cancelButtonText: '留在本页',
|
||||||
|
type: 'success'
|
||||||
|
}).then(() => {
|
||||||
|
router.push('/class/join')
|
||||||
|
}).catch(() => {})
|
||||||
|
}
|
||||||
} catch (e) { ElMessage.error(e.message || '添加失败') }
|
} catch (e) { ElMessage.error(e.message || '添加失败') }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteEdu(edu) {
|
async function deleteEdu(edu) {
|
||||||
try {
|
try {
|
||||||
await ElMessageBox.confirm('确定删除这条履历吗?', '提示', { type: 'warning' })
|
await ElMessageBox.confirm('确定删除这条履历吗?', '提示', { type: 'warning' })
|
||||||
@@ -234,22 +618,71 @@ async function deleteEdu(edu) {
|
|||||||
loadEducation()
|
loadEducation()
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function addContact() {
|
async function addContact() {
|
||||||
if (!contactForm.value) { ElMessage.warning('请输入内容'); return }
|
if (!contactForm.value || !contactForm.value.trim()) {
|
||||||
try {
|
ElMessage.warning('请输入联系方式内容')
|
||||||
await userApi.addContact(contactForm)
|
return
|
||||||
ElMessage.success('已添加')
|
|
||||||
showContactDialog.value = false
|
|
||||||
loadContacts()
|
|
||||||
} catch (e) { ElMessage.error(e.message || '添加失败') }
|
|
||||||
}
|
}
|
||||||
|
contactSaving.value = true
|
||||||
|
try {
|
||||||
|
await userApi.addContact({
|
||||||
|
type: contactForm.type,
|
||||||
|
value: contactForm.value.trim(),
|
||||||
|
is_primary: contactForm.is_primary
|
||||||
|
})
|
||||||
|
ElMessage.success('联系方式添加成功')
|
||||||
|
showContactDialog.value = false
|
||||||
|
contactForm.value = ''
|
||||||
|
contactForm.is_primary = false
|
||||||
|
await loadProfile()
|
||||||
|
await loadContacts()
|
||||||
|
} catch (e) {
|
||||||
|
ElMessage.error(e.message || '添加失败')
|
||||||
|
} finally {
|
||||||
|
contactSaving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setPrimaryContact(row) {
|
||||||
|
try {
|
||||||
|
await userApi.updateContact(row.id, { is_primary: true })
|
||||||
|
ElMessage.success('已设为主要联系方式')
|
||||||
|
await loadProfile()
|
||||||
|
await loadContacts()
|
||||||
|
} catch (e) {
|
||||||
|
ElMessage.error(e.message || '设置失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function deleteContact(c) {
|
async function deleteContact(c) {
|
||||||
try {
|
try {
|
||||||
|
await ElMessageBox.confirm(`确定删除该联系方式「${c.value}」吗?`, '提示', { type: 'warning' })
|
||||||
await userApi.deleteContact(c.id)
|
await userApi.deleteContact(c.id)
|
||||||
ElMessage.success('已删除')
|
ElMessage.success('已删除')
|
||||||
loadContacts()
|
await loadProfile()
|
||||||
} catch (e) { ElMessage.error(e.message || '删除失败') }
|
await loadContacts()
|
||||||
|
} catch (e) {
|
||||||
|
if (e !== 'cancel') ElMessage.error(e.message || '删除失败')
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => route.query,
|
||||||
|
(q) => {
|
||||||
|
if (q.tab) {
|
||||||
|
if (q.tab === 'contacts') {
|
||||||
|
activeTab.value = 'profile'
|
||||||
|
} else {
|
||||||
|
activeTab.value = String(q.tab)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (q.action === 'add' && (q.tab === 'education' || activeTab.value === 'education')) {
|
||||||
|
showEduDialog.value = true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadProfile()
|
loadProfile()
|
||||||
|
|||||||
@@ -1,17 +1,57 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-title">用户设置 - 编辑资料</div>
|
<div class="card-title">用户设置 - 编辑个人基本资料</div>
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>昵称</label>
|
<label>真实姓名</label>
|
||||||
<input v-model="form.nickname" />
|
<input v-model="form.real_name" placeholder="请输入姓名" />
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>性别</label>
|
||||||
|
<select v-model="form.gender">
|
||||||
|
<option value="男">男</option>
|
||||||
|
<option value="女">女</option>
|
||||||
|
<option value="保密">保密</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>民族</label>
|
||||||
|
<input v-model="form.nation" placeholder="如:汉族" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>政治身份</label>
|
||||||
|
<input v-model="form.political_status" placeholder="如:群众、中共党员" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>身份证号</label>
|
||||||
|
<input v-model="form.id_card" maxlength="18" placeholder="18位中国居民身份证号码" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>家庭住址</label>
|
||||||
|
<input v-model="form.address" placeholder="详细常住家庭住址" />
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>联系手机</label>
|
||||||
|
<input v-model="form.phone" placeholder="手机号" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>电子邮箱</label>
|
||||||
|
<input v-model="form.email" placeholder="常用邮箱" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>个性昵称</label>
|
||||||
|
<input v-model="form.nickname" placeholder="昵称" />
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>个人简介</label>
|
<label>个人简介</label>
|
||||||
<textarea v-model="form.bio" rows="3"></textarea>
|
<textarea v-model="form.bio" rows="3" placeholder="一句话介绍自己"></textarea>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-primary" @click="save">保存</button>
|
<button class="btn btn-primary" @click="save">保存基本资料</button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -19,12 +59,36 @@
|
|||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { userApi } from '../../../../api'
|
import { userApi } from '../../../../api'
|
||||||
|
|
||||||
const form = ref({ nickname: '', bio: '' })
|
const form = ref({
|
||||||
|
real_name: '',
|
||||||
|
gender: '男',
|
||||||
|
nation: '汉族',
|
||||||
|
political_status: '群众',
|
||||||
|
id_card: '',
|
||||||
|
address: '',
|
||||||
|
phone: '',
|
||||||
|
email: '',
|
||||||
|
nickname: '',
|
||||||
|
bio: ''
|
||||||
|
})
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
const profile = await userApi.profile()
|
try {
|
||||||
form.value.nickname = profile.user.nickname
|
const res = await userApi.profile()
|
||||||
form.value.bio = profile.user.bio
|
const u = res.data?.user || res.data || {}
|
||||||
|
form.value = {
|
||||||
|
real_name: u.real_name || '',
|
||||||
|
gender: u.gender || '男',
|
||||||
|
nation: u.nation || '汉族',
|
||||||
|
political_status: u.political_status || '群众',
|
||||||
|
id_card: u.id_card || '',
|
||||||
|
address: u.address || '',
|
||||||
|
phone: u.phone || '',
|
||||||
|
email: u.email || '',
|
||||||
|
nickname: u.nickname || '',
|
||||||
|
bio: u.bio || ''
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
})
|
})
|
||||||
|
|
||||||
async function save() {
|
async function save() {
|
||||||
|
|||||||
@@ -1,32 +1,76 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
|
<!-- 个人基本资料(已融入联系方式) -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-title">个人资料</div>
|
<div class="card-title">个人基本资料</div>
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>用户名</label>
|
<label>用户名</label>
|
||||||
<input :value="profile?.user.username" disabled style="background:#f5f7fa" />
|
<input :value="profile?.user.username" disabled style="background:#f5f7fa" />
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>昵称</label>
|
<label>真实姓名</label>
|
||||||
<input v-model="editForm.nickname" />
|
<input v-model="editForm.real_name" placeholder="请输入姓名" />
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>性别</label>
|
||||||
|
<select v-model="editForm.gender">
|
||||||
|
<option value="男">男</option>
|
||||||
|
<option value="女">女</option>
|
||||||
|
<option value="保密">保密</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>民族</label>
|
||||||
|
<input v-model="editForm.nation" placeholder="如:汉族" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>政治身份</label>
|
||||||
|
<input v-model="editForm.political_status" placeholder="如:群众、中共党员" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>身份证号</label>
|
||||||
|
<input v-model="editForm.id_card" maxlength="18" placeholder="18位居民身份证号" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>常用手机</label>
|
||||||
|
<input v-model="editForm.phone" placeholder="联系手机号码" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>绑定邮箱</label>
|
||||||
|
<input v-model="editForm.email" placeholder="电子邮箱地址" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>个性昵称</label>
|
||||||
|
<input v-model="editForm.nickname" placeholder="昵称" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>家庭住址</label>
|
||||||
|
<input v-model="editForm.address" placeholder="详细常住家庭住址" />
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>个人简介</label>
|
<label>个人简介</label>
|
||||||
<textarea v-model="editForm.bio" rows="2" placeholder="一句话介绍自己"></textarea>
|
<textarea v-model="editForm.bio" rows="2" placeholder="一句话介绍自己"></textarea>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-primary" @click="saveProfile" :disabled="saving">{{ saving?'保存中...':'保存资料' }}</button>
|
<div style="margin-bottom:16px">
|
||||||
|
<button class="btn btn-primary" @click="saveProfile" :disabled="saving">{{ saving?'保存中...':'保存基本资料' }}</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 联系方式 -->
|
<!-- 联系方式管理(已融合至基本资料中) -->
|
||||||
<div class="card">
|
<div style="margin-top:20px;padding-top:16px;border-top:1px solid #eee">
|
||||||
<div class="card-title" style="display:flex;justify-content:space-between;align-items:center">
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px">
|
||||||
<span>联系方式</span>
|
<div>
|
||||||
<button class="btn btn-primary btn-sm" @click="showAddContact=true">添加联系方式</button>
|
<strong>多渠道联系方式(QQ / 微信 / 多手机号 / 工作单位等)</strong>
|
||||||
|
<div class="hint" style="margin-top:4px">
|
||||||
|
邮箱最多2个、QQ最多5个、微信最多2个、手机号最多5个、家庭住址和工作单位各1个。同班同学与班级管理员可查看。
|
||||||
</div>
|
</div>
|
||||||
<div class="hint" style="margin-bottom:12px">
|
</div>
|
||||||
邮箱最多2个、QQ最多5个、微信最多2个、手机号最多5个、家庭住址和工作单位各1个。同班同学可互相查看。
|
<button class="btn btn-primary btn-sm" @click="showAddContact=true">添加联系方式</button>
|
||||||
</div>
|
</div>
|
||||||
<table v-if="profile?.contacts.length">
|
<table v-if="profile?.contacts.length">
|
||||||
<thead><tr><th>类型</th><th>内容</th><th>主要</th><th>操作</th></tr></thead>
|
<thead><tr><th>类型</th><th>内容</th><th>主要</th><th>操作</th></tr></thead>
|
||||||
@@ -41,7 +85,8 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<div v-else class="empty">暂无联系方式</div>
|
<div v-else class="empty">暂无更多联系方式,可点击上方按钮添加</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 学习履历 -->
|
<!-- 学习履历 -->
|
||||||
@@ -99,10 +144,10 @@
|
|||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>类型</label>
|
<label>类型</label>
|
||||||
<select v-model="contactForm.type">
|
<select v-model="contactForm.type">
|
||||||
<option value="email">邮箱</option>
|
|
||||||
<option value="qq">QQ</option>
|
|
||||||
<option value="wechat">微信</option>
|
|
||||||
<option value="phone">手机号</option>
|
<option value="phone">手机号</option>
|
||||||
|
<option value="email">邮箱</option>
|
||||||
|
<option value="wechat">微信</option>
|
||||||
|
<option value="qq">QQ</option>
|
||||||
<option value="address">家庭住址</option>
|
<option value="address">家庭住址</option>
|
||||||
<option value="workunit">工作单位</option>
|
<option value="workunit">工作单位</option>
|
||||||
</select>
|
</select>
|
||||||
@@ -111,7 +156,7 @@
|
|||||||
<label>内容</label>
|
<label>内容</label>
|
||||||
<input v-model="contactForm.value" placeholder="请输入" />
|
<input v-model="contactForm.value" placeholder="请输入" />
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group" v-if="['phone','email','address'].includes(contactForm.type)">
|
||||||
<label><input type="checkbox" v-model="contactForm.is_primary" style="width:auto;margin-right:4px" />设为主要联系方式</label>
|
<label><input type="checkbox" v-model="contactForm.is_primary" style="width:auto;margin-right:4px" />设为主要联系方式</label>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="contactError" class="alert alert-danger">{{ contactError }}</div>
|
<div v-if="contactError" class="alert alert-danger">{{ contactError }}</div>
|
||||||
@@ -185,11 +230,22 @@ const profile = ref(null)
|
|||||||
const myClasses = ref([])
|
const myClasses = ref([])
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
|
|
||||||
const editForm = ref({ nickname: '', bio: '' })
|
const editForm = ref({
|
||||||
|
real_name: '',
|
||||||
|
gender: '男',
|
||||||
|
nation: '汉族',
|
||||||
|
political_status: '群众',
|
||||||
|
id_card: '',
|
||||||
|
address: '',
|
||||||
|
phone: '',
|
||||||
|
email: '',
|
||||||
|
nickname: '',
|
||||||
|
bio: ''
|
||||||
|
})
|
||||||
|
|
||||||
// 联系方式
|
// 联系方式
|
||||||
const showAddContact = ref(false)
|
const showAddContact = ref(false)
|
||||||
const contactForm = ref({ type: 'email', value: '', is_primary: false })
|
const contactForm = ref({ type: 'phone', value: '', is_primary: false })
|
||||||
const contactError = ref('')
|
const contactError = ref('')
|
||||||
|
|
||||||
// 履历
|
// 履历
|
||||||
@@ -203,20 +259,37 @@ onMounted(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
async function loadProfile() {
|
async function loadProfile() {
|
||||||
profile.value = await userApi.profile()
|
const res = await userApi.profile()
|
||||||
editForm.value.nickname = profile.value.user.nickname
|
profile.value = res.data || res
|
||||||
editForm.value.bio = profile.value.user.bio
|
const u = profile.value?.user || {}
|
||||||
|
editForm.value = {
|
||||||
|
real_name: u.real_name || '',
|
||||||
|
gender: u.gender || '男',
|
||||||
|
nation: u.nation || '汉族',
|
||||||
|
political_status: u.political_status || '群众',
|
||||||
|
id_card: u.id_card || '',
|
||||||
|
address: u.address || '',
|
||||||
|
phone: u.phone || '',
|
||||||
|
email: u.email || '',
|
||||||
|
nickname: u.nickname || '',
|
||||||
|
bio: u.bio || ''
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadMyClasses() {
|
async function loadMyClasses() {
|
||||||
myClasses.value = await classApi.mine()
|
try {
|
||||||
|
const res = await classApi.mine()
|
||||||
|
myClasses.value = res.data || []
|
||||||
|
} catch (e) {
|
||||||
|
myClasses.value = []
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveProfile() {
|
async function saveProfile() {
|
||||||
saving.value = true
|
saving.value = true
|
||||||
try {
|
try {
|
||||||
await userApi.updateProfile(editForm.value)
|
await userApi.updateProfile(editForm.value)
|
||||||
alert('保存成功')
|
alert('基本资料保存成功')
|
||||||
loadProfile()
|
loadProfile()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert('保存失败:' + e.message)
|
alert('保存失败:' + e.message)
|
||||||
@@ -234,7 +307,7 @@ async function addContact() {
|
|||||||
try {
|
try {
|
||||||
await userApi.addContact(contactForm.value)
|
await userApi.addContact(contactForm.value)
|
||||||
showAddContact.value = false
|
showAddContact.value = false
|
||||||
contactForm.value = { type: 'email', value: '', is_primary: false }
|
contactForm.value = { type: 'phone', value: '', is_primary: false }
|
||||||
loadProfile()
|
loadProfile()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
contactError.value = e.message
|
contactError.value = e.message
|
||||||
@@ -242,18 +315,20 @@ async function addContact() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function deleteContact(id) {
|
async function deleteContact(id) {
|
||||||
if (!confirm('确定删除?')) return
|
if (!confirm('确定删除此联系方式?')) return
|
||||||
try {
|
try {
|
||||||
await userApi.deleteContact(id)
|
await userApi.deleteContact(id)
|
||||||
loadProfile()
|
loadProfile()
|
||||||
} catch (e) { alert(e.message) }
|
} catch (e) {
|
||||||
|
alert('删除失败:' + e.message)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function openEduForm(e) {
|
function openEduForm(e) {
|
||||||
if (e) {
|
if (e) {
|
||||||
eduForm.value = { ...e }
|
eduForm.value = { ...e }
|
||||||
} else {
|
} else {
|
||||||
eduForm.value = { id: 0, school_name: '', college_name: '', major: '', degree: '', start_year: 0, end_year: 0, description: '' }
|
eduForm.value = { id: 0, school_name: '', college_name: '', major: '', degree: '', start_year: new Date().getFullYear(), end_year: 0, description: '' }
|
||||||
}
|
}
|
||||||
eduError.value = ''
|
eduError.value = ''
|
||||||
showEduForm.value = true
|
showEduForm.value = true
|
||||||
@@ -279,23 +354,27 @@ async function saveEdu() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function deleteEdu(id) {
|
async function deleteEdu(id) {
|
||||||
if (!confirm('确定删除这条履历?')) return
|
if (!confirm('确定删除此履历?')) return
|
||||||
try {
|
try {
|
||||||
await userApi.deleteEducation(id)
|
await userApi.deleteEducation(id)
|
||||||
loadProfile()
|
loadProfile()
|
||||||
} catch (e) { alert(e.message) }
|
} catch (e) {
|
||||||
|
alert('删除失败:' + e.message)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function contactTypeText(t) {
|
function contactTypeText(type) {
|
||||||
const map = { email: '邮箱', qq: 'QQ', wechat: '微信', phone: '手机号', address: '家庭住址', workunit: '工作单位' }
|
const map = { email: '邮箱', qq: 'QQ', wechat: '微信', phone: '手机号', address: '家庭住址', workunit: '工作单位' }
|
||||||
return map[t] || t
|
return map[type] || type
|
||||||
}
|
}
|
||||||
|
|
||||||
function statusText(s) {
|
function statusText(s) {
|
||||||
const map = { pending: '待审核', active: '正常', rejected: '被打回', deleted: '已删除' }
|
const m = { pending: '待审核', approved: '已通过', rejected: '已驳回', disbanded: '已解散' }
|
||||||
return map[s] || s
|
return m[s] || s
|
||||||
}
|
}
|
||||||
|
|
||||||
function statusTag(s) {
|
function statusTag(s) {
|
||||||
const map = { pending: 'tag-warning', active: 'tag-success', rejected: 'tag-danger', deleted: 'tag-info' }
|
const m = { pending: 'tag-warning', approved: 'tag-success', rejected: 'tag-danger', disbanded: 'tag-info' }
|
||||||
return map[s] || 'tag-info'
|
return m[s] || ''
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
Reference in New Issue
Block a user