(() => {
;(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, '&')
.replace(//g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''')
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 = `
理性消费决策锁 - 已购推送
推送时间(北京时间):${escapeHtml(formatBeijingDateTime(purchasedAt))}
| 物品名称 | ${escapeHtml(app.basics.name)} |
| 分类 | ${escapeHtml(app.basics.category)} |
| 风险组 | ${escapeHtml(shopping.riskLabel || '未记录')} |
| 金额 | ${escapeHtml(fmtMoney(app.basics.price))} |
| 购买理由 | ${escapeHtml(app.basics.reason)} |
| 购物方式 | ${escapeHtml(shopping.methodLabel || '')} |
| 购买平台 | ${escapeHtml(shopping.platformLabel || '未记录')} |
| 店铺类型 | ${escapeHtml(shopping.sellerTypeLabel || '未记录')} |
| 品相 | ${escapeHtml(shopping.conditionLabel || '未记录')} |
| 购物保障 | ${escapeHtml(shopping.guaranteeLabel || '')} |
| 沟通问题 | ${escapeHtml([shopping.q1, shopping.q2, shopping.q3, shopping.q4, shopping.q5].filter(Boolean).join(';') || '无')} |
| 使用场景 | ${escapeHtml(app.scenarios.where)} |
| 使用频率 | ${escapeHtml(app.scenarios.frequency)} |
| 可解决问题 | ${escapeHtml(app.scenarios.problem)} |
| 替代检查 | ${escapeHtml(replacementSummary(app))} |
| 评分总分 | ${escapeHtml(app.scores.total)} |
| 防冲动验证 | ${escapeHtml(`问题1:${impulse[0] || ''};问题2:${impulse[1] || ''};问题3:${impulse[2] || ''}`)} |
| 反悔可能 | ${escapeHtml(regretPossibility || '未填写')} |
`.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 = `当前分类【${escapeHtml(normalizedCategory)}】暂未配置示例,请按实际风险选择最接近的分类。
`
return
}
box.innerHTML = `
当前分类:${escapeHtml(normalizedCategory)}
${items.map(item => `- ${escapeHtml(item)}
`).join('')}
`
}
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) => `
${escapeHtml(label)}
${escapeHtml(value)}
`
const detailSectionTitle = title => `${escapeHtml(title)}
`
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 ``
}
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 = `申请:${app.basics.name}
冷静期已结束,可选择“已购买”或“标记放弃”
${app.resultReason || ''}
`
return
}
if (app.status === 'cooling') {
const leftInfo = getCoolingLeft(app)
box.className = 'decision wait'
box.innerHTML = `申请:${app.basics.name}
冷静期剩余${leftInfo.days}天(${leftInfo.hours}小时 ${leftInfo.minutes}分 ${leftInfo.seconds}秒)
提交时间:${fmtDateTime(app.createdAt)}(冷静期内不可进行购买/放弃/低使用率标记)
`
return
}
if (app.status === 'approved') {
box.className = 'decision pass'
box.innerHTML = `待决策
${app.basics.name} · ${fmtMoney(app.basics.price)}
冷静期已结束,可选择“已购买”或“标记放弃”
`
return
}
if (app.status === 'purchased') {
const topText = app.usageLow
? '已购买 · 已标记低使用率(同类购买将预警)'
: (app.expenseRecordId ? '已购买 · 已推送至报销系统' : '已购买 · 待同步报销系统')
box.className = 'decision pass'
box.innerHTML = `${topText}
${app.basics.name} · ${fmtMoney(app.basics.price)}
${app.resultReason || ''}
`
return
}
if (app.status === 'abandoned') {
box.className = 'decision reject'
box.innerHTML = `已放弃
${app.basics.name} · ${fmtMoney(app.basics.price)}
${app.resultReason || ''}
`
return
}
box.className = 'decision reject'
box.innerHTML = `不建议购买
${app.basics.name} · ${fmtMoney(app.basics.price)}
${app.resultReason || ''}
`
}
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 = `
${it.name} · ${it.category}
${it.note || '无备注'}
`
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 = `
节省金额
${fmtMoney(st.saved)}
`
}
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 ? '低使用率预警' : ''
const archivedTag = a.archived ? '已归档' : ''
const coolingTip = coolingLocked
? `【冷静期剩余${coolingLeftText(a)}】,期间不可进行购买/放弃/低使用率标记操作
`
: ''
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 = `
${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 = `
${a.basics.name} · ${a.basics.category} · ${fmtMoney(a.basics.price)}
状态:${statusText}${archivedTag ? ` | ${archivedTag}` : ''} ${lowTag ? `| ${lowTag}` : ''} | 总分:${a.scores.total} | 提交:${fmtDateTime(a.createdAt)}
反悔可能:${escapeHtml(a.regretPossibility || '未填写')}
${a.resultReason || ''}
${coolingTip}
${actionsHtml}
`
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()
})()