(() => { ;(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)}
` } 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 = `
本月想买数
${st.wanted}
本月放弃数
${st.abandoned}
本月购买数
${st.purchased}
节省金额
${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() })()