a6f763ffd8
- 修复token泄露风险:改用X-Local-Creds-Token请求头传递token,从URL Hash读取set_token避免日志记录 - 重构全站认证逻辑:提取公用工具函数统一各服务的认证校验流程 - 升级投资账本工具:支持本地上传CSV和粘贴文本,移除硬编码路径,适配新参数格式 - 新增403/404/500标准错误响应文件,统一API错误返回格式 - 优化后端配置:关闭ETag,重构CORS中间件,新增全局错误捕获,限制敏感文件访问 - 优化web zen box的Service Worker,支持音频分片请求与断点续传 - 清理冗余前端代码,调整页面格式与权限控制
2133 lines
94 KiB
JavaScript
2133 lines
94 KiB
JavaScript
(() => {
|
||
;(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, '"')
|
||
.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 = `
|
||
<div>
|
||
<h3>理性消费决策锁 - 已购推送</h3>
|
||
<p>推送时间(北京时间):${escapeHtml(formatBeijingDateTime(purchasedAt))}</p>
|
||
<table border="1" cellspacing="0" cellpadding="6">
|
||
<tr><th>物品名称</th><td>${escapeHtml(app.basics.name)}</td></tr>
|
||
<tr><th>分类</th><td>${escapeHtml(app.basics.category)}</td></tr>
|
||
<tr><th>风险组</th><td>${escapeHtml(shopping.riskLabel || '未记录')}</td></tr>
|
||
<tr><th>金额</th><td>${escapeHtml(fmtMoney(app.basics.price))}</td></tr>
|
||
<tr><th>购买理由</th><td>${escapeHtml(app.basics.reason)}</td></tr>
|
||
<tr><th>购物方式</th><td>${escapeHtml(shopping.methodLabel || '')}</td></tr>
|
||
<tr><th>购买平台</th><td>${escapeHtml(shopping.platformLabel || '未记录')}</td></tr>
|
||
<tr><th>店铺类型</th><td>${escapeHtml(shopping.sellerTypeLabel || '未记录')}</td></tr>
|
||
<tr><th>品相</th><td>${escapeHtml(shopping.conditionLabel || '未记录')}</td></tr>
|
||
<tr><th>购物保障</th><td>${escapeHtml(shopping.guaranteeLabel || '')}</td></tr>
|
||
<tr><th>沟通问题</th><td>${escapeHtml([shopping.q1, shopping.q2, shopping.q3, shopping.q4, shopping.q5].filter(Boolean).join(';') || '无')}</td></tr>
|
||
<tr><th>使用场景</th><td>${escapeHtml(app.scenarios.where)}</td></tr>
|
||
<tr><th>使用频率</th><td>${escapeHtml(app.scenarios.frequency)}</td></tr>
|
||
<tr><th>可解决问题</th><td>${escapeHtml(app.scenarios.problem)}</td></tr>
|
||
<tr><th>替代检查</th><td>${escapeHtml(replacementSummary(app))}</td></tr>
|
||
<tr><th>评分总分</th><td>${escapeHtml(app.scores.total)}</td></tr>
|
||
<tr><th>防冲动验证</th><td>${escapeHtml(`问题1:${impulse[0] || ''};问题2:${impulse[1] || ''};问题3:${impulse[2] || ''}`)}</td></tr>
|
||
<tr><th>反悔可能</th><td>${escapeHtml(regretPossibility || '未填写')}</td></tr>
|
||
</table>
|
||
</div>`.trim()
|
||
return html
|
||
}
|
||
|
||
const pushPurchaseToExpense = async (app, purchasedAt) => {
|
||
const payload = {
|
||
date: formatBeijingDate(purchasedAt),
|
||
purchased_at: purchasedAt,
|
||
amount: toNum(app.basics.price),
|
||
content_html: buildExpenseContentHtml(app, purchasedAt)
|
||
}
|
||
const res = await fetch('/api/expense/rational-lock/push', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(payload)
|
||
})
|
||
let data = null
|
||
try {
|
||
data = await res.json()
|
||
} catch {}
|
||
if (!res.ok || !data || !data.ok) {
|
||
throw new Error((data && data.error) || `HTTP_${res.status}`)
|
||
}
|
||
return data
|
||
}
|
||
|
||
const getScoreTotal = () => {
|
||
return ['score-practical', 'score-necessary', 'score-usage', 'score-cost', 'score-regret']
|
||
.map(id => toNum($(id).value))
|
||
.reduce((a, b) => a + b, 0)
|
||
}
|
||
|
||
const getImpulseAnswers = () => {
|
||
const getOne = name => {
|
||
const el = document.querySelector(`input[name="${name}"]:checked`)
|
||
return el ? el.value : ''
|
||
}
|
||
return [getOne('impulse-1'), getOne('impulse-2'), getOne('impulse-3')]
|
||
}
|
||
|
||
const allChecked = rootId => Array.from(document.querySelectorAll(`#${rootId} input[type="checkbox"]`)).every(n => n.checked)
|
||
|
||
const normalizeCategory = category => {
|
||
const key = String(category || '').trim()
|
||
return LEGACY_CATEGORY_ALIAS_MAP[key] || key
|
||
}
|
||
|
||
const getCategoryRiskInfo = category => {
|
||
const rawKey = String(category || '').trim()
|
||
const key = normalizeCategory(rawKey)
|
||
if (!key) return { level: '', label: '', tip: '', legacy: false }
|
||
if (CATEGORY_RISK_MAP[key]) return CATEGORY_RISK_MAP[key]
|
||
return LEGACY_CATEGORY_RISK_MAP[rawKey] || {
|
||
level: 'high',
|
||
label: '高风险类',
|
||
tip: '这个分类目前没有命中明确规则,系统先按偏严模式看待,宁可多拦一下。',
|
||
legacy: true
|
||
}
|
||
}
|
||
|
||
const getPlatformLabel = value => PLATFORM_LABEL_MAP[String(value || '')] || ''
|
||
const getSellerTypeLabel = value => SELLER_TYPE_LABEL_MAP[String(value || '')] || ''
|
||
const getConditionLabel = value => CONDITION_LABEL_MAP[String(value || '')] || ''
|
||
|
||
const getShoppingFields = () => ({
|
||
method: String(($('shopping-method') && $('shopping-method').value) || ''),
|
||
guarantee: String(($('shopping-guarantee') && $('shopping-guarantee').value) || ''),
|
||
platform: String(($('shopping-platform') && $('shopping-platform').value) || ''),
|
||
sellerType: String(($('shopping-seller-type') && $('shopping-seller-type').value) || ''),
|
||
condition: String(($('shopping-condition') && $('shopping-condition').value) || ''),
|
||
q1: String(($('shopping-q1') && $('shopping-q1').value) || '').trim(),
|
||
q2: String(($('shopping-q2') && $('shopping-q2').value) || '').trim(),
|
||
q3: String(($('shopping-q3') && $('shopping-q3').value) || '').trim(),
|
||
q4: String(($('shopping-q4') && $('shopping-q4').value) || '').trim(),
|
||
q5: String(($('shopping-q5') && $('shopping-q5').value) || '').trim()
|
||
})
|
||
|
||
const countShoppingAnswers = shopping => {
|
||
const s = shopping || getShoppingFields()
|
||
return [s.q1, s.q2, s.q3, s.q4, s.q5].filter(x => String(x || '').trim()).length
|
||
}
|
||
|
||
const evaluateShoppingRisk = (shopping, category) => {
|
||
const s = shopping || getShoppingFields()
|
||
const categoryInfo = getCategoryRiskInfo(category || $('item-category').value)
|
||
const missingQuestions = ['q1', 'q2', 'q3', 'q4', 'q5'].filter(k => !String(s[k] || '').trim()).length
|
||
const result = {
|
||
riskLevel: categoryInfo.level,
|
||
riskLabel: categoryInfo.label,
|
||
categoryTip: categoryInfo.tip,
|
||
isLegacyCategory: !!categoryInfo.legacy,
|
||
isOnline: s.method === 'online',
|
||
allowed: false,
|
||
blocked: false,
|
||
needQuestions: false,
|
||
missingQuestions,
|
||
reason: '',
|
||
tip: '',
|
||
submitText: ''
|
||
}
|
||
if (!s.method) {
|
||
result.reason = '请先选一下“购物方式”,我才能继续判断风险哦。'
|
||
return result
|
||
}
|
||
if (s.method !== 'online') {
|
||
result.allowed = true
|
||
result.tip = '线下店通常可以现场看货,本轮网购风控就先跳过啦。'
|
||
result.submitText = '当前不是网购,本环节已通过。'
|
||
return result
|
||
}
|
||
if (!s.guarantee) {
|
||
result.reason = '你选了网购,先把“购物保障”补上,我才知道售后稳不稳。'
|
||
return result
|
||
}
|
||
if (!s.platform) {
|
||
result.reason = '你选了网购,再告诉我打算在哪个平台买呀。'
|
||
return result
|
||
}
|
||
if (!s.sellerType) {
|
||
result.reason = '平台有了,还差“店铺类型”,这个会直接影响真假和售后。'
|
||
return result
|
||
}
|
||
if (!s.condition) {
|
||
result.reason = '还差“品相”没选,我得先知道你打算买全新还是二手。'
|
||
return result
|
||
}
|
||
|
||
if (categoryInfo.level === 'high') {
|
||
if (!HIGH_RISK_ALLOWED_PLATFORMS.has(s.platform)) {
|
||
result.blocked = true
|
||
result.reason = `【${categoryInfo.label}】别先去${getPlatformLabel(s.platform) || '这个平台'}下单哦,最好走天猫、京东,或平台里带官方背书的渠道。`
|
||
} else if (!HIGH_RISK_ALLOWED_SELLERS.has(s.sellerType)) {
|
||
result.blocked = true
|
||
result.reason = `【${categoryInfo.label}】尽量只走“品牌官旗”或“平台优选 / 自营”,普通店、企业店、个人卖家都不够稳。`
|
||
} else if (!HIGH_RISK_ALLOWED_CONDITIONS.has(s.condition)) {
|
||
result.blocked = true
|
||
result.reason = `【${categoryInfo.label}】对品相要求更严,最好只接受“全新未拆”,拆封、二手或说不清楚的先别碰。`
|
||
} else {
|
||
result.needQuestions = s.guarantee !== 'seven-day'
|
||
result.allowed = !result.needQuestions || result.missingQuestions === 0
|
||
result.tip = result.needQuestions
|
||
? '这单组合已经过了平台、店铺和品相门槛,但售后还不够稳,先把 5 条沟通问题补齐再说。'
|
||
: '这单属于高风险类,但你这次的平台、店铺、品相都过关了,可以继续。'
|
||
}
|
||
} else {
|
||
const riskySignals = []
|
||
if (s.guarantee !== 'seven-day') riskySignals.push('售后偏弱')
|
||
if (s.platform === 'xianyu' || s.platform === 'other') riskySignals.push('平台偏冒险')
|
||
if (s.sellerType === 'personal-seller' || s.sellerType === 'normal-shop') riskySignals.push('店铺不够稳')
|
||
if (s.condition !== 'new-sealed') riskySignals.push('品相不够新')
|
||
result.needQuestions = riskySignals.length > 0
|
||
result.allowed = !result.needQuestions || result.missingQuestions === 0
|
||
result.tip = result.needQuestions
|
||
? `这单可以继续,但${riskySignals.join('、')},为了少踩坑,先把 5 条沟通问题补齐哦。`
|
||
: '这单属于低风险类,而且平台、店铺、品相、售后都比较稳,可以继续。'
|
||
}
|
||
|
||
if (!result.blocked && result.needQuestions && result.missingQuestions > 0) {
|
||
result.reason = `当前这单还偏危险,请先补全 ${result.missingQuestions} 条沟通问题,我再放你继续。`
|
||
}
|
||
result.submitText = result.reason
|
||
? `当前不能提交:${result.reason}`
|
||
: (result.tip || '网购风险核验已通过。')
|
||
return result
|
||
}
|
||
|
||
const shoppingNeedQuestions = shopping => evaluateShoppingRisk(shopping).needQuestions
|
||
|
||
const shoppingIncompleteReason = () => evaluateShoppingRisk().reason
|
||
|
||
const syncCategoryRiskTip = () => {
|
||
const box = $('shopping-risk-group-tip')
|
||
if (!box) return
|
||
const category = $('item-category') ? $('item-category').value : ''
|
||
const normalizedCategory = normalizeCategory(category)
|
||
if (!category) {
|
||
box.textContent = ''
|
||
return
|
||
}
|
||
const info = getCategoryRiskInfo(category)
|
||
if (!info.level) {
|
||
box.textContent = ''
|
||
return
|
||
}
|
||
box.textContent = info.legacy && normalizedCategory && normalizedCategory !== category
|
||
? `系统已把历史分类【${category}】归并到【${normalizedCategory}】,按“${info.label}”来判断。${info.tip}`
|
||
: info.legacy
|
||
? `系统先把【${category}】按“${info.label}”来盯着看。${info.tip}`
|
||
: `系统判定:【${normalizedCategory || category}】属于“${info.label}”。${info.tip}`
|
||
}
|
||
|
||
const syncCategoryHelp = () => {
|
||
const box = $('item-category-help-body')
|
||
if (!box) return
|
||
const category = $('item-category') ? $('item-category').value : ''
|
||
const normalizedCategory = normalizeCategory(category)
|
||
if (!normalizedCategory) {
|
||
box.textContent = '请选择商品分类后查看该分类对应的常见示例。'
|
||
return
|
||
}
|
||
const items = CATEGORY_HELP_MAP[normalizedCategory] || []
|
||
if (!items.length) {
|
||
box.innerHTML = `<div class="field-tip-empty">当前分类【${escapeHtml(normalizedCategory)}】暂未配置示例,请按实际风险选择最接近的分类。</div>`
|
||
return
|
||
}
|
||
box.innerHTML = `
|
||
<div class="field-tip-current">当前分类:${escapeHtml(normalizedCategory)}</div>
|
||
<ul class="field-tip-list">${items.map(item => `<li>${escapeHtml(item)}</li>`).join('')}</ul>
|
||
`
|
||
}
|
||
|
||
const syncShoppingRiskUI = () => {
|
||
if (!hasEl('shopping-method')) return
|
||
const s = getShoppingFields()
|
||
const category = $('item-category') ? $('item-category').value : ''
|
||
const riskEval = evaluateShoppingRisk(s, category)
|
||
const guaranteeWrap = $('shopping-guarantee-wrap')
|
||
const platformWrap = $('shopping-platform-wrap')
|
||
const onlineExtraWrap = $('shopping-online-extra-wrap')
|
||
const qWrap = $('shopping-questions-wrap')
|
||
const tip = $('shopping-risk-tip')
|
||
const submitTip = $('shopping-submit-tip')
|
||
const progressWrap = $('shopping-progress-wrap')
|
||
const progressText = $('shopping-progress-text')
|
||
const isOnline = s.method === 'online'
|
||
if (guaranteeWrap) guaranteeWrap.style.display = isOnline ? '' : 'none'
|
||
if (platformWrap) platformWrap.style.display = isOnline ? '' : 'none'
|
||
if (onlineExtraWrap) onlineExtraWrap.style.display = isOnline ? '' : 'none'
|
||
if (!isOnline) {
|
||
;['shopping-guarantee', 'shopping-platform', 'shopping-seller-type', 'shopping-condition'].forEach(id => {
|
||
if (hasEl(id)) $(id).value = ''
|
||
})
|
||
}
|
||
const needQ = riskEval.needQuestions
|
||
if (qWrap) qWrap.style.display = needQ ? '' : 'none'
|
||
if (progressWrap) progressWrap.style.display = needQ ? '' : 'none'
|
||
if (!needQ) {
|
||
;['shopping-q1','shopping-q2','shopping-q3','shopping-q4','shopping-q5'].forEach(id => { if (hasEl(id)) $(id).value = '' })
|
||
}
|
||
if (needQ) {
|
||
const done = countShoppingAnswers(s)
|
||
if (progressText) progressText.textContent = `沟通问题完成进度:${done}/5`
|
||
for (let i = 1; i <= 5; i += 1) {
|
||
const light = $(`shopping-light-${i}`)
|
||
if (!light) continue
|
||
if (i <= done) light.classList.add('on')
|
||
else light.classList.remove('on')
|
||
}
|
||
} else {
|
||
if (progressText) progressText.textContent = ''
|
||
for (let i = 1; i <= 5; i += 1) {
|
||
const light = $(`shopping-light-${i}`)
|
||
if (light) light.classList.remove('on')
|
||
}
|
||
}
|
||
if (tip) {
|
||
let msg = ''
|
||
if (!category) msg = '先把商品分类选好,我才能自动给你映射风险组。'
|
||
else if (riskEval.reason) msg = riskEval.reason
|
||
else msg = riskEval.tip
|
||
tip.textContent = msg
|
||
tip.style.display = msg ? '' : 'none'
|
||
}
|
||
if (submitTip) {
|
||
submitTip.textContent = riskEval.submitText
|
||
}
|
||
}
|
||
|
||
const openModal = ({ title, message, okText = '知道了', cancelText = '', tone = 'normal', wide = false, html = false }) => {
|
||
return new Promise(resolve => {
|
||
const mask = $('app-modal')
|
||
const panel = mask.querySelector('.modal-panel')
|
||
const titleBox = $('modal-title')
|
||
const msgBox = $('modal-message')
|
||
const actions = $('modal-actions')
|
||
actions.innerHTML = ''
|
||
if (panel) panel.classList.toggle('wide', !!wide)
|
||
titleBox.textContent = title || ''
|
||
msgBox.classList.toggle('is-rich', !!html)
|
||
if (html) msgBox.innerHTML = message || ''
|
||
else msgBox.textContent = message || ''
|
||
const close = val => {
|
||
mask.style.display = 'none'
|
||
state.modal.resolver = null
|
||
resolve(val)
|
||
}
|
||
if (cancelText) {
|
||
const cancelBtn = document.createElement('button')
|
||
cancelBtn.className = 'btn'
|
||
cancelBtn.type = 'button'
|
||
cancelBtn.textContent = cancelText
|
||
cancelBtn.addEventListener('click', () => close(false))
|
||
actions.appendChild(cancelBtn)
|
||
}
|
||
const okBtn = document.createElement('button')
|
||
okBtn.className = tone === 'primary' ? 'btn btn-primary' : 'btn'
|
||
okBtn.type = 'button'
|
||
okBtn.textContent = okText
|
||
okBtn.addEventListener('click', () => close(true))
|
||
actions.appendChild(okBtn)
|
||
mask.style.display = ''
|
||
state.modal.resolver = close
|
||
})
|
||
}
|
||
|
||
const modalAlert = (title, message) => openModal({ title, message, okText: '我知道了' })
|
||
const modalConfirm = (title, message, okText = '继续', cancelText = '取消') => openModal({ title, message, okText, cancelText, tone: 'primary' })
|
||
const modalDetail = (title, message) => openModal({ title, message, okText: '关闭', wide: true, html: true })
|
||
|
||
let busyCount = 0
|
||
const showBusy = (text = '正在帮你处理哦,请稍等~') => {
|
||
busyCount += 1
|
||
$('busy-text').textContent = text
|
||
$('busy-mask').style.display = ''
|
||
}
|
||
|
||
const hideBusy = () => {
|
||
busyCount = Math.max(0, busyCount - 1)
|
||
if (!busyCount) $('busy-mask').style.display = 'none'
|
||
}
|
||
|
||
const withBusy = async (fn, text) => {
|
||
showBusy(text)
|
||
try {
|
||
return await fn()
|
||
} finally {
|
||
hideBusy()
|
||
}
|
||
}
|
||
|
||
const getReplacementSelection = () => {
|
||
const noSimilar = !!document.querySelector('#commit-replacement input[data-replacement="no-similar"]')?.checked
|
||
const damaged = !!document.querySelector('#commit-replacement input[data-replacement="damaged"]')?.checked
|
||
const unrepairable = !!document.querySelector('#commit-replacement input[data-replacement="unrepairable"]')?.checked
|
||
const unique = !!document.querySelector('#commit-replacement input[data-replacement="unique"]')?.checked
|
||
return { noSimilar, damaged, unrepairable, unique }
|
||
}
|
||
|
||
const evaluateReplacementSelection = () => {
|
||
const s = getReplacementSelection()
|
||
if (s.noSimilar && (s.damaged || s.unrepairable)) {
|
||
return {
|
||
valid: false,
|
||
scene: '',
|
||
message: '检测到互斥:你选择了“家中无同类物品”,就不能再选择“已有旧物损坏/无法维修”。\n请二选一:\n1)场景1(家里没有同类)\n2)场景2(家里有旧物且已损坏并无法维修)'
|
||
}
|
||
}
|
||
if (s.noSimilar) {
|
||
return { valid: true, scene: 'scene1', message: '当前为场景1:家里没有同类物品。可选勾选“功能独特”。' }
|
||
}
|
||
if (s.damaged || s.unrepairable) {
|
||
if (!(s.damaged && s.unrepairable)) {
|
||
return {
|
||
valid: false,
|
||
scene: '',
|
||
message: '当前像是场景2,但信息不完整。\n场景2必须同时勾选:\n1)现有同类物品已损坏/过时/无法使用\n2)现有物品无法维修或改造继续使用'
|
||
}
|
||
}
|
||
return { valid: true, scene: 'scene2', message: '当前为场景2:已存在旧物且不可继续使用。可选勾选“功能独特”。' }
|
||
}
|
||
return {
|
||
valid: false,
|
||
scene: '',
|
||
message: '请先选择替代检查场景:\n1)场景1:勾选“家中无完全可替代的同类物品”\n2)场景2:同时勾选“已损坏/过时”和“无法维修/改造”'
|
||
}
|
||
}
|
||
|
||
const statusTextOf = status => ({
|
||
cooling: '待决策',
|
||
approved: '待决策',
|
||
rejected: '不建议购买',
|
||
abandoned: '已放弃',
|
||
purchased: '已购买'
|
||
})[status] || status
|
||
|
||
const actionText = action => ({
|
||
submit: '提交申请',
|
||
purchase: '标记已购买',
|
||
'retry-push': '重试同步报销',
|
||
abandon: '标记放弃',
|
||
'toggle-low': '标记低使用率',
|
||
'archive-on': '归档隐藏',
|
||
'archive-off': '取消归档',
|
||
edit: '修改申请',
|
||
'copy-create': '复制新增'
|
||
})[action] || action
|
||
|
||
const normalizeActionLog = row => ({
|
||
at: Number(row && row.at) || now(),
|
||
action: String((row && row.action) || ''),
|
||
detail: String((row && row.detail) || '')
|
||
})
|
||
|
||
const pushActionLog = (app, action, detail = '') => {
|
||
if (!Array.isArray(app.actionLogs)) app.actionLogs = []
|
||
app.actionLogs.push(normalizeActionLog({ at: now(), action, detail }))
|
||
}
|
||
|
||
const detailRow = (label, value) => `
|
||
<div class="detail-row">
|
||
<div class="detail-label">${escapeHtml(label)}</div>
|
||
<div class="detail-value">${escapeHtml(value)}</div>
|
||
</div>
|
||
`
|
||
|
||
const detailSectionTitle = title => `<div class="detail-section-title">${escapeHtml(title)}</div>`
|
||
|
||
const detailMessage = app => {
|
||
const replacementSel = app && app.commitments && app.commitments.replacementSelection ? app.commitments.replacementSelection : {}
|
||
const replacementTags = []
|
||
if (replacementSel.noSimilar) replacementTags.push('无同类')
|
||
if (replacementSel.damaged) replacementTags.push('旧物损坏/过时')
|
||
if (replacementSel.unrepairable) replacementTags.push('不可维修')
|
||
if (replacementSel.unique) replacementTags.push('功能独特')
|
||
const impulse = Array.isArray(app && app.impulse) ? app.impulse : []
|
||
const logs = Array.isArray(app && app.actionLogs) && app.actionLogs.length
|
||
? app.actionLogs.map(normalizeActionLog)
|
||
: (app
|
||
? [{ at: app.updatedAt || app.createdAt, action: 'legacy', detail: app.resultReason || statusTextOf(app.status) }]
|
||
: [])
|
||
const logRows = logs.length
|
||
? logs.map((log, idx) => detailRow('记录', `${idx + 1}. ${fmtDateTime(log.at)} | ${actionText(log.action)}${log.detail ? ` | ${log.detail}` : ''}`)).join('')
|
||
: detailRow('记录', '暂无操作记录')
|
||
return `
|
||
${detailRow('商品名称', app && app.basics ? app.basics.name : '')}
|
||
${detailRow('商品分类', app && app.basics ? app.basics.category : '')}
|
||
${detailRow('预估价格', fmtMoney(app && app.basics ? app.basics.price : 0))}
|
||
${detailRow('状态', statusTextOf(app && app.status))}
|
||
${detailRow('提交时间', fmtDateTime(app && app.createdAt))}
|
||
${detailRow('冷静期', `${toNum(app && app.coolingDays)}天`)}
|
||
${detailSectionTitle('填写内容')}
|
||
${detailRow('购买原因简述', app && app.basics ? app.basics.reason : '')}
|
||
${detailRow('风险组', app && app.shopping && app.shopping.riskLabel ? app.shopping.riskLabel : '未记录')}
|
||
${detailRow('购物方式', app && app.shopping && app.shopping.methodLabel ? app.shopping.methodLabel : '未记录')}
|
||
${detailRow('购买平台', app && app.shopping && app.shopping.platformLabel ? app.shopping.platformLabel : '未记录')}
|
||
${detailRow('店铺类型', app && app.shopping && app.shopping.sellerTypeLabel ? app.shopping.sellerTypeLabel : '未记录')}
|
||
${detailRow('品相', app && app.shopping && app.shopping.conditionLabel ? app.shopping.conditionLabel : '未记录')}
|
||
${detailRow('购物保障', app && app.shopping && app.shopping.guaranteeLabel ? app.shopping.guaranteeLabel : '未记录')}
|
||
${detailRow('沟通问题1', app && app.shopping && app.shopping.q1 ? app.shopping.q1 : '未填写')}
|
||
${detailRow('沟通问题2', app && app.shopping && app.shopping.q2 ? app.shopping.q2 : '未填写')}
|
||
${detailRow('沟通问题3', app && app.shopping && app.shopping.q3 ? app.shopping.q3 : '未填写')}
|
||
${detailRow('沟通问题4', app && app.shopping && app.shopping.q4 ? app.shopping.q4 : '未填写')}
|
||
${detailRow('沟通问题5', app && app.shopping && app.shopping.q5 ? app.shopping.q5 : '未填写')}
|
||
${detailRow('替代检查', replacementSummary(app || {}))}
|
||
${detailRow('替代标签', replacementTags.length ? replacementTags.join('、') : '未记录')}
|
||
${detailRow('场景1', app && app.scenarios ? app.scenarios.where : '')}
|
||
${detailRow('场景2', app && app.scenarios ? app.scenarios.frequency : '')}
|
||
${detailRow('场景3', app && app.scenarios ? app.scenarios.problem : '')}
|
||
${detailRow('实用程度', String(app && app.scores ? app.scores.practical : ''))}
|
||
${detailRow('必需程度', String(app && app.scores ? app.scores.necessary : ''))}
|
||
${detailRow('预期使用率', String(app && app.scores ? app.scores.usage : ''))}
|
||
${detailRow('性价比合理性', String(app && app.scores ? app.scores.cost : ''))}
|
||
${detailRow('后悔概率', String(app && app.scores ? app.scores.regret : ''))}
|
||
${detailRow('评分总分', String(app && app.scores ? app.scores.total : ''))}
|
||
${detailRow('防冲动验证1', impulse[0] || '未填写')}
|
||
${detailRow('防冲动验证2', impulse[1] || '未填写')}
|
||
${detailRow('防冲动验证3', impulse[2] || '未填写')}
|
||
${detailRow('反悔可能', app && app.regretPossibility ? app.regretPossibility : '未填写')}
|
||
${detailSectionTitle('按钮操作历史')}
|
||
${logRows}
|
||
`
|
||
}
|
||
|
||
const hasCoolingEnded = app => now() >= toNum(app && app.coolingEndAt)
|
||
|
||
const isCoolingLocked = app => {
|
||
if (!app) return false
|
||
if (app.status !== 'cooling' && app.status !== 'approved') return false
|
||
return !hasCoolingEnded(app)
|
||
}
|
||
|
||
const getCoolingLeft = app => {
|
||
const left = Math.max(0, toNum(app && app.coolingEndAt) - now())
|
||
const days = Math.max(1, Math.ceil(left / 86400000))
|
||
const hours = Math.floor((left % 86400000) / 3600000)
|
||
const minutes = Math.floor((left % 3600000) / 60000)
|
||
const seconds = Math.floor((left % 60000) / 1000)
|
||
return { left, days, hours, minutes, seconds }
|
||
}
|
||
|
||
const coolingLeftText = app => {
|
||
const leftInfo = getCoolingLeft(app)
|
||
return `${leftInfo.days}天${leftInfo.hours}小时`
|
||
}
|
||
|
||
const hasCopyCreateResultMark = app => app && (app.status === 'purchased' || app.status === 'abandoned')
|
||
|
||
const copyCreateBlockReasonOfApplication = app => {
|
||
if (!app) return '这条记录不存在或已被移除,暂时不能复制新增。'
|
||
if (!hasCoolingEnded(app)) return `冷静期还剩${coolingLeftText(app)},请等冷静期结束后再复制新增。`
|
||
if (!hasCopyCreateResultMark(app)) return `当前状态是“${statusTextOf(app.status)}”,请先标记“已购买”或“标记放弃”后再复制新增。`
|
||
return ''
|
||
}
|
||
|
||
const loadApplicationToForm = source => {
|
||
const sourceCoolingDays = Number(source && source.coolingDays)
|
||
const coolingDays = Number.isFinite(sourceCoolingDays) && sourceCoolingDays >= 3 && sourceCoolingDays <= 14 ? sourceCoolingDays : DEFAULT_COOLING_DAYS
|
||
const replacementSelection = source && source.commitments && source.commitments.replacementSelection
|
||
? { ...source.commitments.replacementSelection }
|
||
: {}
|
||
const draft = {
|
||
savedAt: now(),
|
||
basics: {
|
||
name: String(source && source.basics && source.basics.name || ''),
|
||
category: String(source && source.basics && source.basics.category || ''),
|
||
price: String(source && source.basics && source.basics.price || ''),
|
||
reason: String(source && source.basics && source.basics.reason || '')
|
||
},
|
||
shopping: {
|
||
method: String(source && source.shopping && source.shopping.method || ''),
|
||
guarantee: String(source && source.shopping && source.shopping.guarantee || ''),
|
||
platform: String(source && source.shopping && source.shopping.platform || ''),
|
||
sellerType: String(source && source.shopping && source.shopping.sellerType || ''),
|
||
condition: String(source && source.shopping && source.shopping.condition || ''),
|
||
q1: String(source && source.shopping && source.shopping.q1 || ''),
|
||
q2: String(source && source.shopping && source.shopping.q2 || ''),
|
||
q3: String(source && source.shopping && source.shopping.q3 || ''),
|
||
q4: String(source && source.shopping && source.shopping.q4 || ''),
|
||
q5: String(source && source.shopping && source.shopping.q5 || '')
|
||
},
|
||
regretPossibility: String(source && source.regretPossibility || ''),
|
||
coolingChecks: [
|
||
!!(source && source.commitments && source.commitments.coolingAllChecked),
|
||
!!(source && source.commitments && source.commitments.coolingAllChecked),
|
||
!!(source && source.commitments && source.commitments.coolingAllChecked)
|
||
],
|
||
coolingDays,
|
||
replacement: {
|
||
noSimilar: !!replacementSelection.noSimilar,
|
||
damaged: !!replacementSelection.damaged,
|
||
unrepairable: !!replacementSelection.unrepairable,
|
||
unique: !!replacementSelection.unique
|
||
},
|
||
scenes: {
|
||
where: String(source && source.scenarios && source.scenarios.where || ''),
|
||
frequency: String(source && source.scenarios && source.scenarios.frequency || ''),
|
||
problem: String(source && source.scenarios && source.scenarios.problem || '')
|
||
},
|
||
scores: {
|
||
practical: String(source && source.scores && source.scores.practical || ''),
|
||
necessary: String(source && source.scores && source.scores.necessary || ''),
|
||
usage: String(source && source.scores && source.scores.usage || ''),
|
||
cost: String(source && source.scores && source.scores.cost || ''),
|
||
regret: String(source && source.scores && source.scores.regret || '')
|
||
},
|
||
impulse: {
|
||
i1: String(source && source.impulse && source.impulse.i1 || ''),
|
||
i2: String(source && source.impulse && source.impulse.i2 || ''),
|
||
i3: String(source && source.impulse && source.impulse.i3 || '')
|
||
},
|
||
finalChecks: [true, true, true]
|
||
}
|
||
applyDraft(draft)
|
||
const formTop = $('application-form')
|
||
if (formTop && formTop.scrollIntoView) formTop.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||
}
|
||
|
||
const buildActionBtn = (id, act, text, blockedReason = '') => {
|
||
const cls = blockedReason ? 'btn btn-soft-disabled' : 'btn'
|
||
const reasonAttr = blockedReason ? ` data-block-reason="${escapeHtml(blockedReason)}" title="${escapeHtml(blockedReason)}"` : ''
|
||
return `<button class="${cls}" data-act="${act}" data-id="${id}"${reasonAttr}>${text}</button>`
|
||
}
|
||
|
||
const getDefaultHistoryRange = () => {
|
||
const end = new Date()
|
||
const start = new Date()
|
||
start.setMonth(start.getMonth() - 3)
|
||
return {
|
||
startDate: formatBeijingDate(start.getTime()),
|
||
endDate: formatBeijingDate(end.getTime())
|
||
}
|
||
}
|
||
|
||
const getHistoryFilteredList = () => {
|
||
const search = String(state.ui.history.search || '').trim().toLowerCase()
|
||
const startDate = state.ui.history.startDate || ''
|
||
const endDate = state.ui.history.endDate || ''
|
||
const showArchived = !!state.ui.history.showArchived
|
||
return state.db.applications
|
||
.slice()
|
||
.sort((a, b) => b.createdAt - a.createdAt)
|
||
.filter(a => {
|
||
if (!showArchived && a.archived) return false
|
||
const day = formatBeijingDate(a.createdAt)
|
||
if (startDate && day < startDate) return false
|
||
if (endDate && day > endDate) return false
|
||
if (!search) return true
|
||
const text = [
|
||
a.basics && a.basics.name,
|
||
a.basics && a.basics.category,
|
||
a.basics && a.basics.reason,
|
||
a.regretPossibility,
|
||
a.resultReason,
|
||
statusTextOf(a.status)
|
||
].map(x => String(x || '').toLowerCase()).join(' ')
|
||
return text.includes(search)
|
||
})
|
||
}
|
||
|
||
const collectFormDraft = () => {
|
||
const getRadio = name => {
|
||
const el = document.querySelector(`input[name="${name}"]:checked`)
|
||
return el ? el.value : ''
|
||
}
|
||
const checkedValues = rootId => Array.from(document.querySelectorAll(`#${rootId} input[type="checkbox"]`)).map(el => !!el.checked)
|
||
return {
|
||
savedAt: now(),
|
||
basics: {
|
||
name: $('item-name').value.trim(),
|
||
category: $('item-category').value,
|
||
price: $('item-price').value,
|
||
reason: $('item-reason').value.trim()
|
||
},
|
||
shopping: getShoppingFields(),
|
||
regretPossibility: $('item-regret-possibility').value.trim(),
|
||
coolingChecks: checkedValues('commit-cooling'),
|
||
coolingDays: getCoolingDays(),
|
||
replacement: getReplacementSelection(),
|
||
scenes: {
|
||
where: $('scene-where').value.trim(),
|
||
frequency: $('scene-frequency').value.trim(),
|
||
problem: $('scene-problem').value.trim()
|
||
},
|
||
scores: {
|
||
practical: $('score-practical').value,
|
||
necessary: $('score-necessary').value,
|
||
usage: $('score-usage').value,
|
||
cost: $('score-cost').value,
|
||
regret: $('score-regret').value
|
||
},
|
||
impulse: {
|
||
i1: getRadio('impulse-1'),
|
||
i2: getRadio('impulse-2'),
|
||
i3: getRadio('impulse-3')
|
||
},
|
||
finalChecks: checkedValues('commit-final')
|
||
}
|
||
}
|
||
|
||
const saveDraft = () => {
|
||
if (!state.auth.currentUser) return null
|
||
const draft = collectFormDraft()
|
||
state.draft = draft
|
||
saveDB()
|
||
return draft
|
||
}
|
||
|
||
const loadDraft = () => {
|
||
if (!state.auth.currentUser) return null
|
||
if (!state.draft || typeof state.draft !== 'object') return null
|
||
return state.draft
|
||
}
|
||
|
||
const clearDraft = () => {
|
||
if (!state.auth.currentUser) return
|
||
state.draft = null
|
||
saveDB()
|
||
}
|
||
|
||
const applyDraft = draft => {
|
||
if (!draft || typeof draft !== 'object') return false
|
||
$('item-name').value = (draft.basics && draft.basics.name) || ''
|
||
$('item-category').value = normalizeCategory((draft.basics && draft.basics.category) || '')
|
||
$('item-price').value = (draft.basics && draft.basics.price) || ''
|
||
$('item-reason').value = (draft.basics && draft.basics.reason) || ''
|
||
const shopping = draft.shopping || {}
|
||
if (hasEl('shopping-method')) $('shopping-method').value = shopping.method || ''
|
||
if (hasEl('shopping-guarantee')) $('shopping-guarantee').value = shopping.guarantee || ''
|
||
if (hasEl('shopping-platform')) $('shopping-platform').value = shopping.platform || ''
|
||
if (hasEl('shopping-seller-type')) $('shopping-seller-type').value = shopping.sellerType || ''
|
||
if (hasEl('shopping-condition')) $('shopping-condition').value = shopping.condition || ''
|
||
if (hasEl('shopping-q1')) $('shopping-q1').value = shopping.q1 || ''
|
||
if (hasEl('shopping-q2')) $('shopping-q2').value = shopping.q2 || ''
|
||
if (hasEl('shopping-q3')) $('shopping-q3').value = shopping.q3 || ''
|
||
if (hasEl('shopping-q4')) $('shopping-q4').value = shopping.q4 || ''
|
||
if (hasEl('shopping-q5')) $('shopping-q5').value = shopping.q5 || ''
|
||
$('item-regret-possibility').value = draft.regretPossibility || ''
|
||
|
||
const coolingInputs = Array.from(document.querySelectorAll('#commit-cooling input[type="checkbox"]'))
|
||
coolingInputs.forEach((el, idx) => {
|
||
el.checked = !!(Array.isArray(draft.coolingChecks) && draft.coolingChecks[idx])
|
||
})
|
||
|
||
const replacement = draft.replacement || {}
|
||
const setReplacement = (key, value) => {
|
||
const el = document.querySelector(`#commit-replacement input[data-replacement="${key}"]`)
|
||
if (el) el.checked = !!value
|
||
}
|
||
setReplacement('no-similar', replacement.noSimilar)
|
||
setReplacement('damaged', replacement.damaged)
|
||
setReplacement('unrepairable', replacement.unrepairable)
|
||
setReplacement('unique', replacement.unique)
|
||
|
||
$('scene-where').value = (draft.scenes && draft.scenes.where) || ''
|
||
$('scene-frequency').value = (draft.scenes && draft.scenes.frequency) || ''
|
||
$('scene-problem').value = (draft.scenes && draft.scenes.problem) || ''
|
||
|
||
$('score-practical').value = (draft.scores && draft.scores.practical) || ''
|
||
$('score-necessary').value = (draft.scores && draft.scores.necessary) || ''
|
||
$('score-usage').value = (draft.scores && draft.scores.usage) || ''
|
||
$('score-cost').value = (draft.scores && draft.scores.cost) || ''
|
||
$('score-regret').value = (draft.scores && draft.scores.regret) || ''
|
||
|
||
;['impulse-1', 'impulse-2', 'impulse-3'].forEach((name, idx) => {
|
||
const val = draft.impulse && draft.impulse[`i${idx + 1}`]
|
||
Array.from(document.querySelectorAll(`input[name="${name}"]`)).forEach(el => {
|
||
el.checked = String(el.value) === String(val || '')
|
||
})
|
||
})
|
||
|
||
const finalInputs = Array.from(document.querySelectorAll('#commit-final input[type="checkbox"]'))
|
||
finalInputs.forEach((el, idx) => {
|
||
el.checked = !!(Array.isArray(draft.finalChecks) && draft.finalChecks[idx])
|
||
})
|
||
|
||
const coolingSelect = $('cooling-days')
|
||
if (coolingSelect && draft.coolingDays) {
|
||
const val = Number(draft.coolingDays)
|
||
if (Number.isFinite(val) && val >= 3 && val <= 14) {
|
||
coolingSelect.value = String(val)
|
||
}
|
||
}
|
||
|
||
syncFormState()
|
||
return true
|
||
}
|
||
|
||
const formCompleted = () => {
|
||
if (!$('item-name').value.trim()) return false
|
||
if (!$('item-category').value) return false
|
||
if (!Number.isFinite(Number($('item-price').value))) return false
|
||
if (!$('item-reason').value.trim()) return false
|
||
if (shoppingIncompleteReason()) return false
|
||
if (!Number.isFinite(getCoolingDays())) return false
|
||
if (!allChecked('commit-cooling')) return false
|
||
if (!evaluateReplacementSelection().valid) return false
|
||
if (!$('scene-where').value.trim()) return false
|
||
if (!$('scene-frequency').value.trim()) return false
|
||
if (!$('scene-problem').value.trim()) return false
|
||
if (!$('item-regret-possibility').value.trim()) return false
|
||
if (getScoreTotal() <= 0) return false
|
||
if (['score-practical', 'score-necessary', 'score-usage', 'score-cost', 'score-regret'].some(id => !$(id).value)) return false
|
||
if (getImpulseAnswers().some(x => !x)) return false
|
||
if (!allChecked('commit-final')) return false
|
||
return true
|
||
}
|
||
|
||
const getFormBlockReason = () => {
|
||
if (!$('item-name').value.trim()) return '先填一下“商品名称”呀,不然我都不知道你想买啥。'
|
||
if (!$('item-category').value) return '先选“商品分类”,系统要靠它自动映射风险组。'
|
||
if (!Number.isFinite(Number($('item-price').value))) return '价格先填成有效数字哈,系统还要拿它做后面的判断。'
|
||
if (!$('item-reason').value.trim()) return '“购买原因简述”还没写,先说清楚你为什么想买。'
|
||
const shoppingReason = shoppingIncompleteReason()
|
||
if (shoppingReason) return shoppingReason
|
||
if (!Number.isFinite(getCoolingDays())) return '冷静期天数有点不对,重新选一下 3 到 14 天吧。'
|
||
if (!allChecked('commit-cooling')) return '冷静期确认项还没全勾完,这一步先别跳。'
|
||
const replacementEval = evaluateReplacementSelection()
|
||
if (!replacementEval.valid) return replacementEval.message.replace(/\n+/g, ' ')
|
||
if (!$('scene-where').value.trim()) return '“使用场景”还没填,先说下你准备在哪儿用。'
|
||
if (!$('scene-frequency').value.trim()) return '“使用频率”还没填,先说下你大概多久会用一次。'
|
||
if (!$('scene-problem').value.trim()) return '“可解决问题”还没填,先说清楚它到底解决什么问题。'
|
||
if (!$('item-regret-possibility').value.trim()) return '“反悔可能”还没填,先诚实评估下自己会不会后悔。'
|
||
if (['score-practical', 'score-necessary', 'score-usage', 'score-cost', 'score-regret'].some(id => !$(id).value)) return '五项评分还没打满,先都选一下。'
|
||
if (getScoreTotal() <= 0) return '当前评分总分还不成立,先把量表补完整哦。'
|
||
if (getImpulseAnswers().some(x => !x)) return '防冲动验证还没答完,三个问题都要选。'
|
||
if (!allChecked('commit-final')) return '最后确认项还没全部勾选,所以现在还不能提交。'
|
||
return ''
|
||
}
|
||
|
||
const evaluationFromForm = () => {
|
||
const scoreTotal = getScoreTotal()
|
||
const impulse = getImpulseAnswers()
|
||
const impulsePass = impulse.every(v => v === '否')
|
||
const scorePass = scoreTotal >= 14
|
||
const qualified = impulsePass && scorePass
|
||
let reason = ''
|
||
if (!impulsePass) reason = '防冲动验证存在“是”,不建议购买'
|
||
else if (!scorePass) reason = `必要性总分 ${scoreTotal} 低于14分,不建议购买`
|
||
return { scoreTotal, impulse, impulsePass, scorePass, qualified, reason }
|
||
}
|
||
|
||
const latestApplication = () => {
|
||
const list = state.db.applications.slice().sort((a, b) => b.createdAt - a.createdAt)
|
||
return list[0] || null
|
||
}
|
||
|
||
const findSimilar = (name, category) => {
|
||
const key = String(name || '').trim().toLowerCase()
|
||
return state.db.items.filter(it => {
|
||
const sameCat = String(it.category || '') === String(category || '')
|
||
const itName = String(it.name || '').toLowerCase()
|
||
const nameLike = key && (itName.includes(key) || key.includes(itName))
|
||
return sameCat || nameLike
|
||
})
|
||
}
|
||
|
||
const findDustRisk = (name, category) => {
|
||
const key = String(name || '').trim().toLowerCase()
|
||
return state.db.applications.filter(a => {
|
||
if (a.status !== 'purchased' || !a.usageLow) return false
|
||
const sameCat = String(a.basics.category || '') === String(category || '')
|
||
const aName = String(a.basics.name || '').toLowerCase()
|
||
const nameLike = key && (aName.includes(key) || key.includes(aName))
|
||
return sameCat || nameLike
|
||
})
|
||
}
|
||
|
||
const upsertItem = (name, category, note) => {
|
||
const n = String(name || '').trim()
|
||
const c = String(category || '').trim()
|
||
if (!n || !c) return
|
||
const existed = state.db.items.find(it => it.name === n && it.category === c)
|
||
if (existed) return
|
||
state.db.items.unshift({ id: uid(), name: n, category: c, note: String(note || ''), createdAt: now() })
|
||
}
|
||
|
||
const refreshWarning = () => {
|
||
const name = $('item-name').value.trim()
|
||
const category = $('item-category').value
|
||
const warningBox = $('warning-box')
|
||
const similar = findSimilar(name, category)
|
||
const dust = findDustRisk(name, category)
|
||
const lines = []
|
||
if (similar.length) lines.push(`同类提醒:已存在 ${similar.length} 条同类物品记录`)
|
||
if (dust.length) lines.push(`吃灰预警:历史已购中有 ${dust.length} 条低使用率同类物品`)
|
||
if (!lines.length) {
|
||
warningBox.style.display = 'none'
|
||
warningBox.className = 'card'
|
||
warningBox.textContent = ''
|
||
return
|
||
}
|
||
warningBox.style.display = ''
|
||
warningBox.className = 'card warn'
|
||
warningBox.textContent = lines.join(';')
|
||
}
|
||
|
||
const setFormStatus = text => {
|
||
$('form-status').textContent = text || ''
|
||
}
|
||
|
||
const syncFormState = () => {
|
||
$('score-total').textContent = String(getScoreTotal())
|
||
syncCategoryHelp()
|
||
syncCategoryRiskTip()
|
||
syncShoppingRiskUI()
|
||
const submitBtn = $('btn-submit')
|
||
const blockReason = getFormBlockReason()
|
||
submitBtn.disabled = false
|
||
submitBtn.classList.toggle('btn-soft-disabled', !!blockReason)
|
||
if (blockReason) {
|
||
submitBtn.setAttribute('title', blockReason)
|
||
submitBtn.setAttribute('data-block-reason', blockReason)
|
||
} else {
|
||
submitBtn.removeAttribute('title')
|
||
submitBtn.removeAttribute('data-block-reason')
|
||
}
|
||
syncCoolingDaysText()
|
||
refreshWarning()
|
||
}
|
||
|
||
const createApplication = () => {
|
||
const evalRes = evaluationFromForm()
|
||
const createdAt = now()
|
||
const coolingDays = getCoolingDays()
|
||
const shoppingEval = evaluateShoppingRisk()
|
||
const app = {
|
||
id: uid(),
|
||
createdAt,
|
||
updatedAt: createdAt,
|
||
coolingDays,
|
||
coolingEndAt: createdAt + coolingDays * 86400000,
|
||
basics: {
|
||
name: $('item-name').value.trim(),
|
||
category: $('item-category').value,
|
||
price: toNum($('item-price').value),
|
||
reason: $('item-reason').value.trim()
|
||
},
|
||
shopping: (() => {
|
||
const s = getShoppingFields()
|
||
const methodLabel = s.method === 'offline' ? '线下店' : (s.method === 'online' ? '网购' : '')
|
||
const guaranteeLabelMap = {
|
||
'seven-day': '有七天无理由退换',
|
||
'no-return': '没有无理由退换',
|
||
'xianyu-used': '闲鱼二手购买'
|
||
}
|
||
return {
|
||
...s,
|
||
riskLevel: shoppingEval.riskLevel || '',
|
||
riskLabel: shoppingEval.riskLabel || '',
|
||
methodLabel,
|
||
platformLabel: getPlatformLabel(s.platform),
|
||
sellerTypeLabel: getSellerTypeLabel(s.sellerType),
|
||
conditionLabel: getConditionLabel(s.condition),
|
||
guaranteeLabel: guaranteeLabelMap[s.guarantee] || '',
|
||
needRiskQuestions: shoppingEval.needQuestions,
|
||
riskTip: shoppingEval.tip || '',
|
||
riskReason: shoppingEval.reason || ''
|
||
}
|
||
})(),
|
||
commitments: {
|
||
coolingAllChecked: allChecked('commit-cooling'),
|
||
replacementValid: evaluateReplacementSelection().valid,
|
||
replacementScene: evaluateReplacementSelection().scene,
|
||
replacementSelection: getReplacementSelection(),
|
||
finalAllChecked: allChecked('commit-final')
|
||
},
|
||
scenarios: {
|
||
where: $('scene-where').value.trim(),
|
||
frequency: $('scene-frequency').value.trim(),
|
||
problem: $('scene-problem').value.trim()
|
||
},
|
||
regretPossibility: $('item-regret-possibility').value.trim(),
|
||
scores: {
|
||
practical: toNum($('score-practical').value),
|
||
necessary: toNum($('score-necessary').value),
|
||
usage: toNum($('score-usage').value),
|
||
cost: toNum($('score-cost').value),
|
||
regret: toNum($('score-regret').value),
|
||
total: evalRes.scoreTotal
|
||
},
|
||
impulse: evalRes.impulse,
|
||
qualified: evalRes.qualified,
|
||
status: evalRes.qualified ? 'cooling' : 'rejected',
|
||
resultReason: evalRes.qualified ? '待决策:冷静期进行中,暂不可购买/放弃' : evalRes.reason,
|
||
usageLow: false
|
||
}
|
||
pushActionLog(app, 'submit', `提交并进入 ${coolingDays} 天冷静期`)
|
||
return app
|
||
}
|
||
|
||
const resetForm = () => {
|
||
$('application-form').reset()
|
||
if (hasEl('shopping-guarantee-wrap')) $('shopping-guarantee-wrap').style.display = 'none'
|
||
if (hasEl('shopping-platform-wrap')) $('shopping-platform-wrap').style.display = 'none'
|
||
if (hasEl('shopping-online-extra-wrap')) $('shopping-online-extra-wrap').style.display = 'none'
|
||
if (hasEl('shopping-questions-wrap')) $('shopping-questions-wrap').style.display = 'none'
|
||
if (hasEl('shopping-risk-tip')) $('shopping-risk-tip').style.display = 'none'
|
||
if (hasEl('shopping-risk-group-tip')) $('shopping-risk-group-tip').textContent = ''
|
||
$('score-total').textContent = '0'
|
||
syncFormState()
|
||
}
|
||
|
||
const renderDecision = () => {
|
||
const box = $('decision-panel')
|
||
const app = latestApplication()
|
||
if (!app) {
|
||
box.className = 'decision'
|
||
box.textContent = '暂无申请记录'
|
||
return
|
||
}
|
||
const current = now()
|
||
if (app.status === 'cooling' && current >= app.coolingEndAt && app.qualified) {
|
||
box.className = 'decision pass'
|
||
box.innerHTML = `<div>申请:${app.basics.name}</div><div>冷静期已结束,可选择“已购买”或“标记放弃”</div><div>${app.resultReason || ''}</div>`
|
||
return
|
||
}
|
||
if (app.status === 'cooling') {
|
||
const leftInfo = getCoolingLeft(app)
|
||
box.className = 'decision wait'
|
||
box.innerHTML = `<div>申请:${app.basics.name}</div><div>冷静期剩余${leftInfo.days}天(${leftInfo.hours}小时 ${leftInfo.minutes}分 ${leftInfo.seconds}秒)</div><div>提交时间:${fmtDateTime(app.createdAt)}(冷静期内不可进行购买/放弃/低使用率标记)</div>`
|
||
return
|
||
}
|
||
if (app.status === 'approved') {
|
||
box.className = 'decision pass'
|
||
box.innerHTML = `<div>待决策</div><div>${app.basics.name} · ${fmtMoney(app.basics.price)}</div><div>冷静期已结束,可选择“已购买”或“标记放弃”</div>`
|
||
return
|
||
}
|
||
if (app.status === 'purchased') {
|
||
const topText = app.usageLow
|
||
? '已购买 · 已标记低使用率(同类购买将预警)'
|
||
: (app.expenseRecordId ? '已购买 · 已推送至报销系统' : '已购买 · 待同步报销系统')
|
||
box.className = 'decision pass'
|
||
box.innerHTML = `<div>${topText}</div><div>${app.basics.name} · ${fmtMoney(app.basics.price)}</div><div>${app.resultReason || ''}</div>`
|
||
return
|
||
}
|
||
if (app.status === 'abandoned') {
|
||
box.className = 'decision reject'
|
||
box.innerHTML = `<div>已放弃</div><div>${app.basics.name} · ${fmtMoney(app.basics.price)}</div><div>${app.resultReason || ''}</div>`
|
||
return
|
||
}
|
||
box.className = 'decision reject'
|
||
box.innerHTML = `<div>不建议购买</div><div>${app.basics.name} · ${fmtMoney(app.basics.price)}</div><div>${app.resultReason || ''}</div>`
|
||
}
|
||
|
||
const renderLibrary = () => {
|
||
if (!hasEl('btn-lib-add') || !hasEl('btn-lib-cancel-edit') || !hasEl('lib-list')) return
|
||
$('btn-lib-add').textContent = state.ui.library.editingId ? '保存修改' : '加入物品库'
|
||
$('btn-lib-cancel-edit').style.display = state.ui.library.editingId ? '' : 'none'
|
||
const box = $('lib-list')
|
||
box.innerHTML = ''
|
||
if (!state.db.items.length) {
|
||
box.textContent = '暂无物品库记录'
|
||
return
|
||
}
|
||
state.db.items.forEach(it => {
|
||
const row = document.createElement('div')
|
||
row.className = 'row'
|
||
row.innerHTML = `
|
||
<div><strong>${it.name}</strong> · ${it.category}</div>
|
||
<div class="meta">${it.note || '无备注'}</div>
|
||
<div class="actions">
|
||
<button class="btn" data-lib-edit="${it.id}">修改</button>
|
||
<button class="btn" data-lib-del="${it.id}">删除</button>
|
||
</div>
|
||
`
|
||
box.appendChild(row)
|
||
})
|
||
}
|
||
|
||
const monthlyStats = () => {
|
||
const d = new Date()
|
||
const y = d.getFullYear()
|
||
const m = d.getMonth()
|
||
const rows = state.db.applications.filter(a => {
|
||
const x = new Date(a.createdAt)
|
||
return x.getFullYear() === y && x.getMonth() === m
|
||
})
|
||
const wanted = rows.length
|
||
const abandoned = rows.filter(r => r.status === 'abandoned').length
|
||
const purchased = rows.filter(r => r.status === 'purchased').length
|
||
const saved = rows.filter(r => r.status === 'abandoned' || r.status === 'rejected').reduce((acc, r) => acc + toNum(r.basics.price), 0)
|
||
return { wanted, abandoned, purchased, saved }
|
||
}
|
||
|
||
const renderStats = () => {
|
||
const st = monthlyStats()
|
||
$('stats').innerHTML = `
|
||
<div class="item"><div>本月想买数</div><div class="value">${st.wanted}</div></div>
|
||
<div class="item"><div>本月放弃数</div><div class="value">${st.abandoned}</div></div>
|
||
<div class="item"><div>本月购买数</div><div class="value">${st.purchased}</div></div>
|
||
<div class="item"><div>节省金额</div><div class="value">${fmtMoney(st.saved)}</div></div>
|
||
`
|
||
}
|
||
|
||
const renderHistory = () => {
|
||
const box = $('history-list')
|
||
const pageInfo = $('history-page-info')
|
||
const prevBtn = $('history-prev')
|
||
const nextBtn = $('history-next')
|
||
box.innerHTML = ''
|
||
const list = getHistoryFilteredList()
|
||
if (!list.length) {
|
||
box.textContent = '暂无符合条件的历史记录'
|
||
pageInfo.textContent = '第 0 / 0 页,共 0 条'
|
||
prevBtn.disabled = true
|
||
nextBtn.disabled = true
|
||
return
|
||
}
|
||
const totalPages = Math.max(1, Math.ceil(list.length / state.ui.history.pageSize))
|
||
if (state.ui.history.page > totalPages) state.ui.history.page = totalPages
|
||
if (state.ui.history.page < 1) state.ui.history.page = 1
|
||
const start = (state.ui.history.page - 1) * state.ui.history.pageSize
|
||
const end = start + state.ui.history.pageSize
|
||
const pageRows = list.slice(start, end)
|
||
pageInfo.textContent = `第 ${state.ui.history.page} / ${totalPages} 页,共 ${list.length} 条`
|
||
prevBtn.disabled = state.ui.history.page <= 1
|
||
nextBtn.disabled = state.ui.history.page >= totalPages
|
||
pageRows.forEach(a => {
|
||
const row = document.createElement('div')
|
||
row.className = 'row'
|
||
const statusText = statusTextOf(a.status)
|
||
const coolingLocked = isCoolingLocked(a)
|
||
const canActAfterCooling = (a.status === 'cooling' || a.status === 'approved') && hasCoolingEnded(a)
|
||
const canRetryPush = a.status === 'purchased' && !a.expenseRecordId
|
||
const canMarkLowUsage = a.status === 'purchased' && !a.usageLow
|
||
const copyCreateBlock = copyCreateBlockReasonOfApplication(a)
|
||
const lowTag = a.usageLow ? '<span class="tag-low">低使用率预警</span>' : ''
|
||
const archivedTag = a.archived ? '<span class="tag-archived">已归档</span>' : ''
|
||
const coolingTip = coolingLocked
|
||
? `<div class="meta cooling-tip">【冷静期剩余${coolingLeftText(a)}】,期间不可进行购买/放弃/低使用率标记操作</div>`
|
||
: ''
|
||
const coolingReason = `冷静期还剩${coolingLeftText(a)},期间不能操作哦~`
|
||
const endedReason = `这个物品已经标记为“${a.status === 'purchased' ? '已购买' : '已放弃'}”啦,为了避免改来改去出错,现在不能再点其他按钮咯~`
|
||
const needPurchaseReason = '要先标记“已购买”之后,才能标记“低使用率”啦。你先确认购买,再过来标记就可以咯~'
|
||
const notEligibleReason = '当前这条记录暂不支持这个操作哦,可以先检查状态说明再决定下一步。'
|
||
const purchaseBlock = coolingLocked
|
||
? coolingReason
|
||
: (a.status === 'purchased' || a.status === 'abandoned'
|
||
? endedReason
|
||
: (!canActAfterCooling && a.status !== 'cooling' && a.status !== 'approved' ? notEligibleReason : ''))
|
||
const abandonBlock = coolingLocked
|
||
? coolingReason
|
||
: (a.status === 'purchased' || a.status === 'abandoned'
|
||
? endedReason
|
||
: (!canActAfterCooling && a.status !== 'cooling' && a.status !== 'approved' ? notEligibleReason : ''))
|
||
const lowBlock = coolingLocked
|
||
? coolingReason
|
||
: (a.status === 'abandoned' ? endedReason : (a.status !== 'purchased' ? needPurchaseReason : (a.usageLow ? '这个物品已经标记过“低使用率”啦~' : '')))
|
||
const editBlock = coolingLocked ? `冷静期还剩${coolingLeftText(a)},为了避免改来改去出错,现在不能修改这个物品的信息啦~` : ''
|
||
const deleteBlock = a.status === 'purchased'
|
||
? '这个物品已经标记为“已购买”啦,为了避免财务记录出错,不能删除哦!你可以改成归档隐藏。'
|
||
: ''
|
||
const actionsHtml = `
|
||
<button class="btn btn-link" data-act="view-detail" data-id="${a.id}" type="button">查看详情</button>
|
||
${buildActionBtn(a.id, 'purchase', '已购买', purchaseBlock)}
|
||
${buildActionBtn(a.id, 'abandon', '标记放弃', abandonBlock)}
|
||
${buildActionBtn(a.id, 'copy-create', '复制新增', copyCreateBlock)}
|
||
${buildActionBtn(a.id, 'toggle-low', canMarkLowUsage ? '标记低使用率' : '标记低使用率', lowBlock)}
|
||
${buildActionBtn(a.id, 'edit-app', '修改申请', editBlock)}
|
||
${buildActionBtn(a.id, 'archive-app', a.archived ? '取消归档' : '归档隐藏')}
|
||
${buildActionBtn(a.id, 'delete-app', '删除记录', deleteBlock)}
|
||
${canRetryPush ? buildActionBtn(a.id, 'retry-push', '重试同步报销') : ''}
|
||
`
|
||
row.innerHTML = `
|
||
<div class="row-head">
|
||
<div><strong>${a.basics.name}</strong> · ${a.basics.category} · ${fmtMoney(a.basics.price)}</div>
|
||
</div>
|
||
<div class="meta">状态:${statusText}${archivedTag ? ` | ${archivedTag}` : ''} ${lowTag ? `| ${lowTag}` : ''} | 总分:${a.scores.total} | 提交:${fmtDateTime(a.createdAt)}</div>
|
||
<div class="meta">反悔可能:${escapeHtml(a.regretPossibility || '未填写')}</div>
|
||
<div class="meta">${a.resultReason || ''}</div>
|
||
${coolingTip}
|
||
<div class="actions">
|
||
${actionsHtml}
|
||
</div>
|
||
`
|
||
box.appendChild(row)
|
||
})
|
||
}
|
||
|
||
const renderAll = () => {
|
||
renderDecision()
|
||
renderLibrary()
|
||
renderStats()
|
||
renderHistory()
|
||
}
|
||
|
||
const getApplicationById = id => state.db.applications.find(a => a.id === id) || null
|
||
|
||
const updateApplication = (id, updater) => {
|
||
const idx = state.db.applications.findIndex(a => a.id === id)
|
||
if (idx < 0) return
|
||
const next = updater({ ...state.db.applications[idx] })
|
||
next.updatedAt = now()
|
||
state.db.applications[idx] = next
|
||
saveDB()
|
||
renderAll()
|
||
refreshWarning()
|
||
}
|
||
|
||
const removeApplication = id => {
|
||
state.db.applications = state.db.applications.filter(a => a.id !== id)
|
||
saveDB()
|
||
renderAll()
|
||
refreshWarning()
|
||
}
|
||
|
||
const bindForm = () => {
|
||
const form = $('application-form')
|
||
form.addEventListener('input', syncFormState)
|
||
form.addEventListener('change', syncFormState)
|
||
form.addEventListener('submit', async e => {
|
||
e.preventDefault()
|
||
if (!(await ensureLoggedIn())) return
|
||
if (!formCompleted()) {
|
||
const reason = getFormBlockReason() || '还有几项没填完,先补齐我再帮你提交。'
|
||
setFormStatus(`仍有未完成项:${reason}`)
|
||
await modalAlert('先别急着提交', reason)
|
||
return
|
||
}
|
||
const name = $('item-name').value.trim()
|
||
const category = $('item-category').value
|
||
const similar = findSimilar(name, category)
|
||
if (similar.length) {
|
||
const ok = await modalConfirm('同类提醒', `您已有同类物品(${similar.length}条),是否确认继续申请?`, '确认继续')
|
||
if (!ok) return
|
||
}
|
||
const dust = findDustRisk(name, category)
|
||
if (dust.length) {
|
||
const ok = await modalConfirm('吃灰预警', `存在同类吃灰预警(${dust.length}条),仍要继续申请吗?`, '仍要申请')
|
||
if (!ok) return
|
||
}
|
||
const app = createApplication()
|
||
state.db.applications.unshift(app)
|
||
saveDB()
|
||
clearDraft()
|
||
setFormStatus(app.status === 'rejected' ? app.resultReason : `新增成功,已进入${app.coolingDays || DEFAULT_COOLING_DAYS}天冷静期`)
|
||
renderAll()
|
||
if (app.status !== 'rejected') {
|
||
await modalAlert('🎉 新增成功!', `你添加的【${app.basics.name}】已经保存好啦,现在进入冷静期(${coolingLeftText(app)}),期间可以看但不能改哦~`)
|
||
}
|
||
resetForm()
|
||
})
|
||
}
|
||
|
||
const bindDraft = () => {
|
||
$('btn-save-draft').addEventListener('click', async () => {
|
||
if (!(await ensureLoggedIn())) return
|
||
const draft = saveDraft()
|
||
if (!draft) return
|
||
setFormStatus(`草稿已保存:${fmtDateTime(draft.savedAt)}`)
|
||
await modalAlert('✅ 保存成功!', `你改的【${draft.basics.name || '当前申请'}】信息已经存好啦,不会丢的~`)
|
||
})
|
||
$('btn-load-draft').addEventListener('click', async () => {
|
||
if (!(await ensureLoggedIn())) return
|
||
const draft = loadDraft()
|
||
if (!draft) {
|
||
await modalAlert('暂无草稿', '当前没有可恢复的草稿记录')
|
||
return
|
||
}
|
||
const ok = applyDraft(draft)
|
||
if (ok) {
|
||
setFormStatus(`已恢复草稿:${fmtDateTime(draft.savedAt || now())}`)
|
||
}
|
||
})
|
||
$('btn-clear-draft').addEventListener('click', async () => {
|
||
if (!(await ensureLoggedIn())) return
|
||
const ok = await modalConfirm('清空草稿', '确认清空已保存的草稿吗?', '确认清空')
|
||
if (!ok) return
|
||
clearDraft()
|
||
setFormStatus('草稿已清空')
|
||
})
|
||
}
|
||
|
||
const bindReplacementRules = () => {
|
||
$('commit-replacement').addEventListener('change', async e => {
|
||
const target = e.target
|
||
if (!target || target.type !== 'checkbox') return
|
||
const s = getReplacementSelection()
|
||
const key = target.getAttribute('data-replacement')
|
||
if (!key) return
|
||
|
||
if (key === 'no-similar' && target.checked && (s.damaged || s.unrepairable)) {
|
||
const damagedInput = document.querySelector('#commit-replacement input[data-replacement="damaged"]')
|
||
const unrepairableInput = document.querySelector('#commit-replacement input[data-replacement="unrepairable"]')
|
||
if (damagedInput) damagedInput.checked = false
|
||
if (unrepairableInput) unrepairableInput.checked = false
|
||
await modalAlert('已自动调整选项', '你选择了“家中无同类物品”,系统已自动取消“损坏/无法维修”两项,避免逻辑冲突。')
|
||
} else if ((key === 'damaged' || key === 'unrepairable') && target.checked && s.noSimilar) {
|
||
const noSimilarInput = document.querySelector('#commit-replacement input[data-replacement="no-similar"]')
|
||
if (noSimilarInput) noSimilarInput.checked = false
|
||
await modalAlert('已自动调整选项', '你选择了“已有旧物状态”项,系统已取消“家中无同类物品”。若是场景2,请同时勾选“损坏/过时”和“无法维修”。')
|
||
}
|
||
|
||
syncFormState()
|
||
})
|
||
}
|
||
|
||
const bindLibrary = () => {
|
||
if (!hasEl('btn-lib-add') || !hasEl('btn-lib-cancel-edit') || !hasEl('lib-list')) return
|
||
$('btn-lib-add').addEventListener('click', async () => {
|
||
if (!(await ensureLoggedIn())) return
|
||
const name = $('lib-name').value.trim()
|
||
const category = $('lib-category').value
|
||
const note = $('lib-note').value.trim()
|
||
if (!name) return
|
||
if (state.ui.library.editingId) {
|
||
const idx = state.db.items.findIndex(it => it.id === state.ui.library.editingId)
|
||
if (idx >= 0) {
|
||
state.db.items[idx] = { ...state.db.items[idx], name, category, note, updatedAt: now() }
|
||
}
|
||
state.ui.library.editingId = ''
|
||
await modalAlert('✅ 保存成功!', `你改的【${name}】信息已经存好啦,不会丢的~`)
|
||
} else {
|
||
upsertItem(name, category, note)
|
||
await modalAlert('🎉 新增成功!', `你添加的【${name}】已经入库啦,后续会用于同类提醒。`)
|
||
}
|
||
saveDB()
|
||
$('lib-name').value = ''
|
||
$('lib-note').value = ''
|
||
renderLibrary()
|
||
refreshWarning()
|
||
})
|
||
$('btn-lib-cancel-edit').addEventListener('click', () => {
|
||
state.ui.library.editingId = ''
|
||
$('lib-name').value = ''
|
||
$('lib-note').value = ''
|
||
renderLibrary()
|
||
})
|
||
$('lib-list').addEventListener('click', async e => {
|
||
if (!(await ensureLoggedIn())) return
|
||
const editId = e.target && e.target.getAttribute('data-lib-edit')
|
||
if (editId) {
|
||
const it = state.db.items.find(x => x.id === editId)
|
||
if (!it) return
|
||
state.ui.library.editingId = editId
|
||
$('lib-name').value = it.name || ''
|
||
$('lib-category').value = it.category || '其他'
|
||
$('lib-note').value = it.note || ''
|
||
renderLibrary()
|
||
setFormStatus(`已加载物品库【${it.name}】到编辑区,修改后点击“保存修改”`)
|
||
return
|
||
}
|
||
const id = e.target && e.target.getAttribute('data-lib-del')
|
||
if (!id) return
|
||
const it = state.db.items.find(x => x.id === id)
|
||
const ok = await modalConfirm('确认删除', `确认删除物品库中的【${it ? it.name : '该条'}】吗?这不会改动历史申请记录。`, '确定删除', '再想想')
|
||
if (!ok) return
|
||
state.db.items = state.db.items.filter(x => x.id !== id)
|
||
if (state.ui.library.editingId === id) state.ui.library.editingId = ''
|
||
saveDB()
|
||
renderLibrary()
|
||
refreshWarning()
|
||
await modalAlert('✅ 删除成功!', `已删除【${it ? it.name : '该物品'}】。历史申请快照不会被改写。`)
|
||
})
|
||
}
|
||
|
||
const bindSettingsNav = () => {
|
||
const btn = $('btn-open-settings')
|
||
if (!btn) return
|
||
btn.addEventListener('click', () => {
|
||
window.location.href = '/tools/rational_lock/settings.html'
|
||
})
|
||
}
|
||
|
||
const bindActions = () => {
|
||
document.addEventListener('click', async e => {
|
||
const btn = e.target.closest('button[data-act]')
|
||
if (!btn) return
|
||
if (!(await ensureLoggedIn())) return
|
||
const id = btn.getAttribute('data-id')
|
||
const act = btn.getAttribute('data-act')
|
||
if (!id || !act) return
|
||
if (act === 'view-detail') {
|
||
const app = getApplicationById(id)
|
||
if (!app) return
|
||
await modalDetail('记录详情', detailMessage(app))
|
||
return
|
||
}
|
||
const blockReason = btn.getAttribute('data-block-reason')
|
||
if (blockReason) {
|
||
await modalAlert('🔔 暂时不能操作', `你好!${blockReason}`)
|
||
return
|
||
}
|
||
if (act === 'copy-create') {
|
||
const app = getApplicationById(id)
|
||
if (!app) return
|
||
const reason = copyCreateBlockReasonOfApplication(app)
|
||
if (reason) {
|
||
await modalAlert('🔔 暂时不能复制新增', `你好!${reason}`)
|
||
return
|
||
}
|
||
loadApplicationToForm(app)
|
||
refreshWarning()
|
||
setFormStatus(`已把【${app.basics && app.basics.name ? app.basics.name : '历史记录'}】复制到表单,可修改后再提交`)
|
||
await modalAlert('✅ 已复制到表单', '已把这条记录带到上方表单。你可以先改一点内容,再点击“提交申请”生成新的冷静期记录。')
|
||
return
|
||
}
|
||
if (act === 'purchase') {
|
||
const app = getApplicationById(id)
|
||
if (!app || app.status === 'purchased' || app.status === 'abandoned') return
|
||
if (isCoolingLocked(app)) {
|
||
await modalAlert('🔔 暂时不能操作', `你好!这个物品的冷静期还没到(剩余 ${coolingLeftText(app)}),为了避免冲动操作,现在不能标记购买 / 放弃 / 低使用率哦。等冷静期结束后,你再决定就好啦!`)
|
||
return
|
||
}
|
||
if (!(app.status === 'cooling' || app.status === 'approved')) return
|
||
const ok = await modalConfirm('✅ 确认要标记“已购买”吗?', '你确定已经买了这个物品吗?\n👉 确认后:相关信息会自动发给报销系统,之后就不能改啦!\n👉 取消的话:可以再想想,不会有任何变化~', '确定购买', '再想想')
|
||
if (!ok) return
|
||
await withBusy(async () => {
|
||
const purchasedAt = now()
|
||
updateApplication(id, a => {
|
||
a.status = 'purchased'
|
||
a.resultReason = '已购买,正在推送至报销系统'
|
||
a.expensePushedAt = purchasedAt
|
||
a.expenseSyncStatus = 'pending'
|
||
a.expenseSyncError = ''
|
||
upsertItem(a.basics.name, a.basics.category, '来自购买记录')
|
||
return a
|
||
})
|
||
try {
|
||
const current = getApplicationById(id)
|
||
const pushed = await pushPurchaseToExpense(current || app, purchasedAt)
|
||
updateApplication(id, a => {
|
||
a.resultReason = `已购买 · 已推送至报销系统(记录ID:${pushed.id})`
|
||
a.expenseRecordId = pushed.id
|
||
a.expenseSyncStatus = 'synced'
|
||
a.expenseSyncError = ''
|
||
pushActionLog(a, 'purchase', `已推送报销(记录ID:${pushed.id})`)
|
||
return a
|
||
})
|
||
} catch (err) {
|
||
const msg = err && err.message ? err.message : String(err)
|
||
updateApplication(id, a => {
|
||
a.expenseSyncStatus = 'pending'
|
||
a.expenseSyncError = msg
|
||
a.expenseRetryCount = toNum(a.expenseRetryCount) + 1
|
||
a.resultReason = `已购买,报销同步失败(可重试同步)。原因:${msg}`
|
||
pushActionLog(a, 'purchase', `报销同步失败:${msg}`)
|
||
return a
|
||
})
|
||
}
|
||
})
|
||
const done = getApplicationById(id)
|
||
const synced = !!(done && done.expenseRecordId)
|
||
await modalAlert('🎉 操作成功!', synced
|
||
? `已经帮你标记“已购买”啦,信息也发给报销系统了。之后这个物品就不能标记放弃或低使用率咯~`
|
||
: `已经帮你标记“已购买”啦。当前还没同步到报销系统,你可以点击“重试同步报销”继续处理。`)
|
||
} else if (act === 'retry-push') {
|
||
const app = getApplicationById(id)
|
||
if (!app || app.status !== 'purchased' || app.expenseRecordId) return
|
||
await withBusy(async () => {
|
||
try {
|
||
const purchasedAt = app.expensePushedAt || app.updatedAt || now()
|
||
const pushed = await pushPurchaseToExpense(app, purchasedAt)
|
||
updateApplication(id, a => {
|
||
a.expenseRecordId = pushed.id
|
||
a.expensePushedAt = purchasedAt
|
||
a.expenseSyncStatus = 'synced'
|
||
a.expenseSyncError = ''
|
||
a.resultReason = `已购买 · 已推送至报销系统(记录ID:${pushed.id})`
|
||
pushActionLog(a, 'retry-push', `同步成功(记录ID:${pushed.id})`)
|
||
return a
|
||
})
|
||
await modalAlert('✅ 操作成功!', `已经同步到报销系统,记录ID:${pushed.id}`)
|
||
} catch (err) {
|
||
const msg = err && err.message ? err.message : String(err)
|
||
updateApplication(id, a => {
|
||
a.expenseSyncStatus = 'pending'
|
||
a.expenseSyncError = msg
|
||
a.expenseRetryCount = toNum(a.expenseRetryCount) + 1
|
||
a.resultReason = `已购买,报销同步失败(可重试同步)。原因:${msg}`
|
||
pushActionLog(a, 'retry-push', `同步失败:${msg}`)
|
||
return a
|
||
})
|
||
await modalAlert('❌ 还没同步成功', `报销系统仍未同步成功。\n原因:${msg}`)
|
||
}
|
||
})
|
||
} else if (act === 'abandon') {
|
||
const app = getApplicationById(id)
|
||
if (!app || app.status === 'abandoned' || app.status === 'purchased') return
|
||
if (isCoolingLocked(app)) {
|
||
await modalAlert('🔔 暂时不能操作', `你好!这个物品的冷静期还没到(剩余 ${coolingLeftText(app)}),为了避免冲动操作,现在不能标记购买 / 放弃 / 低使用率哦。等冷静期结束后,你再决定就好啦!`)
|
||
return
|
||
}
|
||
const ok = await modalConfirm('❌ 确认要标记“放弃”吗?', '你确定不买这个物品了吗?\n👉 确认后:这个物品就会标记为“放弃购买”,不会发给报销系统,之后也不能再改啦!\n👉 取消的话:可以再想想,不会有任何变化~', '确定放弃', '再想想')
|
||
if (!ok) return
|
||
updateApplication(id, a => {
|
||
a.status = 'abandoned'
|
||
a.resultReason = '已放弃,不会推送到报销系统'
|
||
pushActionLog(a, 'abandon', '标记为放弃购买')
|
||
return a
|
||
})
|
||
await modalAlert('✅ 操作成功!', '已经帮你标记“放弃购买”啦,这个物品不会再推送给报销系统咯~')
|
||
} else if (act === 'toggle-low') {
|
||
const app = getApplicationById(id)
|
||
if (!app) return
|
||
if (app.status !== 'purchased') {
|
||
await modalAlert('❌ 暂时不能标记哦', '要先标记“已购买”之后,才能标记“低使用率”啦。你先确认购买,再过来标记就可以咯~')
|
||
return
|
||
}
|
||
if (app.usageLow) {
|
||
await modalAlert('🔒 不能操作啦', '这个物品已经标记过“低使用率”啦,为了避免改来改去出错,现在不能重复标记咯~')
|
||
return
|
||
}
|
||
const ok = await modalConfirm('⚠️ 确认要标记“低使用率”吗?', '你确定这个物品用得很少吗?\n👉 确认后:会标记为“低使用率”,下次你想买和这个一样的东西(比如同款收纳架、同类家居用品),系统会提醒你“别冲动买哦”(红色预警)!\n👉 取消的话:可以再想想,不会有任何变化~', '确定标记', '再想想')
|
||
if (!ok) return
|
||
updateApplication(id, a => {
|
||
a.usageLow = true
|
||
a.resultReason = '已购买 · 已标记低使用率(同类购买将预警)'
|
||
pushActionLog(a, 'toggle-low', '标记为低使用率')
|
||
return a
|
||
})
|
||
await modalAlert('🔴 标记成功!重要提醒!', `这个物品已经标记为“低使用率”啦~\n下次你想买【${app.basics.name}】或者类似的家居收纳用品时,系统会弹出红色提醒,让你再考虑考虑哦!`)
|
||
} else if (act === 'archive-app') {
|
||
const app = getApplicationById(id)
|
||
if (!app) return
|
||
const nextArchived = !app.archived
|
||
updateApplication(id, a => {
|
||
a.archived = nextArchived
|
||
a.resultReason = nextArchived
|
||
? `${a.resultReason || ''}(已归档隐藏)`.trim()
|
||
: String(a.resultReason || '').replace('(已归档隐藏)', '')
|
||
pushActionLog(a, nextArchived ? 'archive-on' : 'archive-off', nextArchived ? '归档隐藏记录' : '取消归档显示')
|
||
return a
|
||
})
|
||
await modalAlert('✅ 操作成功!', nextArchived
|
||
? `已把【${app.basics.name}】归档隐藏啦,你勾选“显示归档”后还能看到。`
|
||
: `已把【${app.basics.name}】恢复到正常列表啦。`)
|
||
} else if (act === 'delete-app') {
|
||
const app = getApplicationById(id)
|
||
if (!app) return
|
||
if (app.status === 'purchased') {
|
||
await modalAlert('🔒 不能删除哦', '这个物品已经推送或准备推送到报销系统了,为了财务不出错,不能删除啦!你可以改成归档隐藏。')
|
||
return
|
||
}
|
||
const ok = await modalConfirm('确认删除记录', `确认删除【${app.basics.name}】吗?删除后不会恢复。`, '确定删除', '再想想')
|
||
if (!ok) return
|
||
removeApplication(id)
|
||
await modalAlert('✅ 删除成功!', `已删除【${app.basics.name}】记录,操作已经入库。`)
|
||
} else if (act === 'edit-app') {
|
||
const app = getApplicationById(id)
|
||
if (!app) return
|
||
if (isCoolingLocked(app)) {
|
||
await modalAlert('❌ 不能修改哦', `冷静期还剩 ${coolingLeftText(app)},为了避免改来改去出错,现在不能修改这个物品的信息啦~`)
|
||
return
|
||
}
|
||
if (app.status === 'purchased' || app.status === 'abandoned') {
|
||
await modalAlert('🔒 不能操作啦', `这个物品已经标记为“${app.status === 'purchased' ? '已购买' : '已放弃'}”啦,为了避免改来改去出错,现在不能再修改咯~`)
|
||
return
|
||
}
|
||
const name = window.prompt('请填写商品名称', app.basics.name || '')
|
||
if (name === null) return
|
||
const priceRaw = window.prompt('请填写预估价格', String(app.basics.price || ''))
|
||
if (priceRaw === null) return
|
||
const price = toNum(priceRaw)
|
||
const reason = window.prompt('请填写购买原因', app.basics.reason || '')
|
||
if (reason === null) return
|
||
updateApplication(id, a => {
|
||
a.basics.name = String(name || '').trim() || a.basics.name
|
||
a.basics.price = price > 0 ? price : a.basics.price
|
||
a.basics.reason = String(reason || '').trim() || a.basics.reason
|
||
pushActionLog(a, 'edit', `修改为:${a.basics.name} | ${fmtMoney(a.basics.price)}`)
|
||
return a
|
||
})
|
||
await modalAlert('✅ 保存成功!', `你改的【${String(name || app.basics.name).trim() || app.basics.name}】信息已经存好啦,不会丢的~`)
|
||
}
|
||
})
|
||
}
|
||
|
||
const bindAuth = () => {
|
||
const tryLogin = async () => {
|
||
const username = String($('login-username').value || '').trim()
|
||
const password = String($('login-password').value || '')
|
||
if (!username || !password) {
|
||
setAuthStatus('请输入账号和密码')
|
||
return
|
||
}
|
||
try {
|
||
await apiRequest('/auth/login', {
|
||
method: 'POST',
|
||
body: { username, password }
|
||
})
|
||
} catch {
|
||
setAuthStatus('账号或密码不正确,请重试')
|
||
await modalAlert('登录失败', '账号或密码不正确,请重新输入。')
|
||
return
|
||
}
|
||
state.auth.currentUser = username
|
||
$('login-password').value = ''
|
||
try {
|
||
await loadDB()
|
||
} catch {
|
||
state.auth.currentUser = ''
|
||
state.db = emptyDB()
|
||
state.draft = null
|
||
setAuthStatus('登录成功,但读取数据失败,请稍后重试')
|
||
renderAll()
|
||
syncFormState()
|
||
renderAuth()
|
||
await modalAlert('数据读取失败', '登录已完成,但暂时无法读取云端数据,请稍后重试。')
|
||
return
|
||
}
|
||
renderAll()
|
||
syncFormState()
|
||
const draft = loadDraft()
|
||
setFormStatus(draft ? '检测到草稿,可点击“继续填写草稿”恢复' : '')
|
||
setAuthStatus(`登录成功,欢迎你:${username}`)
|
||
renderAuth()
|
||
}
|
||
|
||
$('btn-login').addEventListener('click', tryLogin)
|
||
;(async function tryAutoFill() {
|
||
try {
|
||
const token = localStorage.getItem('toolbox_creds_token') || ''
|
||
const r = await fetch('/api/rational_lock/auth/local_creds', {
|
||
headers: token ? { 'X-Local-Creds-Token': token } : {},
|
||
credentials: 'same-origin',
|
||
cache: 'no-store'
|
||
})
|
||
const d = await r.json().catch(() => null)
|
||
if (d && d.ok && d.creds) {
|
||
if (d.creds.username) $('login-username').value = d.creds.username
|
||
if (d.creds.password) $('login-password').value = d.creds.password
|
||
}
|
||
} catch {}
|
||
$('login-username').removeAttribute('readonly')
|
||
$('login-password').removeAttribute('readonly')
|
||
})()
|
||
$('login-password').addEventListener('keydown', async e => {
|
||
if (e.key !== 'Enter') return
|
||
e.preventDefault()
|
||
await tryLogin()
|
||
})
|
||
$('btn-logout').addEventListener('click', async () => {
|
||
try { await apiRequest('/auth/logout', { method: 'POST' }) } catch {}
|
||
state.auth.currentUser = ''
|
||
state.db = emptyDB()
|
||
state.draft = null
|
||
state.ui.library.editingId = ''
|
||
$('application-form').reset()
|
||
if (hasEl('lib-name')) $('lib-name').value = ''
|
||
if (hasEl('lib-note')) $('lib-note').value = ''
|
||
setFormStatus('')
|
||
setAuthStatus('已退出登录')
|
||
renderAll()
|
||
syncFormState()
|
||
renderAuth()
|
||
})
|
||
}
|
||
|
||
const bindHistory = () => {
|
||
const defaults = getDefaultHistoryRange()
|
||
state.ui.history.startDate = defaults.startDate
|
||
state.ui.history.endDate = defaults.endDate
|
||
$('history-start-date').value = defaults.startDate
|
||
$('history-end-date').value = defaults.endDate
|
||
$('history-search').addEventListener('input', e => {
|
||
state.ui.history.search = String(e.target.value || '')
|
||
state.ui.history.page = 1
|
||
renderHistory()
|
||
})
|
||
$('history-start-date').addEventListener('change', e => {
|
||
state.ui.history.startDate = String(e.target.value || '')
|
||
state.ui.history.page = 1
|
||
renderHistory()
|
||
})
|
||
$('history-end-date').addEventListener('change', e => {
|
||
state.ui.history.endDate = String(e.target.value || '')
|
||
state.ui.history.page = 1
|
||
renderHistory()
|
||
})
|
||
$('history-show-archived').addEventListener('change', e => {
|
||
state.ui.history.showArchived = !!e.target.checked
|
||
state.ui.history.page = 1
|
||
renderHistory()
|
||
})
|
||
$('btn-history-reset').addEventListener('click', () => {
|
||
const nextDefaults = getDefaultHistoryRange()
|
||
state.ui.history.search = ''
|
||
state.ui.history.startDate = nextDefaults.startDate
|
||
state.ui.history.endDate = nextDefaults.endDate
|
||
state.ui.history.showArchived = false
|
||
state.ui.history.page = 1
|
||
$('history-search').value = ''
|
||
$('history-start-date').value = nextDefaults.startDate
|
||
$('history-end-date').value = nextDefaults.endDate
|
||
$('history-show-archived').checked = false
|
||
renderHistory()
|
||
})
|
||
$('history-prev').addEventListener('click', () => {
|
||
if (state.ui.history.page <= 1) return
|
||
state.ui.history.page -= 1
|
||
renderHistory()
|
||
})
|
||
$('history-next').addEventListener('click', () => {
|
||
state.ui.history.page += 1
|
||
renderHistory()
|
||
})
|
||
}
|
||
|
||
const init = () => {
|
||
const boot = async () => {
|
||
try {
|
||
const hasSession = await restoreSession()
|
||
if (hasSession) await loadDB()
|
||
else {
|
||
state.db = emptyDB()
|
||
state.draft = null
|
||
}
|
||
renderAll()
|
||
syncCoolingDaysText()
|
||
renderAuth()
|
||
if (hasSession && loadDraft()) {
|
||
setFormStatus('检测到草稿,可点击“继续填写草稿”恢复')
|
||
}
|
||
} catch {
|
||
state.auth.currentUser = ''
|
||
state.db = emptyDB()
|
||
state.draft = null
|
||
setAuthStatus('初始化失败,请刷新后重试')
|
||
renderAll()
|
||
renderAuth()
|
||
}
|
||
}
|
||
bindAuth()
|
||
bindForm()
|
||
bindDraft()
|
||
bindReplacementRules()
|
||
bindLibrary()
|
||
bindSettingsNav()
|
||
bindActions()
|
||
bindHistory()
|
||
syncFormState()
|
||
boot()
|
||
setInterval(() => renderDecision(), 1000)
|
||
}
|
||
|
||
init()
|
||
})()
|