(() => {
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 `
#${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 = ``
}
html += `
`
})
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))
})
})()