钉钉组织架构同步到本地(排除管理员+管理员两版本)

bash 复制代码
import requests, json, os

# ═══════════════════════════════════════════════════
#  🔶 钉钉组织架构同步脚本
#  获取参数:https://open-dev.dingtalk.com/
#  路径:应用开发 → 钉钉应用 → 点击应用 → 凭证与基础信息
# ═══════════════════════════════════════════════════

APP_KEY    = "dinxxxxjrr"
APP_SECRET = "uiGdxxxxx1nOiPoe0-vmkfYWHxMDAeiDIv42QEFY"
BASE       = "https://oapi.dingtalk.com"

# ── 鉴权 ──────────────────────────────────────────
def get_token():
    r = requests.get(f"{BASE}/gettoken",
                     params={"appkey": APP_KEY, "appsecret": APP_SECRET}).json()
    if r.get("errcode") != 0:
        raise Exception(f"❌ Token 获取失败: {r.get('errmsg')}")
    print("✅ Token 获取成功")
    return r["access_token"]

TOKEN = get_token()

def post(url, body):
    return requests.post(url, params={"access_token": TOKEN},
                         headers={"Content-Type": "application/json"},
                         json=body).json()

# ── 递归拉取所有子部门 ─────────────────────────────
def fetch_depts(parent_id=1):
    res = post(f"{BASE}/topapi/v2/department/listsub",
               {"dept_id": parent_id, "language": "zh_CN"})
    depts = res.get("result") or []
    all_depts = []
    for d in depts:
        all_depts.append(d)
        all_depts.extend(fetch_depts(d["dept_id"]))
    return all_depts

# ── 分页拉取部门成员 ───────────────────────────────
def fetch_members(dept_id):
    users, cursor = [], 0
    while True:
        res = post(f"{BASE}/topapi/v2/user/list",
                   {"dept_id": dept_id, "cursor": cursor, "size": 50,
                    "contain_access_level": False})
        result = res.get("result") or {}
        for u in result.get("list", []):
            users.append({
                "name":   u.get("name", ""),
                "title":  u.get("title", ""),
                "avatar": u.get("avatar", ""),
            })
        if not result.get("has_more"):
            break
        cursor = result.get("next_cursor", 0)
    return users

# ── 构建树结构 ─────────────────────────────────────
def build_tree(all_depts, member_map, parent_id):
    children = sorted([d for d in all_depts if d["parent_id"] == parent_id],
                      key=lambda x: x.get("order", 0))
    return [{
        "name":     d["name"],
        "id":       str(d["dept_id"]),
        "members":  member_map.get(d["dept_id"], []),
        "children": build_tree(all_depts, member_map, d["dept_id"]),
    } for d in children]

# ── 主流程 ─────────────────────────────────────────
print("📡 正在拉取部门...")
all_depts = fetch_depts(1)
print(f"✅ 部门:{len(all_depts)} 个")

member_map = {}
print(f"👥 正在拉取 {len(all_depts)} 个部门的成员...")
for d in all_depts:
    members = fetch_members(d["dept_id"])
    if members:
        member_map[d["dept_id"]] = members
print(f"✅ 成员:{sum(len(v) for v in member_map.values())} 名")

root_res  = post(f"{BASE}/topapi/v2/department/get", {"dept_id": 1})
root_name = (root_res.get("result") or {}).get("name", "企业")

tree_data = {
    "name": root_name, "id": "1", "members": [],
    "children": build_tree(all_depts, member_map, 1),
}

# ── 生成 HTML ──────────────────────────────────────
tree_json = json.dumps(tree_data, ensure_ascii=False)

html = f"""<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{root_name} · 组织架构</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@300;400;500;600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
  :root {{
    --bg:        #0d1117;
    --surface:   #161b22;
    --surface2:  #1c2230;
    --border:    #30363d;
    --border2:   #21262d;
    --accent:    #f78166;
    --accent2:   #ffa657;
    --text:      #e6edf3;
    --text2:     #8b949e;
    --text3:     #6e7681;
    --green:     #3fb950;
    --sidebar-w: 300px;
    --header-h:  56px;
  }}
  *, *::before, *::after {{ box-sizing: border-box; margin: 0; padding: 0; }}

  body {{
    font-family: 'Noto Sans SC', sans-serif;
    background: var(--bg);
    color: var(--text);
    height: 100vh;
    overflow: hidden;
    display: flex;
    flex-direction: column;
  }}

  /* ── Header ── */
  header {{
    height: var(--header-h);
    background: var(--surface);
    border-bottom: 1px solid var(--border);
    display: flex;
    align-items: center;
    padding: 0 20px;
    gap: 14px;
    flex-shrink: 0;
    position: relative;
    z-index: 10;
  }}
  .logo {{
    width: 34px; height: 34px;
    background: linear-gradient(135deg, #f78166, #ffa657);
    border-radius: 10px;
    display: flex; align-items: center; justify-content: center;
    font-weight: 700; font-size: 15px; color: #fff;
    flex-shrink: 0;
    box-shadow: 0 0 20px rgba(247,129,102,0.3);
  }}
  .header-title {{
    font-size: 15px; font-weight: 600; color: var(--text);
    letter-spacing: 0.3px;
  }}
  .header-sub {{
    font-size: 12px; color: var(--text3);
    font-family: 'JetBrains Mono', monospace;
    margin-left: auto;
    background: var(--surface2);
    padding: 4px 10px;
    border-radius: 6px;
    border: 1px solid var(--border2);
  }}
  .badge {{
    font-size: 11px;
    background: rgba(63,185,80,0.15);
    color: var(--green);
    border: 1px solid rgba(63,185,80,0.3);
    padding: 3px 8px;
    border-radius: 20px;
    font-family: 'JetBrains Mono', monospace;
  }}

  /* ── Layout ── */
  .layout {{
    display: flex;
    flex: 1;
    overflow: hidden;
  }}

  /* ── Sidebar ── */
  aside {{
    width: var(--sidebar-w);
    background: var(--surface);
    border-right: 1px solid var(--border);
    display: flex;
    flex-direction: column;
    flex-shrink: 0;
  }}
  .search-wrap {{
    padding: 12px;
    border-bottom: 1px solid var(--border2);
  }}
  .search-wrap input {{
    width: 100%;
    background: var(--bg);
    border: 1px solid var(--border);
    border-radius: 8px;
    padding: 8px 12px 8px 34px;
    color: var(--text);
    font-size: 13px;
    font-family: 'Noto Sans SC', sans-serif;
    outline: none;
    transition: border-color .2s;
    background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 24 24' fill='none' stroke='%238b949e' stroke-width='2'%3E%3Ccircle cx='11' cy='11' r='8'/%3E%3Cpath d='m21 21-4.35-4.35'/%3E%3C/svg%3E");
    background-repeat: no-repeat;
    background-position: 10px center;
  }}
  .search-wrap input:focus {{ border-color: var(--accent); }}
  .search-wrap input::placeholder {{ color: var(--text3); }}

  #tree {{ flex: 1; overflow-y: auto; padding: 8px 6px; }}
  #tree::-webkit-scrollbar {{ width: 4px; }}
  #tree::-webkit-scrollbar-track {{ background: transparent; }}
  #tree::-webkit-scrollbar-thumb {{ background: var(--border); border-radius: 4px; }}

  .node-label {{
    display: flex;
    align-items: center;
    gap: 7px;
    padding: 7px 10px;
    border-radius: 7px;
    cursor: pointer;
    font-size: 13px;
    color: var(--text2);
    transition: background .15s, color .15s;
    user-select: none;
    white-space: nowrap;
    overflow: hidden;
  }}
  .node-label:hover {{ background: var(--surface2); color: var(--text); }}
  .node-label.active {{
    background: rgba(247,129,102,0.1);
    color: var(--accent);
    border: 1px solid rgba(247,129,102,0.2);
  }}
  .toggle-icon {{
    width: 12px; height: 12px; flex-shrink: 0;
    color: var(--text3);
    transition: transform .2s;
    font-size: 9px;
    display: flex; align-items: center; justify-content: center;
  }}
  .toggle-icon.open {{ transform: rotate(90deg); }}
  .toggle-icon.leaf {{ opacity: 0; }}
  .dept-icon {{ font-size: 13px; flex-shrink: 0; }}
  .node-name {{ flex: 1; overflow: hidden; text-overflow: ellipsis; }}
  .member-count {{
    font-size: 10px;
    font-family: 'JetBrains Mono', monospace;
    color: var(--text3);
    background: var(--surface2);
    padding: 1px 5px;
    border-radius: 10px;
    flex-shrink: 0;
  }}
  .node-children {{ padding-left: 16px; }}
  .node-children.hidden {{ display: none; }}

  /* ── Main ── */
  main {{
    flex: 1;
    overflow-y: auto;
    padding: 28px;
    background: var(--bg);
  }}
  main::-webkit-scrollbar {{ width: 6px; }}
  main::-webkit-scrollbar-track {{ background: transparent; }}
  main::-webkit-scrollbar-thumb {{ background: var(--border); border-radius: 4px; }}

  .dept-header {{
    display: flex;
    align-items: flex-end;
    gap: 16px;
    margin-bottom: 28px;
    padding-bottom: 20px;
    border-bottom: 1px solid var(--border2);
  }}
  .dept-icon-big {{
    width: 52px; height: 52px;
    background: linear-gradient(135deg, rgba(247,129,102,0.2), rgba(255,166,87,0.2));
    border: 1px solid rgba(247,129,102,0.3);
    border-radius: 14px;
    display: flex; align-items: center; justify-content: center;
    font-size: 24px;
    flex-shrink: 0;
  }}
  .dept-title {{ font-size: 22px; font-weight: 600; color: var(--text); }}
  .dept-meta {{ font-size: 13px; color: var(--text3); margin-top: 4px; font-family: 'JetBrains Mono', monospace; }}

  .section-title {{
    font-size: 11px;
    font-weight: 600;
    letter-spacing: 1.2px;
    text-transform: uppercase;
    color: var(--text3);
    margin-bottom: 14px;
  }}

  .members-grid {{
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
    gap: 12px;
    margin-bottom: 32px;
  }}

  .member-card {{
    background: var(--surface);
    border: 1px solid var(--border2);
    border-radius: 10px;
    padding: 14px 16px;
    display: flex;
    align-items: center;
    gap: 12px;
    transition: border-color .2s, transform .2s, box-shadow .2s;
    cursor: default;
  }}
  .member-card:hover {{
    border-color: var(--border);
    transform: translateY(-2px);
    box-shadow: 0 8px 24px rgba(0,0,0,0.3);
  }}
  .avatar {{
    width: 40px; height: 40px;
    border-radius: 50%;
    overflow: hidden;
    flex-shrink: 0;
    display: flex; align-items: center; justify-content: center;
    font-weight: 600; font-size: 15px; color: #fff;
    background: linear-gradient(135deg, var(--accent), var(--accent2));
  }}
  .avatar img {{ width: 100%; height: 100%; object-fit: cover; }}
  .member-name {{ font-size: 14px; font-weight: 500; color: var(--text); }}
  .member-title {{ font-size: 12px; color: var(--text3); margin-top: 2px; }}

  /* 子部门列表 */
  .sub-depts {{
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
    gap: 10px;
    margin-bottom: 32px;
  }}
  .sub-dept-card {{
    background: var(--surface);
    border: 1px solid var(--border2);
    border-radius: 10px;
    padding: 14px 16px;
    display: flex;
    align-items: center;
    gap: 10px;
    cursor: pointer;
    transition: border-color .2s, background .2s;
    font-size: 13px;
    color: var(--text2);
  }}
  .sub-dept-card:hover {{
    border-color: var(--accent);
    background: rgba(247,129,102,0.05);
    color: var(--text);
  }}
  .sub-dept-name {{ font-weight: 500; }}
  .sub-dept-count {{ font-size: 11px; color: var(--text3); margin-top: 2px; font-family: 'JetBrains Mono', monospace; }}

  .empty {{ color: var(--text3); font-size: 13px; padding: 12px 0; }}
  .placeholder {{
    display: flex; flex-direction: column;
    align-items: center; justify-content: center;
    height: 60vh;
    color: var(--text3);
    gap: 12px;
    font-size: 14px;
  }}
  .placeholder-icon {{ font-size: 48px; opacity: 0.3; }}

  @keyframes fadeIn {{ from {{ opacity:0; transform:translateY(8px) }} to {{ opacity:1; transform:none }} }}
  .fade-in {{ animation: fadeIn .25s ease; }}
</style>
</head>
<body>

<header>
  <div class="logo">钉</div>
  <div>
    <div class="header-title">{root_name} · 组织架构</div>
  </div>
  <div class="badge" id="stat">加载中...</div>
  <div class="header-sub">DingTalk Org Viewer</div>
</header>

<div class="layout">
  <aside>
    <div class="search-wrap">
      <input id="searchInput" placeholder="搜索部门..." oninput="search(this.value)">
    </div>
    <div id="tree"></div>
  </aside>
  <main id="main">
    <div class="placeholder">
      <div class="placeholder-icon">🏢</div>
      <div>点击左侧部门查看详情</div>
    </div>
  </main>
</div>

<script>
const DATA = {tree_json};

// 建立全局索引
const idx = {{}};
let totalMembers = 0;
let totalDepts   = 0;
function buildIdx(n) {{
  idx[n.id] = n;
  totalDepts++;
  totalMembers += (n.members || []).length;
  (n.children || []).forEach(buildIdx);
}}
buildIdx(DATA);
document.getElementById('stat').textContent =
  totalDepts + ' 部门 · ' + totalMembers + ' 成员';

// 统计子树成员数
function countMembers(node) {{
  let c = (node.members || []).length;
  (node.children || []).forEach(ch => c += countMembers(ch));
  return c;
}}

// 渲染侧边树节点
function renderNode(node, depth) {{
  const hasChild = (node.children || []).length > 0;
  const wrap = document.createElement('div');
  wrap.className = 'node-wrap';
  wrap.dataset.name = node.name;

  const label = document.createElement('div');
  label.className = 'node-label';
  const cnt = countMembers(node);
  label.innerHTML =
    `<span class="toggle-icon ${{hasChild?'':'leaf'}}">▶</span>` +
    `<span class="dept-icon">${{depth===0?'🏢':'📁'}}</span>` +
    `<span class="node-name">${{node.name}}</span>` +
    (cnt > 0 ? `<span class="member-count">${{cnt}}</span>` : '');

  label.onclick = e => {{
    e.stopPropagation();
    document.querySelectorAll('.node-label').forEach(l => l.classList.remove('active'));
    label.classList.add('active');
    showDetail(node.id);
    if (hasChild) {{
      const ch = wrap.querySelector(':scope > .node-children');
      ch.classList.toggle('hidden');
      label.querySelector('.toggle-icon').classList.toggle('open');
    }}
  }};
  wrap.appendChild(label);

  if (hasChild) {{
    const ch = document.createElement('div');
    ch.className = 'node-children hidden';
    node.children.forEach(c => ch.appendChild(renderNode(c, depth + 1)));
    wrap.appendChild(ch);
  }}
  return wrap;
}}

// 展示部门详情
function showDetail(id) {{
  const node = idx[id];
  const main = document.getElementById('main');

  let html = `<div class="fade-in">
    <div class="dept-header">
      <div class="dept-icon-big">${{node.id==='1'?'🏢':'📁'}}</div>
      <div>
        <div class="dept-title">${{node.name}}</div>
        <div class="dept-meta">ID: ${{node.id}} &nbsp;·&nbsp; 直属成员: ${{(node.members||[]).length}} &nbsp;·&nbsp; 子部门: ${{(node.children||[]).length}}</div>
      </div>
    </div>`;

  // 子部门
  if ((node.children || []).length > 0) {{
    html += `<div class="section-title">子部门</div><div class="sub-depts">`;
    node.children.forEach(c => {{
      const cnt = countMembers(c);
      html += `<div class="sub-dept-card" onclick="selectNode('${{c.id}}')">
        <span style="font-size:20px">📁</span>
        <div>
          <div class="sub-dept-name">${{c.name}}</div>
          <div class="sub-dept-count">${{cnt}} 人</div>
        </div>
      </div>`;
    }});
    html += `</div>`;
  }}

  // 成员
  html += `<div class="section-title">直属成员</div>`;
  if ((node.members || []).length > 0) {{
    html += `<div class="members-grid">`;
    node.members.forEach(m => {{
      const initial = m.name ? m.name[0] : '?';
      html += `<div class="member-card">
        <div class="avatar">${{m.avatar
          ? `<img src="${{m.avatar}}" onerror="this.parentNode.textContent='${{initial}}'">`
          : initial}}</div>
        <div>
          <div class="member-name">${{m.name}}</div>
          <div class="member-title">${{m.title || '员工'}}</div>
        </div>
      </div>`;
    }});
    html += `</div>`;
  }} else {{
    html += `<div class="empty">暂无直属成员</div>`;
  }}

  html += `</div>`;
  main.innerHTML = html;
}}

// 点击子部门卡片跳转
function selectNode(id) {{
  const node = idx[id];
  if (!node) return;
  showDetail(id);
  // 同步高亮侧边栏
  document.querySelectorAll('.node-label').forEach(l => {{
    if (l.closest('.node-wrap')?.dataset?.name === node.name) {{
      l.classList.add('active');
      l.scrollIntoView({{ block: 'nearest' }});
    }} else {{
      l.classList.remove('active');
    }}
  }});
}}

// 搜索过滤
function search(q) {{
  const kw = q.trim().toLowerCase();
  document.querySelectorAll('.node-wrap').forEach(n => {{
    n.style.display = kw && !n.dataset.name.toLowerCase().includes(kw) ? 'none' : '';
  }});
}}

// 初始化
const treeEl = document.getElementById('tree');
treeEl.appendChild(renderNode(DATA, 0));
showDetail(DATA.id);
</script>
</body>
</html>"""

out = "org_dingtalk.html"
with open(out, "w", encoding="utf-8") as f:
    f.write(html)
print(f"🎉 完成!打开: {os.path.abspath(out)}")

包含管理员版本:

bash 复制代码
# -*- coding: utf-8 -*-
"""
钉钉组织架构同步 + 管理员
"""

import requests
from datetime import datetime

# ================== 配置区 ==================
APP_KEY = "dinxxxxxjrr"
APP_SECRET = "uiGdvr7xxxxxxxxe0-vmkfYWxxxxIv42QEFY"


# ===========================================

def get_access_token():
    url = "https://oapi.dingtalk.com/gettoken"
    params = {"appkey": APP_KEY, "appsecret": APP_SECRET}
    resp = requests.get(url, params=params, timeout=10).json()
    if resp.get("errcode") == 0:
        print("✅ access_token 获取成功")
        return resp["access_token"]
    raise Exception(f"Token 获取失败: {resp.get('errmsg')}")


def get_admin_list(token):
    url = "https://oapi.dingtalk.com/topapi/user/listadmin"
    resp = requests.post(url, params={"access_token": token}, json={}).json()
    admins = {}
    for item in resp.get("result") or []:
        userid = item.get("userid")
        if userid:
            # 优先从 listadmin 取 name,如果没有后面会用 user/get 补充
            admins[userid] = {
                "name": item.get("name", ""),
                "sys_level": item.get("sys_level")
            }
    print(f"✅ 获取到 {len(admins)} 名管理员")
    return admins


def get_user_detail(token, userid):
    url = "https://oapi.dingtalk.com/topapi/v2/user/get"
    data = {"userid": userid, "language": "zh_CN"}
    resp = requests.post(url, params={"access_token": token}, json=data).json()
    if resp.get("errcode") == 0:
        return resp.get("result", {})
    return {}


def get_all_departments(token):
    all_depts = []

    def recurse(dept_id=1):
        url = "https://oapi.dingtalk.com/topapi/v2/department/listsub"
        resp = requests.post(url, params={"access_token": token},
                             json={"dept_id": dept_id, "language": "zh_CN"}).json()
        subs = resp.get("result", []) if resp.get("errcode") == 0 else []
        for d in subs:
            all_depts.append(d)
            recurse(d.get("dept_id"))

    print("📥 正在递归获取所有部门...")
    recurse()
    print(f"✅ 共获取到 {len(all_depts)} 个部门")
    return all_depts


def get_users_in_dept(token, dept_id):
    url = "https://oapi.dingtalk.com/topapi/v2/user/list"
    data = {"dept_id": dept_id, "cursor": 0, "size": 100, "language": "zh_CN"}
    users = []
    while True:
        resp = requests.post(url, params={"access_token": token}, json=data).json()
        if resp.get("errcode") != 0:
            break
        users.extend(resp.get("result", {}).get("list", []))
        if not resp.get("result", {}).get("has_more"):
            break
        data["cursor"] = resp.get("result", {}).get("next_cursor")
    return users


def build_tree(depts, users_map, admins, token):
    dept_dict = {d["dept_id"]: {
        "id": d["dept_id"],
        "name": d.get("name", "未命名"),
        "parent_id": d.get("parent_id", 0),
        "children": [],
        "users": users_map.get(d["dept_id"], [])
    } for d in depts}

    # 确保根部门存在(你的根部门是 "api调试专用企业")
    root_id = 1
    if root_id not in dept_dict:
        dept_dict[root_id] = {
            "id": root_id,
            "name": "xxxx企业",
            "parent_id": 0,
            "children": [],
            "users": []
        }
        print("   ⚠️ 自动创建根部门")

    print("\n=== 强制挂载管理员到根部门 ===")
    for userid, admin_base in admins.items():
        # 先尝试从 user/get 获取完整信息
        detail = get_user_detail(token, userid)
        if not detail:
            # 如果失败,用 listadmin 的基础信息构造
            detail = {
                "userid": userid,
                "name": admin_base.get("name", "管理员"),
                "title": "主管理员",
                "mobile": "",
                "admin": True,
                "boss": True
            }

        name = detail.get("name") or admin_base.get("name") or "管理员"
        if not any(u.get("userid") == userid for u in dept_dict[root_id]["users"]):
            dept_dict[root_id]["users"].append(detail)
            print(f"✅ 管理员 【{name}】 已强制挂载到根部门 【api调试专用企业】")

    # 构建树
    root = []
    for dept in dept_dict.values():
        if dept["parent_id"] == 0 or dept["parent_id"] not in dept_dict:
            root.append(dept)
        else:
            dept_dict[dept["parent_id"]]["children"].append(dept)
    return root


def generate_html(tree):
    html = f"""
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <title>钉钉组织架构(含管理员) {datetime.now().strftime('%Y-%m-%d %H:%M')}</title>
    <style>
        body {{font-family:"Microsoft YaHei",sans-serif;background:#f5f7fa;padding:20px;}}
        h1 {{text-align:center;color:#1e90ff;}}
        .dept {{margin:10px 0;padding:14px;background:white;border-radius:10px;box-shadow:0 3px 10px rgba(0,0,0,0.1);}}
        .dept-name {{font-weight:bold;color:#2c3e50;cursor:pointer;font-size:17px;}}
        .users {{margin-left:40px;margin-top:10px;}}
        .user {{padding:10px 14px;background:#f8f9fa;margin:6px 0;border-radius:8px;font-size:14.5px;border-left:4px solid #3498db;}}
        .user.admin {{border-left:5px solid #e74c3c; background:#fff0f0; font-weight:bold;}}
        .highlight {{color:#e74c3c; font-size:16px;}}
    </style>
</head>
<body>
    <h1>🏢 钉钉组织架构(含管理员)</h1>
    <div style="text-align:center;margin-bottom:25px;color:#666;">
        同步时间:{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}
    </div>
    <div id="org">
"""

    def render(node, level=0):
        nonlocal html
        html += f'''
        <div class="dept" style="margin-left:{level * 28}px">
            <div class="dept-name" onclick="this.nextElementSibling.style.display = this.nextElementSibling.style.display==='none'?'block':'none'">
                ▼ {node.get('name', '未命名部门')} <span style="color:#999;font-size:14px;">({len(node.get('users', []))}人)</span>
            </div>
            <div class="users">
        '''
        for u in node.get("users", []):
            name = u.get("name", "未知")
            is_admin = bool(u.get("admin") or u.get("boss") or u.get("role_list"))
            tag = "👑 主管理员" if is_admin else ""
            admin_class = ' class="user admin"' if is_admin else ' class="user"'
            admin_tag = f'<span class="highlight"> {tag}</span>' if is_admin else ''

            html += f'''
                <div{admin_class}>
                    👤 {name} 
                    <span style="color:#666;margin-left:20px;">{u.get("title", "")}</span>
                    <span style="color:#888;margin-left:20px;">{u.get("mobile", "")}</span>
                    {admin_tag}
                </div>
            '''
        for child in node.get("children", []):
            render(child, level + 1)
        html += "</div></div>"

    for r in tree:
        render(r)

    html += """
    </div>
</body>
</html>
    """
    return html


# ====================== 主程序 ======================
if __name__ == "__main__":
    token = get_access_token()
    admins = get_admin_list(token)
    depts = get_all_departments(token)

    print("📥 正在获取成员...")
    users_map = {}
    for d in depts:
        users = get_users_in_dept(token, d["dept_id"])
        users_map[d["dept_id"]] = users

    tree = build_tree(depts, users_map, admins, token)

    html_content = generate_html(tree)
    filename = "dingtalk_organization_final.html"
    with open(filename, "w", encoding="utf-8") as f:
        f.write(html_content)

    print(f"\n🎉 生成完成!文件:{filename}")
相关推荐
小小AK3 小时前
钉钉与金蝶云星空无缝集成方案
大数据·人工智能·钉钉
dtsola9 天前
小遥搜索v1.8.0版本更新【钉钉文档+知识库支持】
程序员·钉钉·ai搜索·ai创业·独立开发者·个人开发者·一人公司
医疗信息化王工11 天前
钉钉小程序开发实战:投诉管理系统
小程序·钉钉·开发·投诉管理
医疗信息化王工12 天前
钉钉小程序开发实战:手术查询小程序
小程序·钉钉·手术查询
二进喵12 天前
OpenClaw 接入钉钉完整指南
钉钉
Teable任意门互动12 天前
多维表格本地化部署实践解析 企业如何实现数据自主可控路径
数据库·excel·钉钉·飞书·开源软件
水文摸鱼怪13 天前
HHU校园网自动连接监控系统(钉钉机器人版)操作说明书
机器人·钉钉
QDYOKR16814 天前
一文了解什么是OKR
大数据·人工智能·笔记·钉钉·企业微信
MarkHD15 天前
从“能跑”到“好用”:Python脚本监控与告警实战(邮件/钉钉/企业微信)
python·钉钉·企业微信