1360 lines
52 KiB
JavaScript
1360 lines
52 KiB
JavaScript
(() => {
|
||
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 = `
|
||
<td>${row.name || ''}</td>
|
||
<td>${sceneText(row.scene)}</td>
|
||
<td>${row.temp_zone || ''}</td>
|
||
<td>${row.top_desc || ''}</td>
|
||
<td>${row.bottom_desc || ''}</td>
|
||
<td>${row.shoes_desc || ''}</td>
|
||
<td>${row.outer_desc || ''}</td>
|
||
<td>${row.accessory_desc || ''}</td>
|
||
<td>${row.notes || ''}</td>
|
||
<td>${row.linked_items_summary || '-'}</td>
|
||
<td>
|
||
<div class="row-actions">
|
||
<button data-id="${row.id}" data-act="edit">编辑</button>
|
||
<button data-id="${row.id}" data-act="del">删除</button>
|
||
</div>
|
||
</td>
|
||
`
|
||
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 = `
|
||
<input type="checkbox" data-id="${r.id}" data-weight="${r.weight}" checked />
|
||
<span>[${r.rule_group}] ${r.rule_text}</span>
|
||
`
|
||
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 = `
|
||
<td>${row.item_name || ''}</td>
|
||
<td>${slotText(row.slot)}</td>
|
||
<td>${row.category || ''}</td>
|
||
<td>${row.temp_zone || ''}</td>
|
||
<td>${row.brand_options || ''}</td>
|
||
<td>${Number.isFinite(Number(row.price_min)) && Number.isFinite(Number(row.price_max)) ? `${row.price_min}-${row.price_max}` : '-'}</td>
|
||
<td>${Number(row.image_count || 0)} 张</td>
|
||
<td>${row.keywords || ''}</td>
|
||
<td>${row.notes || ''}</td>
|
||
<td>
|
||
<div class="row-actions">
|
||
<button data-id="${row.id}" data-act="viewimg">查看图</button>
|
||
<button data-id="${row.id}" data-act="uploadimg">上传图</button>
|
||
<button data-id="${row.id}" data-act="edititem">编辑</button>
|
||
</div>
|
||
</td>
|
||
`
|
||
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 `
|
||
<a href="${url}" target="_blank" rel="noreferrer" style="display:block;text-decoration:none;color:inherit">
|
||
<div style="border:1px solid rgba(255,255,255,0.15);border-radius:8px;overflow:hidden;background:rgba(255,255,255,0.04)">
|
||
<img src="${url}" alt="img-${idx + 1}" style="width:100%;height:180px;object-fit:cover;display:block" />
|
||
<div style="padding:8px 10px;font-size:12px;opacity:.92">#${idx + 1} · ${r.source_type || ''}</div>
|
||
</div>
|
||
</a>
|
||
`
|
||
}).join('')
|
||
await openModal({
|
||
title: `${itemRow.item_name} · 图片附件(${rows.length})`,
|
||
message: `<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:10px">${cards}</div>`,
|
||
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 = `
|
||
<img src="${w.image_url}" loading="lazy" />
|
||
<div class="wave-card-text">${w.evaluation || 'AI 正在解析中...'}</div>
|
||
`
|
||
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}页)<br>已处理 ${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 = `爬虫已停止<br>已处理 ${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 = `
|
||
<img src="${w.image_url}" loading="lazy" />
|
||
<div class="wave-card-text">${w.evaluation || 'AI 正在解析中...'}</div>
|
||
`
|
||
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}页)<br>已处理 ${data.totalProcessed},AI 采纳 ${data.totalApproved} 条`
|
||
state.koreanCrawlerTimer = setTimeout(checkKoreanCrawlerStatus, 5000)
|
||
} else {
|
||
btn.textContent = '▶ 启动佛系爬虫'
|
||
btn.style.background = 'rgba(37, 99, 235, 0.8)'
|
||
st.innerHTML = `爬虫已停止<br>已处理 ${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 = '<div style="padding: 16px; color: #94a3b8">暂无数据,请先运行抓取脚本</div>'
|
||
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 += `<div class="toc-group">${group}</div>`
|
||
grouped[group].forEach(item => {
|
||
if (!item.contentHtml) {
|
||
html += `<div class="toc-item disabled" title="尚未抓取">${item.title}</div>`
|
||
} else {
|
||
html += `<div class="toc-item" onclick="window.loadZhihuArticle(${item.index})">${item.title}</div>`
|
||
}
|
||
})
|
||
}
|
||
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 = `<div class="ztext">${aHtml}</div>`
|
||
el('zhihuContentArea').scrollTop = 0
|
||
} catch (e) {
|
||
el('zhihuContentArea').innerHTML = `<div class="zhihu-placeholder"><h3>加载文章失败</h3></div>`
|
||
}
|
||
}
|
||
} 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 = `
|
||
<div class="x-placeholder">
|
||
<div class="x-spinner"></div>
|
||
<p>正在加载推文...</p>
|
||
</div>
|
||
`
|
||
|
||
try {
|
||
const res = await fetch(`/data/x_fashion/${account}/data.json`, { cache: 'no-store' })
|
||
if (!res.ok) {
|
||
el('xFeed').innerHTML = '<div class="x-placeholder"><p>暂无数据,请先运行抓取脚本</p></div>'
|
||
return
|
||
}
|
||
const data = await res.json()
|
||
if (data.length === 0) {
|
||
el('xFeed').innerHTML = '<div class="x-placeholder"><p>还没有推文</p></div>'
|
||
return
|
||
}
|
||
|
||
let html = ''
|
||
data.forEach(tweet => {
|
||
const time = new Date(tweet.timestamp).toLocaleString()
|
||
let imagesHtml = ''
|
||
if (tweet.localImages && tweet.localImages.length > 0) {
|
||
imagesHtml = `<div class="x-tweet-images count-${Math.min(tweet.localImages.length, 4)}">`
|
||
tweet.localImages.forEach(img => {
|
||
imagesHtml += `<img src="${img}" loading="lazy" onclick="openXLightbox('${img}')" style="cursor: zoom-in;" />`
|
||
})
|
||
imagesHtml += `</div>`
|
||
}
|
||
|
||
html += `
|
||
<article class="x-tweet">
|
||
<div class="x-tweet-avatar"></div>
|
||
<div class="x-tweet-body">
|
||
<div class="x-tweet-meta">
|
||
<span class="x-tweet-name">${account}</span>
|
||
<span class="x-tweet-handle">@${account}</span>
|
||
<span class="x-tweet-dot">·</span>
|
||
<span class="x-tweet-time">${time}</span>
|
||
</div>
|
||
<div class="x-tweet-text">${tweet.text.replace(/\n/g, '<br>')}</div>
|
||
${imagesHtml}
|
||
<div class="x-tweet-actions">
|
||
<span>💬</span>
|
||
<span>🔁</span>
|
||
<span>❤️</span>
|
||
<span>📊</span>
|
||
</div>
|
||
</div>
|
||
</article>
|
||
`
|
||
})
|
||
el('xFeed').innerHTML = html
|
||
} catch (e) {
|
||
console.error(e)
|
||
el('xFeed').innerHTML = '<div class="x-placeholder"><p>加载数据失败</p></div>'
|
||
}
|
||
}
|
||
|
||
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))
|
||
})
|
||
})()
|