ChatGPT-Workspace-Helper

在 ChatGPT 管理页的助手。

คุณจะต้องติดตั้งส่วนขยาย เช่น Tampermonkey, Greasemonkey หรือ Violentmonkey เพื่อติดตั้งสคริปต์นี้

คุณจะต้องติดตั้งส่วนขยาย เช่น Tampermonkey หรือ Violentmonkey เพื่อติดตั้งสคริปต์นี้

คุณจะต้องติดตั้งส่วนขยาย เช่น Tampermonkey หรือ Violentmonkey เพื่อติดตั้งสคริปต์นี้

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         ChatGPT-Workspace-Helper
// @namespace    https://chatgpt.com
// @version      1.5.1
// @description  在 ChatGPT 管理页的助手。
// @author       Marx
// @license      MIT
// @icon         https://chatgpt.com/favicon.ico
// @match        https://chatgpt.com/*
// @grant        GM_addStyle
// @run-at       document-idle
// ==/UserScript==

(function () {
  const css = `
  :root{--bg:#ffffff;--panel:#f7f9fc;--text:#111827;--muted:#6b7280;--border:#e5e7eb;--accent:#60a5fa;--danger:#ef4444;--success:#16a34a}
  #wdd-fab{position:fixed;top:2.2rem;right:2.2rem;width:4rem;height:4rem;border-radius:999rem;border:none;background:transparent;cursor:pointer;z-index:999999;display:grid;place-items:center;box-shadow:0 .5rem 1.25rem rgba(0,0,0,.12);transition:transform .2s ease,box-shadow .2s ease}
  #wdd-fab:hover{transform:translateY(-.1rem);box-shadow:0 .75rem 1.75rem rgba(0,0,0,.16)}
  #wdd-fab img{width:2.8rem;height:2.8rem;display:block}
  #wdd-modal{position:fixed;inset:0;display:none;align-items:flex-start;justify-content:flex-end;background:transparent;z-index:999998}
  #wdd-modal.open #wdd-card{opacity:1;transform:translateY(0) scale(.9)}
  #wdd-card{width:28rem;max-width:92vw;margin:6rem 2.2rem 0 0;background:var(--panel);color:var(--text);border:0.0625rem solid var(--border);border-radius:1.25rem;box-shadow:0 1.5rem 4rem rgba(0,0,0,.12);overflow:hidden;opacity:0;transform-origin:top right;transform:translateY(-.5rem) scale(.86);transition:opacity .2s ease,transform .2s ease}
  .wdd-hd{display:flex;align-items:center;justify-content:space-between;padding:.9rem 1rem;border-bottom:0.0625rem solid var(--border);background:#fff}
  .wdd-ttl{font-weight:600;font-size:1rem;letter-spacing:.02em}
  .wdd-x{appearance:none;border:none;background:transparent;color:var(--muted);font-size:1.2rem;cursor:pointer;padding:.3rem .6rem;border-radius:.6rem}
  .wdd-x:hover{background:#f3f4f6;color:#111827}
  .wdd-bd{padding:1rem;display:grid;gap:1rem;background:var(--panel)}
  .wdd-row{display:grid;gap:.6rem}
  .wdd-lbl{font-size:.8rem;color:var(--muted)}
  .wdd-inp,.wdd-sel{width:100%;padding:.8rem 1rem;border-radius:.9rem;border:0.0625rem solid var(--border);background:#fff;color:var(--text);outline:none}
  .wdd-inp::placeholder{color:#9ca3af}
  .wdd-kv{position:relative;display:flex;align-items:center;gap:.6rem;border:0.0625rem dashed var(--border);border-radius:.9rem;padding:1.8rem 1rem 1rem 1rem;background:#fff}
  .wdd-kv .kv-hint{position:absolute;top:.55rem;left:.8rem;font-size:.8rem;color:var(--muted)}
  .wdd-kv .kv-row{display:flex;align-items:center;gap:.6rem;width:100%}
  .wdd-kv input{flex:1;min-width:0;background:transparent;border:none;color:var(--text);outline:none;font-size:.95rem}
  .wdd-actions{display:flex;gap:.6rem;flex-wrap:wrap}
  .wdd-btn{flex:1;min-width:0;appearance:none;border:none;border-radius:1rem;padding:.85rem 1.1rem;background:var(--accent);color:#0b1220;font-weight:700;cursor:pointer}
  .wdd-btn.secondary{background:#fff;color:var(--text);border:0.0625rem solid var(--border);font-weight:600}
  .wdd-btn.warn{background:var(--danger);color:#fff}
  .wdd-btn:disabled{opacity:.6;cursor:not-allowed}
  .wdd-note{font-size:.8rem;color:var(--muted);line-height:1.45}
  .wdd-status{display:flex;align-items:center;gap:.5rem;padding:.7rem 1rem;border-radius:.8rem;border:0.0625rem solid var(--border);background:#f3f4f6;color:#374151;font-weight:600}
  .wdd-status.ok{background:#ecfdf5;border-color:#a7f3d0;color:#065f46;font-weight:800}
  .wdd-status.err{background:#fef2f2;border-color:#fecaca;color:#991b1b;font-weight:800}
  .wdd-grid{display:grid;grid-template-columns:1fr 1fr;gap:.6rem}
  #wdd-toast{position:fixed;right:2.2rem;top:10.2rem;background:#111827;color:#fff;padding:.55rem .8rem;border-radius:.6rem;font-size:.85rem;opacity:0;transform:translateY(-.3rem);transition:opacity .15s ease,transform .15s ease;pointer-events:none;z-index:999999}
  #wdd-toast.show{opacity:1;transform:translateY(0)}
  .wdd-switch-btn{appearance:none;border:none;background:#e5e7eb;width:2.8rem;height:1.6rem;border-radius:999rem;position:relative;cursor:pointer;transition:background .2s ease;border:0.0625rem solid var(--border)}
  .wdd-switch-btn::after{content:'';position:absolute;top:.1rem;left:.1rem;width:1.4rem;height:1.4rem;background:#fff;border-radius:50%;transition:transform .2s ease;box-shadow:0 .1rem .25rem rgba(0,0,0,.15)}
  .wdd-switch-btn.on{background:var(--accent)}
  .wdd-switch-btn.on::after{transform:translateX(1.2rem)}
  .wdd-switch-btn:disabled{opacity:.6;cursor:not-allowed}
  .wdd-list{display:grid;gap:.5rem;max-height:12.5rem;overflow:auto}
  .wdd-item{display:flex;align-items:center;justify-content:space-between;gap:.5rem;padding:.6rem .8rem;border:0.0625rem solid var(--border);border-radius:.7rem;background:#fff;cursor:pointer}
  .wdd-item:hover{box-shadow:0 .25rem .75rem rgba(0,0,0,.06)}
  .wdd-host{font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:16rem}
  .wdd-pill{padding:.2rem .55rem;border-radius:999rem;font-size:.75rem;border:0.0625rem solid var(--border);background:#f3f4f6;color:#374151}
  .wdd-pill.ok{background:#ecfdf5;border-color:#a7f3d0;color:#065f46}
  .wdd-pill.wait{background:#fff7ed;border-color:#fed7aa;color:#9a3412}
  .wdd-pill.err{background:#fef2f2;border-color:#fecaca;color:#991b1b}
  .wdd-spin{width:1rem;height:1rem;border:.15rem solid #e5e7eb;border-top-color:var(--accent);border-radius:50%;animation:wddspin 1s linear infinite;flex:0 0 auto}
  @keyframes wddspin{to{transform:rotate(360deg)}}
  .wdd-legacy-row{max-height:0;opacity:0;transform:translateY(-.15rem);overflow:hidden;transition:max-height .25s ease,opacity .2s ease,transform .2s ease}
  .wdd-legacy-row.active{max-height:4rem;opacity:1;transform:translateY(0)}
  .wdd-legacy-wrap{border-radius:.8rem;border:0.0625rem solid var(--border);background:#fff;padding:.55rem .75rem;display:grid;gap:.25rem}
  .wdd-legacy-bar{position:relative;width:100%;height:.45rem;border-radius:999rem;background:#e5e7eb;overflow:hidden}
  .wdd-legacy-bar-inner{position:absolute;left:0;top:0;height:100%;width:0;background:var(--accent);transition:width .2s ease}
  .wdd-legacy-info{display:flex;justify-content:space-between;font-size:.75rem;color:var(--muted);align-items:center}
  `;
  if (typeof GM_addStyle === 'function') GM_addStyle(css); else { const st = document.createElement('style'); st.textContent = css; document.head.appendChild(st); }

  const fab = document.createElement('button'); fab.id = 'wdd-fab'; fab.innerHTML = `<img src="https://chatgpt.com/favicon.ico" alt="gpt">`;
  const modal = document.createElement('div'); modal.id = 'wdd-modal';
  const card = document.createElement('div'); card.id = 'wdd-card';
  const hd = document.createElement('div'); hd.className = 'wdd-hd';
  const ttl = document.createElement('div'); ttl.className = 'wdd-ttl'; ttl.textContent = '域验证助手';
  const btnX = document.createElement('button'); btnX.className = 'wdd-x'; btnX.textContent = '✕';
  hd.appendChild(ttl); hd.appendChild(btnX);

  const bd = document.createElement('div'); bd.className = 'wdd-bd';

  const rowWs = document.createElement('div'); rowWs.className = 'wdd-row';
  const lblWs = document.createElement('div'); lblWs.className = 'wdd-lbl'; lblWs.textContent = '选择工作区';
  const selWs = document.createElement('select'); selWs.className = 'wdd-sel';
  const wsBar = document.createElement('div'); wsBar.className = 'wdd-grid';
  const wsId = document.createElement('input'); wsId.className = 'wdd-inp'; wsId.readOnly = true; wsId.placeholder = 'account_id';
  const orgId = document.createElement('input'); orgId.className = 'wdd-inp'; orgId.readOnly = true; orgId.placeholder = 'organization_id';
  wsBar.append(wsId, orgId);
  const wsActions = document.createElement('div'); wsActions.className = 'wdd-actions';
  const btnRefresh = document.createElement('button'); btnRefresh.className = 'wdd-btn secondary'; btnRefresh.textContent = '刷新工作区';
  const btnReloadDomains = document.createElement('button'); btnReloadDomains.className = 'wdd-btn secondary'; btnReloadDomains.textContent = '刷新域名';
  const btnLegacyAll = document.createElement('button'); btnLegacyAll.className = 'wdd-btn secondary'; btnLegacyAll.textContent = '开启全部 Legacy 模型';
  wsActions.append(btnRefresh, btnReloadDomains, btnLegacyAll);
  rowWs.append(lblWs, selWs, wsBar, wsActions);

  const rowHost = document.createElement('div'); rowHost.className = 'wdd-row';
  const lblHost = document.createElement('div'); lblHost.className = 'wdd-lbl'; lblHost.textContent = '域名';
  const inpHost = document.createElement('input'); inpHost.className = 'wdd-inp'; inpHost.placeholder = 'example.com';
  rowHost.append(lblHost, inpHost);

  const rowTxt = document.createElement('div'); rowTxt.className = 'wdd-row';
  const lblTxt = document.createElement('div'); lblTxt.className = 'wdd-lbl'; lblTxt.textContent = 'TXT 记录值';
  const txtKV = document.createElement('div'); txtKV.className = 'wdd-kv';
  const kvHint = document.createElement('div'); kvHint.className = 'kv-hint'; kvHint.textContent = 'openai-domain-verification=';
  const kvRow = document.createElement('div'); kvRow.className = 'kv-row';
  const txtVal = document.createElement('input'); txtVal.placeholder = 'dv-xxxxxxxx'; txtVal.readOnly = true;
  const btnCopy = document.createElement('button'); btnCopy.className = 'wdd-btn secondary'; btnCopy.style.flex = '0 0 auto'; btnCopy.textContent = '复制';
  kvRow.append(txtVal, btnCopy);
  txtKV.append(kvHint, kvRow);
  rowTxt.append(lblTxt, txtKV);

  const rowInvite = document.createElement('div'); rowInvite.className = 'wdd-row';
  const lblInvite = document.createElement('div'); lblInvite.className = 'wdd-lbl'; lblInvite.textContent = '允许外部域邀请';
  const inviteWrap = document.createElement('div'); inviteWrap.style.display='flex'; inviteWrap.style.alignItems='center'; inviteWrap.style.gap='.6rem';
  const btnInviteSwitch = document.createElement('button'); btnInviteSwitch.className='wdd-switch-btn on'; btnInviteSwitch.setAttribute('aria-pressed','true');
  const inviteStateText = document.createElement('div'); inviteStateText.className='wdd-note'; inviteStateText.textContent='开启';
  inviteWrap.append(btnInviteSwitch, inviteStateText);
  rowInvite.append(lblInvite, inviteWrap);

  const rowLegacy = document.createElement('div'); rowLegacy.className = 'wdd-row wdd-legacy-row';
  const lblLegacy = document.createElement('div'); lblLegacy.className = 'wdd-lbl'; lblLegacy.textContent = 'Legacy 模型批量开启';
  const legacyWrap = document.createElement('div'); legacyWrap.className = 'wdd-legacy-wrap';
  const legacyBar = document.createElement('div'); legacyBar.className = 'wdd-legacy-bar';
  const legacyBarInner = document.createElement('div'); legacyBarInner.className = 'wdd-legacy-bar-inner';
  legacyBar.append(legacyBarInner);
  const legacyInfo = document.createElement('div'); legacyInfo.className = 'wdd-legacy-info';
  const legacyCount = document.createElement('div'); legacyCount.textContent = '0/0';
  const legacyMsg = document.createElement('div'); legacyMsg.textContent = '准备就绪';
  legacyInfo.append(legacyCount, legacyMsg);
  legacyWrap.append(legacyBar, legacyInfo);
  rowLegacy.append(lblLegacy, legacyWrap);

  const rowList = document.createElement('div'); rowList.className = 'wdd-row';
  const lblList = document.createElement('div'); lblList.className = 'wdd-lbl'; lblList.textContent = '已添加域名';
  const domainList = document.createElement('div'); domainList.className = 'wdd-list';
  rowList.append(lblList, domainList);

  const note = document.createElement('div'); note.className = 'wdd-note'; note.textContent = '选择工作区后提交域名以获取TXT,复制后到DNS添加记录,稍候点击“检查”。';

  const actions = document.createElement('div'); actions.className = 'wdd-actions';
  const btnSubmit = document.createElement('button'); btnSubmit.className = 'wdd-btn'; btnSubmit.textContent = '提交域名';
  const btnCheck = document.createElement('button'); btnCheck.className = 'wdd-btn secondary'; btnCheck.textContent = '检查';
  const btnRemove = document.createElement('button'); btnRemove.className = 'wdd-btn warn'; btnRemove.textContent = '移除域';
  actions.append(btnSubmit, btnCheck, btnRemove);

  const status = document.createElement('div'); status.className = 'wdd-status'; status.textContent = '等待操作...';
  const toast = document.createElement('div'); toast.id = 'wdd-toast'; toast.textContent = '已复制TXT';

  const frag = document.createDocumentFragment();
  frag.append(rowWs, rowHost, rowTxt, rowInvite, rowLegacy, rowList, note, actions, status);
  bd.append(frag);
  card.append(hd, bd); modal.appendChild(card);
  document.body.append(fab, modal, toast);

  function showToast(){ toast.classList.add('show'); setTimeout(()=>toast.classList.remove('show'), 400); }

  function openUI(){ modal.style.display='flex'; requestAnimationFrame(()=>modal.classList.add('open')); }
  function closeUI(){ modal.classList.remove('open'); setTimeout(()=>{ modal.style.display='none'; }, 200); }
  fab.addEventListener('click', openUI);
  btnX.addEventListener('click', closeUI);
  modal.addEventListener('click', e=>{ if(e.target===modal) closeUI(); });

  const store = {
    get k(){ return JSON.parse(localStorage.getItem('wdd_store')||'{}'); },
    set v(obj){ localStorage.setItem('wdd_store', JSON.stringify(obj)); },
    upd(p){ const cur=this.k; Object.assign(cur,p); this.v=cur; }
  };

  let token = null;
  let tokenTs = 0;
  let accounts = [];
  let selected = null;
  let lastDomain = null;
  let inviteState = true;
  const domainsCache = new Map();
  let legacyHideTimer = null;

  function setStatus(msg, kind, spin){
    status.classList.remove('ok','err');
    if(kind==='ok') status.classList.add('ok');
    if(kind==='err') status.classList.add('err');
    status.innerHTML = (spin ? `<span class="wdd-spin"></span>` : '') + (msg || '');
  }

  function enableOps(flag){
    btnSubmit.disabled = !flag;
    btnCheck.disabled = !flag || !lastDomain?.id;
    btnRemove.disabled = !flag || !lastDomain?.id;
    btnInviteSwitch.disabled = !flag || !selected;
    btnLegacyAll.disabled = !flag || !accounts?.length;
  }

  function setWsInfo(a){
    if(!a){ wsId.value=''; orgId.value=''; selected=null; enableOps(false); renderDomainList([]); return; }
    wsId.value = a.account.account_id || '';
    orgId.value = a.account.organization_id || '';
    selected = { account_id: a.account.account_id, organization_id: a.account.organization_id, name: a.account.name };
    store.upd({ sel: selected });
    enableOps(true);
    loadAllowExternal();
    loadDomains();
  }

  async function getAccessToken(){
    const r = await fetch('/api/auth/session', { credentials: 'include', cache: 'no-store' });
    if(!r.ok) throw new Error('获取登录会话失败');
    const j = await r.json();
    const t = j && j.accessToken;
    if(!t) throw new Error('未获取到 accessToken');
    return t;
  }

  async function ensureToken(force){
    const now = Date.now();
    if(!token || force || (now - tokenTs) > 4*60*1000){
      token = await getAccessToken();
      tokenTs = Date.now();
    }
    return token;
  }

  async function fetchAccounts(){
    const tz = new Date().getTimezoneOffset();
    const u = `/backend-api/accounts/check/v4-2023-04-27?timezone_offset_min=${encodeURIComponent(tz)}`;
    await ensureToken();
    const r = await fetch(u, { credentials:'include', cache:'no-store', headers:{ 'authorization': 'Bearer '+token, 'accept': '*/*' }});
    if(!r.ok) throw new Error('获取工作区失败');
    const j = await r.json();
    const map = j && j.accounts ? j.accounts : {};
    return Object.entries(map).map(([id, obj]) => ({ id, ...obj }));
  }

  function renderWsOptions(){
    selWs.innerHTML = '';
    accounts.forEach((it)=>{
      const o = document.createElement('option');
      const name = it.account?.name || '(未命名)';
      o.value = it.id;
      o.textContent = `${name} — ${it.id}`;
      selWs.appendChild(o);
    });
    const pref = store.k.sel?.account_id;
    if (pref && accounts.find(x=>x.id===pref)) selWs.value = pref;
    const cur = accounts.find(x=>x.id===selWs.value) || accounts[0];
    setWsInfo(cur);
  }

  async function initWorkspaceList(){
    setStatus('载入工作区中...', null);
    try{
      await ensureToken();
      accounts = await fetchAccounts();
      accounts = accounts.filter(it=>{
        const name = (it.account?.name || '').toLowerCase();
        const id = String(it.id || '').toLowerCase();
        return name !== 'default' && id !== 'default';
      });
      if(!accounts.length) throw new Error('无可用工作区');
      renderWsOptions();
      setStatus('已载入工作区', null);
    }catch(e){ setStatus(String(e.message||e), 'err'); enableOps(false); }
  }

  function legacySetBar(p){
    const v = Math.max(0, Math.min(100, typeof p==='number'?p:0));
    legacyBarInner.style.width = v + '%';
  }

  function legacyShowRow(){
    rowLegacy.classList.add('active');
  }

  function legacyHideRowLater(ms){
    if(legacyHideTimer) clearTimeout(legacyHideTimer);
    legacyHideTimer = setTimeout(()=>{ rowLegacy.classList.remove('active'); }, ms);
  }

  function legacyStart(total){
    if(legacyHideTimer) { clearTimeout(legacyHideTimer); legacyHideTimer=null; }
    legacyCount.textContent = total ? `0/${total}` : '0/0';
    legacyMsg.textContent = total ? '开始批量开启...' : '无可处理工作区';
    legacySetBar(0);
    legacyShowRow();
  }

  function getAccountName(a){
    return (a && a.account && a.account.name) || a?.id || '未知工作区';
  }

  async function updateLegacyModelsFeature(account){
    if(!account || !account.account || !account.account.account_id) throw new Error('无效工作区');
    const accountId = account.account.account_id;
    const u = `/backend-api/accounts/${encodeURIComponent(accountId)}/beta_features`;
    const doFetch = async ()=> fetch(u, { method:'POST', credentials:'include', headers:{
      'accept':'*/*','content-type':'application/json','authorization':'Bearer '+token,'chatgpt-account-id': accountId
    }, body: JSON.stringify({ feature:'legacy_models', value:true }) });
    await ensureToken();
    for(let i=0;i<2;i++){
      let r;
      try{ r = await doFetch(); }
      catch(e){
        if(i===0){
          setStatus('网络错误,重试中...', null, true);
          await new Promise(rs=>setTimeout(rs,800));
          continue;
        }
        throw e;
      }
      if(r.status===401 && i===0){
        await ensureToken(true);
        setStatus('登录过期,重试中...', null, true);
        continue;
      }
      const txt = await r.text(); let j; try{ j=JSON.parse(txt);}catch{ throw new Error('响应解析失败'); }
      if(!r.ok){
        const m = j?.error?.message || '更新失败';
        throw new Error(m);
      }
      if(j && j.legacy_models !== true) throw new Error('服务器未确认开启 legacy_models');
      return j;
    }
    throw new Error('请求失败');
  }

  async function enableLegacyAllWorkspaces(){
    if(!accounts || !accounts.length){
      setStatus('没有可用工作区', 'err');
      return;
    }
    const workspaces = accounts.filter(a=>a?.account?.account_id);
    if(!workspaces.length){
      setStatus('没有可用工作区', 'err');
      legacyStart(0);
      legacyHideRowLater(2000);
      return;
    }
    btnLegacyAll.disabled = true;
    enableOps(false);
    setStatus('正在为所有工作区开启 Legacy 模型...', null, true);
    legacyStart(workspaces.length);
    const ok = [];
    const fail = [];
    let processed = 0;
    for(const a of workspaces){
      const name = getAccountName(a);
      try{
        await updateLegacyModelsFeature(a);
        ok.push(a);
        processed++;
        legacyCount.textContent = `${processed}/${workspaces.length}`;
        legacyMsg.textContent = `已开启:${name}`;
        legacySetBar(processed/workspaces.length*100);
      }catch(e){
        const msg = e && e.message ? String(e.message) : String(e);
        fail.push({ account:a, error:e });
        processed++;
        legacyCount.textContent = `${processed}/${workspaces.length}`;
        legacyMsg.textContent = `失败:${name}(${msg})`;
        legacySetBar(processed/workspaces.length*100);
      }
    }
    enableOps(true);
    btnLegacyAll.disabled = false;
    if(fail.length){
      const failNames = fail.map(x=>getAccountName(x.account)).join('、') || '未知';
      setStatus(`成功 ${ok.length} 个工作区,失败 ${fail.length} 个:${failNames}`, 'err');
    }else{
      setStatus(`已为 ${ok.length} 个工作区开启 Legacy 模型`, 'ok');
    }
    legacyMsg.textContent = fail.length
      ? `完成:成功 ${ok.length},失败 ${fail.length}`
      : `完成:已为 ${ok.length} 个工作区开启 Legacy 模型`;
    legacySetBar(100);
    legacyHideRowLater(5000);
  }

  selWs.addEventListener('change', ()=>{
    const cur = accounts.find(x=>x.id===selWs.value);
    setWsInfo(cur);
  });
  btnRefresh.addEventListener('click', initWorkspaceList);
  btnReloadDomains.addEventListener('click', ()=>{ if(selected) loadDomains(true); });
  btnLegacyAll.addEventListener('click', ()=>{
    if(!accounts || !accounts.length){ setStatus('请先载入工作区', 'err'); return; }
    enableLegacyAllWorkspaces();
  });

  function parseDomainsPayload(j){
    const raw = j?.domains || j?.identity?.domains || j?.domain_whitelist || j?.domain_list || [];
    return (Array.isArray(raw)?raw:[]).map(d=>({
      id: d.id || d.domain_id || d.uuid || null,
      hostname: d.hostname || d.domain || d.name || '',
      status: d.status || (typeof d.verified==='boolean' ? (d.verified?'verified':'unverified') : (d.is_verified?'verified':'unverified')),
      token: d.dns_verification_token || d.token || d.dns_token || ''
    })).filter(x=>x.hostname);
  }

  function renderDomainList(arr){
    domainList.innerHTML = '';
    if(!arr.length){ const empty=document.createElement('div'); empty.className='wdd-note'; empty.textContent='暂无域名'; domainList.appendChild(empty); return; }
    arr.forEach(d=>{
      const it = document.createElement('div'); it.className='wdd-item';
      const host = document.createElement('div'); host.className='wdd-host'; host.textContent = d.hostname;
      const pill = document.createElement('div'); pill.className='wdd-pill';
      const s = String(d.status||'').toLowerCase();
      if(s==='verified') pill.classList.add('ok');
      else if(s==='pending' || s==='unverified') pill.classList.add('wait');
      else pill.classList.add('err');
      pill.textContent = s || 'unknown';
      it.append(host, pill);
      it.addEventListener('click', ()=>{
        lastDomain = { id: d.id || null, hostname: d.hostname || '', token: d.token || '', tokenStr: d.token || '' };
        txtVal.value = lastDomain.token || '';
        inpHost.value = lastDomain.hostname || '';
        store.upd({ lastDomain, lastHost: inpHost.value });
        btnCheck.disabled = !lastDomain?.id;
        btnRemove.disabled = !lastDomain?.id;
      });
      domainList.appendChild(it);
    });
  }

  async function fetchIdentity({onRetry}={}){
    if(!selected) throw new Error('未选择工作区');
    const u = `/backend-api/accounts/${encodeURIComponent(selected.account_id)}/identity`;
    const doFetch = async ()=> fetch(u, { credentials:'include', headers:{
      'accept':'*/*','authorization':'Bearer '+token,'chatgpt-account-id': selected.account_id
    }});
    try{
      await ensureToken();
      let r;
      try{ r = await doFetch(); }
      catch(e){ if(onRetry) onRetry(); setStatus('网络错误,重试中...', null, true); await new Promise(rs=>setTimeout(rs,800)); r = await doFetch(); }
      if(r.status===401){ await ensureToken(true); if(onRetry) onRetry(); setStatus('登录过期,重试中...', null, true); r = await doFetch(); }
      const txt = await r.text(); let j; try{ j=JSON.parse(txt);}catch{ throw new Error('响应解析失败'); }
      if(!r.ok){ const m = j?.error?.message || '获取域名失败'; throw new Error(m); }
      return j;
    }catch(e){ throw e; }
  }

  async function loadDomains(force){
    if(!selected) return;
    const key = selected.account_id;
    if(!force && domainsCache.has(key) && (Date.now()-domainsCache.get(key).ts<30*1000)){
      renderDomainList(domainsCache.get(key).list); return;
    }
    setStatus('加载域名列表中...', null, true);
    try{
      const j = await fetchIdentity({onRetry:()=>{}});
      const list = parseDomainsPayload(j);
      domainsCache.set(key, { ts: Date.now(), list });
      renderDomainList(list);
      setStatus('域名列表已更新', null);
    }catch(e){
      setStatus(String(e.message||e), 'err');
      renderDomainList([]);
    }
  }

  async function getAllowExternalSetting(){
    const u = `/backend-api/accounts/${encodeURIComponent(selected.account_id)}/settings`;
    const doFetch = async ()=> fetch(u, { credentials:'include', headers:{
      'accept':'*/*','authorization':'Bearer '+token,'chatgpt-account-id': selected.account_id
    }});
    await ensureToken();
    let r;
    try{ r = await doFetch(); }
    catch(e){ setStatus('网络错误,重试中...', null, true); await new Promise(rs=>setTimeout(rs,800)); r = await doFetch(); }
    if(r.status===401){ await ensureToken(true); setStatus('登录过期,重试中...', null, true); r = await doFetch(); }
    const txt = await r.text(); let j; try{ j=JSON.parse(txt);}catch{ throw new Error('响应解析失败'); }
    if(!r.ok){ const m = j?.error?.message || '获取设置失败'; throw new Error(m); }
    return j.allow_external_domain_invites;
  }

  async function updateAllowExternalSetting(val){
    const u = `/backend-api/accounts/${encodeURIComponent(selected.account_id)}/settings/allow_external_domain_invites`;
    const doFetch = async ()=> fetch(u, { method:'POST', credentials:'include', headers:{
      'accept':'*/*','content-type':'application/json','authorization':'Bearer '+token,'chatgpt-account-id': selected.account_id
    }, body: JSON.stringify({ value: !!val }) });
    await ensureToken();
    let r;
    try{ r = await doFetch(); }
    catch(e){ setStatus('网络错误,重试中...', null, true); await new Promise(rs=>setTimeout(rs,800)); r = await doFetch(); }
    if(r.status===401){ await ensureToken(true); setStatus('登录过期,重试中...', null, true); r = await doFetch(); }
    const txt = await r.text(); let j; try{ j=JSON.parse(txt);}catch{ throw new Error('响应解析失败'); }
    if(!r.ok){ const m = j?.error?.message || '更新失败'; throw new Error(m); }
    return j.allow_external_domain_invites;
  }

  async function loadAllowExternal(){
    btnInviteSwitch.disabled = true;
    try{
      await ensureToken();
      const val = await getAllowExternalSetting();
      setInviteUI(typeof val==='boolean' ? val : true);
    }catch{ setInviteUI(true); }
    btnInviteSwitch.disabled = false;
  }

  function setInviteUI(flag){
    inviteState = !!flag;
    if(inviteState){ btnInviteSwitch.classList.add('on'); btnInviteSwitch.setAttribute('aria-pressed','true'); inviteStateText.textContent='开启'; }
    else { btnInviteSwitch.classList.remove('on'); btnInviteSwitch.setAttribute('aria-pressed','false'); inviteStateText.textContent='关闭'; }
  }

  btnInviteSwitch.addEventListener('click', async ()=>{
    if(!selected){ setStatus('请选择工作区', 'err'); return; }
    const next = !inviteState;
    btnInviteSwitch.disabled = true;
    setStatus('更新设置中...', null, true);
    try{
      await ensureToken();
      const serverVal = await updateAllowExternalSetting(next);
      setInviteUI(serverVal);
      setStatus(serverVal ? '已开启外部域邀请' : '已关闭外部域邀请', 'ok');
    }catch(e){
      setStatus(String(e.message||e), 'err');
      setInviteUI(inviteState);
    }
    btnInviteSwitch.disabled = false;
  });

  btnCopy.addEventListener('click', async ()=>{
    const v = txtVal.value ? ('openai-domain-verification='+txtVal.value) : '';
    if(!v){ setStatus('无TXT可复制', 'err'); return; }
    try{ await navigator.clipboard.writeText(v); showToast(); }catch{ setStatus('复制失败', 'err'); }
  });

  function hostValid(h){ return /^([a-z0-9-]+\.)+[a-z]{2,}$/i.test(h); }

  async function createDomain(hostname){
    const u = `/backend-api/accounts/${encodeURIComponent(selected.account_id)}/domains`;
    const doFetch = async ()=> fetch(u, { method:'POST', credentials:'include', headers:{
      'accept':'*/*','content-type':'application/json','authorization':'Bearer '+token,'chatgpt-account-id': selected.account_id
    }, body: JSON.stringify({ hostname }) });
    await ensureToken();
    let r;
    try{ r = await doFetch(); }
    catch(e){ setStatus('网络错误,重试中...', null, true); await new Promise(rs=>setTimeout(rs,800)); r = await doFetch(); }
    if(r.status===401){ await ensureToken(true); setStatus('登录过期,重试中...', null, true); r = await doFetch(); }
    const txt = await r.text(); let j; try{ j=JSON.parse(txt);}catch{ throw new Error('响应解析失败'); }
    if(!r.ok){ const m = j?.error?.message || '提交失败'; throw new Error(m); }
    return j;
  }

  async function checkDomain(domainId){
    const u = `/backend-api/accounts/${encodeURIComponent(selected.account_id)}/domains/${encodeURIComponent(domainId)}/check`;
    const doFetch = async ()=> fetch(u, { method:'POST', credentials:'include', headers:{
      'accept':'*/*','authorization':'Bearer '+token,'chatgpt-account-id': selected.account_id
    }});
    await ensureToken();
    let r;
    try{ r = await doFetch(); }
    catch(e){ setStatus('网络错误,重试中...', null, true); await new Promise(rs=>setTimeout(rs,800)); r = await doFetch(); }
    if(r.status===401){ await ensureToken(true); setStatus('登录过期,重试中...', null, true); r = await doFetch(); }
    const txt = await r.text(); let j; try{ j=JSON.parse(txt);}catch{ throw new Error('响应解析失败'); }
    if(!r.ok){ const m = j?.error?.message || '检查失败'; throw new Error(m); }
    return j;
  }

  async function removeDomain(domainId){
    const u = `/backend-api/accounts/${encodeURIComponent(selected.account_id)}/domains/${encodeURIComponent(domainId)}`;
    const doFetch = async ()=> fetch(u, { method:'DELETE', credentials:'include', headers:{
      'accept':'*/*','authorization':'Bearer '+token,'chatgpt-account-id': selected.account_id
    }});
    await ensureToken();
    let r;
    try{ r = await doFetch(); }
    catch(e){ setStatus('网络错误,重试中...', null, true); await new Promise(rs=>setTimeout(rs,800)); r = await doFetch(); }
    if(r.status===401){ await ensureToken(true); setStatus('登录过期,重试中...', null, true); r = await doFetch(); }
    if(!r.ok) throw new Error('移除失败');
    return true;
  }

  async function onSubmit(){
    const host = (inpHost.value||'').trim();
    if(!host){ setStatus('请填写域名', 'err'); return; }
    if(!hostValid(host)){ setStatus('域名格式不正确', 'err'); return; }
    if(!selected){ setStatus('请选择工作区', 'err'); return; }
    setStatus('提交中...', null, true);
    enableOps(false);
    try{
      const j = await createDomain(host);
      lastDomain = { id: j.id, hostname: j.hostname, token: j.dns_verification_token, tokenStr: j.dns_verification_token_string };
      txtVal.value = j.dns_verification_token || '';
      store.upd({ lastDomain, lastHost: host });
      btnCheck.disabled = false; btnRemove.disabled = false;
      const v = txtVal.value ? ('openai-domain-verification='+txtVal.value) : '';
      if(v){ try{ await navigator.clipboard.writeText(v); showToast(); }catch{} }
      setStatus('已获取TXT,请到DNS添加记录后再“检查”', null);
      await loadDomains(true);
    }catch(e){ setStatus(String(e.message||e), 'err'); }
    enableOps(true);
  }
  btnSubmit.addEventListener('click', onSubmit);
  inpHost.addEventListener('keydown', e=>{ if(e.key==='Enter') onSubmit(); });

  btnCheck.addEventListener('click', async ()=>{
    if(!lastDomain?.id){ setStatus('没有可检查的域', 'err'); return; }
    setStatus('检查中...', null, true);
    enableOps(false);
    try{
      const j = await checkDomain(lastDomain.id);
      if(j.status==='verified'){
        setStatus('🎉 域名已验证成功!', 'ok');
      }else{
        const s = j.status ? String(j.status) : '未验证';
        setStatus(`未验证:当前状态 ${s}。请确认已添加 TXT 并等待 DNS 传播。`, 'err');
      }
      await loadDomains(true);
    }catch(e){
      setStatus(String(e.message||e), 'err');
    }
    enableOps(true);
  });

  btnRemove.addEventListener('click', async ()=>{
    if(!lastDomain?.id){ setStatus('没有可移除的域', 'err'); return; }
    setStatus('移除中...', null, true);
    enableOps(false);
    try{
      await removeDomain(lastDomain.id);
      lastDomain=null; txtVal.value=''; store.upd({ lastDomain:null });
      btnCheck.disabled = true; btnRemove.disabled = true;
      setStatus('已移除该域', null);
      await loadDomains(true);
    }catch(e){ setStatus(String(e.message||e), 'err'); }
    enableOps(true);
  });

  function tick(){
    const p = location.pathname || '';
    if(/^\/admin(?:\/.*)?$/.test(p)){ fab.style.display='grid'; } else { fab.style.display='none'; closeUI(); }
    requestAnimationFrame(tick);
  }
  requestAnimationFrame(tick);

  (function hydrate(){
    const s = store.k;
    if(s.lastHost) inpHost.value = s.lastHost;
    if(s.lastDomain){ lastDomain = s.lastDomain; if(lastDomain?.token) txtVal.value = lastDomain.token; }
    btnCheck.disabled = !lastDomain?.id; btnRemove.disabled = !lastDomain?.id;
  })();

  (function boot(){
    initWorkspaceList();
  })();
})();