ChatGPT-Workspace-Helper

在 ChatGPT 管理页的助手。

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==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();
  })();
})();