移植自 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 带网关原话 / 正常返回)。
522 lines
29 KiB
Python
522 lines
29 KiB
Python
/**
|
||
* 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 =>
|
||
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[m]));
|
||
}
|