(() => { const apiBase = '/api/style_check' // 自动化接口走相对路径,由 8976 web 引擎内部反代到 8977;如需直连可改为 'http://host:8977' const AUTOMATION_API = '' const el = id => document.getElementById(id) const state = { modalResolver: null, aiCountdownTimer: 0, profileKey: 'mature_relaxed', profiles: { mature_relaxed: { name: '中老年休闲沉稳版', slogan: '稳重有质感,低饱和、利落、耐看' }, mature_fashion40: { name: '中老年流行时尚版', slogan: '50岁按40岁时尚审美:懂流行、有表达、不另类' } } } const sceneText = v => ({ commute: '通勤', weekend: '周末轻运动', business_light: '轻商务会面' }[v] || v || '') const slotText = v => ({ top: '上衣', bottom: '下装', shoes: '鞋', outer: '外搭', accessory: '配饰' }[v] || v || '') const profileText = v => (state.profiles[v] ? state.profiles[v].name : v) const withProfileUrl = rawUrl => { const u = new URL(rawUrl, window.location.origin) u.searchParams.set('profile_key', state.profileKey) return `${u.pathname}${u.search}` } const req = async (url, options = {}) => { const res = await fetch(url, { credentials: 'same-origin', cache: 'no-store', ...options }) const data = await res.json().catch(() => ({})) if (!res.ok || !data.ok) throw new Error(data.error || `HTTP_${res.status}`) return data } // ===== 调试跟踪模态窗:记录爬虫开关请求的完整响应链 ===== const trace = { open(title) { const m = el('debugTraceModal') if (!m) return el('debugTraceTitle').textContent = title || '调试跟踪' el('debugTraceStatus').textContent = '运行中...' el('debugTraceStatus').style.color = '#64748b' el('debugTraceContent').textContent = '' m.style.display = 'flex' }, line(text) { const pre = el('debugTraceContent') if (!pre) return const ts = new Date().toISOString().slice(11, 23) pre.textContent += `[${ts}] ${text}\n` pre.scrollTop = pre.scrollHeight }, json(label, obj) { let s try { s = JSON.stringify(obj, null, 2) } catch { s = String(obj) } this.line(label + ' →\n' + s) }, done(ok, msg) { const st = el('debugTraceStatus') if (st) { st.textContent = ok ? ('✅ ' + (msg || '完成')) : ('❌ ' + (msg || '已中断')) st.style.color = ok ? '#16a34a' : '#dc2626' } this.line(ok ? '===== END (OK) =====' : '===== END (ERROR,保留现场以供拷贝) =====') } } // 带详细跟踪的 fetch:打印请求 URL/method/status/headers/raw body。任意环节出错报错并保留模态窗 const traceFetch = async (label, url, options = {}) => { trace.line(`→ ${label}`) trace.line(` URL : ${url}`) trace.line(` Method : ${(options.method || 'GET').toUpperCase()}`) if (options.body) trace.line(` Body : ${options.body}`) let res try { res = await fetch(url, { credentials: 'same-origin', cache: 'no-store', ...options }) } catch (netErr) { trace.line(` ❌ NETWORK ERROR: ${netErr.name} - ${netErr.message}`) trace.line(` 可能原因:8976 web 引擎未启动、IIS 反代未生效、请求被防火墙拦截、URL 有误`) throw netErr } trace.line(` Status : ${res.status} ${res.statusText}`) trace.line(` CT : ${res.headers.get('content-type') || '(none)'}`) const raw = await res.text() trace.line(` RawLen : ${raw.length}`) if (raw.length <= 2000) { trace.line(` RawBody: ${raw || '(empty)'}`) } else { trace.line(` RawBody(前2000字): ${raw.slice(0, 2000)} ...`) } let json = null try { json = raw ? JSON.parse(raw) : null } catch (e) { trace.line(` ⚠ 响应不是合法 JSON:${e.message}`) } if (json) trace.json(' JSON', json) return { res, raw, json } } const showMsg = (text) => { el('msg').textContent = text || '' } const applyProfileTheme = () => { document.body.setAttribute('data-profile', state.profileKey) const relaxedBtn = el('tabRelaxed') const fashionBtn = el('tabFashion40') const waveBtn = el('tabWave') const koreanBtn = el('tabKorean') const zhihuBtn = el('tabZhihu') const xBtn = el('tabX') if (relaxedBtn) relaxedBtn.classList.toggle('active', state.profileKey === 'mature_relaxed') if (fashionBtn) fashionBtn.classList.toggle('active', state.profileKey === 'mature_fashion40') if (waveBtn) waveBtn.classList.remove('active') if (koreanBtn) koreanBtn.classList.remove('active') if (zhihuBtn) zhihuBtn.classList.remove('active') if (xBtn) xBtn.classList.remove('active') el('mainSections').style.display = 'block' el('waveSection').style.display = 'none' el('koreanSection').style.display = 'none' el('zhihuSection').style.display = 'none' el('xSection').style.display = 'none' const sloganEl = el('profileSlogan') if (sloganEl) { const p = state.profiles[state.profileKey] || {} sloganEl.textContent = `${p.name || ''} · ${p.slogan || ''}` } } const setProfile = async profileKey => { const nextKey = profileKey === 'mature_fashion40' ? 'mature_fashion40' : 'mature_relaxed' state.profileKey = nextKey localStorage.setItem('style_check_profile_key', nextKey) applyProfileTheme() resetForm() resetLibForm() el('aiAuditResult').textContent = `请上传穿搭照片后开始AI审核(当前:${profileText(nextKey)})` el('aiAuditIssues').textContent = '' await Promise.all([loadRules(), loadOutfits(), loadRecentChecks(), loadToday(), loadLibrary(), loadUnmatchedReport()]) } const closeModal = (value) => { const mask = el('appModal') if (!mask) return mask.classList.remove('show') const resolver = state.modalResolver state.modalResolver = null if (resolver) resolver(value) } const openModal = ({ title, message, okText = '确定', cancelText = '', danger = false, allowHtml = false }) => { const mask = el('appModal') const titleBox = el('modalTitle') const msgBox = el('modalMessage') const actions = el('modalActions') if (!mask || !titleBox || !msgBox || !actions) return Promise.resolve(true) titleBox.textContent = String(title || '') if (allowHtml) msgBox.innerHTML = String(message || '') else msgBox.textContent = String(message || '') actions.innerHTML = '' const okBtn = document.createElement('button') okBtn.textContent = okText if (danger) okBtn.className = 'danger' okBtn.onclick = () => closeModal(true) if (cancelText) { const cancelBtn = document.createElement('button') cancelBtn.textContent = cancelText cancelBtn.className = 'secondary' cancelBtn.onclick = () => closeModal(false) actions.appendChild(cancelBtn) } actions.appendChild(okBtn) mask.classList.add('show') return new Promise(resolve => { state.modalResolver = resolve }) } const modalAlert = (title, message) => openModal({ title, message, okText: '我知道了' }) const modalConfirm = (title, message, okText = '确认', cancelText = '取消', danger = false) => openModal({ title, message, okText, cancelText, danger }) const showProgressModal = title => { const mask = el('appModal') const titleBox = el('modalTitle') const msgBox = el('modalMessage') const actions = el('modalActions') if (!mask || !titleBox || !msgBox || !actions) return { log: () => {}, success: () => Promise.resolve(), fail: () => {} } titleBox.textContent = String(title || '') msgBox.textContent = '' actions.innerHTML = '' const closeBtn = document.createElement('button') closeBtn.textContent = '关闭' closeBtn.className = 'secondary' closeBtn.style.display = 'none' closeBtn.onclick = () => closeModal(false) actions.appendChild(closeBtn) mask.classList.add('show') const logLines = [] const render = () => { msgBox.textContent = logLines.join('\n') } const api = { log: line => { logLines.push(String(line || '')) render() }, async success(finalLine, countdownSec = 3) { if (finalLine) { logLines.push(String(finalLine)) render() } for (let s = countdownSec; s > 0; s -= 1) { logLines.push(`成功,${s}s 后自动关闭...`) render() await new Promise(resolve => { state.aiCountdownTimer = window.setTimeout(resolve, 1000) }) logLines.pop() } closeModal(true) }, fail(errLine) { if (state.aiCountdownTimer) { clearTimeout(state.aiCountdownTimer) state.aiCountdownTimer = 0 } logLines.push(String(errLine || '处理失败')) logLines.push('请根据以上错误信息排查。') render() closeBtn.style.display = '' } } return api } const toDataUrl = file => new Promise((resolve, reject) => { const reader = new FileReader() reader.onload = () => resolve(String(reader.result || '')) reader.onerror = () => reject(new Error('读取图片失败')) reader.readAsDataURL(file) }) const resetForm = () => { el('fId').value = '' el('fName').value = '' el('fScene').value = 'commute' el('fTempZone').value = '12-22℃' el('fTop').value = '' el('fBottom').value = '' el('fShoes').value = '' el('fOuter').value = '' el('fAccessory').value = '' el('fNotes').value = '' } const resetLibForm = () => { el('libItemId').value = '' el('libItemName').value = '' el('libItemSlot').value = 'top' el('libItemCategory').value = '' el('libItemTempZone').value = 'ALL' el('libItemBrands').value = '' el('libItemPriceMin').value = '' el('libItemPriceMax').value = '' el('libItemKeywords').value = '' el('libItemNotes').value = '' } const fillLibForm = (row) => { el('libItemId').value = row.id || '' el('libItemName').value = row.item_name || '' el('libItemSlot').value = row.slot || 'top' el('libItemCategory').value = row.category || '' el('libItemTempZone').value = row.temp_zone || 'ALL' el('libItemBrands').value = row.brand_options || '' el('libItemPriceMin').value = Number.isFinite(Number(row.price_min)) ? row.price_min : '' el('libItemPriceMax').value = Number.isFinite(Number(row.price_max)) ? row.price_max : '' el('libItemKeywords').value = row.keywords || '' el('libItemNotes').value = row.notes || '' } const fillForm = (row) => { el('fId').value = row.id el('fName').value = row.name || '' el('fScene').value = row.scene || 'commute' el('fTempZone').value = row.temp_zone || '12-22℃' el('fTop').value = row.top_desc || '' el('fBottom').value = row.bottom_desc || '' el('fShoes').value = row.shoes_desc || '' el('fOuter').value = row.outer_desc || '' el('fAccessory').value = row.accessory_desc || '' el('fNotes').value = row.notes || '' } const loadOutfits = async () => { const scene = el('filterScene').value const tempZone = el('filterTempZone').value const q = el('filterQ').value.trim() const sp = new URLSearchParams() if (scene) sp.set('scene', scene) if (tempZone) sp.set('temp_zone', tempZone) if (q) sp.set('q', q) sp.set('include_links', '1') const data = await req(withProfileUrl(`${apiBase}/outfits?${sp.toString()}`)) const tbody = el('outfitBody') tbody.innerHTML = '' data.rows.forEach(row => { const tr = document.createElement('tr') tr.innerHTML = ` ${row.name || ''} ${sceneText(row.scene)} ${row.temp_zone || ''} ${row.top_desc || ''} ${row.bottom_desc || ''} ${row.shoes_desc || ''} ${row.outer_desc || ''} ${row.accessory_desc || ''} ${row.notes || ''} ${row.linked_items_summary || '-'}
` tbody.appendChild(tr) }) tbody.querySelectorAll('button[data-act="edit"]').forEach(btn => { btn.onclick = () => { const id = Number(btn.getAttribute('data-id')) const row = data.rows.find(r => Number(r.id) === id) if (row) fillForm(row) } }) tbody.querySelectorAll('button[data-act="del"]').forEach(btn => { btn.onclick = async () => { const id = Number(btn.getAttribute('data-id')) if (!id) return const ok = await modalConfirm('确认删除', '确认删除这条搭配吗?删除后不可恢复。', '确定删除', '再想想', true) if (!ok) return try { await req(withProfileUrl(`${apiBase}/outfits/${id}`), { method: 'DELETE' }) await modalAlert('删除成功', '这条搭配已删除。') await loadOutfits() await loadUnmatchedReport() } catch (e) { await modalAlert('删除失败', String(e.message || e)) } } }) } const loadRules = async () => { const data = await req(withProfileUrl(`${apiBase}/rules`)) const box = el('ruleList') box.innerHTML = '' data.rows.forEach(r => { const div = document.createElement('label') div.className = 'sc-rule-item' div.innerHTML = ` [${r.rule_group}] ${r.rule_text} ` box.appendChild(div) }) } const loadLibrary = async () => { const slot = el('libSlot').value const tempZone = el('libTempZone').value const q = el('libQ').value.trim() const sp = new URLSearchParams() if (slot) sp.set('slot', slot) if (tempZone) sp.set('temp_zone', tempZone) if (q) sp.set('q', q) const data = await req(withProfileUrl(`${apiBase}/library/items?${sp.toString()}`)) const tbody = el('libBody') tbody.innerHTML = '' data.rows.forEach(row => { const tr = document.createElement('tr') tr.innerHTML = ` ${row.item_name || ''} ${slotText(row.slot)} ${row.category || ''} ${row.temp_zone || ''} ${row.brand_options || ''} ${Number.isFinite(Number(row.price_min)) && Number.isFinite(Number(row.price_max)) ? `${row.price_min}-${row.price_max}` : '-'} ${Number(row.image_count || 0)} 张 ${row.keywords || ''} ${row.notes || ''}
` tbody.appendChild(tr) }) tbody.querySelectorAll('button[data-act="viewimg"]').forEach(btn => { btn.onclick = async () => { const id = Number(btn.getAttribute('data-id')) const row = data.rows.find(r => Number(r.id) === id) if (!row) return await showItemImages(row) } }) tbody.querySelectorAll('button[data-act="uploadimg"]').forEach(btn => { btn.onclick = async () => { const id = Number(btn.getAttribute('data-id')) const row = data.rows.find(r => Number(r.id) === id) if (!row) return await uploadLibraryImage(row) } }) tbody.querySelectorAll('button[data-act="edititem"]').forEach(btn => { btn.onclick = () => { const id = Number(btn.getAttribute('data-id')) const row = data.rows.find(r => Number(r.id) === id) if (row) fillLibForm(row) } }) } const showItemImages = async (itemRow) => { const data = await req(withProfileUrl(`${apiBase}/library/items/${itemRow.id}/images`)) const rows = Array.isArray(data.rows) ? data.rows : [] if (!rows.length) { await modalAlert('图片为空', `【${itemRow.item_name}】暂时没有图片,建议先上传。`) return } const cards = rows.map((r, idx) => { const url = String(r.image_url || '') return `
img-${idx + 1}
#${idx + 1} · ${r.source_type || ''}
` }).join('') await openModal({ title: `${itemRow.item_name} · 图片附件(${rows.length})`, message: `
${cards}
`, okText: '关闭', allowHtml: true }) } const uploadLibraryImage = async (itemRow) => { const input = el('libUploadInput') if (!input) return input.value = '' const picked = await new Promise(resolve => { input.onchange = () => { const file = input.files && input.files[0] resolve(file || null) } input.click() }) if (!picked) return const imageDataUrl = await toDataUrl(picked) await req(`${apiBase}/library/items/${itemRow.id}/images`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ profile_key: state.profileKey, image_data_url: imageDataUrl, caption: `${itemRow.item_name} 上传图片` }) }) await modalAlert('上传成功', `【${itemRow.item_name}】图片已上传。`) await loadLibrary() } const saveLibraryItem = async () => { const id = Number(el('libItemId').value || 0) const payload = { profile_key: state.profileKey, item_name: el('libItemName').value.trim(), slot: el('libItemSlot').value, category: el('libItemCategory').value.trim(), temp_zone: el('libItemTempZone').value, brand_options: el('libItemBrands').value.trim(), price_min: el('libItemPriceMin').value === '' ? null : Number(el('libItemPriceMin').value), price_max: el('libItemPriceMax').value === '' ? null : Number(el('libItemPriceMax').value), keywords: el('libItemKeywords').value.trim(), notes: el('libItemNotes').value.trim() } if (!payload.item_name) { await modalAlert('无法保存', '服饰名称不能为空。') return } if (!payload.category) { await modalAlert('无法保存', '服饰类别不能为空。') return } if (id > 0) { await req(`${apiBase}/library/items/${id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }) await modalAlert('保存成功', '服饰条目已更新。') } else { await req(`${apiBase}/library/items`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }) await modalAlert('新增成功', '服饰条目已新增。') } resetLibForm() await loadLibrary() await loadUnmatchedReport() } const loadUnmatchedReport = async () => { const data = await req(withProfileUrl(`${apiBase}/library/unmatched_report`)) if (!data.rows || !data.rows.length) { el('unmatchedReport').textContent = '未匹配报告:当前全部已匹配' return } const text = data.rows.map(r => `${r.outfit_name}|${slotText(r.slot)}|${r.text}`).join('\n') el('unmatchedReport').textContent = `未匹配报告(${data.unmatched_count}条):\n${text}` } const rebuildLinks = async () => { const data = await req(`${apiBase}/library/rebuild_links`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ profile_key: state.profileKey }) }) const rows = Array.isArray(data.unmatched) ? data.unmatched : [] if (!rows.length) { el('unmatchedReport').textContent = `重建完成:已匹配 ${data.matched_count} 条链接,未匹配 0 条` return } const text = rows.map(r => `${r.outfit_name}|${slotText(r.slot)}|${r.text}`).join('\n') el('unmatchedReport').textContent = `重建完成:已匹配 ${data.matched_count} 条链接,未匹配 ${data.unmatched_count} 条\n${text}` await modalAlert('重建完成', `已匹配 ${data.matched_count} 条链接,未匹配 ${data.unmatched_count} 条。`) } const fetchLocalImages = async () => { const progress = showProgressModal('本地化找图处理中') try { progress.log(`已开始:按当前版本(${profileText(state.profileKey)})逐条服饰抓取图片并保存到本地...`) const data = await req(`${apiBase}/library/images/fetch_local`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ profile_key: state.profileKey, target_per_item: 5, force: 1 }) }) progress.log(`处理完成:共 ${data.item_count || 0} 条服饰,缺失(<3张) ${data.missing_count || 0} 条`) if (Array.isArray(data.missing) && data.missing.length) { const text = data.missing.map(x => `- ${x.item_name}(${slotText(x.slot)}): ${x.image_count}张`).join('\n') progress.log(`缺失明细:\n${text}`) } await loadLibrary() await progress.success('本地化找图完成。', 3) } catch (e) { progress.fail(`本地化找图失败:${String((e && e.message) || e)}`) } } const renderAiReport = (report) => { const r = report || {} const ruleResult = r.rule_result || {} const lib = r.library_result || {} const rules = Array.isArray(ruleResult.rules) ? ruleResult.rules : [] const failRules = rules.filter(x => !x.pass).map(x => `- ${x.title}|${x.detail}`).join('\n') const conflicts = Array.isArray(lib.conflict_list) ? lib.conflict_list.map(x => `- ${x}`).join('\n') : '' const similar = Array.isArray(lib.similar_outfits) ? lib.similar_outfits.map(x => `- ${x.name}(重叠${x.overlap_count})`).join('\n') : '' el('aiAuditResult').textContent = [ `结论:${r.result_level || '-'}`, `分数:${r.total_score || 0}`, `温区:${r.temp_zone || '-'}`, `摘要:${r.summary || '-'}`, similar ? `近似搭配:\n${similar}` : '近似搭配:无' ].join('\n') el('aiAuditIssues').textContent = [ failRules ? `规则未通过:\n${failRules}` : '规则未通过:无', conflicts ? `库冲突/未匹配:\n${conflicts}` : '库冲突/未匹配:无' ].join('\n\n') } const runAiAudit = async () => { const file = el('aiImage').files && el('aiImage').files[0] if (!file) { await modalAlert('缺少照片', '请先选择穿搭照片,再开始AI审核。') return } const progress = showProgressModal('AI审核处理中') try { progress.log('已开始处理。') progress.log('1/4 正在读取图片...') const imageDataUrl = await toDataUrl(file) progress.log('2/4 正在调用AI识别(上衣/下装/鞋/外搭/配饰、颜色、版型、LOGO)...') const payload = { profile_key: state.profileKey, scene: el('aiScene').value, temp_zone: el('aiTempZone').value || el('tempZone').value, temp_c: Number(el('tempC').value || NaN), image_data_url: imageDataUrl } const data = await req(`${apiBase}/ai/audit`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }) progress.log('3/4 正在执行规则校验与库冲突匹配...') renderAiReport(data.report) progress.log('4/4 已完成结果渲染。') await progress.success('AI审核完成。', 3) } catch (e) { progress.fail(`AI审核失败:${String((e && e.message) || e)}`) } } const loadRecentChecks = async () => { const data = await req(withProfileUrl(`${apiBase}/checks/recent?limit=7`)) if (!data.rows.length) { el('recentChecks').textContent = '最近暂无镜检记录' return } const text = data.rows.map(r => { const issues = Array.isArray(r.issues) ? r.issues.join(';') : '' return `${r.check_date}|${sceneText(r.scene)}|${r.temp_zone}|评分${r.total_score}|${r.result_level}${issues ? `|问题:${issues}` : ''}` }).join('\n') el('recentChecks').textContent = text } const loadToday = async () => { const scene = el('scene').value const tempC = el('tempC').value.trim() const tempZone = el('tempZone').value const sp = new URLSearchParams() sp.set('scene', scene) if (tempC) sp.set('temp_c', tempC) if (tempZone) sp.set('temp_zone', tempZone) const data = await req(withProfileUrl(`${apiBase}/today?${sp.toString()}`)) const c = data.chosen if (!c) { el('todayBox').textContent = '当前条件暂无可用搭配,请在搭配库新增。' return } const alts = (data.alternatives || []).map(x => `- ${x.name}(${x.temp_zone})`).join('\n') el('todayBox').textContent = [ `推荐:${c.name}`, `场景:${sceneText(c.scene)},温区:${c.temp_zone}`, `上衣:${c.top_desc || '-'}`, `下装:${c.bottom_desc || '-'}`, `鞋:${c.shoes_desc || '-'}`, `外搭:${c.outer_desc || '-'}`, `配饰:${c.accessory_desc || '-'}`, `备注:${c.notes || '-'}`, alts ? `备选:\n${alts}` : '' ].filter(Boolean).join('\n') } // === WAVE Gallery & Crawler === const loadWaveGallery = async () => { try { const data = await req(`${apiBase}/wave?page=1&limit=500`) state.waveList = data.rows || [] renderWaveGrid() } catch (e) { console.error(e) } } const renderWaveGrid = () => { const grid = el('waveGrid') grid.innerHTML = '' state.waveList.forEach((w, idx) => { const card = document.createElement('div') card.className = 'wave-card' card.innerHTML = `
${w.evaluation || 'AI 正在解析中...'}
` card.onclick = () => openWaveLightbox(idx) grid.appendChild(card) }) } const openWaveLightbox = (idx) => { if (!state.waveList || !state.waveList[idx]) return state.waveIndex = idx const w = state.waveList[idx] el('waveLbImg').src = w.image_url el('waveLbEval').textContent = w.evaluation || '暂无评价' el('waveLbNotes').textContent = w.notes || '暂无注意事项' el('waveLbSource').href = w.source_url || '#' el('waveLightbox').style.display = 'flex' } const closeWaveLightbox = () => { el('waveLightbox').style.display = 'none' } const switchWaveLightbox = (dir) => { let nextIdx = state.waveIndex + dir if (nextIdx < 0) nextIdx = state.waveList.length - 1 if (nextIdx >= state.waveList.length) nextIdx = 0 openWaveLightbox(nextIdx) } const checkWaveCrawlerStatus = async () => { try { const data = await req(`${apiBase}/wave/status`) const btn = el('btnWaveToggleCrawler') const st = el('waveCrawlerStatus') const logs = data.logs || [] if (el('waveLogsModal').style.display === 'flex') { const pre = el('waveLogsContent') pre.textContent = logs.join('\n') pre.scrollTop = pre.scrollHeight } if (data.isRunning) { btn.textContent = '⏹ 停止佛系爬虫' btn.style.background = 'rgba(239, 68, 68, 0.8)' st.innerHTML = `爬虫运行中 (第${data.currentPage}页)
已处理 ${data.totalProcessed},AI 采纳 ${data.totalApproved} 条` state.waveCrawlerTimer = setTimeout(checkWaveCrawlerStatus, 5000) // Poll every 5s if running } else { btn.textContent = '▶ 启动佛系爬虫 (每页缓爬)' btn.style.background = 'rgba(37, 99, 235, 0.8)' st.innerHTML = `爬虫已停止
已处理 ${data.totalProcessed},AI 采纳 ${data.totalApproved} 条` if (state.waveCrawlerTimer) { clearTimeout(state.waveCrawlerTimer) state.waveCrawlerTimer = null } } } catch (e) { console.error(e) } } const toggleWaveCrawler = async () => { trace.open('佛系爬虫开关 (wave) - 全链跟踪') trace.line(`Page Origin: ${window.location.origin}`) trace.line(`AUTOMATION_API: ${AUTOMATION_API || '(空串,走相对路径,由 8976 反代到 8977)'}`) const toggleUrl = `${AUTOMATION_API}${apiBase}/wave/toggle` const statusUrl = `${apiBase}/wave/status` try { // 1) 切换状态 const r1 = await traceFetch('Step 1 切换爬虫状态', toggleUrl, { method: 'POST' }) if (!r1.res.ok) throw new Error(`HTTP_${r1.res.status} (切换接口返回非 2xx)`) if (!r1.json || r1.json.ok !== true) throw new Error((r1.json && r1.json.error) || '切换接口 ok=false') trace.line(`✓ 服务端 isRunning = ${r1.json.isRunning}`) // 2) 拉一次状态确认 const r2 = await traceFetch('Step 2 读取爬虫状态', statusUrl) if (!r2.res.ok) throw new Error(`HTTP_${r2.res.status} (状态接口返回非 2xx)`) if (!r2.json || r2.json.ok !== true) throw new Error((r2.json && r2.json.error) || '状态接口 ok=false') trace.done(true, `isRunning=${r2.json.isRunning}、page=${r2.json.currentPage}、processed=${r2.json.totalProcessed}`) checkWaveCrawlerStatus() } catch (e) { trace.line(`❌ 异常: ${e.name || 'Error'} - ${e.message}`) if (e.stack) trace.line(`Stack:\n${e.stack}`) trace.done(false, e.message) // 不弹 alert,全部信息保留在调试模态窗内,点“复制全部”拷贝即可 } } const loadKoreanGallery = async () => { try { const data = await req(`${apiBase}/korean?page=1&limit=500`) state.koreanList = data.rows || [] renderKoreanGrid() } catch (e) { console.error(e) } } const renderKoreanGrid = () => { const grid = el('koreanGrid') grid.innerHTML = '' state.koreanList.forEach((w, idx) => { const card = document.createElement('div') card.className = 'wave-card' card.innerHTML = `
${w.evaluation || 'AI 正在解析中...'}
` card.onclick = () => openKoreanLightbox(idx) grid.appendChild(card) }) } const openKoreanLightbox = (idx) => { if (!state.koreanList || !state.koreanList[idx]) return state.koreanIndex = idx const w = state.koreanList[idx] el('koreanLbImg').src = w.image_url el('koreanLbEval').textContent = w.evaluation || '暂无评价' el('koreanLbNotes').textContent = w.notes || '暂无注意事项' el('koreanLbSource').href = w.source_url || '#' el('koreanLightbox').style.display = 'flex' } const closeKoreanLightbox = () => { el('koreanLightbox').style.display = 'none' } const switchKoreanLightbox = (dir) => { let nextIdx = state.koreanIndex + dir if (nextIdx < 0) nextIdx = state.koreanList.length - 1 if (nextIdx >= state.koreanList.length) nextIdx = 0 openKoreanLightbox(nextIdx) } const checkKoreanCrawlerStatus = async () => { try { const data = await req(`${apiBase}/korean/status`) const btn = el('btnKoreanToggleCrawler') const st = el('koreanCrawlerStatus') const logs = data.logs || [] if (el('koreanLogsModal').style.display === 'flex') { const pre = el('koreanLogsContent') pre.textContent = logs.join('\n') pre.scrollTop = pre.scrollHeight } if (data.isRunning) { btn.textContent = '⏹ 停止佛系爬虫' btn.style.background = 'rgba(239, 68, 68, 0.8)' st.innerHTML = `爬虫运行中 (第${data.currentPage}页)
已处理 ${data.totalProcessed},AI 采纳 ${data.totalApproved} 条` state.koreanCrawlerTimer = setTimeout(checkKoreanCrawlerStatus, 5000) } else { btn.textContent = '▶ 启动佛系爬虫' btn.style.background = 'rgba(37, 99, 235, 0.8)' st.innerHTML = `爬虫已停止
已处理 ${data.totalProcessed},AI 采纳 ${data.totalApproved} 条` if (state.koreanCrawlerTimer) { clearTimeout(state.koreanCrawlerTimer) state.koreanCrawlerTimer = null } } } catch (e) { console.error(e) } } const toggleKoreanCrawler = async () => { trace.open('佛系爬虫开关 (korean) - 全链跟踪') trace.line(`Page Origin: ${window.location.origin}`) trace.line(`AUTOMATION_API: ${AUTOMATION_API || '(空串,走相对路径,由 8976 反代到 8977)'}`) const toggleUrl = `${AUTOMATION_API}${apiBase}/korean/toggle` const statusUrl = `${apiBase}/korean/status` try { const r1 = await traceFetch('Step 1 切换爬虫状态', toggleUrl, { method: 'POST' }) if (!r1.res.ok) throw new Error(`HTTP_${r1.res.status} (切换接口返回非 2xx)`) if (!r1.json || r1.json.ok !== true) throw new Error((r1.json && r1.json.error) || '切换接口 ok=false') trace.line(`✓ 服务端 isRunning = ${r1.json.isRunning}`) const r2 = await traceFetch('Step 2 读取爬虫状态', statusUrl) if (!r2.res.ok) throw new Error(`HTTP_${r2.res.status} (状态接口返回非 2xx)`) if (!r2.json || r2.json.ok !== true) throw new Error((r2.json && r2.json.error) || '状态接口 ok=false') trace.done(true, `isRunning=${r2.json.isRunning}、page=${r2.json.currentPage}、processed=${r2.json.totalProcessed}`) checkKoreanCrawlerStatus() } catch (e) { trace.line(`❌ 异常: ${e.name || 'Error'} - ${e.message}`) if (e.stack) trace.line(`Stack:\n${e.stack}`) trace.done(false, e.message) } } const loadZhihuIndex = async () => { try { const res = await fetch('/data/zhihu_articles/index.json', { cache: 'no-store' }) if (!res.ok) { el('zhihuTocBody').innerHTML = '
暂无数据,请先运行抓取脚本
' return } const data = await res.json() const grouped = {} data.forEach((item, i) => { if (!grouped[item.group]) grouped[item.group] = [] grouped[item.group].push({ ...item, index: i }) }) let html = '' for (const group in grouped) { html += `
${group}
` grouped[group].forEach(item => { if (!item.contentHtml) { html += `
${item.title}
` } else { html += `
${item.title}
` } }) } el('zhihuTocBody').innerHTML = html window.loadZhihuArticle = async (index) => { document.querySelectorAll('.toc-item').forEach(el => el.classList.remove('active')) event.currentTarget.classList.add('active') try { const aRes = await fetch(`/data/zhihu_articles/article_${index}.html`, { cache: 'no-store' }) if (!aRes.ok) throw new Error('Load failed') const aHtml = await aRes.text() el('zhihuContentArea').innerHTML = `
${aHtml}
` el('zhihuContentArea').scrollTop = 0 } catch (e) { el('zhihuContentArea').innerHTML = `

加载文章失败

` } } } catch (e) { console.error('Failed to load zhihu index', e) } } window.openXLightbox = (src) => { el('xLbImg').src = src el('xLightbox').style.display = 'flex' } window.closeXLightbox = () => { el('xLightbox').style.display = 'none' } window.loadXAccount = async (account) => { document.querySelectorAll('.x-nav-item').forEach(el => el.classList.remove('active')) if (event && event.currentTarget) { event.currentTarget.classList.add('active') } el('xHeaderTitle').textContent = `@${account}` el('xFeed').innerHTML = `

正在加载推文...

` try { const res = await fetch(`/data/x_fashion/${account}/data.json`, { cache: 'no-store' }) if (!res.ok) { el('xFeed').innerHTML = '

暂无数据,请先运行抓取脚本

' return } const data = await res.json() if (data.length === 0) { el('xFeed').innerHTML = '

还没有推文

' return } let html = '' data.forEach(tweet => { const time = new Date(tweet.timestamp).toLocaleString() let imagesHtml = '' if (tweet.localImages && tweet.localImages.length > 0) { imagesHtml = `
` tweet.localImages.forEach(img => { imagesHtml += `` }) imagesHtml += `
` } html += `
${account} @${account} · ${time}
${tweet.text.replace(/\n/g, '
')}
${imagesHtml}
💬 🔁 ❤️ 📊
` }) el('xFeed').innerHTML = html } catch (e) { console.error(e) el('xFeed').innerHTML = '

加载数据失败

' } } const saveOutfit = async () => { const id = Number(el('fId').value || 0) const payload = { profile_key: state.profileKey, name: el('fName').value.trim(), scene: el('fScene').value, temp_zone: el('fTempZone').value, top_desc: el('fTop').value.trim(), bottom_desc: el('fBottom').value.trim(), shoes_desc: el('fShoes').value.trim(), outer_desc: el('fOuter').value.trim(), accessory_desc: el('fAccessory').value.trim(), notes: el('fNotes').value.trim() } if (!payload.name) { await modalAlert('无法保存', '名称不能为空。') return } if (id > 0) { await req(`${apiBase}/outfits/${id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }) await modalAlert('保存成功', '搭配已更新。') } else { await req(`${apiBase}/outfits`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }) await modalAlert('新增成功', '搭配已新增。') } resetForm() await loadOutfits() await loadUnmatchedReport() } const submitCheck = async () => { const checkboxes = [...document.querySelectorAll('#ruleList input[type="checkbox"]')] const passed = checkboxes.filter(x => x.checked) const failed = checkboxes.filter(x => !x.checked) const fullScore = checkboxes.reduce((s, x) => s + Number(x.getAttribute('data-weight') || 0), 0) const score = passed.reduce((s, x) => s + Number(x.getAttribute('data-weight') || 0), 0) const totalScore = fullScore > 0 ? Math.round(score * 100 / fullScore) : 0 const resultLevel = totalScore >= 80 ? 'pass' : totalScore >= 60 ? 'warn' : 'fail' const issues = failed.map(x => { const text = x.parentElement ? x.parentElement.textContent : '' return String(text || '').trim() }) const payload = { profile_key: state.profileKey, check_date: new Date().toISOString().slice(0, 10), scene: el('scene').value, temp_c: Number(el('tempC').value || NaN), temp_zone: el('tempZone').value, total_score: totalScore, result_level: resultLevel, issues } await req(`${apiBase}/checks`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }) el('recentChecks').textContent = `本次评分:${totalScore}(${resultLevel})` await loadRecentChecks() await modalAlert('镜检已提交', `本次评分:${totalScore}(${resultLevel})`) } const init = async () => { const saved = localStorage.getItem('style_check_profile_key') state.profileKey = saved === 'mature_fashion40' ? 'mature_fashion40' : 'mature_relaxed' applyProfileTheme() const urlParams = new URLSearchParams(window.location.search) const currentTab = urlParams.get('tab') el('tabRelaxed').onclick = () => { if (currentTab) { localStorage.setItem('style_check_profile_key', 'mature_relaxed') window.location.href = '/tools/style_check' } else { setProfile('mature_relaxed').catch(async e => { await modalAlert('切换失败', String(e.message || e)) }) } } el('tabFashion40').onclick = () => { if (currentTab) { localStorage.setItem('style_check_profile_key', 'mature_fashion40') window.location.href = '/tools/style_check' } else { setProfile('mature_fashion40').catch(async e => { await modalAlert('切换失败', String(e.message || e)) }) } } const showWaveTab = () => { el('tabRelaxed').classList.remove('active') el('tabFashion40').classList.remove('active') el('tabKorean').classList.remove('active') el('tabZhihu').classList.remove('active') el('tabX').classList.remove('active') el('tabWave').classList.add('active') el('mainSections').style.display = 'none' el('koreanSection').style.display = 'none' el('zhihuSection').style.display = 'none' el('xSection').style.display = 'none' el('waveSection').style.display = 'block' loadWaveGallery() checkWaveCrawlerStatus() } const showKoreanTab = () => { el('tabRelaxed').classList.remove('active') el('tabFashion40').classList.remove('active') el('tabWave').classList.remove('active') el('tabZhihu').classList.remove('active') el('tabX').classList.remove('active') el('tabKorean').classList.add('active') el('mainSections').style.display = 'none' el('waveSection').style.display = 'none' el('zhihuSection').style.display = 'none' el('xSection').style.display = 'none' el('koreanSection').style.display = 'block' loadKoreanGallery() checkKoreanCrawlerStatus() } const showZhihuTab = () => { el('tabRelaxed').classList.remove('active') el('tabFashion40').classList.remove('active') el('tabWave').classList.remove('active') el('tabKorean').classList.remove('active') el('tabX').classList.remove('active') el('tabZhihu').classList.add('active') el('mainSections').style.display = 'none' el('waveSection').style.display = 'none' el('koreanSection').style.display = 'none' el('xSection').style.display = 'none' el('zhihuSection').style.display = 'block' loadZhihuIndex() } const showXTab = () => { el('tabRelaxed').classList.remove('active') el('tabFashion40').classList.remove('active') el('tabWave').classList.remove('active') el('tabKorean').classList.remove('active') el('tabZhihu').classList.remove('active') el('tabX').classList.add('active') el('mainSections').style.display = 'none' el('waveSection').style.display = 'none' el('koreanSection').style.display = 'none' el('zhihuSection').style.display = 'none' el('xSection').style.display = 'block' window.loadXAccount('mensgrinding') } // WAVE el('tabWave').onclick = () => { if (currentTab !== 'wave') window.open('/tools/style_check?tab=wave', '_blank') } el('btnWaveToggleCrawler').onclick = toggleWaveCrawler // Wave manual links upload const uploadInput = el('waveLinkFile') const uploadBtn = el('btnWaveUploadLinks') if (uploadBtn && uploadInput) { uploadBtn.onclick = () => uploadInput.click() uploadInput.onchange = async (e) => { const file = e.target.files && e.target.files[0] if (!file) return try { const text = await file.text() let links = [] try { links = JSON.parse(text) if (!Array.isArray(links)) throw new Error('Not an array') } catch { // fallback to split by newline links = text.split('\n').map(l => l.trim()).filter(l => l.startsWith('http')) } if (links.length === 0) { alert('文件中没有找到有效的图片链接') return } const res = await req(`${AUTOMATION_API}${apiBase}/wave/upload_links`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ links }) }) alert(`成功投喂 ${links.length} 条链接!目前队列中还有 ${res.queueLength} 张图片等待后台佛系下载和AI审核。`) checkWaveCrawlerStatus() } catch (err) { alert('读取文件失败: ' + err.message + '\n请确认 8977 自动化服务已启动') } uploadInput.value = '' } } el('btnWaveShowLogs').onclick = async () => { el('waveLogsModal').style.display = 'flex' await checkWaveCrawlerStatus() } el('btnWaveLogsClose').onclick = () => { el('waveLogsModal').style.display = 'none' } // 调试跟踪模态窗:关闭 / 复制全部 const btnTraceClose = el('btnDebugTraceClose') if (btnTraceClose) btnTraceClose.onclick = () => { el('debugTraceModal').style.display = 'none' } const btnTraceCopy = el('btnDebugTraceCopy') if (btnTraceCopy) btnTraceCopy.onclick = async () => { const text = el('debugTraceContent').textContent || '' try { await navigator.clipboard.writeText(text) btnTraceCopy.textContent = '✓ 已复制' setTimeout(() => { btnTraceCopy.textContent = '复制全部' }, 1500) } catch { // 降级:选中让用户手动 Ctrl+C const range = document.createRange() range.selectNodeContents(el('debugTraceContent')) const sel = window.getSelection() sel.removeAllRanges(); sel.addRange(range) btnTraceCopy.textContent = '已选中,请 Ctrl+C' setTimeout(() => { btnTraceCopy.textContent = '复制全部' }, 2000) } } el('waveLbClose').onclick = closeWaveLightbox el('waveLbPrev').onclick = () => switchWaveLightbox(-1) el('waveLbNext').onclick = () => switchWaveLightbox(1) // KOREAN el('tabKorean').onclick = () => { if (currentTab !== 'korean') window.open('/tools/style_check?tab=korean', '_blank') } el('btnKoreanToggleCrawler').onclick = toggleKoreanCrawler // ZHIHU el('tabZhihu').onclick = () => { if (currentTab !== 'zhihu') window.open('/tools/style_check?tab=zhihu', '_blank') } // X (Twitter) el('tabX').onclick = () => { if (currentTab !== 'x') window.open('/tools/style_check?tab=x', '_blank') } if (el('xLbClose')) { el('xLbClose').addEventListener('click', window.closeXLightbox) el('xLightbox').addEventListener('click', (e) => { if (e.target === el('xLightbox')) window.closeXLightbox() }) } if (el('btnXHelp')) { el('btnXHelp').addEventListener('click', () => { el('xHelpModal').style.display = 'flex' }) } if (el('btnXHelpClose')) { el('btnXHelpClose').addEventListener('click', () => { el('xHelpModal').style.display = 'none' }) } const uploadInputK = el('koreanLinkFile') const uploadBtnK = el('btnKoreanUploadLinks') if (uploadBtnK && uploadInputK) { uploadBtnK.onclick = () => uploadInputK.click() uploadInputK.onchange = async (e) => { const file = e.target.files && e.target.files[0] if (!file) return try { const text = await file.text() let links = [] try { links = JSON.parse(text) if (!Array.isArray(links)) throw new Error('Not an array') } catch { links = text.split('\n').map(l => l.trim()).filter(l => l.startsWith('http')) } if (links.length === 0) { alert('文件中没有找到有效的图片链接') return } const res = await req(`${AUTOMATION_API}${apiBase}/korean/upload_links`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ links }) }) alert(`成功投喂 ${links.length} 条链接!目前队列中还有 ${res.queueLength} 张图片等待后台佛系下载和AI审核。`) checkKoreanCrawlerStatus() } catch (err) { alert('读取文件失败: ' + err.message + '\n请确认 8977 自动化服务已启动') } uploadInputK.value = '' } } el('btnKoreanShowLogs').onclick = async () => { el('koreanLogsModal').style.display = 'flex' await checkKoreanCrawlerStatus() } el('btnKoreanLogsClose').onclick = () => { el('koreanLogsModal').style.display = 'none' } el('koreanLbClose').onclick = closeKoreanLightbox el('koreanLbPrev').onclick = () => switchKoreanLightbox(-1) el('koreanLbNext').onclick = () => switchKoreanLightbox(1) el('btnToday').onclick = () => loadToday().catch(async e => { await modalAlert('获取失败', String(e.message || e)) }) el('btnSearch').onclick = () => loadOutfits().catch(async e => { await modalAlert('查询失败', String(e.message || e)) }) el('btnLibSearch').onclick = () => loadLibrary().catch(async e => { await modalAlert('服饰库查询失败', String(e.message || e)) }) el('btnRebuildLinks').onclick = () => rebuildLinks().then(() => loadOutfits()).catch(async e => { await modalAlert('重建失败', String(e.message || e)) }) const btnFetch = el('btnFetchLocalImages') if (btnFetch) btnFetch.onclick = () => fetchLocalImages().catch(async e => { await modalAlert('找图失败', String(e.message || e)) }) el('btnLibSave').onclick = () => saveLibraryItem().catch(async e => { await modalAlert('服饰保存失败', String(e.message || e)) }) el('btnLibReset').onclick = () => resetLibForm() el('btnAiAudit').onclick = () => runAiAudit().catch(async e => { await modalAlert('AI审核失败', String(e.message || e)) }) el('aiImage').onchange = () => { const f = el('aiImage').files && el('aiImage').files[0] if (!f) { el('aiPreview').removeAttribute('src') return } toDataUrl(f).then(url => { el('aiPreview').src = url }).catch(() => {}) } el('btnSave').onclick = () => saveOutfit().catch(async e => { await modalAlert('保存失败', String(e.message || e)) }) el('btnReset').onclick = () => { resetForm() showMsg('') } el('btnCheck').onclick = () => submitCheck().catch(async e => { await modalAlert('提交失败', String(e.message || e)) }) if (currentTab === 'wave') { document.body.classList.add('full-width-mode') showWaveTab() } else if (currentTab === 'korean') { document.body.classList.add('full-width-mode') showKoreanTab() } else if (currentTab === 'zhihu') { document.body.classList.add('full-width-mode') showZhihuTab() } else if (currentTab === 'x') { document.body.classList.add('full-width-mode') showXTab() } else { resetForm() resetLibForm() el('aiScene').value = 'commute' el('aiTempZone').value = '' el('aiAuditResult').textContent = `请上传穿搭照片后开始AI审核(当前:${profileText(state.profileKey)})` el('aiAuditIssues').textContent = '' await loadRules() await Promise.all([loadOutfits(), loadRecentChecks(), loadToday(), loadLibrary(), loadUnmatchedReport()]) } } init().catch(e => { modalAlert('初始化失败', String((e && e.message) || e)) }) })()