feat(monitor): 抖音 Web 接口客户端 —— 绕开爬虫子进程,直接发 HTTP
Deploy VitePress site to Pages / build (push) Canceled after 0s
Deploy VitePress site to Pages / Deploy (push) Canceled after 0s

移植自 mac-agent-os 的 mediacrawler_adapter:不起子进程、不开页面,用浏览器里那份
登录态直接调抖音 Web 接口。产物键名照抄 store/douyin,所以 ingest 那条链路一个字不用改。

**目前能用的(真环境实测,非推断)**:

    profile/other : 200, 7075 字节  —— 博主主页指标(粉丝/获赞/作品数/昵称)
    aweme/detail  : 200, 45425 字节 —— 单条作品详情(含点赞/评论/收藏/分享)

**目前不能用的:作品列表 `aweme/post`。** 两个互相独立的原因:
  1. 这个接口被抖音单独升级成了真校验:不带 x-tt-argus 回 403「Uifid Not Found」,
     带上 dummy 值回 200 + **空 body**。也就是说「头在不在」骗得过,「真校验」过不了。
     同一套头打 profile/other 和 aweme/detail 都是通的 —— 抖音是挑着接口加保护的,
     挑中的恰好是「批量拉作品列表」这个最敏感的动作。
  2. 改走页面截获也不行:CDP 浏览器打开博主主页会落到「验证码中间页」(当天大量探测的
     代价,过几小时要重测)。

  所以现在的边界是:**已知作品的指标刷新能做,自动发现新作品做不了**。

**排查中控住变量后得到的两条事实**(都写进注释了):
  · `Accept` / `Accept-Language` / `Referer` 才是主页接口能返回真数据的原因 —— 只有
    UA+client hints+Cookie 时是 200 但仅 121 字节的空壳,补上这三个头变 7074 字节。
    (我先前猜的 sec-ch-ua 不是关键。)
  · 因此 UA 与 client hints 必须**成套地取自同一个浏览器**,所以 BrowserIdentity 一次
    从 CDP 取齐 cookie + UA + hints,而不是各自写死。

「200 + 空 body 必须当场报错」也是刻意写死的:放过去它会在下游变成「这个博主没作品」,
把一次失败伪装成一条正常结果 —— 爬虫那条路正是这么栽的,还被翻译成「账号被封」。

测试 +11:cookie 解析、请求头成套性(含 uifid 缺失/回退)、产物键名与 store 对齐、
以及 _get 的三条失败路径(空 body / 403 带网关原话 / 正常返回)。
This commit is contained in:
2026-10-10 17:12:25 +08:00
parent 21ff01b894
commit 2112c1a870
1601 changed files with 367425 additions and 0 deletions
@@ -0,0 +1,521 @@
/**
* characters.js — 🧑 角色库(P3 重做版)
*
* 后端:/api/ave/assets/characters(列表/搜索/筛选/详情/新建/更新/删除/上传文件)
* 数据:agent-local/tools/ave/assets/characters/{id}/(meta.json + 参考图 + 音色)
*
* 功能:
* · 搜索(名字/标签/描述)+ 筛选(类型/用途/项目/标签/有音色)+ 排序
* · 卡片 / 表格 两种视图
* · 新建(名字/类型/用途/描述)
* · 详情抽屉:编辑字段 + 上传参考图(正/侧/背/全身)+ 删除
*/
const API = '/api/ave/assets/characters';
// 模块状态
let _items = [];
let _stats = {};
let _view = localStorage.getItem('ave_char_view') || 'card'; // card | table
let _filters = { q: '', type: '', role: '', tag: '', hasVoice: false, sort: 'updated_desc' };
let _current = null; // 当前打开详情的角色
let _uid = '';
const TYPES = { real: '真人', anime: '动漫', '3d': '3D' };
const ROLES = { lead: '主角', support: '配角', extra: '群演' };
const REF_KEYS = [
['front', '正面'], ['side', '侧面'], ['back', '背面'], ['full', '全身'],
];
export async function loadView(container) {
_uid = 'ch';
container.innerHTML = shell();
bind();
await refresh();
}
function shell() {
return `
<div style="padding:16px">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;flex-wrap:wrap;gap:8px">
<h2 style="font-size:18px;margin:0">🧑 角色库
<span id="chCount" style="font-size:12px;color:var(--text2)"></span>
</h2>
<div style="display:flex;gap:6px">
<button onclick="window._chToggleView()" id="chViewBtn" style="background:var(--bg3);color:var(--text);border:1px solid var(--border);padding:5px 12px;border-radius:6px;cursor:pointer;font-size:12px">📋 表格视图</button>
<button onclick="window._chNew()" style="background:var(--primary);color:#fff;border:none;padding:5px 14px;border-radius:6px;cursor:pointer;font-size:12px">➕ 新建角色</button>
</div>
</div>
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-bottom:12px;padding:10px;background:var(--bg3);border:1px solid var(--border);border-radius:8px">
<input id="chQ" placeholder="🔍 搜索名字 / 标签 / 描述" style="flex:1;min-width:180px;padding:5px 10px;background:var(--bg2);border:1px solid var(--border);color:var(--text);border-radius:6px;font-size:12px">
<select id="chType" style="padding:5px 8px;background:var(--bg2);border:1px solid var(--border);color:var(--text);border-radius:6px;font-size:12px">
<option value="">类型:全部</option>
<option value="real">真人</option><option value="anime">动漫</option><option value="3d">3D</option>
</select>
<select id="chRole" style="padding:5px 8px;background:var(--bg2);border:1px solid var(--border);color:var(--text);border-radius:6px;font-size:12px">
<option value="">用途:全部</option>
<option value="lead">主角</option><option value="support">配角</option><option value="extra">群演</option>
</select>
<input id="chTag" placeholder="标签" style="width:90px;padding:5px 8px;background:var(--bg2);border:1px solid var(--border);color:var(--text);border-radius:6px;font-size:12px">
<label style="display:flex;align-items:center;gap:4px;font-size:12px;color:var(--text2);cursor:pointer">
<input type="checkbox" id="chVoice"> 有音色
</label>
<select id="chSort" style="padding:5px 8px;background:var(--bg2);border:1px solid var(--border);color:var(--text);border-radius:6px;font-size:12px">
<option value="updated_desc">最近更新</option>
<option value="name">名字</option>
<option value="usage">使用次数</option>
</select>
<button onclick="window._chReset()" style="background:none;border:1px solid var(--border);color:var(--text2);padding:5px 10px;border-radius:6px;cursor:pointer;font-size:11px">重置</button>
</div>
<div id="chList"><div style="color:var(--text2);font-size:12px">⏳ 加载中...</div></div>
</div>
<!-- 详情抽屉 -->
<div id="chDrawer" style="display:none;position:fixed;top:0;right:0;width:520px;max-width:92vw;height:100vh;background:var(--bg2);border-left:1px solid var(--border);z-index:999;overflow-y:auto;box-shadow:-4px 0 24px rgba(0,0,0,.3)">
<div id="chDrawerBody" style="padding:16px"></div>
</div>
<div id="chMask" onclick="window._chClose()" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.35);z-index:998"></div>`;
}
function bind() {
const q = document.getElementById('chQ');
let t = null;
q.addEventListener('input', () => {
clearTimeout(t);
t = setTimeout(() => { _filters.q = q.value.trim(); refresh(); }, 300);
});
document.getElementById('chType').addEventListener('change', e => { _filters.type = e.target.value; refresh(); });
document.getElementById('chRole').addEventListener('change', e => { _filters.role = e.target.value; refresh(); });
document.getElementById('chTag').addEventListener('input', e => {
clearTimeout(t);
t = setTimeout(() => { _filters.tag = e.target.value.trim(); refresh(); }, 300);
});
document.getElementById('chVoice').addEventListener('change', e => { _filters.hasVoice = e.target.checked; refresh(); });
document.getElementById('chSort').addEventListener('change', e => { _filters.sort = e.target.value; refresh(); });
updateViewBtn();
}
function updateViewBtn() {
const b = document.getElementById('chViewBtn');
if (b) b.textContent = _view === 'card' ? '📋 表格视图' : '🔳 卡片视图';
}
window._chToggleView = () => {
_view = _view === 'card' ? 'table' : 'card';
localStorage.setItem('ave_char_view', _view);
updateViewBtn();
renderList();
};
window._chReset = () => {
_filters = { q: '', type: '', role: '', tag: '', hasVoice: false, sort: 'updated_desc' };
['chQ', 'chTag'].forEach(id => { const e = document.getElementById(id); if (e) e.value = ''; });
['chType', 'chRole', 'chSort'].forEach(id => { const e = document.getElementById(id); if (e) e.value = id === 'chSort' ? 'updated_desc' : ''; });
const v = document.getElementById('chVoice'); if (v) v.checked = false;
refresh();
};
async function refresh() {
const p = new URLSearchParams();
if (_filters.q) p.set('q', _filters.q);
if (_filters.type) p.set('type', _filters.type);
if (_filters.role) p.set('role', _filters.role);
if (_filters.tag) p.set('tag', _filters.tag);
if (_filters.hasVoice) p.set('has_voice', '1');
p.set('sort', _filters.sort);
try {
const r = await fetch(`${API}?${p}`);
const d = await r.json();
_items = d.items || [];
renderList();
const sr = await fetch(`${API}/stats`);
_stats = (await sr.json()).stats || {};
const c = document.getElementById('chCount');
if (c) c.textContent = `共 ${_stats.total || 0} 个(真人 ${(_stats.by_type || {}).real || 0} · 动漫 ${(_stats.by_type || {}).anime || 0} · 有音色 ${_stats.with_voice || 0})`;
} catch (e) {
const el = document.getElementById('chList');
if (el) el.innerHTML = `<div style="color:#f59e0b;font-size:12px">❌ 加载失败:${e.message}</div>`;
}
}
function renderList() {
const el = document.getElementById('chList');
if (!el) return;
if (!_items.length) {
const hasFilter = _filters.q || _filters.type || _filters.role || _filters.tag || _filters.hasVoice;
el.innerHTML = `<div style="color:var(--text2);font-size:12px;padding:20px;text-align:center">
${hasFilter ? '🔍 没有匹配的角色(试试重置筛选)' : '还没有角色,点右上角「➕ 新建角色」开始'}</div>`;
return;
}
el.innerHTML = _view === 'card' ? _cardHtml() : _tableHtml();
}
/** 📌 加载生成历史(版本 / 提示词 / seed / 模型 —— 供版本说明追溯) */
window._chLoadHistory = async () => {
const el = document.getElementById('chHistory');
if (!el || !_current) return;
el.innerHTML = '<div style="font-size:11px;color:var(--text2)">⏳ 加载生成历史…</div>';
try {
const r = await fetch(`${API}/${encodeURIComponent(_current.id)}/history`);
const d = await r.json();
if (d.status !== 'ok') { el.innerHTML = ''; return; }
const hist = d.history || [];
if (!hist.length) {
el.innerHTML = `<div style="font-size:11px;color:var(--text2)">📌 暂无生成记录(用 AI 生成的图会自动记录:提示词 / seed / 模型 / 引擎)</div>`;
return;
}
el.innerHTML = `<div style="font-size:12px;font-weight:600;margin-bottom:6px">📌 生成历史(${hist.length} 版${d.active_version ? ` · 当前 v${d.active_version}` : ''})</div>
<div style="display:flex;flex-direction:column;gap:6px">
${hist.slice().reverse().map(h => `
<div style="background:var(--bg3);border:1px solid var(--border);border-radius:6px;padding:8px;font-size:11px">
<div style="display:flex;justify-content:space-between;align-items:center">
<b>v${h.version}</b><span style="color:var(--text2);font-size:10px">${esc(h.at || '')}</span>
</div>
<div style="color:var(--text2);margin-top:4px">
${h.engine ? `引擎 <b>${esc(h.engine)}</b>` : ''}${h.model ? ` · 模型 <b>${esc(h.model)}</b>` : ''}${h.seed != null ? ` · seed <b>${h.seed}</b>` : ''}${h.slot ? ` · 槽位 ${esc(h.slot)}` : ''}
</div>
${h.prompt ? `<div style="margin-top:5px;background:var(--bg2);border-radius:4px;padding:5px;max-height:90px;overflow:auto;line-height:1.6">${esc(h.prompt)}</div>` : ''}
</div>`).join('')}
</div>`;
} catch (e) {
el.innerHTML = `<div style="font-size:11px;color:#f59e0b">❌ 历史加载失败:${esc(e.message)}</div>`;
}
};
window._chLock = async () => {
const reason = prompt('🔒 锁定原因(可留空):', '手动锁定');
if (reason === null) return;
try {
const r = await fetch(`${API}/${encodeURIComponent(_current.id)}/lock`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ reason: reason || '手动锁定' }),
});
const d = await r.json();
if (d.detail) { alert('❌ ' + d.detail); return; }
alert('🔒 已锁定(参考图/seed/音色已冻结)'); window._chOpen(_current.id);
} catch (e) { alert('❌ ' + e.message); }
};
window._chUnlock = async () => {
const reason = prompt('🔓 解锁原因(会记入历史,便于追溯):', '');
if (reason === null) return;
try {
const r = await fetch(`${API}/${encodeURIComponent(_current.id)}/unlock`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ reason: reason || '' }),
});
const d = await r.json();
if (d.detail) { alert('❌ ' + d.detail); return; }
alert('🔓 已解锁(改动已记入历史)'); window._chOpen(_current.id);
} catch (e) { alert('❌ ' + e.message); }
};
function thumb(m) {
const refs = m.reference_images || {};
// 优先标准槽位;否则取**任意第一个**有图的槽位(兼容 gen1 / portrait / front_face 等)
const preferred = ['front', 'portrait', 'front_face', 'side', 'full', 'back'];
const k = preferred.find(x => refs[x]) || Object.keys(refs).find(x => refs[x]);
if (!k || !refs[k]) {
return `<div style="width:100%;height:120px;background:var(--bg3);border-radius:6px;display:flex;align-items:center;justify-content:center;color:var(--text2);font-size:28px">🧑</div>`;
}
const url = `${API}/${encodeURIComponent(m.id)}/file/${encodeURIComponent(refs[k])}`;
return `<img src="${url}" loading="lazy" title="点击看大图"
onclick="event.stopPropagation();window._chViewImage('${esc(url)}','${esc(m.name || m.id)}')"
style="width:100%;height:120px;object-fit:contain;border-radius:6px;background:var(--bg2);cursor:zoom-in"
onerror="this.outerHTML='<div style=\\'width:100%;height:120px;background:var(--bg3);border-radius:6px;display:flex;align-items:center;justify-content:center;color:var(--text2);font-size:28px\\'>🧑</div>'">`;
}
/** 点击缩略图 → 全屏看大图(含「保存到本地」) */
window._chViewImage = (url, title) => {
const ov = document.createElement('div');
ov.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,.92);z-index:99999;display:flex;align-items:center;justify-content:center;flex-direction:column;gap:10px;cursor:zoom-out';
ov.onclick = () => ov.remove();
ov.innerHTML = `
<div style="color:#fff;font-size:13px">${title}</div>
<img src="${url}" style="max-width:92vw;max-height:80vh;object-fit:contain;border-radius:8px;background:#111">
<div style="display:flex;gap:10px;align-items:center">
<a href="${url}" download="${title}.png" onclick="event.stopPropagation()"
style="color:#4ade80;font-size:12px;text-decoration:none">⬇ 下载原图</a>
<span style="color:#888;font-size:11px">点击任意处关闭</span>
</div>`;
document.body.appendChild(ov);
};
function _cardHtml() {
return `<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:12px">
${_items.map(m => `
<div onclick="window._chOpen('${esc(m.id)}')" style="background:var(--bg3);border:1px solid var(--border);border-radius:8px;padding:10px;cursor:pointer">
${thumb(m)}
<div style="font-size:13px;font-weight:600;margin-top:8px">${esc(m.name || m.id)}</div>
<div style="font-size:11px;color:var(--text2);margin-top:2px">
${TYPES[m.type] || '真人'} · ${ROLES[m.role] || '主角'}${(m.voice || {}).sample ? ' · 🔊' : ''}
</div>
<div style="font-size:10px;color:var(--text2);margin-top:4px">
用过 ${m.usage_count || 0} 次${(m.tags || []).length ? ' · ' + (m.tags || []).slice(0, 2).map(esc).join('/') : ''}
</div>
</div>`).join('')}
</div>`;
}
function _tableHtml() {
return `<div style="overflow-x:auto">
<table style="width:100%;border-collapse:collapse;font-size:12px">
<thead><tr style="text-align:left;color:var(--text2);border-bottom:1px solid var(--border)">
<th style="padding:6px">名字</th><th style="padding:6px">类型</th><th style="padding:6px">用途</th>
<th style="padding:6px">标签</th><th style="padding:6px">音色</th>
<th style="padding:6px">使用</th><th style="padding:6px">更新时间</th><th style="padding:6px">操作</th>
</tr></thead>
<tbody>
${_items.map(m => `<tr style="border-bottom:1px solid var(--border)">
<td style="padding:6px;font-weight:600">${esc(m.name || m.id)}</td>
<td style="padding:6px">${TYPES[m.type] || '真人'}</td>
<td style="padding:6px">${ROLES[m.role] || '主角'}</td>
<td style="padding:6px;color:var(--text2)">${(m.tags || []).slice(0, 3).map(esc).join(' / ') || '—'}</td>
<td style="padding:6px">${(m.voice || {}).sample ? '🔊' : '—'}</td>
<td style="padding:6px;color:var(--text2)">${m.usage_count || 0}</td>
<td style="padding:6px;color:var(--text2)">${esc((m.updated_at || '').slice(5, 16))}</td>
<td style="padding:6px"><button onclick="event.stopPropagation();window._chOpen('${esc(m.id)}')" style="background:var(--bg2);color:var(--text);border:1px solid var(--border);padding:2px 8px;border-radius:4px;cursor:pointer;font-size:11px">详情</button></td>
</tr>`).join('')}
</tbody>
</table>
</div>`;
}
// ── 详情抽屉 ────────────────────────────────────────────
window._chOpen = async (aid) => {
try {
const r = await fetch(`${API}/${encodeURIComponent(aid)}`);
const d = await r.json();
if (d.status !== 'ok') { alert('❌ ' + (d.detail || '加载失败')); return; }
_current = d.item;
renderDrawer();
document.getElementById('chDrawer').style.display = 'block';
document.getElementById('chMask').style.display = 'block';
window._chLoadHistory(); // 📌 加载生成历史(版本/提示词/seed/模型)
} catch (e) { alert('❌ ' + e.message); }
};
window._chClose = () => {
document.getElementById('chDrawer').style.display = 'none';
document.getElementById('chMask').style.display = 'none';
_current = null;
};
function renderDrawer() {
const m = _current;
const refs = m.reference_images || {};
const files = m.files || {};
document.getElementById('chDrawerBody').innerHTML = `
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px">
<div style="font-size:16px;font-weight:600">🧑 ${esc(m.name || m.id)}</div>
<button onclick="window._chClose()" style="background:none;border:none;color:var(--text2);cursor:pointer;font-size:18px">✕</button>
</div>
<div style="font-size:11px;color:var(--text2);margin-bottom:12px">
ID:<code>${esc(m.id)}</code> · 用过 ${m.usage_count || 0} 次 · 更新 ${esc((m.updated_at || '').slice(0, 16))}
</div>
<!-- 🔒 锁定状态(角色一致性保障:使用后自动锁定) -->
<div style="margin-bottom:12px;padding:8px 10px;border-radius:6px;font-size:11px;
background:${m.locked ? 'rgba(245,158,11,.1)' : 'var(--bg3)'};
border:1px solid ${m.locked ? '#f59e0b' : 'var(--border)'}">
${m.locked
? `<b style="color:#f59e0b">🔒 已锁定</b> · ${esc(m.locked_reason || '')}
<div style="color:var(--text2);margin-top:2px">锁定时间:${esc(m.locked_at || '')}${m.locked_by === 'auto' ? '(自动)' : '(手动)'} · 参考图/生成历史/seed/音色已冻结</div>
<button onclick="window._chUnlock()" style="margin-top:6px;background:var(--bg2);color:var(--text);border:1px solid var(--border);padding:3px 10px;border-radius:4px;cursor:pointer;font-size:11px">🔓 解锁(需说明原因)</button>`
: `<b>🔓 未锁定</b> · 参考图/seed/音色可修改
<div style="color:var(--text2);margin-top:2px">提示:角色一经流程使用会**自动锁定**(保证一致性);也可手动锁定</div>
<button onclick="window._chLock()" style="margin-top:6px;background:var(--bg2);color:var(--text);border:1px solid var(--border);padding:3px 10px;border-radius:4px;cursor:pointer;font-size:11px">🔒 立即锁定</button>`}
</div>
<!-- 📌 生成历史(版本 / 提示词 / seed / 模型 —— 供版本说明追溯) -->
<div id="chHistory" style="margin-bottom:12px"></div>
<div style="display:grid;gap:8px;margin-bottom:16px">
<label style="font-size:11px;color:var(--text2)">名字
<input id="dName" value="${esc(m.name || '')}" style="width:100%;margin-top:3px;padding:5px 8px;background:var(--bg3);border:1px solid var(--border);color:var(--text);border-radius:6px;font-size:12px"></label>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px">
<label style="font-size:11px;color:var(--text2)">类型
<select id="dType" style="width:100%;margin-top:3px;padding:5px 8px;background:var(--bg3);border:1px solid var(--border);color:var(--text);border-radius:6px;font-size:12px">
${Object.entries(TYPES).map(([k, v]) => `<option value="${k}" ${m.type === k ? 'selected' : ''}>${v}</option>`).join('')}
</select></label>
<label style="font-size:11px;color:var(--text2)">用途
<select id="dRole" style="width:100%;margin-top:3px;padding:5px 8px;background:var(--bg3);border:1px solid var(--border);color:var(--text);border-radius:6px;font-size:12px">
${Object.entries(ROLES).map(([k, v]) => `<option value="${k}" ${m.role === k ? 'selected' : ''}>${v}</option>`).join('')}
</select></label>
</div>
<label style="font-size:11px;color:var(--text2)">标签(逗号分隔)
<input id="dTags" value="${esc((m.tags || []).join(', '))}" style="width:100%;margin-top:3px;padding:5px 8px;background:var(--bg3);border:1px solid var(--border);color:var(--text);border-radius:6px;font-size:12px"></label>
<label style="font-size:11px;color:var(--text2)">描述
<textarea id="dDesc" rows="3" style="width:100%;margin-top:3px;padding:5px 8px;background:var(--bg3);border:1px solid var(--border);color:var(--text);border-radius:6px;font-size:12px;resize:vertical">${esc(m.description || '')}</textarea></label>
<button onclick="window._chSave()" style="background:var(--primary);color:#fff;border:none;padding:6px 14px;border-radius:6px;cursor:pointer;font-size:12px;justify-self:start">💾 保存</button>
</div>
<div style="font-size:12px;font-weight:600;margin-bottom:6px">📷 参考图</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:12px">
${(() => {
// 🔧 动态槽位:**已存在的全部槽位**(含 gen1 / portrait 等自定义名)+ 未填的标准槽位
const LABELS = { front: '正面', side: '侧面', back: '背面', full: '全身',
portrait: '半身', front_face: '正面脸部', grid: '九宫格',
scene: '场景照', expression: '表情特写' };
const out = [];
const seen = new Set();
Object.keys(refs).filter(x => refs[x]).forEach(x => {
out.push([x, LABELS[x] || x]); seen.add(x);
});
REF_KEYS.forEach(([x, lb]) => { if (!seen.has(x)) out.push([x, lb]); });
return out;
})().map(([k, label]) => {
const f = refs[k];
const url = f ? `${API}/${encodeURIComponent(m.id)}/file/${encodeURIComponent(f)}` : '';
return `<div style="background:var(--bg3);border:1px solid var(--border);border-radius:6px;padding:6px">
<div style="font-size:10px;color:var(--text2);margin-bottom:4px">${label}</div>
${f ? `<img src="${url}" title="点击看大图"
onclick="window._chViewImage('${esc(url)}','${esc(label)}')"
style="width:100%;height:120px;object-fit:contain;border-radius:4px;background:var(--bg2);cursor:zoom-in">
<button onclick="window._chDelRef('${k}')" style="margin-top:4px;background:none;border:1px solid var(--border);color:#f59e0b;padding:2px 6px;border-radius:4px;cursor:pointer;font-size:10px">删除</button>`
: `<div style="height:120px;display:flex;align-items:center;justify-content:center;color:var(--text2);font-size:20px">+</div>`}
<input type="file" accept="image/*" onchange="window._chUpRef('${k}', this)" style="margin-top:4px;font-size:10px;width:100%">
</div>`;
}).join('')}
</div>
<div style="font-size:12px;font-weight:600;margin-bottom:6px">🔊 音色</div>
<div style="background:var(--bg3);border:1px solid var(--border);border-radius:6px;padding:8px;margin-bottom:12px">
${(m.voice || {}).sample
? `<audio controls src="${API}/${encodeURIComponent(m.id)}/file/${encodeURIComponent(m.voice.sample)}" style="width:100%"></audio>`
: '<div style="font-size:11px;color:var(--text2)">还没有音色样本</div>'}
<input type="file" accept="audio/*" onchange="window._chUpVoice(this)" style="margin-top:6px;font-size:10px;width:100%">
</div>
<div style="display:flex;gap:8px;border-top:1px solid var(--border);padding-top:12px">
<button onclick="window._chUse()" style="background:var(--bg3);color:var(--text);border:1px solid var(--border);padding:5px 12px;border-radius:6px;cursor:pointer;font-size:11px">🔗 引用信息</button>
<button onclick="window._chDelete()" style="background:none;color:#f59e0b;border:1px solid var(--border);padding:5px 12px;border-radius:6px;cursor:pointer;font-size:11px;margin-left:auto">🗑 删除</button>
</div>
${Object.keys(files).length ? `<div style="margin-top:12px;font-size:10px;color:var(--text2)">目录文件:${Object.entries(files).map(([d, fs]) => `${d}/ (${fs.length})`).join(' · ')}</div>` : ''}`;
}
window._chSave = async () => {
if (!_current) return;
const patch = {
name: document.getElementById('dName').value.trim(),
type: document.getElementById('dType').value,
role: document.getElementById('dRole').value,
tags: document.getElementById('dTags').value.split(',').map(s => s.trim()).filter(Boolean),
description: document.getElementById('dDesc').value.trim(),
};
try {
const r = await fetch(`${API}/${encodeURIComponent(_current.id)}`, {
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(patch),
});
const d = await r.json();
if (d.status !== 'ok') { alert('❌ ' + (d.detail || '保存失败')); return; }
_current = d.item;
renderDrawer();
await refresh();
} catch (e) { alert('❌ ' + e.message); }
};
window._chUpRef = async (key, input) => {
const f = input.files && input.files[0];
if (!f || !_current) return;
const fd = new FormData();
fd.append('file', f);
try {
const r = await fetch(`${API}/${encodeURIComponent(_current.id)}/file?name=${encodeURIComponent('ref_' + key + (f.name.match(/\.\w+$/) || ['.png'])[0])}`,
{ method: 'POST', body: fd });
const d = await r.json();
if (d.status !== 'ok') { alert('❌ ' + (d.detail || '上传失败')); return; }
// 更新 meta 里的 reference_images 映射
const refs = { ...(_current.reference_images || {}), [key]: d.path };
const r2 = await fetch(`${API}/${encodeURIComponent(_current.id)}`, {
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ reference_images: refs }),
});
_current = (await r2.json()).item;
renderDrawer();
await refresh();
} catch (e) { alert('❌ ' + e.message); }
};
window._chDelRef = async (key) => {
if (!_current || !confirm(`删除「${key}」参考图?(仅解除引用,文件保留在目录)`)) return;
const refs = { ...(_current.reference_images || {}) };
delete refs[key];
const r = await fetch(`${API}/${encodeURIComponent(_current.id)}`, {
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ reference_images: refs }),
});
_current = (await r.json()).item;
renderDrawer();
await refresh();
};
window._chUpVoice = async (input) => {
const f = input.files && input.files[0];
if (!f || !_current) return;
const fd = new FormData();
fd.append('file', f);
try {
const ext = (f.name.match(/\.\w+$/) || ['.mp3'])[0];
const r = await fetch(`${API}/${encodeURIComponent(_current.id)}/file?name=${encodeURIComponent('voice_sample' + ext)}`,
{ method: 'POST', body: fd });
const d = await r.json();
if (d.status !== 'ok') { alert('❌ ' + (d.detail || '上传失败')); return; }
const r2 = await fetch(`${API}/${encodeURIComponent(_current.id)}`, {
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ voice: { ...(_current.voice || {}), sample: d.path } }),
});
_current = (await r2.json()).item;
renderDrawer();
await refresh();
} catch (e) { alert('❌ ' + e.message); }
};
window._chUse = () => {
if (!_current) return;
// 存到 localStorage → 导演创作页自动预选(不用手抄 ID)
try { localStorage.setItem('dir_preset_character', _current.id); } catch (e) {}
if (confirm(`🔗 使用角色「${_current.name}」\n\n· 执行器会按 ID 从资源库取该角色的参考图 / 音色\n· 分镜级:可在每个镜头单独指定不同角色\n\n是否跳转到「🎬 导演创作」并自动选中它?`)) {
if (window.switchView) window.switchView('ave-director');
}
};
window._chDelete = async () => {
if (!_current || !confirm(`删除角色「${_current.name || _current.id}」?\n(默认移到 _trash/,可恢复)`)) return;
try {
const r = await fetch(`${API}/${encodeURIComponent(_current.id)}`, { method: 'DELETE' });
const d = await r.json();
if (d.status !== 'ok') { alert('❌ ' + (d.detail || '删除失败')); return; }
window._chClose();
await refresh();
} catch (e) { alert('❌ ' + e.message); }
};
window._chNew = async () => {
const name = prompt('角色名字:');
if (!name) return;
const type = prompt('类型(real 真人 / anime 动漫 / 3d):', 'real') || 'real';
const role = prompt('用途(lead 主角 / support 配角 / extra 群演):', 'lead') || 'lead';
const desc = prompt('一句话描述(可空):') || '';
try {
const r = await fetch(API, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, type, role, description: desc }),
});
const d = await r.json();
if (d.status !== 'ok') { alert('❌ ' + (d.detail || '创建失败')); return; }
await refresh();
window._chOpen(d.item.id); // 直接打开详情,方便补图和音色
} catch (e) { alert('❌ ' + e.message); }
};
function esc(s) {
return String(s ?? '').replace(/[&<>"']/g, m =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[m]));
}