Merge branch 'master' of https://git.yunzer.cn/yunzerwebsite/yunzerwebsiteallinone
This commit is contained in:
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)).
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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 ""
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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} <a href="www.yunzer.cn" rel="nofollow"> 云泽网 </a></span>
|
||||
|
||||
@@ -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} <a href="www.yunzer.cn" rel="nofollow"> 云泽网 </a></span>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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} <a href="www.yunzer.cn" rel="nofollow"> 云泽网 </a></span>
|
||||
|
||||
@@ -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} <a href="www.yunzer.cn" rel="nofollow"> 云泽网 </a></span>
|
||||
|
||||
@@ -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} <a href="www.yunzer.cn" rel="nofollow"> 云泽网 </a></span>
|
||||
|
||||
Reference in New Issue
Block a user