This commit is contained in:
2026-08-28 14:04:31 +08:00
54 changed files with 6272 additions and 4696 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+479
View File
@@ -0,0 +1,479 @@
package controllers
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"strconv"
"strings"
"time"
"server/models"
)
// 本文件包含组织架构模块的 DTO 组装、树结构与层级校验,以及请求参数解析工具。
// ---------------------------------------------------------------------------
// DTO 组装
// ---------------------------------------------------------------------------
// organizationDTOList 批量组装组织 DTO。一次性把父组织名、负责人名、员工数查出来,
// 避免逐行查询导致的 N+1 问题。
func (c *BackendOrganizationController) organizationDTOList(tid uint64, rows []models.BackendOrganization) []organizationDTO {
list := make([]organizationDTO, 0, len(rows))
if len(rows) == 0 {
return list
}
nameByID := c.orgNameMap(tid)
leaderNames := c.employeeNameMap(tid)
counts := c.employeeCountByDepartment(tid)
for _, row := range rows {
list = append(list, c.assembleOrganizationDTO(row, nameByID, leaderNames, counts))
}
return list
}
func (c *BackendOrganizationController) organizationDTO(tid uint64, row models.BackendOrganization) organizationDTO {
return c.assembleOrganizationDTO(row,
c.orgNameMap(tid), c.employeeNameMap(tid), c.employeeCountByDepartment(tid))
}
func (c *BackendOrganizationController) assembleOrganizationDTO(
row models.BackendOrganization,
nameByID map[uint64]string,
leaderNames map[uint64]string,
counts map[string]int64,
) organizationDTO {
leaderID := uint64(0)
if row.LeaderID != nil {
leaderID = *row.LeaderID
}
return organizationDTO{
ID: row.ID,
Tid: row.Tid,
TenantID: row.Tid,
OrgName: row.OrgName,
OrgCode: row.OrgCode,
ParentID: row.ParentID,
ParentName: nameByID[row.ParentID],
LeaderID: leaderID,
LeaderName: leaderNames[leaderID],
IsCompany: row.IsCompany,
Sort: row.Sort,
Status: row.Status,
Remark: derefString(row.Remark),
EmployeeCount: counts[strconv.FormatUint(row.ID, 10)],
CreateTime: formatDateTime(&row.CreateTime),
UpdateTime: formatDateTime(&row.UpdateTime),
}
}
func (c *BackendOrganizationController) employeeDTOList(tid uint64, rows []models.BackendEmployee) []employeeDTO {
list := make([]employeeDTO, 0, len(rows))
if len(rows) == 0 {
return list
}
nameByID := c.orgNameMap(tid)
for _, row := range rows {
list = append(list, c.assembleEmployeeDTO(row, nameByID))
}
return list
}
func (c *BackendOrganizationController) employeeDTO(tid uint64, row models.BackendEmployee) employeeDTO {
return c.assembleEmployeeDTO(row, c.orgNameMap(tid))
}
func (c *BackendOrganizationController) assembleEmployeeDTO(
row models.BackendEmployee,
nameByID map[uint64]string,
) employeeDTO {
tid := 0
if row.Tid != nil {
tid = *row.Tid
}
birthday := ""
if row.Birthday != nil {
birthday = row.Birthday.Format("2006-01-02")
}
affiliateUnit := derefString(row.AffiliateUnit)
department := derefString(row.Department)
return employeeDTO{
ID: row.ID,
Tid: tid,
TenantID: tid,
Account: row.Account,
Name: row.Name,
Gender: row.Gender,
Sex: row.Gender,
Birthday: birthday,
AffiliateUnit: affiliateUnit,
AffiliateUnitName: orgNameByIDString(nameByID, affiliateUnit),
Department: department,
DepartmentName: orgNameByIDString(nameByID, department),
Position: derefString(row.Position),
Education: derefString(row.Education),
Nation: derefString(row.Nation),
Phone: derefString(row.Phone),
Wechat: derefString(row.Wechat),
Email: derefString(row.Email),
HomeAddress: derefString(row.HomeAddress),
AccountStatus: row.AccountStatus,
Status: row.AccountStatus,
CreateTime: formatDateTime(&row.CreateTime),
}
}
func (c *BackendOrganizationController) positionDTO(tid uint64, row models.BackendPosition) positionDTO {
nameByID := c.orgNameMap(tid)
return positionDTO{
ID: row.ID,
Tid: row.Tid,
TenantID: row.Tid,
DepartmentID: row.DepartmentID,
DepartmentName: nameByID[row.DepartmentID],
PositionCode: row.PositionCode,
PositionName: row.PositionName,
PositionType: row.PositionType,
Status: row.Status,
Sort: row.Sort,
Remark: derefString(row.Remark),
CreateTime: formatDateTime(&row.CreateTime),
}
}
// ---------------------------------------------------------------------------
// 组织关系查询
// ---------------------------------------------------------------------------
func (c *BackendOrganizationController) orgNameMap(tid uint64) map[uint64]string {
result := map[uint64]string{}
var rows []models.BackendOrganization
if _, err := c.orgQuery(tid).All(&rows, "ID", "OrgName"); err != nil {
return result
}
for _, row := range rows {
result[row.ID] = row.OrgName
}
return result
}
func (c *BackendOrganizationController) employeeNameMap(tid uint64) map[uint64]string {
result := map[uint64]string{}
var rows []models.BackendEmployee
if _, err := c.employeeQuery(tid).All(&rows, "ID", "Name"); err != nil {
return result
}
for _, row := range rows {
result[uint64(row.ID)] = row.Name
}
return result
}
// employeeCountByDepartment 统计各部门(department 存的是组织ID字符串)的员工数。
func (c *BackendOrganizationController) employeeCountByDepartment(tid uint64) map[string]int64 {
result := map[string]int64{}
var rows []models.BackendEmployee
if _, err := c.employeeQuery(tid).All(&rows, "Department"); err != nil {
return result
}
for _, row := range rows {
key := strings.TrimSpace(derefString(row.Department))
if key == "" {
continue
}
result[key]++
}
return result
}
func (c *BackendOrganizationController) orgExists(tid, id uint64) bool {
if id == 0 {
return false
}
return c.orgQuery(tid).Filter("id", id).Exist()
}
// parentMap 返回 组织ID -> 上级组织ID 的映射,用于层级与环路判断。
func (c *BackendOrganizationController) parentMap(tid uint64) map[uint64]uint64 {
result := map[uint64]uint64{}
var rows []models.BackendOrganization
if _, err := c.orgQuery(tid).All(&rows, "ID", "ParentID"); err != nil {
return result
}
for _, row := range rows {
result[row.ID] = row.ParentID
}
return result
}
// orgDepth 计算组织所在层级,顶级为 1。
func (c *BackendOrganizationController) orgDepth(tid, id uint64) (int, error) {
parents := c.parentMap(tid)
depth := 0
current := id
for current > 0 {
depth++
if depth > 64 {
return depth, errors.New("组织层级数据异常(可能存在环路)")
}
next, ok := parents[current]
if !ok {
break
}
current = next
}
return depth, nil
}
// subtreeHeight 计算以 id 为根的子树高度(只有自身时为 1)。
func (c *BackendOrganizationController) subtreeHeight(tid, id uint64) int {
childrenOf := map[uint64][]uint64{}
var rows []models.BackendOrganization
if _, err := c.orgQuery(tid).All(&rows, "ID", "ParentID"); err != nil {
return 1
}
for _, row := range rows {
childrenOf[row.ParentID] = append(childrenOf[row.ParentID], row.ID)
}
return subtreeHeightFrom(childrenOf, id, 0)
}
func subtreeHeightFrom(childrenOf map[uint64][]uint64, id uint64, depth int) int {
if depth > 64 {
return depth
}
height := 1
for _, child := range childrenOf[id] {
if h := subtreeHeightFrom(childrenOf, child, depth+1) + 1; h > height {
height = h
}
}
return height
}
// collectOrgIDs 返回 rootID 及其所有下级组织的ID。
func (c *BackendOrganizationController) collectOrgIDs(tid, rootID uint64) []uint64 {
childrenOf := map[uint64][]uint64{}
var rows []models.BackendOrganization
if _, err := c.orgQuery(tid).All(&rows, "ID", "ParentID"); err != nil {
return []uint64{rootID}
}
for _, row := range rows {
childrenOf[row.ParentID] = append(childrenOf[row.ParentID], row.ID)
}
result := []uint64{rootID}
queue := []uint64{rootID}
visited := map[uint64]bool{rootID: true}
for len(queue) > 0 {
current := queue[0]
queue = queue[1:]
for _, child := range childrenOf[current] {
if visited[child] {
continue
}
visited[child] = true
result = append(result, child)
queue = append(queue, child)
}
}
return result
}
// rootCompanyID 沿上级链向上找到所属的顶层公司ID。
func (c *BackendOrganizationController) rootCompanyID(tid uint64, org models.BackendOrganization) uint64 {
if org.IsCompany == 1 {
return org.ID
}
parents := c.parentMap(tid)
current := org.ParentID
for i := 0; i < 64 && current > 0; i++ {
var row models.BackendOrganization
if err := c.orgQuery(tid).Filter("id", current).One(&row); err != nil {
return 0
}
if row.IsCompany == 1 {
return row.ID
}
next, ok := parents[current]
if !ok {
return 0
}
current = next
}
return 0
}
// validateParentChange 校验把 orgID 挂到 newParentID 下是否合法:
// 不能挂到自己或自己的后代(形成环路),且移动后总层级不超过设置上限。
func (c *BackendOrganizationController) validateParentChange(tid, orgID, newParentID uint64, settings orgSettings) error {
if newParentID == 0 {
return nil
}
if newParentID == orgID {
return errors.New("上级组织不能是自己")
}
if !c.orgExists(tid, newParentID) {
return errors.New("上级组织不存在")
}
for _, id := range c.collectOrgIDs(tid, orgID) {
if id == newParentID {
return errors.New("不能将组织移动到自己的下级组织中")
}
}
parentDepth, err := c.orgDepth(tid, newParentID)
if err != nil {
return err
}
if settings.MaxOrgLevels > 0 && parentDepth+c.subtreeHeight(tid, orgID) > settings.MaxOrgLevels {
return fmt.Errorf("移动后组织层级将超过上限 %d 级", settings.MaxOrgLevels)
}
if settings.MaxOrgChildren > 0 {
count, err := c.orgQuery(tid).Filter("parent_id", newParentID).
Exclude("id", orgID).Exclude("status", 0).Count()
if err == nil && int(count) >= settings.MaxOrgChildren {
return fmt.Errorf("同一上级下最多 %d 个子组织", settings.MaxOrgChildren)
}
}
return nil
}
// validateEmployeeOrg 校验员工的隶属单位与部门必须是当前租户下已存在的组织。
func (c *BackendOrganizationController) validateEmployeeOrg(tid uint64, affiliateUnit, department string) error {
for label, raw := range map[string]string{"隶属单位": affiliateUnit, "部门": department} {
raw = strings.TrimSpace(raw)
if raw == "" {
continue
}
id, err := strconv.ParseUint(raw, 10, 64)
if err != nil {
return fmt.Errorf("%s格式不正确", label)
}
if !c.orgExists(tid, id) {
return fmt.Errorf("%s不存在", label)
}
}
return nil
}
// buildOrganizationTree 把扁平的组织 DTO 列表组装成树。
// 上级不在列表中的节点(如上级被禁用)作为根节点返回,避免数据丢失。
func buildOrganizationTree(list []organizationDTO) []map[string]interface{} {
nodeMap := make(map[uint64]map[string]interface{}, len(list))
order := make([]uint64, 0, len(list))
for _, item := range list {
raw, _ := json.Marshal(item)
node := map[string]interface{}{}
_ = json.Unmarshal(raw, &node)
node["children"] = make([]map[string]interface{}, 0)
nodeMap[item.ID] = node
order = append(order, item.ID)
}
tree := make([]map[string]interface{}, 0)
for _, id := range order {
node := nodeMap[id]
parentID := uint64(0)
if v, ok := node["parent_id"].(float64); ok {
parentID = uint64(v)
}
if parent, exists := nodeMap[parentID]; parentID > 0 && exists {
parent["children"] = append(parent["children"].([]map[string]interface{}), node)
continue
}
tree = append(tree, node)
}
return tree
}
// treeDepth 计算树的最大深度。
func treeDepth(childrenOf map[uint64][]uint64, rootID uint64, depth int) int {
if depth > 64 {
return depth
}
maxDepth := depth
for _, child := range childrenOf[rootID] {
if d := treeDepth(childrenOf, child, depth+1); d > maxDepth {
maxDepth = d
}
}
return maxDepth
}
func orgNameByIDString(nameByID map[uint64]string, raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
id, err := strconv.ParseUint(raw, 10, 64)
if err != nil {
return ""
}
return nameByID[id]
}
func uniqueMessage(unique bool, subject string) string {
if unique {
return subject + "可用"
}
return subject + "已存在"
}
func formatDateTime(t *time.Time) string {
if t == nil || t.IsZero() {
return ""
}
return t.Format("2006-01-02 15:04:05")
}
func clampInt(v, min, max int) int {
if v < min {
return min
}
if v > max {
return max
}
return v
}
func maxInt(a, b int) int {
if a > b {
return a
}
return b
}
// generateCode 生成形如 ORG20260827193012 的编码,长度不足时补时间戳末尾数字。
func (c *BackendOrganizationController) generateCode(prefix string, length int) string {
prefix = strings.TrimSpace(prefix)
stamp := time.Now().Format("20060102150405")
code := prefix + stamp
if length > 0 && len(code) > length && len(prefix) < length {
keep := length - len(prefix)
code = prefix + stamp[len(stamp)-keep:]
}
return code
}
// hashEmployeePassword 适配 password varchar(64),使用 sha256 hex;空密码返回空串。
func hashEmployeePassword(plain string) string {
plain = strings.TrimSpace(plain)
if plain == "" {
return ""
}
sum := sha256.Sum256([]byte(plain))
return hex.EncodeToString(sum[:])
}
@@ -0,0 +1,289 @@
package controllers
import (
"encoding/json"
"strconv"
"strings"
"time"
)
// 本文件包含组织架构模块的请求参数解析工具。
// 前端存在 JSON、form-urlencoded 与 multipart/form-data 三种提交方式,
// 因此每个取值函数都先读 JSON body,再回退到表单参数。
func (c *BackendOrganizationController) parseJSONBody() map[string]interface{} {
body := map[string]interface{}{}
contentType := strings.ToLower(c.Ctx.Input.Header("Content-Type"))
if !strings.Contains(contentType, "json") {
return body
}
if len(c.Ctx.Input.RequestBody) == 0 {
return body
}
_ = json.Unmarshal(c.Ctx.Input.RequestBody, &body)
return body
}
// ensureFormParsed 在读取表单参数前确保请求体已被解析。
func (c *BackendOrganizationController) ensureFormParsed() {
if c.Ctx.Request.Form == nil && c.Ctx.Request.PostForm == nil && c.Ctx.Request.MultipartForm == nil {
_ = c.Ctx.Request.ParseMultipartForm(32 << 20)
}
}
func (c *BackendOrganizationController) getStringValue(body map[string]interface{}, keys ...string) (string, bool) {
for _, key := range keys {
if v, ok := body[key]; ok {
switch val := v.(type) {
case string:
return val, true
case float64:
return strconv.FormatFloat(val, 'f', -1, 64), true
case bool:
return strconv.FormatBool(val), true
case nil:
return "", true
}
}
c.ensureFormParsed()
if val := c.GetString(key); val != "" {
return val, true
}
}
return "", false
}
func (c *BackendOrganizationController) getIntValue(body map[string]interface{}, keys ...string) (int, bool) {
for _, key := range keys {
if v, ok := body[key]; ok {
switch val := v.(type) {
case float64:
return int(val), true
case int:
return val, true
case bool:
return boolInt(val), true
case string:
trimmed := strings.TrimSpace(val)
if trimmed == "" {
return 0, true
}
parsed, err := strconv.Atoi(trimmed)
return parsed, err == nil
}
}
c.ensureFormParsed()
if val := c.GetString(key); val != "" {
parsed, err := strconv.Atoi(strings.TrimSpace(val))
return parsed, err == nil
}
}
return 0, false
}
func (c *BackendOrganizationController) getUintValue(body map[string]interface{}, keys ...string) (uint, bool) {
v, ok := c.getIntValue(body, keys...)
if !ok || v < 0 {
return 0, ok && v >= 0
}
return uint(v), true
}
func (c *BackendOrganizationController) getUint64Value(body map[string]interface{}, keys ...string) (uint64, bool) {
for _, key := range keys {
if v, ok := body[key]; ok {
switch val := v.(type) {
case float64:
if val < 0 {
return 0, false
}
return uint64(val), true
case int:
if val < 0 {
return 0, false
}
return uint64(val), true
case nil:
return 0, true
case string:
trimmed := strings.TrimSpace(val)
if trimmed == "" {
return 0, true
}
parsed, err := strconv.ParseUint(trimmed, 10, 64)
return parsed, err == nil
}
}
c.ensureFormParsed()
if val := c.GetString(key); val != "" {
parsed, err := strconv.ParseUint(strings.TrimSpace(val), 10, 64)
return parsed, err == nil
}
}
return 0, false
}
func (c *BackendOrganizationController) getBoolValue(body map[string]interface{}, keys ...string) (bool, bool) {
for _, key := range keys {
if v, ok := body[key]; ok {
switch val := v.(type) {
case bool:
return val, true
case float64:
return val != 0, true
case string:
trimmed := strings.ToLower(strings.TrimSpace(val))
switch trimmed {
case "1", "true", "yes", "on":
return true, true
case "0", "false", "no", "off", "":
return false, true
}
}
}
c.ensureFormParsed()
if val := c.GetString(key); val != "" {
switch strings.ToLower(strings.TrimSpace(val)) {
case "1", "true", "yes", "on":
return true, true
case "0", "false", "no", "off":
return false, true
}
}
}
return false, false
}
// getUint64Slice 解析 ID 数组,兼容 JSON 数组与逗号分隔字符串。
func (c *BackendOrganizationController) getUint64Slice(body map[string]interface{}, keys ...string) []uint64 {
result := make([]uint64, 0)
seen := map[uint64]bool{}
appendID := func(id uint64) {
if id == 0 || seen[id] {
return
}
seen[id] = true
result = append(result, id)
}
for _, key := range keys {
if v, ok := body[key]; ok {
switch val := v.(type) {
case []interface{}:
for _, item := range val {
switch num := item.(type) {
case float64:
appendID(uint64(num))
case string:
if parsed, err := strconv.ParseUint(strings.TrimSpace(num), 10, 64); err == nil {
appendID(parsed)
}
}
}
case string:
for _, part := range strings.Split(val, ",") {
if parsed, err := strconv.ParseUint(strings.TrimSpace(part), 10, 64); err == nil {
appendID(parsed)
}
}
case float64:
appendID(uint64(val))
}
}
if len(result) > 0 {
return result
}
c.ensureFormParsed()
if raw := c.GetString(key); raw != "" {
for _, part := range strings.Split(raw, ",") {
if parsed, err := strconv.ParseUint(strings.TrimSpace(part), 10, 64); err == nil {
appendID(parsed)
}
}
}
if len(result) > 0 {
return result
}
}
return result
}
func (c *BackendOrganizationController) getUintSlice(body map[string]interface{}, keys ...string) []uint {
ids := c.getUint64Slice(body, keys...)
result := make([]uint, 0, len(ids))
for _, id := range ids {
result = append(result, uint(id))
}
return result
}
func (c *BackendOrganizationController) pathUint(name string) (uint, bool) {
id, err := strconv.ParseUint(c.Ctx.Input.Param(name), 10, 64)
return uint(id), err == nil && id > 0
}
func (c *BackendOrganizationController) pathUint64(name string) (uint64, bool) {
id, err := strconv.ParseUint(c.Ctx.Input.Param(name), 10, 64)
return id, err == nil && id > 0
}
func (c *BackendOrganizationController) nowString() string {
return time.Now().Format("2006-01-02 15:04:05")
}
// ---------------------------------------------------------------------------
// 与 ORM 交互的通用小工具
// ---------------------------------------------------------------------------
func strPtrIfNotEmpty(v string) *string {
v = strings.TrimSpace(v)
if v == "" {
return nil
}
return &v
}
// nullableString 空字符串写入 NULL,便于统一区分“未填写”与“空值”。
func nullableString(v string) interface{} {
v = strings.TrimSpace(v)
if v == "" {
return nil
}
return v
}
func nullableUint64(v uint64) interface{} {
if v == 0 {
return nil
}
return v
}
func derefString(v *string) string {
if v == nil {
return ""
}
return *v
}
func boolInt(v bool) int {
if v {
return 1
}
return 0
}
func parseDatePtr(v string) *time.Time {
v = strings.TrimSpace(v)
if v == "" {
return nil
}
if t, err := time.Parse("2006-01-02", v); err == nil {
return &t
}
if t, err := time.Parse("2006-01-02 15:04:05", v); err == nil {
return &t
}
return nil
}
@@ -0,0 +1,405 @@
package controllers
import (
"encoding/csv"
"encoding/json"
"fmt"
"io"
"strconv"
"strings"
"time"
"server/models"
"github.com/beego/beego/v2/client/orm"
)
// 本文件承载组织架构模块的设置读写、CSV 导入导出,以及 DTO 组装与通用工具函数。
// ---------------------------------------------------------------------------
// 组织架构设置(按租户存放在 yz_backend_normal_setting 中)
// ---------------------------------------------------------------------------
// GetOrgSettings 获取当前租户的组织架构设置。
// GET /backend/{erp|oa}/getOrgSettings
func (c *BackendOrganizationController) GetOrgSettings() {
tid, ok := c.tenantID()
if !ok {
return
}
c.jsonOK(c.loadOrgSettings(tid))
}
// SaveOrgSettings 保存当前租户的组织架构设置。
// POST /backend/{erp|oa}/saveOrgSettings
func (c *BackendOrganizationController) SaveOrgSettings() {
tid, ok := c.tenantID()
if !ok {
return
}
body := c.parseJSONBody()
settings := c.loadOrgSettings(tid)
if v, has := c.getStringValue(body, "org_code_prefix", "code_prefix"); has {
settings.OrgCodePrefix = strings.TrimSpace(v)
}
if v, has := c.getStringValue(body, "employee_code_prefix"); has {
settings.EmployeeCodePrefix = strings.TrimSpace(v)
}
if v, has := c.getStringValue(body, "position_code_prefix"); has {
settings.PositionCodePrefix = strings.TrimSpace(v)
}
if v, has := c.getBoolValue(body, "auto_generate_codes", "auto_generate_code"); has {
settings.AutoGenerateCodes = v
}
if v, has := c.getIntValue(body, "code_length"); has {
settings.CodeLength = clampInt(v, 4, 32)
}
if v, has := c.getIntValue(body, "default_org_type"); has {
settings.DefaultOrgType = v
}
if v, has := c.getIntValue(body, "default_sort"); has {
settings.DefaultSort = maxInt(v, 0)
}
if v, has := c.getIntValue(body, "default_status"); has {
settings.DefaultStatus = v
}
if v, has := c.getIntValue(body, "max_org_levels", "max_level"); has {
settings.MaxOrgLevels = clampInt(v, 1, 32)
}
if v, has := c.getIntValue(body, "max_org_children"); has {
settings.MaxOrgChildren = clampInt(v, 1, 1000)
}
if v, has := c.getBoolValue(body, "allow_duplicate_codes"); has {
settings.AllowDuplicateCode = v
}
if v, has := c.getBoolValue(body, "batch_operations"); has {
settings.BatchOperations = v
}
if v, has := c.getBoolValue(body, "export_enabled"); has {
settings.ExportEnabled = v
}
if v, has := c.getBoolValue(body, "import_enabled"); has {
settings.ImportEnabled = v
}
if err := c.persistOrgSettings(tid, settings); err != nil {
c.jsonError(500, "保存组织设置失败: "+err.Error())
return
}
c.jsonOK(settings)
}
func (c *BackendOrganizationController) orgSettingsCode(tid uint64) string {
return fmt.Sprintf("%s_%d", orgSettingsCodePrefix, tid)
}
// loadOrgSettings 读取租户设置;无记录或解析失败时回退到默认值,保证接口始终可用。
func (c *BackendOrganizationController) loadOrgSettings(tid uint64) orgSettings {
settings := defaultOrgSettings()
var row models.BackendNormalSetting
err := models.Orm.QueryTable(new(models.BackendNormalSetting)).
Filter("code", c.orgSettingsCode(tid)).
Filter("delete_time__isnull", true).
One(&row)
if err != nil || strings.TrimSpace(row.Value) == "" {
return settings
}
if err := json.Unmarshal([]byte(row.Value), &settings); err != nil {
return defaultOrgSettings()
}
if settings.CodeLength <= 0 {
settings.CodeLength = 8
}
return settings
}
func (c *BackendOrganizationController) persistOrgSettings(tid uint64, settings orgSettings) error {
raw, err := json.Marshal(settings)
if err != nil {
return err
}
code := c.orgSettingsCode(tid)
var row models.BackendNormalSetting
err = models.Orm.QueryTable(new(models.BackendNormalSetting)).
Filter("code", code).
Filter("delete_time__isnull", true).
One(&row)
if err == nil {
now := time.Now()
row.Value = string(raw)
row.UpdateTime = &now
_, err = models.Orm.Update(&row, "value", "update_time")
return err
}
row = models.BackendNormalSetting{
Name: "组织架构设置",
Code: code,
Value: string(raw),
Remark: fmt.Sprintf("租户 %d 的组织架构设置", tid),
}
_, err = models.Orm.Insert(&row)
return err
}
// ---------------------------------------------------------------------------
// 导入 / 导出(CSV,带 UTF-8 BOM,Excel 可直接打开)
// ---------------------------------------------------------------------------
var organizationExportHeader = []string{
"组织编码", "组织名称", "上级组织编码", "是否公司(1是0否)", "排序", "状态(1启用0禁用)", "备注",
}
// ExportOrganization 导出当前租户组织架构为 CSV。
// GET /backend/{erp|oa}/exportOrganization
func (c *BackendOrganizationController) ExportOrganization() {
tid, ok := c.tenantID()
if !ok {
return
}
if !c.loadOrgSettings(tid).ExportEnabled {
c.jsonError(400, "导出功能已关闭")
return
}
var rows []models.BackendOrganization
if _, err := c.orgQuery(tid).OrderBy("sort", "id").All(&rows); err != nil {
c.jsonError(500, "导出组织架构失败: "+err.Error())
return
}
codeByID := map[uint64]string{}
for _, row := range rows {
codeByID[row.ID] = row.OrgCode
}
c.Ctx.Output.Header("Content-Type", "text/csv; charset=utf-8")
c.Ctx.Output.Header("Content-Disposition",
fmt.Sprintf("attachment; filename=organization_%s.csv", time.Now().Format("20060102150405")))
// UTF-8 BOM,避免 Excel 打开中文乱码
_, _ = c.Ctx.ResponseWriter.Write([]byte{0xEF, 0xBB, 0xBF})
writer := csv.NewWriter(c.Ctx.ResponseWriter)
_ = writer.Write(organizationExportHeader)
for _, row := range rows {
_ = writer.Write([]string{
row.OrgCode,
row.OrgName,
codeByID[row.ParentID],
strconv.Itoa(row.IsCompany),
strconv.FormatUint(uint64(row.Sort), 10),
strconv.Itoa(int(row.Status)),
derefString(row.Remark),
})
}
writer.Flush()
}
// ImportOrganization 从 CSV 导入组织架构。
// 已存在的组织编码执行更新,不存在的新增;上级组织通过编码关联,
// 上级关系在所有行入库后统一回填,因此 CSV 行序不影响结果。
// POST /backend/{erp|oa}/importOrganization (multipart/form-data, field=file)
func (c *BackendOrganizationController) ImportOrganization() {
tid, ok := c.tenantID()
if !ok {
return
}
if !c.loadOrgSettings(tid).ImportEnabled {
c.jsonError(400, "导入功能已关闭")
return
}
file, _, err := c.GetFile("file")
if err != nil {
c.jsonError(400, "请上传 CSV 文件")
return
}
defer file.Close()
reader := csv.NewReader(newBOMTrimReader(file))
reader.FieldsPerRecord = -1
records, err := reader.ReadAll()
if err != nil {
c.jsonError(400, "解析 CSV 失败: "+err.Error())
return
}
if len(records) <= 1 {
c.jsonError(400, "CSV 中没有可导入的数据")
return
}
idByCode := map[string]uint64{}
var existing []models.BackendOrganization
if _, err := c.orgQuery(tid).All(&existing); err != nil {
c.jsonError(500, "读取已有组织失败: "+err.Error())
return
}
for _, row := range existing {
idByCode[row.OrgCode] = row.ID
}
type pendingParent struct {
code string
parentCode string
}
created, updated := 0, 0
failures := make([]string, 0)
pending := make([]pendingParent, 0, len(records))
for i, record := range records[1:] {
lineNo := i + 2
if len(record) < 2 {
failures = append(failures, fmt.Sprintf("第 %d 行:列数不足", lineNo))
continue
}
orgCode := strings.TrimSpace(record[0])
orgName := strings.TrimSpace(record[1])
if orgCode == "" || orgName == "" {
failures = append(failures, fmt.Sprintf("第 %d 行:组织编码与名称不能为空", lineNo))
continue
}
parentCode := csvField(record, 2)
isCompany := csvInt(record, 3, 0)
sortVal := csvInt(record, 4, 0)
status := csvInt(record, 5, 1)
remark := csvField(record, 6)
if id, exists := idByCode[orgCode]; exists {
update := orm.Params{
"org_name": orgName,
"is_company": isCompany,
"sort": uint(maxInt(sortVal, 0)),
"status": int8(status),
"remark": nullableString(remark),
}
if _, err := c.orgQuery(tid).Filter("id", id).Update(update); err != nil {
failures = append(failures, fmt.Sprintf("第 %d 行:更新失败 %s", lineNo, err.Error()))
continue
}
updated++
} else {
row := models.BackendOrganization{
Tid: tid,
OrgName: orgName,
OrgCode: orgCode,
IsCompany: isCompany,
Sort: uint(maxInt(sortVal, 0)),
Status: int8(status),
Remark: strPtrIfNotEmpty(remark),
}
id, err := models.Orm.Insert(&row)
if err != nil {
failures = append(failures, fmt.Sprintf("第 %d 行:创建失败 %s", lineNo, err.Error()))
continue
}
idByCode[orgCode] = uint64(id)
created++
}
pending = append(pending, pendingParent{code: orgCode, parentCode: parentCode})
}
for _, item := range pending {
selfID := idByCode[item.code]
if selfID == 0 {
continue
}
parentID := uint64(0)
if item.parentCode != "" {
parentID = idByCode[item.parentCode]
if parentID == 0 {
failures = append(failures, fmt.Sprintf("组织 %s:上级编码 %s 不存在", item.code, item.parentCode))
continue
}
if parentID == selfID {
failures = append(failures, fmt.Sprintf("组织 %s:上级不能是自己", item.code))
continue
}
}
_, _ = c.orgQuery(tid).Filter("id", selfID).
Update(orm.Params{"parent_id": parentID, "is_company": boolInt(parentID == 0)})
}
c.jsonOK(map[string]interface{}{
"created": created,
"updated": updated,
"failed": len(failures),
"failures": failures,
})
}
// GetImportTemplate 下载导入模板(仅表头 + 一行示例)。
// GET /backend/{erp|oa}/organizationImportTemplate
func (c *BackendOrganizationController) GetImportTemplate() {
if _, ok := c.tenantID(); !ok {
return
}
c.Ctx.Output.Header("Content-Type", "text/csv; charset=utf-8")
c.Ctx.Output.Header("Content-Disposition", "attachment; filename=organization_template.csv")
_, _ = c.Ctx.ResponseWriter.Write([]byte{0xEF, 0xBB, 0xBF})
writer := csv.NewWriter(c.Ctx.ResponseWriter)
_ = writer.Write(organizationExportHeader)
_ = writer.Write([]string{"COM001", "示例总公司", "", "1", "0", "1", "顶级组织,上级编码留空"})
_ = writer.Write([]string{"DEP001", "示例研发部", "COM001", "0", "1", "1", "隶属 COM001"})
writer.Flush()
}
// bomTrimReader 去掉 CSV 文件开头可能存在的 UTF-8 BOM。
type bomTrimReader struct {
reader io.Reader
checked bool
buf []byte
}
func newBOMTrimReader(r io.Reader) io.Reader {
return &bomTrimReader{reader: r}
}
func (r *bomTrimReader) Read(p []byte) (int, error) {
if !r.checked {
r.checked = true
head := make([]byte, 3)
n, err := io.ReadFull(r.reader, head)
if n == 3 && head[0] == 0xEF && head[1] == 0xBB && head[2] == 0xBF {
r.buf = nil
} else {
r.buf = head[:n]
}
if err != nil && err != io.ErrUnexpectedEOF && err != io.EOF {
return 0, err
}
}
if len(r.buf) > 0 {
n := copy(p, r.buf)
r.buf = r.buf[n:]
return n, nil
}
return r.reader.Read(p)
}
func csvField(record []string, index int) string {
if index >= len(record) {
return ""
}
return strings.TrimSpace(record[index])
}
func csvInt(record []string, index int, fallback int) int {
raw := csvField(record, index)
if raw == "" {
return fallback
}
v, err := strconv.Atoi(raw)
if err != nil {
return fallback
}
return v
}
+185
View File
@@ -0,0 +1,185 @@
package controllers
import (
"io"
"strings"
"testing"
)
// 这些用例只覆盖组织架构模块中不依赖数据库的纯函数:树组装、层级计算、CSV 解析与编码生成。
func TestBuildOrganizationTree(t *testing.T) {
list := []organizationDTO{
{ID: 1, OrgName: "总公司", ParentID: 0},
{ID: 2, OrgName: "研发部", ParentID: 1},
{ID: 3, OrgName: "前端组", ParentID: 2},
{ID: 4, OrgName: "孤儿部门", ParentID: 999}, // 上级不在列表中,应作为根节点保留
}
tree := buildOrganizationTree(list)
if len(tree) != 2 {
t.Fatalf("期望 2 个根节点,实际 %d", len(tree))
}
root := tree[0]
if root["org_name"] != "总公司" {
t.Fatalf("第一个根节点应为总公司,实际 %v", root["org_name"])
}
children, ok := root["children"].([]map[string]interface{})
if !ok || len(children) != 1 {
t.Fatalf("总公司应有 1 个子节点,实际 %v", root["children"])
}
grandChildren, ok := children[0]["children"].([]map[string]interface{})
if !ok || len(grandChildren) != 1 || grandChildren[0]["org_name"] != "前端组" {
t.Fatalf("研发部下应有前端组,实际 %v", children[0]["children"])
}
if tree[1]["org_name"] != "孤儿部门" {
t.Fatalf("上级缺失的节点应作为根节点保留,实际 %v", tree[1]["org_name"])
}
}
func TestTreeDepth(t *testing.T) {
childrenOf := map[uint64][]uint64{
0: {1},
1: {2, 3},
2: {4},
}
if got := treeDepth(childrenOf, 0, 0); got != 3 {
t.Fatalf("期望深度 3,实际 %d", got)
}
if got := treeDepth(map[uint64][]uint64{}, 0, 0); got != 0 {
t.Fatalf("空树深度应为 0,实际 %d", got)
}
}
func TestSubtreeHeightFrom(t *testing.T) {
childrenOf := map[uint64][]uint64{
1: {2, 3},
2: {4},
}
if got := subtreeHeightFrom(childrenOf, 1, 0); got != 3 {
t.Fatalf("以 1 为根的子树高度应为 3,实际 %d", got)
}
if got := subtreeHeightFrom(childrenOf, 4, 0); got != 1 {
t.Fatalf("叶子节点高度应为 1,实际 %d", got)
}
}
func TestCSVHelpers(t *testing.T) {
record := []string{" COM001 ", "总公司", "", "1"}
if got := csvField(record, 0); got != "COM001" {
t.Fatalf("csvField 应去掉空格,实际 %q", got)
}
if got := csvField(record, 9); got != "" {
t.Fatalf("越界应返回空串,实际 %q", got)
}
if got := csvInt(record, 3, 0); got != 1 {
t.Fatalf("csvInt 应解析出 1,实际 %d", got)
}
if got := csvInt(record, 2, 7); got != 7 {
t.Fatalf("空值应返回默认值 7,实际 %d", got)
}
if got := csvInt(record, 1, 5); got != 5 {
t.Fatalf("非数字应返回默认值 5,实际 %d", got)
}
}
func TestBOMTrimReader(t *testing.T) {
withBOM := append([]byte{0xEF, 0xBB, 0xBF}, []byte("编码,名称\n")...)
got, err := io.ReadAll(newBOMTrimReader(strings.NewReader(string(withBOM))))
if err != nil {
t.Fatalf("读取失败: %v", err)
}
if string(got) != "编码,名称\n" {
t.Fatalf("BOM 未被去掉,实际 %q", string(got))
}
got, err = io.ReadAll(newBOMTrimReader(strings.NewReader("编码,名称\n")))
if err != nil {
t.Fatalf("读取失败: %v", err)
}
if string(got) != "编码,名称\n" {
t.Fatalf("无 BOM 时内容被破坏,实际 %q", string(got))
}
// 内容短于 3 字节时不能丢数据
got, err = io.ReadAll(newBOMTrimReader(strings.NewReader("ab")))
if err != nil {
t.Fatalf("读取失败: %v", err)
}
if string(got) != "ab" {
t.Fatalf("短内容被破坏,实际 %q", string(got))
}
}
func TestGenerateCode(t *testing.T) {
c := &BackendOrganizationController{}
code := c.generateCode("ORG", 8)
if len(code) != 8 || !strings.HasPrefix(code, "ORG") {
t.Fatalf("期望 8 位且以 ORG 开头,实际 %q", code)
}
// 长度限制小于前缀长度时不截断前缀
code = c.generateCode("PREFIX", 3)
if !strings.HasPrefix(code, "PREFIX") {
t.Fatalf("前缀不应被截断,实际 %q", code)
}
// 不限制长度时返回完整前缀 + 时间戳
code = c.generateCode("EMP", 0)
if len(code) != len("EMP")+14 {
t.Fatalf("未限制长度时应为前缀加 14 位时间戳,实际 %q", code)
}
}
func TestUniqueMessageAndClamp(t *testing.T) {
if got := uniqueMessage(true, "编码"); got != "编码可用" {
t.Fatalf("实际 %q", got)
}
if got := uniqueMessage(false, "账号"); got != "账号已存在" {
t.Fatalf("实际 %q", got)
}
if got := clampInt(0, 4, 32); got != 4 {
t.Fatalf("下界钳制失败,实际 %d", got)
}
if got := clampInt(99, 4, 32); got != 32 {
t.Fatalf("上界钳制失败,实际 %d", got)
}
if got := clampInt(10, 4, 32); got != 10 {
t.Fatalf("区间内不应改变,实际 %d", got)
}
}
func TestHashEmployeePassword(t *testing.T) {
if got := hashEmployeePassword(" "); got != "" {
t.Fatalf("空密码应返回空串,实际 %q", got)
}
got := hashEmployeePassword("secret123")
if len(got) != 64 {
t.Fatalf("sha256 hex 应为 64 位,实际 %d", len(got))
}
if got != hashEmployeePassword("secret123") {
t.Fatal("相同输入应得到相同结果")
}
}
func TestOrgNameByIDString(t *testing.T) {
nameByID := map[uint64]string{7: "研发部"}
if got := orgNameByIDString(nameByID, "7"); got != "研发部" {
t.Fatalf("实际 %q", got)
}
if got := orgNameByIDString(nameByID, ""); got != "" {
t.Fatalf("空值应返回空串,实际 %q", got)
}
if got := orgNameByIDString(nameByID, "abc"); got != "" {
t.Fatalf("非数字应返回空串,实际 %q", got)
}
if got := orgNameByIDString(nameByID, "99"); got != "" {
t.Fatalf("未命中应返回空串,实际 %q", got)
}
}
+32 -2
View File
@@ -3,6 +3,9 @@ package controllers
import (
"fmt"
"net/url"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
@@ -17,6 +20,31 @@ type IndexPortalController struct {
beego.Controller
}
// reNewsRow 从模板标签 {yz:newscenter row="N" ...} 中提取 row 值
var reNewsRow = regexp.MustCompile(`\{yz:newscenter[^}]*\s+row\s*=\s*"(\d+)"`)
// templateNewsRow 读取当前租户首页模板中 newscenter 标签的 row 参数,
// 作为 API 默认条数,使接口与前端模板保持一致。
func templateNewsRow(tid uint64) int {
code := strings.TrimSpace(cmsTenantTemplateCode(tid))
if code == "" || code == "default" {
code = "default"
}
if !cmsTemplateDirExists(code) {
code = "default"
}
data, err := os.ReadFile(filepath.Join(cmsThemesRoot(), code, "index.html"))
if err != nil {
return 10
}
if m := reNewsRow.FindSubmatch(data); len(m) == 2 {
if n, e := strconv.Atoi(string(m[1])); e == nil && n > 0 && n <= 50 {
return n
}
}
return 10
}
// getTidByHost 根据访问域名识别租户 ID,默认返回 1
func (c *IndexPortalController) getTidByHost() uint64 {
host := c.Ctx.Request.Host
@@ -267,13 +295,15 @@ func (c *IndexPortalController) queryCmsArticles(categoryName string, categoryID
tid := c.getTidByHost()
_ = models.EnsureCmsArticleTables()
defaultLimit := templateNewsRow(tid)
page, _ := c.GetInt("page", 1)
limit, _ := c.GetInt("limit", 10)
limit, _ := c.GetInt("limit", defaultLimit)
if page < 1 {
page = 1
}
if limit < 1 {
limit = 10
limit = defaultLimit
}
qs := models.Orm.QueryTable(new(models.CmsArticle)).
+9 -4
View File
@@ -136,7 +136,7 @@ func (c *TenantSiteController) rewriteStorageURLs(html string) string {
if err != nil || strings.TrimSpace(cfg.QiniuDomain) == "" {
return html
}
return rewriteLegacyQiniuURLs(html, storagePublicBaseURL(cfg.QiniuDomain))
return rewriteLegacyQiniuURLs(html, storagePublicBaseURL(cfg.QiniuDomain), cfg.QiniuDomain)
}
func storagePublicBaseURL(domain string) string {
@@ -147,13 +147,18 @@ func storagePublicBaseURL(domain string) string {
return "https://" + base
}
func rewriteLegacyQiniuURLs(html, targetBase string) string {
func rewriteLegacyQiniuURLs(html, targetBase, qiniuDomain string) string {
if targetBase == "" {
return html
}
for _, domain := range legacyQiniuDomains {
domains := append(legacyQiniuDomains, qiniuDomain)
for _, domain := range domains {
d := strings.TrimSpace(domain)
if d == "" {
continue
}
for _, scheme := range []string{"http://", "https://"} {
html = strings.ReplaceAll(html, scheme+domain+"/", targetBase+"/")
html = strings.ReplaceAll(html, scheme+d+"/", targetBase+"/")
}
}
return html
@@ -1,73 +1,73 @@
package models
import "time"
// BackendErpOrganization 全局组织架构表 yz_backend_organization
type BackendErpOrganization struct {
ID uint64 `orm:"column(id);pk;auto" json:"id"`
Tid uint64 `orm:"column(tid)" json:"tid"`
OrgName string `orm:"column(org_name);size(128)" json:"org_name"`
OrgCode string `orm:"column(org_code);size(64)" json:"org_code"`
ParentID uint64 `orm:"column(parent_id);default(0)" json:"parent_id"`
Sort uint `orm:"column(sort);default(0)" json:"sort"`
LeaderID *uint64 `orm:"column(leader_id);null" json:"leader_id"`
IsCompany int `orm:"column(is_company);default(0)" json:"is_company"`
Status int8 `orm:"column(status);default(1)" json:"status"`
CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"`
UpdateTime time.Time `orm:"column(update_time);auto_now;type(datetime)" json:"update_time"`
DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"`
Remark *string `orm:"column(remark);size(512);null" json:"remark"`
}
// TableName 自定义表名
func (m *BackendErpOrganization) TableName() string {
return "yz_backend_organization"
}
// BackendErpEmployee 全局员工信息表 yz_backend_employee
type BackendErpEmployee struct {
ID uint `orm:"column(id);pk;auto" json:"id"`
Tid *int `orm:"column(tid);null" json:"tid"`
Account string `orm:"column(account);size(50)" json:"account"`
Password string `orm:"column(password);size(64);default()" json:"-"`
Name string `orm:"column(name);size(30)" json:"name"`
Gender int8 `orm:"column(gender);default(0)" json:"gender"`
Birthday *time.Time `orm:"column(birthday);type(date);null" json:"birthday"`
AffiliateUnit *string `orm:"column(affiliate_unit);size(100);null" json:"affiliate_unit"`
Department *string `orm:"column(department);size(50);null" json:"department"`
Position *string `orm:"column(position);size(50);null" json:"position"`
Education *string `orm:"column(education);size(20);null" json:"education"`
Nation *string `orm:"column(nation);size(20);null" json:"nation"`
Phone *string `orm:"column(phone);size(20);null" json:"phone"`
Wechat *string `orm:"column(wechat);size(50);null" json:"wechat"`
Email *string `orm:"column(email);size(100);null" json:"email"`
HomeAddress *string `orm:"column(home_address);size(255);null" json:"home_address"`
AccountStatus int8 `orm:"column(account_status);default(1)" json:"account_status"`
CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"`
UpdateTime time.Time `orm:"column(update_time);auto_now;type(datetime)" json:"update_time"`
DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"`
}
// TableName 自定义表名
func (m *BackendErpEmployee) TableName() string {
return "yz_backend_employee"
}
// BackendErpPosition 全局职位表 yz_backend_position
type BackendErpPosition struct {
ID uint64 `orm:"column(id);pk;auto" json:"id"`
Tid uint64 `orm:"column(tid)" json:"tid"`
DepartmentID uint64 `orm:"column(department_id)" json:"department_id"`
PositionCode string `orm:"column(position_code);size(50)" json:"position_code"`
PositionName string `orm:"column(position_name);size(100)" json:"position_name"`
PositionType int8 `orm:"column(position_type);default(0)" json:"position_type"`
Status int8 `orm:"column(status);default(1)" json:"status"`
Sort uint `orm:"column(sort);default(0)" json:"sort"`
CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"`
UpdateTime time.Time `orm:"column(update_time);auto_now;type(datetime)" json:"update_time"`
}
// TableName 自定义表名
func (m *BackendErpPosition) TableName() string {
return "yz_backend_position"
}
package models
import "time"
// BackendErpOrganization 全局组织架构表 yz_backend_organization
type BackendErpOrganization struct {
ID uint64 `orm:"column(id);pk;auto" json:"id"`
Tid uint64 `orm:"column(tid)" json:"tid"`
OrgName string `orm:"column(org_name);size(128)" json:"org_name"`
OrgCode string `orm:"column(org_code);size(64)" json:"org_code"`
ParentID uint64 `orm:"column(parent_id);default(0)" json:"parent_id"`
Sort uint `orm:"column(sort);default(0)" json:"sort"`
LeaderID *uint64 `orm:"column(leader_id);null" json:"leader_id"`
IsCompany int `orm:"column(is_company);default(0)" json:"is_company"`
Status int8 `orm:"column(status);default(1)" json:"status"`
CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"`
UpdateTime time.Time `orm:"column(update_time);auto_now;type(datetime)" json:"update_time"`
DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"`
Remark *string `orm:"column(remark);size(512);null" json:"remark"`
}
// TableName 自定义表名
func (m *BackendErpOrganization) TableName() string {
return "yz_backend_erp_organization"
}
// BackendErpEmployee 员工信息表 yz_backend_erp_employee
type BackendErpEmployee struct {
ID uint `orm:"column(id);pk;auto" json:"id"`
Tid *int `orm:"column(tid);null" json:"tid"`
Account string `orm:"column(account);size(50)" json:"account"`
Password string `orm:"column(password);size(64);default()" json:"-"`
Name string `orm:"column(name);size(30)" json:"name"`
Gender int8 `orm:"column(gender);default(0)" json:"gender"`
Birthday *time.Time `orm:"column(birthday);type(date);null" json:"birthday"`
AffiliateUnit *string `orm:"column(affiliate_unit);size(100);null" json:"affiliate_unit"`
Department *string `orm:"column(department);size(50);null" json:"department"`
Position *string `orm:"column(position);size(50);null" json:"position"`
Education *string `orm:"column(education);size(20);null" json:"education"`
Nation *string `orm:"column(nation);size(20);null" json:"nation"`
Phone *string `orm:"column(phone);size(20);null" json:"phone"`
Wechat *string `orm:"column(wechat);size(50);null" json:"wechat"`
Email *string `orm:"column(email);size(100);null" json:"email"`
HomeAddress *string `orm:"column(home_address);size(255);null" json:"home_address"`
AccountStatus int8 `orm:"column(account_status);default(1)" json:"account_status"`
CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"`
UpdateTime time.Time `orm:"column(update_time);auto_now;type(datetime)" json:"update_time"`
DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"`
}
// TableName 自定义表名
func (m *BackendErpEmployee) TableName() string {
return "yz_backend_erp_employee"
}
// BackendErpPosition 职位表 yz_backend_erp_position
type BackendErpPosition struct {
ID uint64 `orm:"column(id);pk;auto" json:"id"`
Tid uint64 `orm:"column(tid)" json:"tid"`
DepartmentID uint64 `orm:"column(department_id)" json:"department_id"`
PositionCode string `orm:"column(position_code);size(50)" json:"position_code"`
PositionName string `orm:"column(position_name);size(100)" json:"position_name"`
PositionType int8 `orm:"column(position_type);default(0)" json:"position_type"`
Status int8 `orm:"column(status);default(1)" json:"status"`
Sort uint `orm:"column(sort);default(0)" json:"sort"`
CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"`
UpdateTime time.Time `orm:"column(update_time);auto_now;type(datetime)" json:"update_time"`
}
// TableName 自定义表名
func (m *BackendErpPosition) TableName() string {
return "yz_backend_erp_position"
}
+17
View File
@@ -126,6 +126,23 @@ func CmsCategoryNameMap(tid uint64, ids []uint64) map[uint64]string {
return out
}
func CmsCategoryImageMap(tid uint64, ids []uint64) map[uint64]string {
out := make(map[uint64]string)
if len(ids) == 0 {
return out
}
var rows []CmsArticleCategory
qs := Orm.QueryTable(new(CmsArticleCategory)).
Filter("id__in", ids).
Filter("delete_time__isnull", true)
cond := orm.NewCondition().Or("tid", 0).Or("tid", tid)
_, _ = qs.SetCond(cond).All(&rows, "ID", "Image")
for _, r := range rows {
out[r.ID] = r.Image
}
return out
}
func CmsFormatTime(t *time.Time) string {
if t == nil {
return ""
+52 -6
View File
@@ -426,11 +426,12 @@ func arclistProvider(tid uint64, params map[string]string, ctx *RenderCtx) ([]ma
if err != nil && err != orm.ErrNoRows {
return nil, err
}
return articleRows(rows, titleLen), nil
cateImgMap := models.CmsCategoryImageMap(tid, collectCateIDs(rows))
return articleRows(rows, titleLen, cateImgMap), nil
}
// articleRows 文章行转标签字段
func articleRows(rows []models.CmsArticle, titleLen int) []map[string]string {
func articleRows(rows []models.CmsArticle, titleLen int, cateImgMap map[uint64]string) []map[string]string {
out := make([]map[string]string, 0, len(rows))
for _, a := range rows {
pub := ""
@@ -443,12 +444,16 @@ func articleRows(rows []models.CmsArticle, titleLen int) []map[string]string {
if titleLen > 0 {
title = truncateTitle(title, titleLen)
}
img := a.Image
if img == "" {
img = cateImgMap[a.CateID]
}
out = append(out, map[string]string{
"id": strconv.FormatUint(a.ID, 10),
"title": title,
"titlefull": a.Title,
"desc": a.Desc,
"image": a.Image,
"image": img,
"arcurl": "/news/" + strconv.FormatUint(a.ID, 10),
"pubdate": pub,
"views": strconv.Itoa(a.Views),
@@ -458,6 +463,19 @@ func articleRows(rows []models.CmsArticle, titleLen int) []map[string]string {
return out
}
// collectCateIDs 从文章列表中收集不重复的分类 ID
func collectCateIDs(rows []models.CmsArticle) []uint64 {
seen := make(map[uint64]bool)
var ids []uint64
for _, a := range rows {
if a.CateID > 0 && !seen[a.CateID] {
seen[a.CateID] = true
ids = append(ids, a.CateID)
}
}
return ids
}
// arcviewProvider 文章详情(详情页模板内),取当前路由文章
func arcviewProvider(tid uint64, params map[string]string, ctx *RenderCtx) ([]map[string]string, error) {
if ctx.ArticleID == 0 {
@@ -478,12 +496,17 @@ func arcviewProvider(tid uint64, params map[string]string, ctx *RenderCtx) ([]ma
} else {
pub = a.CreateTime.Format(datetimeLayout)
}
img := a.Image
if img == "" && a.CateID > 0 {
cateImgMap := models.CmsCategoryImageMap(tid, []uint64{a.CateID})
img = cateImgMap[a.CateID]
}
return []map[string]string{{
"id": strconv.FormatUint(a.ID, 10),
"title": a.Title,
"content": a.Content,
"desc": a.Desc,
"image": a.Image,
"image": img,
"author": a.Author,
"pubdate": pub,
"views": strconv.Itoa(a.Views),
@@ -547,12 +570,34 @@ func newscenterHandler(tid uint64, params map[string]string, ctx *RenderCtx, bod
All(&children)
}
// collectDescendantIDs 递归收集分类及其所有子孙分类 ID
collectDescendantIDs := func(rootID uint64) []uint64 {
ids := []uint64{rootID}
queue := []uint64{rootID}
for len(queue) > 0 {
pid := queue[0]
queue = queue[1:]
var subs []models.CmsArticleCategory
_, _ = models.Orm.QueryTable(new(models.CmsArticleCategory)).
Filter("cid", pid).
Filter("tid__in", []uint64{0, tid}).
Filter("status", 1).
Filter("delete_time__isnull", true).
All(&subs)
for _, s := range subs {
ids = append(ids, s.ID)
queue = append(queue, s.ID)
}
}
return ids
}
renderCards := func(cateID uint64) string {
qs := publishedCond(models.Orm.QueryTable(new(models.CmsArticle)).
Filter("tid", tid).
Filter("delete_time__isnull", true))
if cateID > 0 {
qs = qs.Filter("cate_id", cateID)
qs = qs.Filter("cate_id__in", collectDescendantIDs(cateID))
} else if parent != nil {
ids := []uint64{parent.ID}
for _, c := range children {
@@ -562,8 +607,9 @@ func newscenterHandler(tid uint64, params map[string]string, ctx *RenderCtx, bod
}
var arts []models.CmsArticle
_, _ = qs.OrderBy("-top", "-id").Limit(row).All(&arts)
cateImgMap := models.CmsCategoryImageMap(tid, collectCateIDs(arts))
var sb strings.Builder
for _, a := range articleRows(arts, titleLen) {
for _, a := range articleRows(arts, titleLen, cateImgMap) {
sb.WriteString(applyFields(body, a))
}
return sb.String()
+53 -29
View File
@@ -96,35 +96,11 @@ func RegisterAuthRoutes() {
beego.Router("/backend/deleteUser/:id", &controllers.BackendAdminUserController{}, "delete:DeleteUser")
beego.Router("/backend/changePassword", &controllers.BackendAdminUserController{}, "post:ChangePassword")
// ERP 接口
beego.Router("/backend/erp/getOrganization", &controllers.BackendErpController{}, "get:GetOrganization")
beego.Router("/backend/erp/getOrganizationDetail/:id", &controllers.BackendErpController{}, "get:GetOrganizationDetail")
beego.Router("/backend/erp/createOrganization", &controllers.BackendErpController{}, "post:CreateOrganization")
beego.Router("/backend/erp/editOrganization/:id", &controllers.BackendErpController{}, "post:EditOrganization")
beego.Router("/backend/erp/deleteOrganization/:id", &controllers.BackendErpController{}, "delete:DeleteOrganization")
beego.Router("/backend/erp/getCompanys", &controllers.BackendErpController{}, "get:GetCompanys")
beego.Router("/backend/erp/getDepartments", &controllers.BackendErpController{}, "get:GetDepartments")
beego.Router("/backend/erp/getEmployee", &controllers.BackendErpController{}, "get:GetEmployee")
beego.Router("/backend/erp/getEmployeeDetail/:id", &controllers.BackendErpController{}, "get:GetEmployeeDetail")
beego.Router("/backend/erp/createEmployee", &controllers.BackendErpController{}, "post:CreateEmployee")
beego.Router("/backend/erp/editEmployee/:id", &controllers.BackendErpController{}, "post:EditEmployee")
beego.Router("/backend/erp/deleteEmployee/:id", &controllers.BackendErpController{}, "delete:DeleteEmployee")
beego.Router("/backend/erp/getPosition", &controllers.BackendErpController{}, "get:GetPosition")
beego.Router("/backend/erp/getPositionDetail/:id", &controllers.BackendErpController{}, "get:GetPositionDetail")
beego.Router("/backend/erp/createPosition", &controllers.BackendErpController{}, "post:CreatePosition")
beego.Router("/backend/erp/editPosition/:id", &controllers.BackendErpController{}, "post:EditPosition")
beego.Router("/backend/erp/deletePosition/:id", &controllers.BackendErpController{}, "delete:DeletePosition")
// 新增组织架构接口
beego.Router("/backend/erp/getOrgSettings", &controllers.BackendErpController{}, "get:GetOrgSettings")
beego.Router("/backend/erp/saveOrgSettings", &controllers.BackendErpController{}, "post:SaveOrgSettings")
beego.Router("/backend/erp/getOrganizationTree", &controllers.BackendErpController{}, "get:GetOrganizationTree")
beego.Router("/backend/erp/searchOrganizations", &controllers.BackendErpController{}, "get:SearchOrganizations")
beego.Router("/backend/erp/getOrganizationEmployees/:org_id", &controllers.BackendErpController{}, "get:GetOrganizationEmployees")
beego.Router("/backend/erp/moveEmployeeToOrg", &controllers.BackendErpController{}, "post:MoveEmployeeToOrg")
beego.Router("/backend/erp/getOrganizationHierarchy/:org_id", &controllers.BackendErpController{}, "get:GetOrganizationHierarchy")
beego.Router("/backend/erp/checkOrgCodeUnique", &controllers.BackendErpController{}, "get:CheckOrgCodeUnique")
beego.Router("/backend/erp/checkEmployeeAccountUnique", &controllers.BackendErpController{}, "get:CheckEmployeeAccountUnique")
// 组织架构(组织 / 员工 / 职位)
// 组织架构是租户端通用基础数据:进销存(erp)与办公自动化(oa)两套界面读写同一份数据,
// 数据按 JWT 中的租户ID 隔离。两个前缀注册到同一个控制器,便于两端各自的菜单与权限配置。
registerOrganizationRoutes("erp")
registerOrganizationRoutes("oa")
// 通讯录管理(与组织架构联动)
beego.Router("/backend/erp/contact/list", &controllers.BackendErpContactController{}, "get:List")
@@ -252,3 +228,51 @@ func RegisterAuthRoutes() {
beego.Router("/backend/reminder/batchDelete", &controllers.BackendReminderController{}, "post:BatchDeleteReminder")
beego.Router("/backend/reminder/finish/:id", &controllers.BackendReminderController{}, "post:FinishReminder")
}
// registerOrganizationRoutes 为指定模块前缀注册组织架构路由。
// 进销存(erp)与办公自动化(oa)各有独立界面,但共用 BackendOrganizationController 与同一套表,
// 因此这里用同一份定义注册两遍,只有 URL 前缀不同。
func registerOrganizationRoutes(module string) {
prefix := "/backend/" + module
// 组织机构
beego.Router(prefix+"/getOrganization", &controllers.BackendOrganizationController{}, "get:GetOrganization")
beego.Router(prefix+"/getOrganizationTree", &controllers.BackendOrganizationController{}, "get:GetOrganizationTree")
beego.Router(prefix+"/getOrganizationDetail/:id", &controllers.BackendOrganizationController{}, "get:GetOrganizationDetail")
beego.Router(prefix+"/createOrganization", &controllers.BackendOrganizationController{}, "post:CreateOrganization")
beego.Router(prefix+"/editOrganization/:id", &controllers.BackendOrganizationController{}, "post:EditOrganization")
beego.Router(prefix+"/deleteOrganization/:id", &controllers.BackendOrganizationController{}, "delete:DeleteOrganization")
beego.Router(prefix+"/getCompanys", &controllers.BackendOrganizationController{}, "get:GetCompanys")
beego.Router(prefix+"/getDepartments", &controllers.BackendOrganizationController{}, "get:GetDepartments")
beego.Router(prefix+"/searchOrganizations", &controllers.BackendOrganizationController{}, "get:SearchOrganizations")
beego.Router(prefix+"/getOrganizationHierarchy/:org_id", &controllers.BackendOrganizationController{}, "get:GetOrganizationHierarchy")
beego.Router(prefix+"/getOrganizationStats", &controllers.BackendOrganizationController{}, "get:GetOrganizationStats")
beego.Router(prefix+"/moveOrganization", &controllers.BackendOrganizationController{}, "post:MoveOrganization")
beego.Router(prefix+"/batchOrganizeOrganizations", &controllers.BackendOrganizationController{}, "post:BatchOrganizeOrganizations")
beego.Router(prefix+"/checkOrgCodeUnique", &controllers.BackendOrganizationController{}, "get:CheckOrgCodeUnique")
// 员工
beego.Router(prefix+"/getEmployee", &controllers.BackendOrganizationController{}, "get:GetEmployee")
beego.Router(prefix+"/getEmployeeDetail/:id", &controllers.BackendOrganizationController{}, "get:GetEmployeeDetail")
beego.Router(prefix+"/createEmployee", &controllers.BackendOrganizationController{}, "post:CreateEmployee")
beego.Router(prefix+"/editEmployee/:id", &controllers.BackendOrganizationController{}, "post:EditEmployee")
beego.Router(prefix+"/deleteEmployee/:id", &controllers.BackendOrganizationController{}, "delete:DeleteEmployee")
beego.Router(prefix+"/getOrganizationEmployees/:org_id", &controllers.BackendOrganizationController{}, "get:GetOrganizationEmployees")
beego.Router(prefix+"/moveEmployeeToOrg", &controllers.BackendOrganizationController{}, "post:MoveEmployeeToOrg")
beego.Router(prefix+"/checkEmployeeAccountUnique", &controllers.BackendOrganizationController{}, "get:CheckEmployeeAccountUnique")
// 职位
beego.Router(prefix+"/getPosition", &controllers.BackendOrganizationController{}, "get:GetPosition")
beego.Router(prefix+"/getPositionDetail/:id", &controllers.BackendOrganizationController{}, "get:GetPositionDetail")
beego.Router(prefix+"/createPosition", &controllers.BackendOrganizationController{}, "post:CreatePosition")
beego.Router(prefix+"/editPosition/:id", &controllers.BackendOrganizationController{}, "post:EditPosition")
beego.Router(prefix+"/deletePosition/:id", &controllers.BackendOrganizationController{}, "delete:DeletePosition")
beego.Router(prefix+"/checkPositionCodeUnique", &controllers.BackendOrganizationController{}, "get:CheckPositionCodeUnique")
// 设置与导入导出
beego.Router(prefix+"/getOrgSettings", &controllers.BackendOrganizationController{}, "get:GetOrgSettings")
beego.Router(prefix+"/saveOrgSettings", &controllers.BackendOrganizationController{}, "post:SaveOrgSettings")
beego.Router(prefix+"/exportOrganization", &controllers.BackendOrganizationController{}, "get:ExportOrganization")
beego.Router(prefix+"/importOrganization", &controllers.BackendOrganizationController{}, "post:ImportOrganization")
beego.Router(prefix+"/organizationImportTemplate", &controllers.BackendOrganizationController{}, "get:GetImportTemplate")
}
+16
View File
@@ -0,0 +1,16 @@
package backend
import "testing"
// TestRegisterOrganizationRoutes 确认组织架构路由能为 erp 与 oa 两个前缀完成注册且不冲突。
// beego 在注册重复路由或非法方法名时会 panic,这里通过实际注册来兜住这类错误。
func TestRegisterOrganizationRoutes(t *testing.T) {
defer func() {
if r := recover(); r != nil {
t.Fatalf("注册组织架构路由失败: %v", r)
}
}()
registerOrganizationRoutes("erp")
registerOrganizationRoutes("oa")
}
+1 -1
View File
@@ -24,7 +24,7 @@
{yz:arclist row="4" titlelen="24"}
<li class="yz-news-card">
<a href="[field:arcurl/]">
<img class="yz-news-thumb" src="[field:image/]" alt="[field:titlefull/]" onerror="this.style.display='none'">
<img class="yz-news-thumb" src="[field:image/]" alt="[field:titlefull/]">
<h3 title="[field:titlefull/]">[field:title/]</h3>
<p class="yz-news-desc">[field:desc/]</p>
<span class="yz-news-date">[field:pubdate/]</span>
+2 -2
View File
@@ -23,7 +23,7 @@
<div class="col-lg-12">
<nav class="navbar navbar-expand-lg">
<a class="navbar-brand" href="/">
<img src="{yz:logo}" alt="{yz:sitename}" />
<img src="{yz:logo}" alt="{yz:sitename}" style="width: 200px;" />
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNine"
aria-controls="navbarNine" aria-expanded="false" aria-label="切换导航">
@@ -121,7 +121,7 @@
<div class="row">
<div class="col-lg-4 col-md-6 col-12">
<div class="footer-widget f-about">
<div class="logo"><a href="/"><img src="{yz:logo}" alt="{yz:sitename}" class="img-fluid" /></a></div>
<div class="logo"><a href="/"><img src="{yz:logo}" alt="{yz:sitename}" class="img-fluid" style="width: 250px;" /></a></div>
<p>{yz:description}</p>
<p class="copyright-text">
<span>备案号:{yz:icp}<br>{yz:copyright}&nbsp;&nbsp;<a href="www.yunzer.cn" rel="nofollow"> 云泽网 </a></span>
+2 -2
View File
@@ -23,7 +23,7 @@
<div class="col-lg-12">
<nav class="navbar navbar-expand-lg">
<a class="navbar-brand" href="/">
<img src="{yz:logo}" alt="{yz:sitename}" />
<img src="{yz:logo}" alt="{yz:sitename}" style="width: 200px;" />
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNine"
aria-controls="navbarNine" aria-expanded="false" aria-label="切换导航">
@@ -185,7 +185,7 @@
<div class="row">
<div class="col-lg-4 col-md-6 col-12">
<div class="footer-widget f-about">
<div class="logo"><a href="/"><img src="{yz:logo}" alt="{yz:sitename}" class="img-fluid" /></a></div>
<div class="logo"><a href="/"><img src="{yz:logo}" alt="{yz:sitename}" class="img-fluid" style="width: 250px;" /></a></div>
<p>{yz:description}</p>
<p class="copyright-text">
<span>备案号:{yz:icp}<br>{yz:copyright}&nbsp;&nbsp;<a href="www.yunzer.cn" rel="nofollow"> 云泽网 </a></span>
+3 -16
View File
@@ -39,7 +39,7 @@
<div class="col-lg-12">
<nav class="navbar navbar-expand-lg">
<a class="navbar-brand" href="/">
<img src="{yz:logo}" alt="{yz:sitename}" />
<img src="{yz:logo}" alt="{yz:sitename}" style="width: 200px;" />
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNine"
aria-controls="navbarNine" aria-expanded="false" aria-label="切换导航">
@@ -168,7 +168,7 @@
<div class="col-lg-3 col-md-6 col-12">
<div class="single-news news-center-item">
<div class="image">
<a href="[field:arcurl/]"><img class="thumb" src="[field:image/]" alt="[field:titlefull/]" onerror="this.style.display='none'" /></a>
<a href="[field:arcurl/]"><img class="thumb" src="[field:image/]" alt="[field:titlefull/]" /></a>
<div class="meta-details">
<span>[field:pubdate/]</span>
</div>
@@ -500,11 +500,7 @@
<div class="col-lg-4 col-md-6 col-12">
<!-- 单个小组件 -->
<div class="footer-widget f-about">
<div class="logo">
<a href="/">
<img src="{yz:logo}" alt="云泽网" class="img-fluid" />
</a>
</div>
<div class="logo"><a href="/"><img src="{yz:logo}" alt="{yz:sitename}" class="img-fluid" style="width: 250px;" /></a></div>
<p>
{yz:description}
</p>
@@ -606,15 +602,6 @@
overlayLeft.classList.add("open");
});
//========= 初始化 glightbox 视频播放
GLightbox({
'href': 'https://www.youtube.com/watch?v=r44RKWyfcFw&fbclid=IwAR21beSJORalzmzokxDRcGfkZA1AtRTE__l5N4r09HcGS5Y6vOluyouM9EM',
'type': 'video',
'source': 'youtube', // vimeo, youtube 或本地视频
'width': 900,
'autoplayVideos': true,
});
</script>
</body>
+4 -8
View File
@@ -24,7 +24,7 @@
<div class="col-lg-12">
<nav class="navbar navbar-expand-lg">
<a class="navbar-brand" href="/">
<img src="{yz:logo}" alt="{yz:sitename}" />
<img src="{yz:logo}" alt="{yz:sitename}" style="width: 200px;" />
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNine"
aria-controls="navbarNine" aria-expanded="false" aria-label="切换导航">
@@ -122,11 +122,11 @@
<div class="container">
<div class="row">
{yz:arclist row="10" titlelen="40"}
{yz:arclist row="8" titlelen="40"}
<div class="col-lg-3 col-md-6 col-12">
<div class="single-news news-center-item">
<div class="image">
<a href="[field:arcurl/]"><img class="thumb" src="[field:image/]" alt="[field:titlefull/]" onerror="this.style.display='none'" /></a>
<a href="[field:arcurl/]"><img class="thumb" src="[field:image/]" alt="[field:titlefull/]" /></a>
<div class="meta-details">
<span>[field:pubdate/]</span>
</div>
@@ -159,11 +159,7 @@
<div class="row">
<div class="col-lg-4 col-md-6 col-12">
<div class="footer-widget f-about">
<div class="logo">
<a href="/">
<img src="{yz:logo}" alt="{yz:sitename}" class="img-fluid" />
</a>
</div>
<div class="logo"><a href="/"><img src="{yz:logo}" alt="{yz:sitename}" class="img-fluid" style="width: 250px;" /></a></div>
<p>{yz:description}</p>
<p class="copyright-text">
<span>备案号:{yz:icp}<br>{yz:copyright}&nbsp;&nbsp;<a href="www.yunzer.cn" rel="nofollow"> 云泽网 </a></span>
+4 -8
View File
@@ -24,7 +24,7 @@
<div class="col-lg-12">
<nav class="navbar navbar-expand-lg">
<a class="navbar-brand" href="/">
<img src="{yz:logo}" alt="{yz:sitename}" />
<img src="{yz:logo}" alt="{yz:sitename}" style="width: 200px;" />
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNine"
aria-controls="navbarNine" aria-expanded="false" aria-label="切换导航">
@@ -114,8 +114,8 @@
<div class="col-lg-10 col-xl-9">
{yz:arcview}
<article class="article-detail-card">
<div class="article-detail-thumb" style="display:none;">
<img src="[field:image/]" alt="[field:title/]" class="article-cover" onerror="this.closest('.article-detail-thumb').style.display='none'" onload="this.closest('.article-detail-thumb').style.display='block'" />
<div class="article-detail-thumb">
<img src="[field:image/]" alt="[field:title/]" class="article-cover" onerror="this.closest('.article-detail-thumb').style.display='none'" />
</div>
<h1 class="article-detail-title">[field:title/]</h1>
@@ -151,11 +151,7 @@
<div class="row">
<div class="col-lg-4 col-md-6 col-12">
<div class="footer-widget f-about">
<div class="logo">
<a href="/">
<img src="{yz:logo}" alt="{yz:sitename}" class="img-fluid" />
</a>
</div>
<div class="logo"><a href="/"><img src="{yz:logo}" alt="{yz:sitename}" class="img-fluid" style="width: 250px;" /></a></div>
<p>{yz:description}</p>
<p class="copyright-text">
<span>备案号:{yz:icp}<br>{yz:copyright}&nbsp;&nbsp;<a href="www.yunzer.cn" rel="nofollow"> 云泽网 </a></span>
+2 -2
View File
@@ -23,7 +23,7 @@
<div class="col-lg-12">
<nav class="navbar navbar-expand-lg">
<a class="navbar-brand" href="/">
<img src="{yz:logo}" alt="{yz:sitename}" />
<img src="{yz:logo}" alt="{yz:sitename}" style="width: 200px;" />
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNine"
aria-controls="navbarNine" aria-expanded="false" aria-label="切换导航">
@@ -114,7 +114,7 @@
<div class="row">
<div class="col-lg-4 col-md-6 col-12">
<div class="footer-widget f-about">
<div class="logo"><a href="/"><img src="{yz:logo}" alt="{yz:sitename}" class="img-fluid" /></a></div>
<div class="logo"><a href="/"><img src="{yz:logo}" alt="{yz:sitename}" class="img-fluid" style="width: 250px;" /></a></div>
<p>{yz:description}</p>
<p class="copyright-text">
<span>备案号:{yz:icp}<br>{yz:copyright}&nbsp;&nbsp;<a href="www.yunzer.cn" rel="nofollow"> 云泽网 </a></span>