Files
yangxiangyuan a6f763ffd8 feat: 重构认证流程,升级工具功能并优化后端配置
- 修复token泄露风险:改用X-Local-Creds-Token请求头传递token,从URL Hash读取set_token避免日志记录
- 重构全站认证逻辑:提取公用工具函数统一各服务的认证校验流程
- 升级投资账本工具:支持本地上传CSV和粘贴文本,移除硬编码路径,适配新参数格式
- 新增403/404/500标准错误响应文件,统一API错误返回格式
- 优化后端配置:关闭ETag,重构CORS中间件,新增全局错误捕获,限制敏感文件访问
- 优化web zen box的Service Worker,支持音频分片请求与断点续传
- 清理冗余前端代码,调整页面格式与权限控制
2026-08-01 21:50:05 +08:00

2133 lines
94 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
(() => {
;(function handleSetToken() {
try {
const u = new URL(window.location.href)
const hashRaw = String(u.hash || '').replace(/^#/, '')
const hashParams = new URLSearchParams(hashRaw.startsWith('?') ? hashRaw.slice(1) : hashRaw)
const token = hashParams.get('set_token')
if (token) {
localStorage.setItem('toolbox_creds_token', token)
hashParams.delete('set_token')
u.hash = hashParams.toString() ? `#${hashParams.toString()}` : ''
window.history.replaceState(null, '', u.toString())
}
} catch {}
})()
const LEGACY_STORAGE_KEY = 'rational_lock_db_v1'
const LEGACY_DRAFT_KEY = 'rational_lock_form_draft_v1'
const LEGACY_USER_DB_KEY = 'rational_lock_user_dbs_v1'
const LEGACY_USER_DRAFT_KEY = 'rational_lock_user_drafts_v1'
const API_BASE = '/api/rational_lock'
const DEFAULT_COOLING_DAYS = 3
const HISTORY_PAGE_SIZE = 10
const DEFAULT_USERNAME = 'admin'
const CATEGORY_RISK_MAP = {
'园艺绿植低风险类': { level: 'low', label: '低风险类', tip: '这类通常可以在拼多多、淘宝、闲鱼买,但也别闭眼下单哦。' },
'家居软装小物件': { level: 'low', label: '低风险类', tip: '这类通常可以在拼多多、淘宝、闲鱼买,重点看描述和做工就好。' },
'日用消耗非接触类': { level: 'low', label: '低风险类', tip: '这类总体风险不高,但碰到二手、个人卖家时还是要多问几句。' },
'临时/消耗/低值易耗品': { level: 'low', label: '低风险类', tip: '这类可买范围宽一些,主要防踩坑,不用上升到过度纠结。' },
'其他低风险': { level: 'low', label: '低风险类', tip: '这类暂时没必要分得太细,按低风险兜底处理,但买之前还是看下描述、做工和售后。' },
'食品&入口类': { level: 'high', label: '高风险类', tip: '入口的东西风险最高,平台、店铺和品相都要更严。' },
'健康&身体接触类': { level: 'high', label: '高风险类', tip: '身体接触类要优先考虑正品和售后,不要图便宜硬上。' },
'安全&电器类': { level: 'high', label: '高风险类', tip: '带电、发热、爆裂风险的,平台和店铺必须更稳。' },
'贵重&保真刚需品': { level: 'high', label: '高风险类', tip: '贵重和保真需求强的,先保真,再谈便宜。' },
'其他高风险': { level: 'high', label: '高风险类', tip: '这类暂时拿不准就先按高风险处理,平台、店铺、品相和售后都多看一眼。' }
}
const CATEGORY_HELP_MAP = {
'园艺绿植低风险类': [
'塑料花盆、陶盆、树脂盆、花托、托盘',
'园艺小工具、园艺绑带、铺面石、营养土、垫底陶粒',
'小摆件、花园装饰、围栏、花架(非承重重型)',
'普通绿植 / 草花 / 多肉(低价练手款)'
],
'家居软装小物件': [
'小相框、装饰画、墙面贴纸、挂钩、收纳盒',
'桌面收纳、置物筐、防尘罩、简易布艺收纳',
'装饰类摆件、工业风小装饰、墙面软装'
],
'日用消耗非接触类': [
'垃圾袋、清洁刷子、拖把配件、抹布(非贴身)',
'打包袋、收纳袋、纸箱、标签贴纸、小五金配件',
'鱼缸装饰、造景石头、躲避屋(非过滤电器)'
],
'临时/消耗/低值易耗品': [
'一次性非入口用品、快递打包材料、简易耗材'
],
'其他低风险': [
'偏低价、非入口、非贴身、非通电、非保真刚需的小物件',
'如果只是不好细分,但整体风险不高,可以先归到这里'
],
'食品&入口类': [
'生鲜肉类、海鲜、果蔬、冷冻食品',
'零食、饮料、酒水、牛奶酸奶、乳制品',
'粮油米面、调味品、代餐、保健品、滋补品',
'茶叶、咖啡、冲泡类、速食、预制菜'
],
'健康&身体接触类': [
'药品、外用膏剂、消毒用品、医用耗材',
'保健品、维生素、益生菌、膳食补充剂',
'贴身衣物、袜子、毛巾、浴巾、床上四件套',
'洗漱护肤、洗发水、沐浴露、面膜、面霜、牙刷牙膏',
'体重秤、血压计、理疗小电器、按摩仪'
],
'安全&电器类': [
'大小家电、插排、插座、电线、充电头、数据线',
'锂电池产品、夜灯、取暖器、加湿器、鱼缸电器',
'厨房小家电(烧水壶、电饭煲、空气炸锅)'
],
'贵重&保真刚需品': [
'工具五金(高强度、防锈、精密工具)'
],
'其他高风险': [
'涉及入口、贴身接触、用电、防爆、保真,但一时不好细分的商品',
'拿不准时先归到这里,后续按高风险规则严一点判断'
]
}
const LEGACY_CATEGORY_ALIAS_MAP = {
'服饰': '其他高风险',
'数码': '安全&电器类',
'家居': '家居软装小物件',
'美妆': '健康&身体接触类',
'其他': '其他高风险'
}
const LEGACY_CATEGORY_RISK_MAP = {
'服饰': { level: 'high', label: '高风险类', tip: '历史分类“服饰”先按偏严模式看待,尤其贴身类更要稳一点。', legacy: true },
'数码': { level: 'high', label: '高风险类', tip: '历史分类“数码”先按偏严模式看待,尽量走正规渠道。', legacy: true },
'家居': { level: 'low', label: '低风险类', tip: '历史分类“家居”先按偏宽模式看,但如果其实是电器,请改选更准确分类哦。', legacy: true },
'美妆': { level: 'high', label: '高风险类', tip: '历史分类“美妆”先按偏严模式看待,接触皮肤的别太冒险。', legacy: true },
'其他': { level: 'high', label: '高风险类', tip: '历史分类“其他”信息太宽泛,系统先按偏严模式拦一手。', legacy: true }
}
const PLATFORM_LABEL_MAP = {
pdd: '拼多多',
taobao: '淘宝',
xianyu: '闲鱼',
tmall: '天猫',
jd: '京东',
other: '其他平台'
}
const SELLER_TYPE_LABEL_MAP = {
'brand-flagship': '品牌官旗',
'platform-premium': '平台优选 / 自营',
'enterprise-shop': '企业店',
'normal-shop': '普通店',
'personal-seller': '个人卖家'
}
const CONDITION_LABEL_MAP = {
'new-sealed': '全新未拆',
'new-opened': '全新已拆',
'used-good': '二手良品',
'used-flawed': '二手瑕疵',
unknown: '品相不确定'
}
const HIGH_RISK_ALLOWED_PLATFORMS = new Set(['tmall', 'jd', 'pdd', 'taobao'])
const HIGH_RISK_ALLOWED_SELLERS = new Set(['brand-flagship', 'platform-premium'])
const HIGH_RISK_ALLOWED_CONDITIONS = new Set(['new-sealed'])
const $ = id => document.getElementById(id)
const hasEl = id => !!$(id)
const now = () => Date.now()
const uid = () => `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
const emptyDB = () => ({ items: [], applications: [] })
const getCoolingDays = () => {
const el = $('cooling-days')
const raw = el ? Number(el.value) : NaN
if (Number.isFinite(raw) && raw >= 3 && raw <= 14) return raw
return DEFAULT_COOLING_DAYS
}
const syncCoolingDaysText = () => {
const el = $('cooling-days-text')
if (el) el.textContent = String(getCoolingDays())
}
const state = {
db: emptyDB(),
modal: { resolver: null },
auth: {
currentUser: ''
},
draft: null,
persist: {
inFlight: false,
pending: false
},
ui: {
history: {
search: '',
startDate: '',
endDate: '',
showArchived: false,
page: 1,
pageSize: HISTORY_PAGE_SIZE
},
library: {
editingId: ''
}
}
}
const parseJSON = (raw, fallback) => {
try {
const parsed = JSON.parse(raw)
if (parsed === null || typeof parsed !== 'object') return fallback
return parsed
} catch {
return fallback
}
}
const apiRequest = async (path, options = {}) => {
const res = await fetch(`${API_BASE}${path}`, {
method: options.method || 'GET',
headers: {
'Content-Type': 'application/json',
...(options.headers || {})
},
body: options.body === undefined ? undefined : JSON.stringify(options.body),
credentials: 'include'
})
let payload = null
try {
payload = await res.json()
} catch {
payload = null
}
if (!res.ok) {
const msg = payload && payload.error ? String(payload.error) : `http_${res.status}`
throw new Error(msg)
}
return payload || {}
}
const listKeyOf = (row, type) => {
const id = row && row.id ? String(row.id) : ''
if (id) return `${type}:${id}`
if (type === 'item') {
const name = String((row && row.name) || '').trim()
const category = String((row && row.category) || '').trim()
const createdAt = String((row && row.createdAt) || '')
return `${type}:${name}|${category}|${createdAt}`
}
const name = String((row && row.basics && row.basics.name) || '').trim()
const category = String((row && row.basics && row.basics.category) || '').trim()
const createdAt = String((row && row.createdAt) || '')
return `${type}:${name}|${category}|${createdAt}`
}
const mergeRows = (currentRows, legacyRows, type) => {
const result = Array.isArray(currentRows) ? currentRows.slice() : []
const seen = new Set(result.map(r => listKeyOf(r, type)))
;(Array.isArray(legacyRows) ? legacyRows : []).forEach(row => {
const key = listKeyOf(row, type)
if (seen.has(key)) return
seen.add(key)
result.push(row)
})
return result
}
const normalizeDB = value => {
const base = value && typeof value === 'object' ? value : {}
return {
items: Array.isArray(base.items) ? base.items : [],
applications: Array.isArray(base.applications) ? base.applications : []
}
}
const hasNonEmptyData = payload => {
const db = normalizeDB(payload && payload.db ? payload.db : payload)
return db.items.length > 0 || db.applications.length > 0
}
const readLegacyBrowserData = () => {
let legacyDB = null
let legacyDraft = null
try {
legacyDB = parseJSON(localStorage.getItem(LEGACY_STORAGE_KEY) || 'null', null)
if (legacyDB === null) {
const store = parseJSON(localStorage.getItem(LEGACY_USER_DB_KEY) || '{}', {})
const adminDB = store && typeof store === 'object' ? store[DEFAULT_USERNAME] : null
legacyDB = adminDB && typeof adminDB === 'object' ? adminDB : null
}
} catch {}
try {
legacyDraft = parseJSON(localStorage.getItem(LEGACY_DRAFT_KEY) || 'null', null)
if (legacyDraft === null) {
const store = parseJSON(localStorage.getItem(LEGACY_USER_DRAFT_KEY) || '{}', {})
const adminDraft = store && typeof store === 'object' ? store[DEFAULT_USERNAME] : null
legacyDraft = adminDraft && typeof adminDraft === 'object' ? adminDraft : null
}
} catch {}
return {
db: normalizeDB(legacyDB),
draft: legacyDraft && typeof legacyDraft === 'object' ? legacyDraft : null
}
}
const clearLegacyBrowserData = () => {
try { localStorage.removeItem(LEGACY_STORAGE_KEY) } catch {}
try { localStorage.removeItem(LEGACY_DRAFT_KEY) } catch {}
try { localStorage.removeItem(LEGACY_USER_DB_KEY) } catch {}
try { localStorage.removeItem(LEGACY_USER_DRAFT_KEY) } catch {}
try { localStorage.removeItem('rational_lock_users_v1') } catch {}
try { localStorage.removeItem('rational_lock_session_v1') } catch {}
}
const setAuthStatus = text => {
$('auth-status').textContent = text || ''
}
const renderAuth = () => {
const user = state.auth.currentUser
$('auth-login-box').style.display = user ? 'none' : ''
$('auth-logged-box').style.display = user ? '' : 'none'
$('app-content').style.display = user ? '' : 'none'
$('auth-current-user').textContent = user ? `当前账号:${user}` : ''
}
const restoreSession = async () => {
try {
const payload = await apiRequest('/auth/me')
const username = String(payload.username || '').trim()
if (!username) return false
state.auth.currentUser = username
return true
} catch {
state.auth.currentUser = ''
return false
}
}
const ensureLoggedIn = async () => {
if (state.auth.currentUser) return true
await modalAlert('请先登录', '请先输入账号和密码登录后再操作。')
return false
}
const loadDB = async () => {
if (!state.auth.currentUser) {
state.db = emptyDB()
state.draft = null
return
}
const payload = await apiRequest('/data')
state.db = normalizeDB(payload.db)
state.draft = payload.draft && typeof payload.draft === 'object' ? payload.draft : null
const legacy = readLegacyBrowserData()
const needMigrateData = hasNonEmptyData(legacy) && !hasNonEmptyData(payload)
const needMigrateDraft = legacy.draft && !state.draft
if (needMigrateData || needMigrateDraft) {
state.db = {
items: mergeRows(state.db.items, legacy.db.items, 'item'),
applications: mergeRows(state.db.applications, legacy.db.applications, 'app')
}
if (!state.draft && legacy.draft) state.draft = legacy.draft
await apiRequest('/data', {
method: 'PUT',
body: { db: state.db, draft: state.draft }
})
clearLegacyBrowserData()
}
}
const saveDB = () => {
if (!state.auth.currentUser) return
const persistNow = async () => {
if (!state.auth.currentUser) return
state.persist.inFlight = true
try {
await apiRequest('/data', {
method: 'PUT',
body: {
db: normalizeDB(state.db),
draft: state.draft && typeof state.draft === 'object' ? state.draft : null
}
})
} catch {}
state.persist.inFlight = false
if (state.persist.pending) {
state.persist.pending = false
persistNow()
}
}
if (state.persist.inFlight) {
state.persist.pending = true
return
}
persistNow()
}
const toNum = v => {
const n = Number(v)
return Number.isFinite(n) ? n : 0
}
const fmtDateTime = ts => {
const d = new Date(ts)
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
const h = String(d.getHours()).padStart(2, '0')
const mm = String(d.getMinutes()).padStart(2, '0')
return `${y}-${m}-${day} ${h}:${mm}`
}
const fmtMoney = v => {
const n = toNum(v)
return `¥${n.toFixed(2)}`
}
const escapeHtml = s => String(s || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
const formatBeijingDate = ts => {
const d = ts ? new Date(ts) : new Date()
const parts = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit'
}).formatToParts(d)
const y = parts.find(p => p.type === 'year')?.value || '1970'
const m = parts.find(p => p.type === 'month')?.value || '01'
const day = parts.find(p => p.type === 'day')?.value || '01'
return `${y}-${m}-${day}`
}
const formatBeijingDateTime = ts => {
const d = ts ? new Date(ts) : new Date()
const parts = new Intl.DateTimeFormat('zh-CN', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false
}).formatToParts(d)
const pick = t => parts.find(p => p.type === t)?.value || '00'
return `${pick('year')}-${pick('month')}-${pick('day')} ${pick('hour')}:${pick('minute')}`
}
const replacementSummary = app => {
const sel = app.commitments && app.commitments.replacementSelection ? app.commitments.replacementSelection : {}
const items = []
if (sel.noSimilar) items.push('家中无完全可替代的同类物品')
if (sel.damaged) items.push('现有同类物品已损坏、过时或无法使用')
if (sel.unrepairable) items.push('现有物品无法维修或改造继续使用')
if (sel.unique) items.push('该物品功能独特且无可替代')
if (!items.length) return '未记录'
return items.join(';')
}
const buildExpenseContentHtml = (app, purchasedAt) => {
const impulse = Array.isArray(app.impulse) ? app.impulse : []
const regretPossibility = String(app.regretPossibility || '')
const shopping = app && app.shopping ? app.shopping : {}
const html = `
<div>
<h3>理性消费决策锁 - 已购推送</h3>
<p>推送时间(北京时间):${escapeHtml(formatBeijingDateTime(purchasedAt))}</p>
<table border="1" cellspacing="0" cellpadding="6">
<tr><th>物品名称</th><td>${escapeHtml(app.basics.name)}</td></tr>
<tr><th>分类</th><td>${escapeHtml(app.basics.category)}</td></tr>
<tr><th>风险组</th><td>${escapeHtml(shopping.riskLabel || '未记录')}</td></tr>
<tr><th>金额</th><td>${escapeHtml(fmtMoney(app.basics.price))}</td></tr>
<tr><th>购买理由</th><td>${escapeHtml(app.basics.reason)}</td></tr>
<tr><th>购物方式</th><td>${escapeHtml(shopping.methodLabel || '')}</td></tr>
<tr><th>购买平台</th><td>${escapeHtml(shopping.platformLabel || '未记录')}</td></tr>
<tr><th>店铺类型</th><td>${escapeHtml(shopping.sellerTypeLabel || '未记录')}</td></tr>
<tr><th>品相</th><td>${escapeHtml(shopping.conditionLabel || '未记录')}</td></tr>
<tr><th>购物保障</th><td>${escapeHtml(shopping.guaranteeLabel || '')}</td></tr>
<tr><th>沟通问题</th><td>${escapeHtml([shopping.q1, shopping.q2, shopping.q3, shopping.q4, shopping.q5].filter(Boolean).join(';') || '无')}</td></tr>
<tr><th>使用场景</th><td>${escapeHtml(app.scenarios.where)}</td></tr>
<tr><th>使用频率</th><td>${escapeHtml(app.scenarios.frequency)}</td></tr>
<tr><th>可解决问题</th><td>${escapeHtml(app.scenarios.problem)}</td></tr>
<tr><th>替代检查</th><td>${escapeHtml(replacementSummary(app))}</td></tr>
<tr><th>评分总分</th><td>${escapeHtml(app.scores.total)}</td></tr>
<tr><th>防冲动验证</th><td>${escapeHtml(`问题1:${impulse[0] || ''};问题2:${impulse[1] || ''};问题3:${impulse[2] || ''}`)}</td></tr>
<tr><th>反悔可能</th><td>${escapeHtml(regretPossibility || '未填写')}</td></tr>
</table>
</div>`.trim()
return html
}
const pushPurchaseToExpense = async (app, purchasedAt) => {
const payload = {
date: formatBeijingDate(purchasedAt),
purchased_at: purchasedAt,
amount: toNum(app.basics.price),
content_html: buildExpenseContentHtml(app, purchasedAt)
}
const res = await fetch('/api/expense/rational-lock/push', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
})
let data = null
try {
data = await res.json()
} catch {}
if (!res.ok || !data || !data.ok) {
throw new Error((data && data.error) || `HTTP_${res.status}`)
}
return data
}
const getScoreTotal = () => {
return ['score-practical', 'score-necessary', 'score-usage', 'score-cost', 'score-regret']
.map(id => toNum($(id).value))
.reduce((a, b) => a + b, 0)
}
const getImpulseAnswers = () => {
const getOne = name => {
const el = document.querySelector(`input[name="${name}"]:checked`)
return el ? el.value : ''
}
return [getOne('impulse-1'), getOne('impulse-2'), getOne('impulse-3')]
}
const allChecked = rootId => Array.from(document.querySelectorAll(`#${rootId} input[type="checkbox"]`)).every(n => n.checked)
const normalizeCategory = category => {
const key = String(category || '').trim()
return LEGACY_CATEGORY_ALIAS_MAP[key] || key
}
const getCategoryRiskInfo = category => {
const rawKey = String(category || '').trim()
const key = normalizeCategory(rawKey)
if (!key) return { level: '', label: '', tip: '', legacy: false }
if (CATEGORY_RISK_MAP[key]) return CATEGORY_RISK_MAP[key]
return LEGACY_CATEGORY_RISK_MAP[rawKey] || {
level: 'high',
label: '高风险类',
tip: '这个分类目前没有命中明确规则,系统先按偏严模式看待,宁可多拦一下。',
legacy: true
}
}
const getPlatformLabel = value => PLATFORM_LABEL_MAP[String(value || '')] || ''
const getSellerTypeLabel = value => SELLER_TYPE_LABEL_MAP[String(value || '')] || ''
const getConditionLabel = value => CONDITION_LABEL_MAP[String(value || '')] || ''
const getShoppingFields = () => ({
method: String(($('shopping-method') && $('shopping-method').value) || ''),
guarantee: String(($('shopping-guarantee') && $('shopping-guarantee').value) || ''),
platform: String(($('shopping-platform') && $('shopping-platform').value) || ''),
sellerType: String(($('shopping-seller-type') && $('shopping-seller-type').value) || ''),
condition: String(($('shopping-condition') && $('shopping-condition').value) || ''),
q1: String(($('shopping-q1') && $('shopping-q1').value) || '').trim(),
q2: String(($('shopping-q2') && $('shopping-q2').value) || '').trim(),
q3: String(($('shopping-q3') && $('shopping-q3').value) || '').trim(),
q4: String(($('shopping-q4') && $('shopping-q4').value) || '').trim(),
q5: String(($('shopping-q5') && $('shopping-q5').value) || '').trim()
})
const countShoppingAnswers = shopping => {
const s = shopping || getShoppingFields()
return [s.q1, s.q2, s.q3, s.q4, s.q5].filter(x => String(x || '').trim()).length
}
const evaluateShoppingRisk = (shopping, category) => {
const s = shopping || getShoppingFields()
const categoryInfo = getCategoryRiskInfo(category || $('item-category').value)
const missingQuestions = ['q1', 'q2', 'q3', 'q4', 'q5'].filter(k => !String(s[k] || '').trim()).length
const result = {
riskLevel: categoryInfo.level,
riskLabel: categoryInfo.label,
categoryTip: categoryInfo.tip,
isLegacyCategory: !!categoryInfo.legacy,
isOnline: s.method === 'online',
allowed: false,
blocked: false,
needQuestions: false,
missingQuestions,
reason: '',
tip: '',
submitText: ''
}
if (!s.method) {
result.reason = '请先选一下“购物方式”,我才能继续判断风险哦。'
return result
}
if (s.method !== 'online') {
result.allowed = true
result.tip = '线下店通常可以现场看货,本轮网购风控就先跳过啦。'
result.submitText = '当前不是网购,本环节已通过。'
return result
}
if (!s.guarantee) {
result.reason = '你选了网购,先把“购物保障”补上,我才知道售后稳不稳。'
return result
}
if (!s.platform) {
result.reason = '你选了网购,再告诉我打算在哪个平台买呀。'
return result
}
if (!s.sellerType) {
result.reason = '平台有了,还差“店铺类型”,这个会直接影响真假和售后。'
return result
}
if (!s.condition) {
result.reason = '还差“品相”没选,我得先知道你打算买全新还是二手。'
return result
}
if (categoryInfo.level === 'high') {
if (!HIGH_RISK_ALLOWED_PLATFORMS.has(s.platform)) {
result.blocked = true
result.reason = `【${categoryInfo.label}】别先去${getPlatformLabel(s.platform) || '这个平台'}下单哦,最好走天猫、京东,或平台里带官方背书的渠道。`
} else if (!HIGH_RISK_ALLOWED_SELLERS.has(s.sellerType)) {
result.blocked = true
result.reason = `【${categoryInfo.label}】尽量只走“品牌官旗”或“平台优选 / 自营”,普通店、企业店、个人卖家都不够稳。`
} else if (!HIGH_RISK_ALLOWED_CONDITIONS.has(s.condition)) {
result.blocked = true
result.reason = `【${categoryInfo.label}】对品相要求更严,最好只接受“全新未拆”,拆封、二手或说不清楚的先别碰。`
} else {
result.needQuestions = s.guarantee !== 'seven-day'
result.allowed = !result.needQuestions || result.missingQuestions === 0
result.tip = result.needQuestions
? '这单组合已经过了平台、店铺和品相门槛,但售后还不够稳,先把 5 条沟通问题补齐再说。'
: '这单属于高风险类,但你这次的平台、店铺、品相都过关了,可以继续。'
}
} else {
const riskySignals = []
if (s.guarantee !== 'seven-day') riskySignals.push('售后偏弱')
if (s.platform === 'xianyu' || s.platform === 'other') riskySignals.push('平台偏冒险')
if (s.sellerType === 'personal-seller' || s.sellerType === 'normal-shop') riskySignals.push('店铺不够稳')
if (s.condition !== 'new-sealed') riskySignals.push('品相不够新')
result.needQuestions = riskySignals.length > 0
result.allowed = !result.needQuestions || result.missingQuestions === 0
result.tip = result.needQuestions
? `这单可以继续,但${riskySignals.join('、')},为了少踩坑,先把 5 条沟通问题补齐哦。`
: '这单属于低风险类,而且平台、店铺、品相、售后都比较稳,可以继续。'
}
if (!result.blocked && result.needQuestions && result.missingQuestions > 0) {
result.reason = `当前这单还偏危险,请先补全 ${result.missingQuestions} 条沟通问题,我再放你继续。`
}
result.submitText = result.reason
? `当前不能提交:${result.reason}`
: (result.tip || '网购风险核验已通过。')
return result
}
const shoppingNeedQuestions = shopping => evaluateShoppingRisk(shopping).needQuestions
const shoppingIncompleteReason = () => evaluateShoppingRisk().reason
const syncCategoryRiskTip = () => {
const box = $('shopping-risk-group-tip')
if (!box) return
const category = $('item-category') ? $('item-category').value : ''
const normalizedCategory = normalizeCategory(category)
if (!category) {
box.textContent = ''
return
}
const info = getCategoryRiskInfo(category)
if (!info.level) {
box.textContent = ''
return
}
box.textContent = info.legacy && normalizedCategory && normalizedCategory !== category
? `系统已把历史分类【${category}】归并到【${normalizedCategory}】,按“${info.label}”来判断。${info.tip}`
: info.legacy
? `系统先把【${category}】按“${info.label}”来盯着看。${info.tip}`
: `系统判定:【${normalizedCategory || category}】属于“${info.label}”。${info.tip}`
}
const syncCategoryHelp = () => {
const box = $('item-category-help-body')
if (!box) return
const category = $('item-category') ? $('item-category').value : ''
const normalizedCategory = normalizeCategory(category)
if (!normalizedCategory) {
box.textContent = '请选择商品分类后查看该分类对应的常见示例。'
return
}
const items = CATEGORY_HELP_MAP[normalizedCategory] || []
if (!items.length) {
box.innerHTML = `<div class="field-tip-empty">当前分类【${escapeHtml(normalizedCategory)}】暂未配置示例,请按实际风险选择最接近的分类。</div>`
return
}
box.innerHTML = `
<div class="field-tip-current">当前分类:${escapeHtml(normalizedCategory)}</div>
<ul class="field-tip-list">${items.map(item => `<li>${escapeHtml(item)}</li>`).join('')}</ul>
`
}
const syncShoppingRiskUI = () => {
if (!hasEl('shopping-method')) return
const s = getShoppingFields()
const category = $('item-category') ? $('item-category').value : ''
const riskEval = evaluateShoppingRisk(s, category)
const guaranteeWrap = $('shopping-guarantee-wrap')
const platformWrap = $('shopping-platform-wrap')
const onlineExtraWrap = $('shopping-online-extra-wrap')
const qWrap = $('shopping-questions-wrap')
const tip = $('shopping-risk-tip')
const submitTip = $('shopping-submit-tip')
const progressWrap = $('shopping-progress-wrap')
const progressText = $('shopping-progress-text')
const isOnline = s.method === 'online'
if (guaranteeWrap) guaranteeWrap.style.display = isOnline ? '' : 'none'
if (platformWrap) platformWrap.style.display = isOnline ? '' : 'none'
if (onlineExtraWrap) onlineExtraWrap.style.display = isOnline ? '' : 'none'
if (!isOnline) {
;['shopping-guarantee', 'shopping-platform', 'shopping-seller-type', 'shopping-condition'].forEach(id => {
if (hasEl(id)) $(id).value = ''
})
}
const needQ = riskEval.needQuestions
if (qWrap) qWrap.style.display = needQ ? '' : 'none'
if (progressWrap) progressWrap.style.display = needQ ? '' : 'none'
if (!needQ) {
;['shopping-q1','shopping-q2','shopping-q3','shopping-q4','shopping-q5'].forEach(id => { if (hasEl(id)) $(id).value = '' })
}
if (needQ) {
const done = countShoppingAnswers(s)
if (progressText) progressText.textContent = `沟通问题完成进度:${done}/5`
for (let i = 1; i <= 5; i += 1) {
const light = $(`shopping-light-${i}`)
if (!light) continue
if (i <= done) light.classList.add('on')
else light.classList.remove('on')
}
} else {
if (progressText) progressText.textContent = ''
for (let i = 1; i <= 5; i += 1) {
const light = $(`shopping-light-${i}`)
if (light) light.classList.remove('on')
}
}
if (tip) {
let msg = ''
if (!category) msg = '先把商品分类选好,我才能自动给你映射风险组。'
else if (riskEval.reason) msg = riskEval.reason
else msg = riskEval.tip
tip.textContent = msg
tip.style.display = msg ? '' : 'none'
}
if (submitTip) {
submitTip.textContent = riskEval.submitText
}
}
const openModal = ({ title, message, okText = '知道了', cancelText = '', tone = 'normal', wide = false, html = false }) => {
return new Promise(resolve => {
const mask = $('app-modal')
const panel = mask.querySelector('.modal-panel')
const titleBox = $('modal-title')
const msgBox = $('modal-message')
const actions = $('modal-actions')
actions.innerHTML = ''
if (panel) panel.classList.toggle('wide', !!wide)
titleBox.textContent = title || ''
msgBox.classList.toggle('is-rich', !!html)
if (html) msgBox.innerHTML = message || ''
else msgBox.textContent = message || ''
const close = val => {
mask.style.display = 'none'
state.modal.resolver = null
resolve(val)
}
if (cancelText) {
const cancelBtn = document.createElement('button')
cancelBtn.className = 'btn'
cancelBtn.type = 'button'
cancelBtn.textContent = cancelText
cancelBtn.addEventListener('click', () => close(false))
actions.appendChild(cancelBtn)
}
const okBtn = document.createElement('button')
okBtn.className = tone === 'primary' ? 'btn btn-primary' : 'btn'
okBtn.type = 'button'
okBtn.textContent = okText
okBtn.addEventListener('click', () => close(true))
actions.appendChild(okBtn)
mask.style.display = ''
state.modal.resolver = close
})
}
const modalAlert = (title, message) => openModal({ title, message, okText: '我知道了' })
const modalConfirm = (title, message, okText = '继续', cancelText = '取消') => openModal({ title, message, okText, cancelText, tone: 'primary' })
const modalDetail = (title, message) => openModal({ title, message, okText: '关闭', wide: true, html: true })
let busyCount = 0
const showBusy = (text = '正在帮你处理哦,请稍等~') => {
busyCount += 1
$('busy-text').textContent = text
$('busy-mask').style.display = ''
}
const hideBusy = () => {
busyCount = Math.max(0, busyCount - 1)
if (!busyCount) $('busy-mask').style.display = 'none'
}
const withBusy = async (fn, text) => {
showBusy(text)
try {
return await fn()
} finally {
hideBusy()
}
}
const getReplacementSelection = () => {
const noSimilar = !!document.querySelector('#commit-replacement input[data-replacement="no-similar"]')?.checked
const damaged = !!document.querySelector('#commit-replacement input[data-replacement="damaged"]')?.checked
const unrepairable = !!document.querySelector('#commit-replacement input[data-replacement="unrepairable"]')?.checked
const unique = !!document.querySelector('#commit-replacement input[data-replacement="unique"]')?.checked
return { noSimilar, damaged, unrepairable, unique }
}
const evaluateReplacementSelection = () => {
const s = getReplacementSelection()
if (s.noSimilar && (s.damaged || s.unrepairable)) {
return {
valid: false,
scene: '',
message: '检测到互斥:你选择了“家中无同类物品”,就不能再选择“已有旧物损坏/无法维修”。\n请二选一:\n1)场景1(家里没有同类)\n2)场景2(家里有旧物且已损坏并无法维修)'
}
}
if (s.noSimilar) {
return { valid: true, scene: 'scene1', message: '当前为场景1:家里没有同类物品。可选勾选“功能独特”。' }
}
if (s.damaged || s.unrepairable) {
if (!(s.damaged && s.unrepairable)) {
return {
valid: false,
scene: '',
message: '当前像是场景2,但信息不完整。\n场景2必须同时勾选:\n1)现有同类物品已损坏/过时/无法使用\n2)现有物品无法维修或改造继续使用'
}
}
return { valid: true, scene: 'scene2', message: '当前为场景2:已存在旧物且不可继续使用。可选勾选“功能独特”。' }
}
return {
valid: false,
scene: '',
message: '请先选择替代检查场景:\n1)场景1:勾选“家中无完全可替代的同类物品”\n2)场景2:同时勾选“已损坏/过时”和“无法维修/改造”'
}
}
const statusTextOf = status => ({
cooling: '待决策',
approved: '待决策',
rejected: '不建议购买',
abandoned: '已放弃',
purchased: '已购买'
})[status] || status
const actionText = action => ({
submit: '提交申请',
purchase: '标记已购买',
'retry-push': '重试同步报销',
abandon: '标记放弃',
'toggle-low': '标记低使用率',
'archive-on': '归档隐藏',
'archive-off': '取消归档',
edit: '修改申请',
'copy-create': '复制新增'
})[action] || action
const normalizeActionLog = row => ({
at: Number(row && row.at) || now(),
action: String((row && row.action) || ''),
detail: String((row && row.detail) || '')
})
const pushActionLog = (app, action, detail = '') => {
if (!Array.isArray(app.actionLogs)) app.actionLogs = []
app.actionLogs.push(normalizeActionLog({ at: now(), action, detail }))
}
const detailRow = (label, value) => `
<div class="detail-row">
<div class="detail-label">${escapeHtml(label)}</div>
<div class="detail-value">${escapeHtml(value)}</div>
</div>
`
const detailSectionTitle = title => `<div class="detail-section-title">${escapeHtml(title)}</div>`
const detailMessage = app => {
const replacementSel = app && app.commitments && app.commitments.replacementSelection ? app.commitments.replacementSelection : {}
const replacementTags = []
if (replacementSel.noSimilar) replacementTags.push('无同类')
if (replacementSel.damaged) replacementTags.push('旧物损坏/过时')
if (replacementSel.unrepairable) replacementTags.push('不可维修')
if (replacementSel.unique) replacementTags.push('功能独特')
const impulse = Array.isArray(app && app.impulse) ? app.impulse : []
const logs = Array.isArray(app && app.actionLogs) && app.actionLogs.length
? app.actionLogs.map(normalizeActionLog)
: (app
? [{ at: app.updatedAt || app.createdAt, action: 'legacy', detail: app.resultReason || statusTextOf(app.status) }]
: [])
const logRows = logs.length
? logs.map((log, idx) => detailRow('记录', `${idx + 1}. ${fmtDateTime(log.at)} | ${actionText(log.action)}${log.detail ? ` | ${log.detail}` : ''}`)).join('')
: detailRow('记录', '暂无操作记录')
return `
${detailRow('商品名称', app && app.basics ? app.basics.name : '')}
${detailRow('商品分类', app && app.basics ? app.basics.category : '')}
${detailRow('预估价格', fmtMoney(app && app.basics ? app.basics.price : 0))}
${detailRow('状态', statusTextOf(app && app.status))}
${detailRow('提交时间', fmtDateTime(app && app.createdAt))}
${detailRow('冷静期', `${toNum(app && app.coolingDays)}天`)}
${detailSectionTitle('填写内容')}
${detailRow('购买原因简述', app && app.basics ? app.basics.reason : '')}
${detailRow('风险组', app && app.shopping && app.shopping.riskLabel ? app.shopping.riskLabel : '未记录')}
${detailRow('购物方式', app && app.shopping && app.shopping.methodLabel ? app.shopping.methodLabel : '未记录')}
${detailRow('购买平台', app && app.shopping && app.shopping.platformLabel ? app.shopping.platformLabel : '未记录')}
${detailRow('店铺类型', app && app.shopping && app.shopping.sellerTypeLabel ? app.shopping.sellerTypeLabel : '未记录')}
${detailRow('品相', app && app.shopping && app.shopping.conditionLabel ? app.shopping.conditionLabel : '未记录')}
${detailRow('购物保障', app && app.shopping && app.shopping.guaranteeLabel ? app.shopping.guaranteeLabel : '未记录')}
${detailRow('沟通问题1', app && app.shopping && app.shopping.q1 ? app.shopping.q1 : '未填写')}
${detailRow('沟通问题2', app && app.shopping && app.shopping.q2 ? app.shopping.q2 : '未填写')}
${detailRow('沟通问题3', app && app.shopping && app.shopping.q3 ? app.shopping.q3 : '未填写')}
${detailRow('沟通问题4', app && app.shopping && app.shopping.q4 ? app.shopping.q4 : '未填写')}
${detailRow('沟通问题5', app && app.shopping && app.shopping.q5 ? app.shopping.q5 : '未填写')}
${detailRow('替代检查', replacementSummary(app || {}))}
${detailRow('替代标签', replacementTags.length ? replacementTags.join('、') : '未记录')}
${detailRow('场景1', app && app.scenarios ? app.scenarios.where : '')}
${detailRow('场景2', app && app.scenarios ? app.scenarios.frequency : '')}
${detailRow('场景3', app && app.scenarios ? app.scenarios.problem : '')}
${detailRow('实用程度', String(app && app.scores ? app.scores.practical : ''))}
${detailRow('必需程度', String(app && app.scores ? app.scores.necessary : ''))}
${detailRow('预期使用率', String(app && app.scores ? app.scores.usage : ''))}
${detailRow('性价比合理性', String(app && app.scores ? app.scores.cost : ''))}
${detailRow('后悔概率', String(app && app.scores ? app.scores.regret : ''))}
${detailRow('评分总分', String(app && app.scores ? app.scores.total : ''))}
${detailRow('防冲动验证1', impulse[0] || '未填写')}
${detailRow('防冲动验证2', impulse[1] || '未填写')}
${detailRow('防冲动验证3', impulse[2] || '未填写')}
${detailRow('反悔可能', app && app.regretPossibility ? app.regretPossibility : '未填写')}
${detailSectionTitle('按钮操作历史')}
${logRows}
`
}
const hasCoolingEnded = app => now() >= toNum(app && app.coolingEndAt)
const isCoolingLocked = app => {
if (!app) return false
if (app.status !== 'cooling' && app.status !== 'approved') return false
return !hasCoolingEnded(app)
}
const getCoolingLeft = app => {
const left = Math.max(0, toNum(app && app.coolingEndAt) - now())
const days = Math.max(1, Math.ceil(left / 86400000))
const hours = Math.floor((left % 86400000) / 3600000)
const minutes = Math.floor((left % 3600000) / 60000)
const seconds = Math.floor((left % 60000) / 1000)
return { left, days, hours, minutes, seconds }
}
const coolingLeftText = app => {
const leftInfo = getCoolingLeft(app)
return `${leftInfo.days}天${leftInfo.hours}小时`
}
const hasCopyCreateResultMark = app => app && (app.status === 'purchased' || app.status === 'abandoned')
const copyCreateBlockReasonOfApplication = app => {
if (!app) return '这条记录不存在或已被移除,暂时不能复制新增。'
if (!hasCoolingEnded(app)) return `冷静期还剩${coolingLeftText(app)},请等冷静期结束后再复制新增。`
if (!hasCopyCreateResultMark(app)) return `当前状态是“${statusTextOf(app.status)}”,请先标记“已购买”或“标记放弃”后再复制新增。`
return ''
}
const loadApplicationToForm = source => {
const sourceCoolingDays = Number(source && source.coolingDays)
const coolingDays = Number.isFinite(sourceCoolingDays) && sourceCoolingDays >= 3 && sourceCoolingDays <= 14 ? sourceCoolingDays : DEFAULT_COOLING_DAYS
const replacementSelection = source && source.commitments && source.commitments.replacementSelection
? { ...source.commitments.replacementSelection }
: {}
const draft = {
savedAt: now(),
basics: {
name: String(source && source.basics && source.basics.name || ''),
category: String(source && source.basics && source.basics.category || ''),
price: String(source && source.basics && source.basics.price || ''),
reason: String(source && source.basics && source.basics.reason || '')
},
shopping: {
method: String(source && source.shopping && source.shopping.method || ''),
guarantee: String(source && source.shopping && source.shopping.guarantee || ''),
platform: String(source && source.shopping && source.shopping.platform || ''),
sellerType: String(source && source.shopping && source.shopping.sellerType || ''),
condition: String(source && source.shopping && source.shopping.condition || ''),
q1: String(source && source.shopping && source.shopping.q1 || ''),
q2: String(source && source.shopping && source.shopping.q2 || ''),
q3: String(source && source.shopping && source.shopping.q3 || ''),
q4: String(source && source.shopping && source.shopping.q4 || ''),
q5: String(source && source.shopping && source.shopping.q5 || '')
},
regretPossibility: String(source && source.regretPossibility || ''),
coolingChecks: [
!!(source && source.commitments && source.commitments.coolingAllChecked),
!!(source && source.commitments && source.commitments.coolingAllChecked),
!!(source && source.commitments && source.commitments.coolingAllChecked)
],
coolingDays,
replacement: {
noSimilar: !!replacementSelection.noSimilar,
damaged: !!replacementSelection.damaged,
unrepairable: !!replacementSelection.unrepairable,
unique: !!replacementSelection.unique
},
scenes: {
where: String(source && source.scenarios && source.scenarios.where || ''),
frequency: String(source && source.scenarios && source.scenarios.frequency || ''),
problem: String(source && source.scenarios && source.scenarios.problem || '')
},
scores: {
practical: String(source && source.scores && source.scores.practical || ''),
necessary: String(source && source.scores && source.scores.necessary || ''),
usage: String(source && source.scores && source.scores.usage || ''),
cost: String(source && source.scores && source.scores.cost || ''),
regret: String(source && source.scores && source.scores.regret || '')
},
impulse: {
i1: String(source && source.impulse && source.impulse.i1 || ''),
i2: String(source && source.impulse && source.impulse.i2 || ''),
i3: String(source && source.impulse && source.impulse.i3 || '')
},
finalChecks: [true, true, true]
}
applyDraft(draft)
const formTop = $('application-form')
if (formTop && formTop.scrollIntoView) formTop.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
const buildActionBtn = (id, act, text, blockedReason = '') => {
const cls = blockedReason ? 'btn btn-soft-disabled' : 'btn'
const reasonAttr = blockedReason ? ` data-block-reason="${escapeHtml(blockedReason)}" title="${escapeHtml(blockedReason)}"` : ''
return `<button class="${cls}" data-act="${act}" data-id="${id}"${reasonAttr}>${text}</button>`
}
const getDefaultHistoryRange = () => {
const end = new Date()
const start = new Date()
start.setMonth(start.getMonth() - 3)
return {
startDate: formatBeijingDate(start.getTime()),
endDate: formatBeijingDate(end.getTime())
}
}
const getHistoryFilteredList = () => {
const search = String(state.ui.history.search || '').trim().toLowerCase()
const startDate = state.ui.history.startDate || ''
const endDate = state.ui.history.endDate || ''
const showArchived = !!state.ui.history.showArchived
return state.db.applications
.slice()
.sort((a, b) => b.createdAt - a.createdAt)
.filter(a => {
if (!showArchived && a.archived) return false
const day = formatBeijingDate(a.createdAt)
if (startDate && day < startDate) return false
if (endDate && day > endDate) return false
if (!search) return true
const text = [
a.basics && a.basics.name,
a.basics && a.basics.category,
a.basics && a.basics.reason,
a.regretPossibility,
a.resultReason,
statusTextOf(a.status)
].map(x => String(x || '').toLowerCase()).join(' ')
return text.includes(search)
})
}
const collectFormDraft = () => {
const getRadio = name => {
const el = document.querySelector(`input[name="${name}"]:checked`)
return el ? el.value : ''
}
const checkedValues = rootId => Array.from(document.querySelectorAll(`#${rootId} input[type="checkbox"]`)).map(el => !!el.checked)
return {
savedAt: now(),
basics: {
name: $('item-name').value.trim(),
category: $('item-category').value,
price: $('item-price').value,
reason: $('item-reason').value.trim()
},
shopping: getShoppingFields(),
regretPossibility: $('item-regret-possibility').value.trim(),
coolingChecks: checkedValues('commit-cooling'),
coolingDays: getCoolingDays(),
replacement: getReplacementSelection(),
scenes: {
where: $('scene-where').value.trim(),
frequency: $('scene-frequency').value.trim(),
problem: $('scene-problem').value.trim()
},
scores: {
practical: $('score-practical').value,
necessary: $('score-necessary').value,
usage: $('score-usage').value,
cost: $('score-cost').value,
regret: $('score-regret').value
},
impulse: {
i1: getRadio('impulse-1'),
i2: getRadio('impulse-2'),
i3: getRadio('impulse-3')
},
finalChecks: checkedValues('commit-final')
}
}
const saveDraft = () => {
if (!state.auth.currentUser) return null
const draft = collectFormDraft()
state.draft = draft
saveDB()
return draft
}
const loadDraft = () => {
if (!state.auth.currentUser) return null
if (!state.draft || typeof state.draft !== 'object') return null
return state.draft
}
const clearDraft = () => {
if (!state.auth.currentUser) return
state.draft = null
saveDB()
}
const applyDraft = draft => {
if (!draft || typeof draft !== 'object') return false
$('item-name').value = (draft.basics && draft.basics.name) || ''
$('item-category').value = normalizeCategory((draft.basics && draft.basics.category) || '')
$('item-price').value = (draft.basics && draft.basics.price) || ''
$('item-reason').value = (draft.basics && draft.basics.reason) || ''
const shopping = draft.shopping || {}
if (hasEl('shopping-method')) $('shopping-method').value = shopping.method || ''
if (hasEl('shopping-guarantee')) $('shopping-guarantee').value = shopping.guarantee || ''
if (hasEl('shopping-platform')) $('shopping-platform').value = shopping.platform || ''
if (hasEl('shopping-seller-type')) $('shopping-seller-type').value = shopping.sellerType || ''
if (hasEl('shopping-condition')) $('shopping-condition').value = shopping.condition || ''
if (hasEl('shopping-q1')) $('shopping-q1').value = shopping.q1 || ''
if (hasEl('shopping-q2')) $('shopping-q2').value = shopping.q2 || ''
if (hasEl('shopping-q3')) $('shopping-q3').value = shopping.q3 || ''
if (hasEl('shopping-q4')) $('shopping-q4').value = shopping.q4 || ''
if (hasEl('shopping-q5')) $('shopping-q5').value = shopping.q5 || ''
$('item-regret-possibility').value = draft.regretPossibility || ''
const coolingInputs = Array.from(document.querySelectorAll('#commit-cooling input[type="checkbox"]'))
coolingInputs.forEach((el, idx) => {
el.checked = !!(Array.isArray(draft.coolingChecks) && draft.coolingChecks[idx])
})
const replacement = draft.replacement || {}
const setReplacement = (key, value) => {
const el = document.querySelector(`#commit-replacement input[data-replacement="${key}"]`)
if (el) el.checked = !!value
}
setReplacement('no-similar', replacement.noSimilar)
setReplacement('damaged', replacement.damaged)
setReplacement('unrepairable', replacement.unrepairable)
setReplacement('unique', replacement.unique)
$('scene-where').value = (draft.scenes && draft.scenes.where) || ''
$('scene-frequency').value = (draft.scenes && draft.scenes.frequency) || ''
$('scene-problem').value = (draft.scenes && draft.scenes.problem) || ''
$('score-practical').value = (draft.scores && draft.scores.practical) || ''
$('score-necessary').value = (draft.scores && draft.scores.necessary) || ''
$('score-usage').value = (draft.scores && draft.scores.usage) || ''
$('score-cost').value = (draft.scores && draft.scores.cost) || ''
$('score-regret').value = (draft.scores && draft.scores.regret) || ''
;['impulse-1', 'impulse-2', 'impulse-3'].forEach((name, idx) => {
const val = draft.impulse && draft.impulse[`i${idx + 1}`]
Array.from(document.querySelectorAll(`input[name="${name}"]`)).forEach(el => {
el.checked = String(el.value) === String(val || '')
})
})
const finalInputs = Array.from(document.querySelectorAll('#commit-final input[type="checkbox"]'))
finalInputs.forEach((el, idx) => {
el.checked = !!(Array.isArray(draft.finalChecks) && draft.finalChecks[idx])
})
const coolingSelect = $('cooling-days')
if (coolingSelect && draft.coolingDays) {
const val = Number(draft.coolingDays)
if (Number.isFinite(val) && val >= 3 && val <= 14) {
coolingSelect.value = String(val)
}
}
syncFormState()
return true
}
const formCompleted = () => {
if (!$('item-name').value.trim()) return false
if (!$('item-category').value) return false
if (!Number.isFinite(Number($('item-price').value))) return false
if (!$('item-reason').value.trim()) return false
if (shoppingIncompleteReason()) return false
if (!Number.isFinite(getCoolingDays())) return false
if (!allChecked('commit-cooling')) return false
if (!evaluateReplacementSelection().valid) return false
if (!$('scene-where').value.trim()) return false
if (!$('scene-frequency').value.trim()) return false
if (!$('scene-problem').value.trim()) return false
if (!$('item-regret-possibility').value.trim()) return false
if (getScoreTotal() <= 0) return false
if (['score-practical', 'score-necessary', 'score-usage', 'score-cost', 'score-regret'].some(id => !$(id).value)) return false
if (getImpulseAnswers().some(x => !x)) return false
if (!allChecked('commit-final')) return false
return true
}
const getFormBlockReason = () => {
if (!$('item-name').value.trim()) return '先填一下“商品名称”呀,不然我都不知道你想买啥。'
if (!$('item-category').value) return '先选“商品分类”,系统要靠它自动映射风险组。'
if (!Number.isFinite(Number($('item-price').value))) return '价格先填成有效数字哈,系统还要拿它做后面的判断。'
if (!$('item-reason').value.trim()) return '“购买原因简述”还没写,先说清楚你为什么想买。'
const shoppingReason = shoppingIncompleteReason()
if (shoppingReason) return shoppingReason
if (!Number.isFinite(getCoolingDays())) return '冷静期天数有点不对,重新选一下 3 到 14 天吧。'
if (!allChecked('commit-cooling')) return '冷静期确认项还没全勾完,这一步先别跳。'
const replacementEval = evaluateReplacementSelection()
if (!replacementEval.valid) return replacementEval.message.replace(/\n+/g, ' ')
if (!$('scene-where').value.trim()) return '“使用场景”还没填,先说下你准备在哪儿用。'
if (!$('scene-frequency').value.trim()) return '“使用频率”还没填,先说下你大概多久会用一次。'
if (!$('scene-problem').value.trim()) return '“可解决问题”还没填,先说清楚它到底解决什么问题。'
if (!$('item-regret-possibility').value.trim()) return '“反悔可能”还没填,先诚实评估下自己会不会后悔。'
if (['score-practical', 'score-necessary', 'score-usage', 'score-cost', 'score-regret'].some(id => !$(id).value)) return '五项评分还没打满,先都选一下。'
if (getScoreTotal() <= 0) return '当前评分总分还不成立,先把量表补完整哦。'
if (getImpulseAnswers().some(x => !x)) return '防冲动验证还没答完,三个问题都要选。'
if (!allChecked('commit-final')) return '最后确认项还没全部勾选,所以现在还不能提交。'
return ''
}
const evaluationFromForm = () => {
const scoreTotal = getScoreTotal()
const impulse = getImpulseAnswers()
const impulsePass = impulse.every(v => v === '否')
const scorePass = scoreTotal >= 14
const qualified = impulsePass && scorePass
let reason = ''
if (!impulsePass) reason = '防冲动验证存在“是”,不建议购买'
else if (!scorePass) reason = `必要性总分 ${scoreTotal} 低于14分,不建议购买`
return { scoreTotal, impulse, impulsePass, scorePass, qualified, reason }
}
const latestApplication = () => {
const list = state.db.applications.slice().sort((a, b) => b.createdAt - a.createdAt)
return list[0] || null
}
const findSimilar = (name, category) => {
const key = String(name || '').trim().toLowerCase()
return state.db.items.filter(it => {
const sameCat = String(it.category || '') === String(category || '')
const itName = String(it.name || '').toLowerCase()
const nameLike = key && (itName.includes(key) || key.includes(itName))
return sameCat || nameLike
})
}
const findDustRisk = (name, category) => {
const key = String(name || '').trim().toLowerCase()
return state.db.applications.filter(a => {
if (a.status !== 'purchased' || !a.usageLow) return false
const sameCat = String(a.basics.category || '') === String(category || '')
const aName = String(a.basics.name || '').toLowerCase()
const nameLike = key && (aName.includes(key) || key.includes(aName))
return sameCat || nameLike
})
}
const upsertItem = (name, category, note) => {
const n = String(name || '').trim()
const c = String(category || '').trim()
if (!n || !c) return
const existed = state.db.items.find(it => it.name === n && it.category === c)
if (existed) return
state.db.items.unshift({ id: uid(), name: n, category: c, note: String(note || ''), createdAt: now() })
}
const refreshWarning = () => {
const name = $('item-name').value.trim()
const category = $('item-category').value
const warningBox = $('warning-box')
const similar = findSimilar(name, category)
const dust = findDustRisk(name, category)
const lines = []
if (similar.length) lines.push(`同类提醒:已存在 ${similar.length} 条同类物品记录`)
if (dust.length) lines.push(`吃灰预警:历史已购中有 ${dust.length} 条低使用率同类物品`)
if (!lines.length) {
warningBox.style.display = 'none'
warningBox.className = 'card'
warningBox.textContent = ''
return
}
warningBox.style.display = ''
warningBox.className = 'card warn'
warningBox.textContent = lines.join(';')
}
const setFormStatus = text => {
$('form-status').textContent = text || ''
}
const syncFormState = () => {
$('score-total').textContent = String(getScoreTotal())
syncCategoryHelp()
syncCategoryRiskTip()
syncShoppingRiskUI()
const submitBtn = $('btn-submit')
const blockReason = getFormBlockReason()
submitBtn.disabled = false
submitBtn.classList.toggle('btn-soft-disabled', !!blockReason)
if (blockReason) {
submitBtn.setAttribute('title', blockReason)
submitBtn.setAttribute('data-block-reason', blockReason)
} else {
submitBtn.removeAttribute('title')
submitBtn.removeAttribute('data-block-reason')
}
syncCoolingDaysText()
refreshWarning()
}
const createApplication = () => {
const evalRes = evaluationFromForm()
const createdAt = now()
const coolingDays = getCoolingDays()
const shoppingEval = evaluateShoppingRisk()
const app = {
id: uid(),
createdAt,
updatedAt: createdAt,
coolingDays,
coolingEndAt: createdAt + coolingDays * 86400000,
basics: {
name: $('item-name').value.trim(),
category: $('item-category').value,
price: toNum($('item-price').value),
reason: $('item-reason').value.trim()
},
shopping: (() => {
const s = getShoppingFields()
const methodLabel = s.method === 'offline' ? '线下店' : (s.method === 'online' ? '网购' : '')
const guaranteeLabelMap = {
'seven-day': '有七天无理由退换',
'no-return': '没有无理由退换',
'xianyu-used': '闲鱼二手购买'
}
return {
...s,
riskLevel: shoppingEval.riskLevel || '',
riskLabel: shoppingEval.riskLabel || '',
methodLabel,
platformLabel: getPlatformLabel(s.platform),
sellerTypeLabel: getSellerTypeLabel(s.sellerType),
conditionLabel: getConditionLabel(s.condition),
guaranteeLabel: guaranteeLabelMap[s.guarantee] || '',
needRiskQuestions: shoppingEval.needQuestions,
riskTip: shoppingEval.tip || '',
riskReason: shoppingEval.reason || ''
}
})(),
commitments: {
coolingAllChecked: allChecked('commit-cooling'),
replacementValid: evaluateReplacementSelection().valid,
replacementScene: evaluateReplacementSelection().scene,
replacementSelection: getReplacementSelection(),
finalAllChecked: allChecked('commit-final')
},
scenarios: {
where: $('scene-where').value.trim(),
frequency: $('scene-frequency').value.trim(),
problem: $('scene-problem').value.trim()
},
regretPossibility: $('item-regret-possibility').value.trim(),
scores: {
practical: toNum($('score-practical').value),
necessary: toNum($('score-necessary').value),
usage: toNum($('score-usage').value),
cost: toNum($('score-cost').value),
regret: toNum($('score-regret').value),
total: evalRes.scoreTotal
},
impulse: evalRes.impulse,
qualified: evalRes.qualified,
status: evalRes.qualified ? 'cooling' : 'rejected',
resultReason: evalRes.qualified ? '待决策:冷静期进行中,暂不可购买/放弃' : evalRes.reason,
usageLow: false
}
pushActionLog(app, 'submit', `提交并进入 ${coolingDays} 天冷静期`)
return app
}
const resetForm = () => {
$('application-form').reset()
if (hasEl('shopping-guarantee-wrap')) $('shopping-guarantee-wrap').style.display = 'none'
if (hasEl('shopping-platform-wrap')) $('shopping-platform-wrap').style.display = 'none'
if (hasEl('shopping-online-extra-wrap')) $('shopping-online-extra-wrap').style.display = 'none'
if (hasEl('shopping-questions-wrap')) $('shopping-questions-wrap').style.display = 'none'
if (hasEl('shopping-risk-tip')) $('shopping-risk-tip').style.display = 'none'
if (hasEl('shopping-risk-group-tip')) $('shopping-risk-group-tip').textContent = ''
$('score-total').textContent = '0'
syncFormState()
}
const renderDecision = () => {
const box = $('decision-panel')
const app = latestApplication()
if (!app) {
box.className = 'decision'
box.textContent = '暂无申请记录'
return
}
const current = now()
if (app.status === 'cooling' && current >= app.coolingEndAt && app.qualified) {
box.className = 'decision pass'
box.innerHTML = `<div>申请:${app.basics.name}</div><div>冷静期已结束,可选择“已购买”或“标记放弃”</div><div>${app.resultReason || ''}</div>`
return
}
if (app.status === 'cooling') {
const leftInfo = getCoolingLeft(app)
box.className = 'decision wait'
box.innerHTML = `<div>申请:${app.basics.name}</div><div>冷静期剩余${leftInfo.days}天(${leftInfo.hours}小时 ${leftInfo.minutes}分 ${leftInfo.seconds}秒)</div><div>提交时间:${fmtDateTime(app.createdAt)}(冷静期内不可进行购买/放弃/低使用率标记)</div>`
return
}
if (app.status === 'approved') {
box.className = 'decision pass'
box.innerHTML = `<div>待决策</div><div>${app.basics.name} · ${fmtMoney(app.basics.price)}</div><div>冷静期已结束,可选择“已购买”或“标记放弃”</div>`
return
}
if (app.status === 'purchased') {
const topText = app.usageLow
? '已购买 · 已标记低使用率(同类购买将预警)'
: (app.expenseRecordId ? '已购买 · 已推送至报销系统' : '已购买 · 待同步报销系统')
box.className = 'decision pass'
box.innerHTML = `<div>${topText}</div><div>${app.basics.name} · ${fmtMoney(app.basics.price)}</div><div>${app.resultReason || ''}</div>`
return
}
if (app.status === 'abandoned') {
box.className = 'decision reject'
box.innerHTML = `<div>已放弃</div><div>${app.basics.name} · ${fmtMoney(app.basics.price)}</div><div>${app.resultReason || ''}</div>`
return
}
box.className = 'decision reject'
box.innerHTML = `<div>不建议购买</div><div>${app.basics.name} · ${fmtMoney(app.basics.price)}</div><div>${app.resultReason || ''}</div>`
}
const renderLibrary = () => {
if (!hasEl('btn-lib-add') || !hasEl('btn-lib-cancel-edit') || !hasEl('lib-list')) return
$('btn-lib-add').textContent = state.ui.library.editingId ? '保存修改' : '加入物品库'
$('btn-lib-cancel-edit').style.display = state.ui.library.editingId ? '' : 'none'
const box = $('lib-list')
box.innerHTML = ''
if (!state.db.items.length) {
box.textContent = '暂无物品库记录'
return
}
state.db.items.forEach(it => {
const row = document.createElement('div')
row.className = 'row'
row.innerHTML = `
<div><strong>${it.name}</strong> · ${it.category}</div>
<div class="meta">${it.note || '无备注'}</div>
<div class="actions">
<button class="btn" data-lib-edit="${it.id}">修改</button>
<button class="btn" data-lib-del="${it.id}">删除</button>
</div>
`
box.appendChild(row)
})
}
const monthlyStats = () => {
const d = new Date()
const y = d.getFullYear()
const m = d.getMonth()
const rows = state.db.applications.filter(a => {
const x = new Date(a.createdAt)
return x.getFullYear() === y && x.getMonth() === m
})
const wanted = rows.length
const abandoned = rows.filter(r => r.status === 'abandoned').length
const purchased = rows.filter(r => r.status === 'purchased').length
const saved = rows.filter(r => r.status === 'abandoned' || r.status === 'rejected').reduce((acc, r) => acc + toNum(r.basics.price), 0)
return { wanted, abandoned, purchased, saved }
}
const renderStats = () => {
const st = monthlyStats()
$('stats').innerHTML = `
<div class="item"><div>本月想买数</div><div class="value">${st.wanted}</div></div>
<div class="item"><div>本月放弃数</div><div class="value">${st.abandoned}</div></div>
<div class="item"><div>本月购买数</div><div class="value">${st.purchased}</div></div>
<div class="item"><div>节省金额</div><div class="value">${fmtMoney(st.saved)}</div></div>
`
}
const renderHistory = () => {
const box = $('history-list')
const pageInfo = $('history-page-info')
const prevBtn = $('history-prev')
const nextBtn = $('history-next')
box.innerHTML = ''
const list = getHistoryFilteredList()
if (!list.length) {
box.textContent = '暂无符合条件的历史记录'
pageInfo.textContent = '第 0 / 0 页,共 0 条'
prevBtn.disabled = true
nextBtn.disabled = true
return
}
const totalPages = Math.max(1, Math.ceil(list.length / state.ui.history.pageSize))
if (state.ui.history.page > totalPages) state.ui.history.page = totalPages
if (state.ui.history.page < 1) state.ui.history.page = 1
const start = (state.ui.history.page - 1) * state.ui.history.pageSize
const end = start + state.ui.history.pageSize
const pageRows = list.slice(start, end)
pageInfo.textContent = `第 ${state.ui.history.page} / ${totalPages} 页,共 ${list.length} 条`
prevBtn.disabled = state.ui.history.page <= 1
nextBtn.disabled = state.ui.history.page >= totalPages
pageRows.forEach(a => {
const row = document.createElement('div')
row.className = 'row'
const statusText = statusTextOf(a.status)
const coolingLocked = isCoolingLocked(a)
const canActAfterCooling = (a.status === 'cooling' || a.status === 'approved') && hasCoolingEnded(a)
const canRetryPush = a.status === 'purchased' && !a.expenseRecordId
const canMarkLowUsage = a.status === 'purchased' && !a.usageLow
const copyCreateBlock = copyCreateBlockReasonOfApplication(a)
const lowTag = a.usageLow ? '<span class="tag-low">低使用率预警</span>' : ''
const archivedTag = a.archived ? '<span class="tag-archived">已归档</span>' : ''
const coolingTip = coolingLocked
? `<div class="meta cooling-tip">【冷静期剩余${coolingLeftText(a)}】,期间不可进行购买/放弃/低使用率标记操作</div>`
: ''
const coolingReason = `冷静期还剩${coolingLeftText(a)},期间不能操作哦~`
const endedReason = `这个物品已经标记为“${a.status === 'purchased' ? '已购买' : '已放弃'}”啦,为了避免改来改去出错,现在不能再点其他按钮咯~`
const needPurchaseReason = '要先标记“已购买”之后,才能标记“低使用率”啦。你先确认购买,再过来标记就可以咯~'
const notEligibleReason = '当前这条记录暂不支持这个操作哦,可以先检查状态说明再决定下一步。'
const purchaseBlock = coolingLocked
? coolingReason
: (a.status === 'purchased' || a.status === 'abandoned'
? endedReason
: (!canActAfterCooling && a.status !== 'cooling' && a.status !== 'approved' ? notEligibleReason : ''))
const abandonBlock = coolingLocked
? coolingReason
: (a.status === 'purchased' || a.status === 'abandoned'
? endedReason
: (!canActAfterCooling && a.status !== 'cooling' && a.status !== 'approved' ? notEligibleReason : ''))
const lowBlock = coolingLocked
? coolingReason
: (a.status === 'abandoned' ? endedReason : (a.status !== 'purchased' ? needPurchaseReason : (a.usageLow ? '这个物品已经标记过“低使用率”啦~' : '')))
const editBlock = coolingLocked ? `冷静期还剩${coolingLeftText(a)},为了避免改来改去出错,现在不能修改这个物品的信息啦~` : ''
const deleteBlock = a.status === 'purchased'
? '这个物品已经标记为“已购买”啦,为了避免财务记录出错,不能删除哦!你可以改成归档隐藏。'
: ''
const actionsHtml = `
<button class="btn btn-link" data-act="view-detail" data-id="${a.id}" type="button">查看详情</button>
${buildActionBtn(a.id, 'purchase', '已购买', purchaseBlock)}
${buildActionBtn(a.id, 'abandon', '标记放弃', abandonBlock)}
${buildActionBtn(a.id, 'copy-create', '复制新增', copyCreateBlock)}
${buildActionBtn(a.id, 'toggle-low', canMarkLowUsage ? '标记低使用率' : '标记低使用率', lowBlock)}
${buildActionBtn(a.id, 'edit-app', '修改申请', editBlock)}
${buildActionBtn(a.id, 'archive-app', a.archived ? '取消归档' : '归档隐藏')}
${buildActionBtn(a.id, 'delete-app', '删除记录', deleteBlock)}
${canRetryPush ? buildActionBtn(a.id, 'retry-push', '重试同步报销') : ''}
`
row.innerHTML = `
<div class="row-head">
<div><strong>${a.basics.name}</strong> · ${a.basics.category} · ${fmtMoney(a.basics.price)}</div>
</div>
<div class="meta">状态:${statusText}${archivedTag ? ` | ${archivedTag}` : ''} ${lowTag ? `| ${lowTag}` : ''} | 总分:${a.scores.total} | 提交:${fmtDateTime(a.createdAt)}</div>
<div class="meta">反悔可能:${escapeHtml(a.regretPossibility || '未填写')}</div>
<div class="meta">${a.resultReason || ''}</div>
${coolingTip}
<div class="actions">
${actionsHtml}
</div>
`
box.appendChild(row)
})
}
const renderAll = () => {
renderDecision()
renderLibrary()
renderStats()
renderHistory()
}
const getApplicationById = id => state.db.applications.find(a => a.id === id) || null
const updateApplication = (id, updater) => {
const idx = state.db.applications.findIndex(a => a.id === id)
if (idx < 0) return
const next = updater({ ...state.db.applications[idx] })
next.updatedAt = now()
state.db.applications[idx] = next
saveDB()
renderAll()
refreshWarning()
}
const removeApplication = id => {
state.db.applications = state.db.applications.filter(a => a.id !== id)
saveDB()
renderAll()
refreshWarning()
}
const bindForm = () => {
const form = $('application-form')
form.addEventListener('input', syncFormState)
form.addEventListener('change', syncFormState)
form.addEventListener('submit', async e => {
e.preventDefault()
if (!(await ensureLoggedIn())) return
if (!formCompleted()) {
const reason = getFormBlockReason() || '还有几项没填完,先补齐我再帮你提交。'
setFormStatus(`仍有未完成项:${reason}`)
await modalAlert('先别急着提交', reason)
return
}
const name = $('item-name').value.trim()
const category = $('item-category').value
const similar = findSimilar(name, category)
if (similar.length) {
const ok = await modalConfirm('同类提醒', `您已有同类物品(${similar.length}条),是否确认继续申请?`, '确认继续')
if (!ok) return
}
const dust = findDustRisk(name, category)
if (dust.length) {
const ok = await modalConfirm('吃灰预警', `存在同类吃灰预警(${dust.length}条),仍要继续申请吗?`, '仍要申请')
if (!ok) return
}
const app = createApplication()
state.db.applications.unshift(app)
saveDB()
clearDraft()
setFormStatus(app.status === 'rejected' ? app.resultReason : `新增成功,已进入${app.coolingDays || DEFAULT_COOLING_DAYS}天冷静期`)
renderAll()
if (app.status !== 'rejected') {
await modalAlert('🎉 新增成功!', `你添加的【${app.basics.name}】已经保存好啦,现在进入冷静期(${coolingLeftText(app)}),期间可以看但不能改哦~`)
}
resetForm()
})
}
const bindDraft = () => {
$('btn-save-draft').addEventListener('click', async () => {
if (!(await ensureLoggedIn())) return
const draft = saveDraft()
if (!draft) return
setFormStatus(`草稿已保存:${fmtDateTime(draft.savedAt)}`)
await modalAlert('✅ 保存成功!', `你改的【${draft.basics.name || '当前申请'}】信息已经存好啦,不会丢的~`)
})
$('btn-load-draft').addEventListener('click', async () => {
if (!(await ensureLoggedIn())) return
const draft = loadDraft()
if (!draft) {
await modalAlert('暂无草稿', '当前没有可恢复的草稿记录')
return
}
const ok = applyDraft(draft)
if (ok) {
setFormStatus(`已恢复草稿:${fmtDateTime(draft.savedAt || now())}`)
}
})
$('btn-clear-draft').addEventListener('click', async () => {
if (!(await ensureLoggedIn())) return
const ok = await modalConfirm('清空草稿', '确认清空已保存的草稿吗?', '确认清空')
if (!ok) return
clearDraft()
setFormStatus('草稿已清空')
})
}
const bindReplacementRules = () => {
$('commit-replacement').addEventListener('change', async e => {
const target = e.target
if (!target || target.type !== 'checkbox') return
const s = getReplacementSelection()
const key = target.getAttribute('data-replacement')
if (!key) return
if (key === 'no-similar' && target.checked && (s.damaged || s.unrepairable)) {
const damagedInput = document.querySelector('#commit-replacement input[data-replacement="damaged"]')
const unrepairableInput = document.querySelector('#commit-replacement input[data-replacement="unrepairable"]')
if (damagedInput) damagedInput.checked = false
if (unrepairableInput) unrepairableInput.checked = false
await modalAlert('已自动调整选项', '你选择了“家中无同类物品”,系统已自动取消“损坏/无法维修”两项,避免逻辑冲突。')
} else if ((key === 'damaged' || key === 'unrepairable') && target.checked && s.noSimilar) {
const noSimilarInput = document.querySelector('#commit-replacement input[data-replacement="no-similar"]')
if (noSimilarInput) noSimilarInput.checked = false
await modalAlert('已自动调整选项', '你选择了“已有旧物状态”项,系统已取消“家中无同类物品”。若是场景2,请同时勾选“损坏/过时”和“无法维修”。')
}
syncFormState()
})
}
const bindLibrary = () => {
if (!hasEl('btn-lib-add') || !hasEl('btn-lib-cancel-edit') || !hasEl('lib-list')) return
$('btn-lib-add').addEventListener('click', async () => {
if (!(await ensureLoggedIn())) return
const name = $('lib-name').value.trim()
const category = $('lib-category').value
const note = $('lib-note').value.trim()
if (!name) return
if (state.ui.library.editingId) {
const idx = state.db.items.findIndex(it => it.id === state.ui.library.editingId)
if (idx >= 0) {
state.db.items[idx] = { ...state.db.items[idx], name, category, note, updatedAt: now() }
}
state.ui.library.editingId = ''
await modalAlert('✅ 保存成功!', `你改的【${name}】信息已经存好啦,不会丢的~`)
} else {
upsertItem(name, category, note)
await modalAlert('🎉 新增成功!', `你添加的【${name}】已经入库啦,后续会用于同类提醒。`)
}
saveDB()
$('lib-name').value = ''
$('lib-note').value = ''
renderLibrary()
refreshWarning()
})
$('btn-lib-cancel-edit').addEventListener('click', () => {
state.ui.library.editingId = ''
$('lib-name').value = ''
$('lib-note').value = ''
renderLibrary()
})
$('lib-list').addEventListener('click', async e => {
if (!(await ensureLoggedIn())) return
const editId = e.target && e.target.getAttribute('data-lib-edit')
if (editId) {
const it = state.db.items.find(x => x.id === editId)
if (!it) return
state.ui.library.editingId = editId
$('lib-name').value = it.name || ''
$('lib-category').value = it.category || '其他'
$('lib-note').value = it.note || ''
renderLibrary()
setFormStatus(`已加载物品库【${it.name}】到编辑区,修改后点击“保存修改”`)
return
}
const id = e.target && e.target.getAttribute('data-lib-del')
if (!id) return
const it = state.db.items.find(x => x.id === id)
const ok = await modalConfirm('确认删除', `确认删除物品库中的【${it ? it.name : '该条'}】吗?这不会改动历史申请记录。`, '确定删除', '再想想')
if (!ok) return
state.db.items = state.db.items.filter(x => x.id !== id)
if (state.ui.library.editingId === id) state.ui.library.editingId = ''
saveDB()
renderLibrary()
refreshWarning()
await modalAlert('✅ 删除成功!', `已删除【${it ? it.name : '该物品'}】。历史申请快照不会被改写。`)
})
}
const bindSettingsNav = () => {
const btn = $('btn-open-settings')
if (!btn) return
btn.addEventListener('click', () => {
window.location.href = '/tools/rational_lock/settings.html'
})
}
const bindActions = () => {
document.addEventListener('click', async e => {
const btn = e.target.closest('button[data-act]')
if (!btn) return
if (!(await ensureLoggedIn())) return
const id = btn.getAttribute('data-id')
const act = btn.getAttribute('data-act')
if (!id || !act) return
if (act === 'view-detail') {
const app = getApplicationById(id)
if (!app) return
await modalDetail('记录详情', detailMessage(app))
return
}
const blockReason = btn.getAttribute('data-block-reason')
if (blockReason) {
await modalAlert('🔔 暂时不能操作', `你好!${blockReason}`)
return
}
if (act === 'copy-create') {
const app = getApplicationById(id)
if (!app) return
const reason = copyCreateBlockReasonOfApplication(app)
if (reason) {
await modalAlert('🔔 暂时不能复制新增', `你好!${reason}`)
return
}
loadApplicationToForm(app)
refreshWarning()
setFormStatus(`已把【${app.basics && app.basics.name ? app.basics.name : '历史记录'}】复制到表单,可修改后再提交`)
await modalAlert('✅ 已复制到表单', '已把这条记录带到上方表单。你可以先改一点内容,再点击“提交申请”生成新的冷静期记录。')
return
}
if (act === 'purchase') {
const app = getApplicationById(id)
if (!app || app.status === 'purchased' || app.status === 'abandoned') return
if (isCoolingLocked(app)) {
await modalAlert('🔔 暂时不能操作', `你好!这个物品的冷静期还没到(剩余 ${coolingLeftText(app)}),为了避免冲动操作,现在不能标记购买 / 放弃 / 低使用率哦。等冷静期结束后,你再决定就好啦!`)
return
}
if (!(app.status === 'cooling' || app.status === 'approved')) return
const ok = await modalConfirm('✅ 确认要标记“已购买”吗?', '你确定已经买了这个物品吗?\n👉 确认后:相关信息会自动发给报销系统,之后就不能改啦!\n👉 取消的话:可以再想想,不会有任何变化~', '确定购买', '再想想')
if (!ok) return
await withBusy(async () => {
const purchasedAt = now()
updateApplication(id, a => {
a.status = 'purchased'
a.resultReason = '已购买,正在推送至报销系统'
a.expensePushedAt = purchasedAt
a.expenseSyncStatus = 'pending'
a.expenseSyncError = ''
upsertItem(a.basics.name, a.basics.category, '来自购买记录')
return a
})
try {
const current = getApplicationById(id)
const pushed = await pushPurchaseToExpense(current || app, purchasedAt)
updateApplication(id, a => {
a.resultReason = `已购买 · 已推送至报销系统(记录ID:${pushed.id})`
a.expenseRecordId = pushed.id
a.expenseSyncStatus = 'synced'
a.expenseSyncError = ''
pushActionLog(a, 'purchase', `已推送报销(记录ID:${pushed.id})`)
return a
})
} catch (err) {
const msg = err && err.message ? err.message : String(err)
updateApplication(id, a => {
a.expenseSyncStatus = 'pending'
a.expenseSyncError = msg
a.expenseRetryCount = toNum(a.expenseRetryCount) + 1
a.resultReason = `已购买,报销同步失败(可重试同步)。原因:${msg}`
pushActionLog(a, 'purchase', `报销同步失败:${msg}`)
return a
})
}
})
const done = getApplicationById(id)
const synced = !!(done && done.expenseRecordId)
await modalAlert('🎉 操作成功!', synced
? `已经帮你标记“已购买”啦,信息也发给报销系统了。之后这个物品就不能标记放弃或低使用率咯~`
: `已经帮你标记“已购买”啦。当前还没同步到报销系统,你可以点击“重试同步报销”继续处理。`)
} else if (act === 'retry-push') {
const app = getApplicationById(id)
if (!app || app.status !== 'purchased' || app.expenseRecordId) return
await withBusy(async () => {
try {
const purchasedAt = app.expensePushedAt || app.updatedAt || now()
const pushed = await pushPurchaseToExpense(app, purchasedAt)
updateApplication(id, a => {
a.expenseRecordId = pushed.id
a.expensePushedAt = purchasedAt
a.expenseSyncStatus = 'synced'
a.expenseSyncError = ''
a.resultReason = `已购买 · 已推送至报销系统(记录ID:${pushed.id})`
pushActionLog(a, 'retry-push', `同步成功(记录ID:${pushed.id})`)
return a
})
await modalAlert('✅ 操作成功!', `已经同步到报销系统,记录ID:${pushed.id}`)
} catch (err) {
const msg = err && err.message ? err.message : String(err)
updateApplication(id, a => {
a.expenseSyncStatus = 'pending'
a.expenseSyncError = msg
a.expenseRetryCount = toNum(a.expenseRetryCount) + 1
a.resultReason = `已购买,报销同步失败(可重试同步)。原因:${msg}`
pushActionLog(a, 'retry-push', `同步失败:${msg}`)
return a
})
await modalAlert('❌ 还没同步成功', `报销系统仍未同步成功。\n原因:${msg}`)
}
})
} else if (act === 'abandon') {
const app = getApplicationById(id)
if (!app || app.status === 'abandoned' || app.status === 'purchased') return
if (isCoolingLocked(app)) {
await modalAlert('🔔 暂时不能操作', `你好!这个物品的冷静期还没到(剩余 ${coolingLeftText(app)}),为了避免冲动操作,现在不能标记购买 / 放弃 / 低使用率哦。等冷静期结束后,你再决定就好啦!`)
return
}
const ok = await modalConfirm('❌ 确认要标记“放弃”吗?', '你确定不买这个物品了吗?\n👉 确认后:这个物品就会标记为“放弃购买”,不会发给报销系统,之后也不能再改啦!\n👉 取消的话:可以再想想,不会有任何变化~', '确定放弃', '再想想')
if (!ok) return
updateApplication(id, a => {
a.status = 'abandoned'
a.resultReason = '已放弃,不会推送到报销系统'
pushActionLog(a, 'abandon', '标记为放弃购买')
return a
})
await modalAlert('✅ 操作成功!', '已经帮你标记“放弃购买”啦,这个物品不会再推送给报销系统咯~')
} else if (act === 'toggle-low') {
const app = getApplicationById(id)
if (!app) return
if (app.status !== 'purchased') {
await modalAlert('❌ 暂时不能标记哦', '要先标记“已购买”之后,才能标记“低使用率”啦。你先确认购买,再过来标记就可以咯~')
return
}
if (app.usageLow) {
await modalAlert('🔒 不能操作啦', '这个物品已经标记过“低使用率”啦,为了避免改来改去出错,现在不能重复标记咯~')
return
}
const ok = await modalConfirm('⚠️ 确认要标记“低使用率”吗?', '你确定这个物品用得很少吗?\n👉 确认后:会标记为“低使用率”,下次你想买和这个一样的东西(比如同款收纳架、同类家居用品),系统会提醒你“别冲动买哦”(红色预警)!\n👉 取消的话:可以再想想,不会有任何变化~', '确定标记', '再想想')
if (!ok) return
updateApplication(id, a => {
a.usageLow = true
a.resultReason = '已购买 · 已标记低使用率(同类购买将预警)'
pushActionLog(a, 'toggle-low', '标记为低使用率')
return a
})
await modalAlert('🔴 标记成功!重要提醒!', `这个物品已经标记为“低使用率”啦~\n下次你想买【${app.basics.name}】或者类似的家居收纳用品时,系统会弹出红色提醒,让你再考虑考虑哦!`)
} else if (act === 'archive-app') {
const app = getApplicationById(id)
if (!app) return
const nextArchived = !app.archived
updateApplication(id, a => {
a.archived = nextArchived
a.resultReason = nextArchived
? `${a.resultReason || ''}(已归档隐藏)`.trim()
: String(a.resultReason || '').replace('(已归档隐藏)', '')
pushActionLog(a, nextArchived ? 'archive-on' : 'archive-off', nextArchived ? '归档隐藏记录' : '取消归档显示')
return a
})
await modalAlert('✅ 操作成功!', nextArchived
? `已把【${app.basics.name}】归档隐藏啦,你勾选“显示归档”后还能看到。`
: `已把【${app.basics.name}】恢复到正常列表啦。`)
} else if (act === 'delete-app') {
const app = getApplicationById(id)
if (!app) return
if (app.status === 'purchased') {
await modalAlert('🔒 不能删除哦', '这个物品已经推送或准备推送到报销系统了,为了财务不出错,不能删除啦!你可以改成归档隐藏。')
return
}
const ok = await modalConfirm('确认删除记录', `确认删除【${app.basics.name}】吗?删除后不会恢复。`, '确定删除', '再想想')
if (!ok) return
removeApplication(id)
await modalAlert('✅ 删除成功!', `已删除【${app.basics.name}】记录,操作已经入库。`)
} else if (act === 'edit-app') {
const app = getApplicationById(id)
if (!app) return
if (isCoolingLocked(app)) {
await modalAlert('❌ 不能修改哦', `冷静期还剩 ${coolingLeftText(app)},为了避免改来改去出错,现在不能修改这个物品的信息啦~`)
return
}
if (app.status === 'purchased' || app.status === 'abandoned') {
await modalAlert('🔒 不能操作啦', `这个物品已经标记为“${app.status === 'purchased' ? '已购买' : '已放弃'}”啦,为了避免改来改去出错,现在不能再修改咯~`)
return
}
const name = window.prompt('请填写商品名称', app.basics.name || '')
if (name === null) return
const priceRaw = window.prompt('请填写预估价格', String(app.basics.price || ''))
if (priceRaw === null) return
const price = toNum(priceRaw)
const reason = window.prompt('请填写购买原因', app.basics.reason || '')
if (reason === null) return
updateApplication(id, a => {
a.basics.name = String(name || '').trim() || a.basics.name
a.basics.price = price > 0 ? price : a.basics.price
a.basics.reason = String(reason || '').trim() || a.basics.reason
pushActionLog(a, 'edit', `修改为:${a.basics.name} | ${fmtMoney(a.basics.price)}`)
return a
})
await modalAlert('✅ 保存成功!', `你改的【${String(name || app.basics.name).trim() || app.basics.name}】信息已经存好啦,不会丢的~`)
}
})
}
const bindAuth = () => {
const tryLogin = async () => {
const username = String($('login-username').value || '').trim()
const password = String($('login-password').value || '')
if (!username || !password) {
setAuthStatus('请输入账号和密码')
return
}
try {
await apiRequest('/auth/login', {
method: 'POST',
body: { username, password }
})
} catch {
setAuthStatus('账号或密码不正确,请重试')
await modalAlert('登录失败', '账号或密码不正确,请重新输入。')
return
}
state.auth.currentUser = username
$('login-password').value = ''
try {
await loadDB()
} catch {
state.auth.currentUser = ''
state.db = emptyDB()
state.draft = null
setAuthStatus('登录成功,但读取数据失败,请稍后重试')
renderAll()
syncFormState()
renderAuth()
await modalAlert('数据读取失败', '登录已完成,但暂时无法读取云端数据,请稍后重试。')
return
}
renderAll()
syncFormState()
const draft = loadDraft()
setFormStatus(draft ? '检测到草稿,可点击“继续填写草稿”恢复' : '')
setAuthStatus(`登录成功,欢迎你:${username}`)
renderAuth()
}
$('btn-login').addEventListener('click', tryLogin)
;(async function tryAutoFill() {
try {
const token = localStorage.getItem('toolbox_creds_token') || ''
const r = await fetch('/api/rational_lock/auth/local_creds', {
headers: token ? { 'X-Local-Creds-Token': token } : {},
credentials: 'same-origin',
cache: 'no-store'
})
const d = await r.json().catch(() => null)
if (d && d.ok && d.creds) {
if (d.creds.username) $('login-username').value = d.creds.username
if (d.creds.password) $('login-password').value = d.creds.password
}
} catch {}
$('login-username').removeAttribute('readonly')
$('login-password').removeAttribute('readonly')
})()
$('login-password').addEventListener('keydown', async e => {
if (e.key !== 'Enter') return
e.preventDefault()
await tryLogin()
})
$('btn-logout').addEventListener('click', async () => {
try { await apiRequest('/auth/logout', { method: 'POST' }) } catch {}
state.auth.currentUser = ''
state.db = emptyDB()
state.draft = null
state.ui.library.editingId = ''
$('application-form').reset()
if (hasEl('lib-name')) $('lib-name').value = ''
if (hasEl('lib-note')) $('lib-note').value = ''
setFormStatus('')
setAuthStatus('已退出登录')
renderAll()
syncFormState()
renderAuth()
})
}
const bindHistory = () => {
const defaults = getDefaultHistoryRange()
state.ui.history.startDate = defaults.startDate
state.ui.history.endDate = defaults.endDate
$('history-start-date').value = defaults.startDate
$('history-end-date').value = defaults.endDate
$('history-search').addEventListener('input', e => {
state.ui.history.search = String(e.target.value || '')
state.ui.history.page = 1
renderHistory()
})
$('history-start-date').addEventListener('change', e => {
state.ui.history.startDate = String(e.target.value || '')
state.ui.history.page = 1
renderHistory()
})
$('history-end-date').addEventListener('change', e => {
state.ui.history.endDate = String(e.target.value || '')
state.ui.history.page = 1
renderHistory()
})
$('history-show-archived').addEventListener('change', e => {
state.ui.history.showArchived = !!e.target.checked
state.ui.history.page = 1
renderHistory()
})
$('btn-history-reset').addEventListener('click', () => {
const nextDefaults = getDefaultHistoryRange()
state.ui.history.search = ''
state.ui.history.startDate = nextDefaults.startDate
state.ui.history.endDate = nextDefaults.endDate
state.ui.history.showArchived = false
state.ui.history.page = 1
$('history-search').value = ''
$('history-start-date').value = nextDefaults.startDate
$('history-end-date').value = nextDefaults.endDate
$('history-show-archived').checked = false
renderHistory()
})
$('history-prev').addEventListener('click', () => {
if (state.ui.history.page <= 1) return
state.ui.history.page -= 1
renderHistory()
})
$('history-next').addEventListener('click', () => {
state.ui.history.page += 1
renderHistory()
})
}
const init = () => {
const boot = async () => {
try {
const hasSession = await restoreSession()
if (hasSession) await loadDB()
else {
state.db = emptyDB()
state.draft = null
}
renderAll()
syncCoolingDaysText()
renderAuth()
if (hasSession && loadDraft()) {
setFormStatus('检测到草稿,可点击“继续填写草稿”恢复')
}
} catch {
state.auth.currentUser = ''
state.db = emptyDB()
state.draft = null
setAuthStatus('初始化失败,请刷新后重试')
renderAll()
renderAuth()
}
}
bindAuth()
bindForm()
bindDraft()
bindReplacementRules()
bindLibrary()
bindSettingsNav()
bindActions()
bindHistory()
syncFormState()
boot()
setInterval(() => renderDecision(), 1000)
}
init()
})()