feat: 初始提交(仅核心代码,已排除大文件)

This commit is contained in:
yangxiangyuan
2026-07-08 10:29:50 +08:00
commit be7c5842f1
623 changed files with 332897 additions and 0 deletions
+316
View File
@@ -0,0 +1,316 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>理性消费决策锁</title>
<link rel="stylesheet" href="/common/common.css" />
<link rel="stylesheet" href="/tools/rational_lock/rational_lock.css" />
</head>
<body>
<main class="page">
<header class="hero">
<div class="hero-main">
<div>
<h1>理性消费决策锁</h1>
<p>耐用品消费需完成全量勾选与冷静期后,才可解锁购买许可</p>
</div>
<div class="hero-auth">
<div id="auth-login-box" class="auth-login-inline">
<input id="login-username" autocomplete="off" readonly />
<input id="login-password" type="password" autocomplete="off" readonly />
<button id="btn-login" class="btn btn-primary" type="button">登录</button>
</div>
<div id="auth-logged-box" style="display:none">
<div class="auth-row">
<div id="auth-current-user" class="auth-current-user"></div>
<button id="btn-logout" class="btn" type="button">退出登录</button>
</div>
</div>
<div id="auth-status" class="status"></div>
</div>
</div>
</header>
<div id="app-content" style="display:none">
<section class="card" id="warning-box" style="display:none"></section>
<section class="card">
<h2>想买申请</h2>
<form id="application-form">
<div class="grid two">
<label>商品名称<input id="item-name" required /></label>
<label>商品分类
<select id="item-category" required>
<option value="">请选择</option>
<optgroup label="低风险类">
<option value="园艺绿植低风险类">园艺绿植低风险类</option>
<option value="家居软装小物件">家居软装小物件</option>
<option value="日用消耗非接触类">日用消耗非接触类</option>
<option value="临时/消耗/低值易耗品">临时 / 消耗 / 低值易耗品</option>
<option value="其他低风险">其他低风险</option>
</optgroup>
<optgroup label="高风险类">
<option value="食品&入口类">食品 & 入口类</option>
<option value="健康&身体接触类">健康 & 身体接触类</option>
<option value="安全&电器类">安全 & 电器类</option>
<option value="贵重&保真刚需品">贵重 & 保真刚需品</option>
<option value="其他高风险">其他高风险</option>
</optgroup>
</select>
<div id="item-category-help" class="field-tip field-tip-rich">
<div class="field-tip-title">分类示例说明</div>
<div id="item-category-help-body">请选择商品分类后查看该分类对应的常见示例。</div>
</div>
</label>
<label>预估价格<input id="item-price" type="number" min="0" step="0.01" required /></label>
<label>购买原因简述<input id="item-reason" required /></label>
</div>
<div id="shopping-risk-group-tip" class="status"></div>
<h3>网购风险核验</h3>
<div class="grid two">
<label>购物方式
<select id="shopping-method" required>
<option value="">请选择</option>
<option value="offline">线下店</option>
<option value="online">网购</option>
</select>
</label>
<label id="shopping-guarantee-wrap" style="display:none">购物保障
<select id="shopping-guarantee">
<option value="">请选择</option>
<option value="seven-day">有七天无理由退换</option>
<option value="no-return">没有无理由退换</option>
<option value="xianyu-used">闲鱼二手购买</option>
</select>
</label>
<label id="shopping-platform-wrap" style="display:none">购买平台
<select id="shopping-platform">
<option value="">请选择</option>
<option value="pdd">拼多多</option>
<option value="taobao">淘宝</option>
<option value="xianyu">闲鱼</option>
<option value="tmall">天猫</option>
<option value="jd">京东</option>
<option value="other">其他平台</option>
</select>
</label>
</div>
<div id="shopping-online-extra-wrap" class="grid two" style="display:none">
<label>店铺类型
<select id="shopping-seller-type">
<option value="">请选择</option>
<option value="brand-flagship">品牌官旗</option>
<option value="platform-premium">平台优选 / 自营</option>
<option value="enterprise-shop">企业店</option>
<option value="normal-shop">普通店</option>
<option value="personal-seller">个人卖家</option>
</select>
</label>
<label>品相
<select id="shopping-condition">
<option value="">请选择</option>
<option value="new-sealed">全新未拆</option>
<option value="new-opened">全新已拆</option>
<option value="used-good">二手良品</option>
<option value="used-flawed">二手瑕疵</option>
<option value="unknown">品相不确定</option>
</select>
</label>
</div>
<div id="shopping-risk-tip" class="warn" style="display:none"></div>
<div id="shopping-progress-wrap" style="display:none">
<div id="shopping-progress-text" class="status"></div>
<div id="shopping-progress-lights" class="risk-lights">
<span class="risk-light" id="shopping-light-1"></span>
<span class="risk-light" id="shopping-light-2"></span>
<span class="risk-light" id="shopping-light-3"></span>
<span class="risk-light" id="shopping-light-4"></span>
<span class="risk-light" id="shopping-light-5"></span>
</div>
</div>
<div id="shopping-questions-wrap" class="grid one" style="display:none">
<label>沟通问题1
<input id="shopping-q1" placeholder="例如:商品是否全新?是否有拆修或暗病?" />
</label>
<label>沟通问题2
<input id="shopping-q2" placeholder="例如:发货时间、物流方式、到货时效?" />
</label>
<label>沟通问题3
<input id="shopping-q3" placeholder="例如:不满意时是否支持退换?具体规则?" />
</label>
<label>沟通问题4
<input id="shopping-q4" placeholder="例如:售后联系人和处理时效?" />
</label>
<label>沟通问题5
<input id="shopping-q5" placeholder="例如:描述不符时如何处理?" />
</label>
</div>
<div id="shopping-submit-tip" class="status"></div>
<h3>冷静期设置</h3>
<div class="grid one">
<label>冷静期天数
<select id="cooling-days" required>
<option value="3" selected>3天</option>
<option value="4">4天</option>
<option value="5">5天</option>
<option value="6">6天</option>
<option value="7">7天</option>
<option value="8">8天</option>
<option value="9">9天</option>
<option value="10">10天</option>
<option value="11">11天</option>
<option value="12">12天</option>
<option value="13">13天</option>
<option value="14">14天</option>
</select>
</label>
</div>
<h3>冷静期强制承诺</h3>
<div class="check-grid" id="commit-cooling">
<label><input type="checkbox" value="1" />我确认不是即时冲动,愿意等待<span id="cooling-days-text">3</span>天冷静期</label>
<label><input type="checkbox" value="1" />我不是因打折、直播、促销被迫下单</label>
<label><input type="checkbox" value="1" />我不是跟风、攀比、炫耀而购买</label>
<label><input type="checkbox" value="1" />本次购买不属于吃喝类,需严格审核</label>
</div>
<h3>现有物品替代检查</h3>
<div class="check-grid" id="commit-replacement">
<label><input type="checkbox" value="1" data-replacement="no-similar" />家中无完全可替代的同类物品</label>
<label><input type="checkbox" value="1" data-replacement="damaged" />现有同类物品已损坏、过时或无法使用</label>
<label><input type="checkbox" value="1" data-replacement="unrepairable" />现有物品无法维修或改造继续使用</label>
<label><input type="checkbox" value="1" data-replacement="unique" />该物品功能独特且无可替代</label>
</div>
<h3>真实使用场景验证</h3>
<div class="grid one">
<label>场景1:我将在<input id="scene-where" required placeholder="输入使用场景" /></label>
<label>场景2:我预计每周/每月使用<input id="scene-frequency" required placeholder="输入次数" /></label>
<label>场景3:该物品能解决我<input id="scene-problem" required placeholder="输入实际问题" /></label>
</div>
<h3>必要性评分(1-5分)</h3>
<div class="grid two">
<label>实用程度
<select id="score-practical" required>
<option value="">请选择</option><option>1</option><option>2</option><option>3</option><option>4</option><option>5</option>
</select>
</label>
<label>必需程度
<select id="score-necessary" required>
<option value="">请选择</option><option>1</option><option>2</option><option>3</option><option>4</option><option>5</option>
</select>
</label>
<label>预期使用率
<select id="score-usage" required>
<option value="">请选择</option><option>1</option><option>2</option><option>3</option><option>4</option><option>5</option>
</select>
</label>
<label>性价比合理性
<select id="score-cost" required>
<option value="">请选择</option><option>1</option><option>2</option><option>3</option><option>4</option><option>5</option>
</select>
</label>
<label>后悔概率(越低越好)
<select id="score-regret" required>
<option value="">请选择</option><option>1</option><option>2</option><option>3</option><option>4</option><option>5</option>
</select>
</label>
<div class="score-box">总分:<span id="score-total">0</span> / 25(低于14不建议购买)</div>
</div>
<h3>最终防冲动验证(必须全选“否”)</h3>
<div class="radio-grid">
<div>
<div>是否只是觉得好看/新鲜?</div>
<label><input type="radio" name="impulse-1" value="是" />是</label>
<label><input type="radio" name="impulse-1" value="否" />否</label>
</div>
<div>
<div>是否买后大概率闲置吃灰?</div>
<label><input type="radio" name="impulse-2" value="是" />是</label>
<label><input type="radio" name="impulse-2" value="否" />否</label>
</div>
<div>
<div>是否为了发朋友圈/满足虚荣心?</div>
<label><input type="radio" name="impulse-3" value="是" />是</label>
<label><input type="radio" name="impulse-3" value="否" />否</label>
</div>
</div>
<h3>反悔可能</h3>
<div class="grid one">
<label>任何物品都有反悔的可能,你写下哪些反悔的可能?
<textarea id="item-regret-possibility" required placeholder="例如:使用频率可能低于预期、同类已有替代、后续维护成本偏高"></textarea>
</label>
</div>
<h3>最终承诺</h3>
<div class="check-grid" id="commit-final">
<label><input type="checkbox" value="1" />我确认所有信息真实有效</label>
<label><input type="checkbox" value="1" />我确认是真需要,而非单纯想要</label>
<label><input type="checkbox" value="1" />我愿意为本次消费承担责任,不浪费</label>
</div>
<div class="form-actions">
<button id="btn-save-draft" class="btn" type="button">保存草稿</button>
<button id="btn-load-draft" class="btn" type="button">继续填写草稿</button>
<button id="btn-clear-draft" class="btn" type="button">清空草稿</button>
<button id="btn-open-settings" class="btn" type="button">设置</button>
</div>
<button id="btn-submit" class="btn btn-primary btn-soft-disabled" type="submit">提交并开始冷静期</button>
<div id="form-status" class="status"></div>
</form>
</section>
<section class="card">
<h2>冷静期与结果</h2>
<div id="decision-panel" class="decision"></div>
</section>
<section class="card">
<h2>历史记录</h2>
<div class="history-toolbar">
<label>搜索
<input id="history-search" placeholder="商品名称/分类/原因" />
</label>
<label>开始日期
<input id="history-start-date" type="date" />
</label>
<label>结束日期
<input id="history-end-date" type="date" />
</label>
<label class="history-check">
<input id="history-show-archived" type="checkbox" />
显示归档
</label>
<button id="btn-history-reset" class="btn" type="button">重置筛选</button>
</div>
<div id="stats" class="stats"></div>
<div id="history-list" class="list"></div>
<div class="history-pager">
<button id="history-prev" class="btn" type="button">上一页</button>
<span id="history-page-info" class="meta"></span>
<button id="history-next" class="btn" type="button">下一页</button>
</div>
</section>
</div>
</main>
<div id="app-modal" class="modal-mask" style="display:none">
<div class="modal-panel">
<div id="modal-title" class="modal-title"></div>
<div id="modal-message" class="modal-message"></div>
<div id="modal-actions" class="modal-actions"></div>
</div>
</div>
<div id="busy-mask" class="busy-mask" style="display:none">
<div class="busy-panel">
<div class="busy-spinner"></div>
<div id="busy-text" class="busy-text">正在帮你处理哦,请稍等~</div>
</div>
</div>
<script src="/tools/rational_lock/rational_lock.js"></script>
</body>
</html>
@@ -0,0 +1,611 @@
body {
margin: 0;
background: #f3f6fb;
color: #1f2937;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "PingFang SC", "Microsoft YaHei", sans-serif;
}
.page {
max-width: 960px;
margin: 0 auto;
padding: 12px;
display: grid;
gap: 12px;
}
.hero {
background: #111827;
color: #fff;
border-radius: 12px;
padding: 14px;
}
.hero-main {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.hero h1 {
margin: 0 0 8px;
font-size: 22px;
}
.hero p {
margin: 0;
opacity: .92;
}
.hero-auth {
min-width: 320px;
display: grid;
gap: 6px;
justify-items: end;
}
.auth-login-inline {
display: flex;
gap: 6px;
align-items: center;
}
.auth-login-inline input {
width: 110px;
height: 34px;
border-color: #334155;
background: #0f172a;
color: #fff;
}
.auth-login-inline input::placeholder {
color: #94a3b8;
}
.hero-auth .btn-primary {
width: auto;
margin-top: 0;
height: 34px;
padding: 0 12px;
}
.auth-row {
display: flex;
align-items: center;
gap: 8px;
}
.hero-auth .auth-row .btn {
height: 34px;
padding: 0 12px;
background: #1e293b;
}
.auth-current-user {
font-size: 13px;
color: #e2e8f0;
font-weight: 600;
}
.hero-auth .status {
margin-top: 0;
min-height: 16px;
font-size: 12px;
color: #cbd5e1;
text-align: right;
}
.card {
background: #fff;
border-radius: 12px;
padding: 14px;
box-shadow: 0 2px 12px rgba(17, 24, 39, 0.06);
}
h2 {
margin: 0 0 12px;
font-size: 18px;
}
h3 {
margin: 14px 0 8px;
font-size: 15px;
}
label {
display: grid;
gap: 6px;
font-size: 14px;
}
input, select, textarea {
height: 38px;
border: 1px solid #cbd5e1;
border-radius: 8px;
padding: 0 10px;
background: #fff;
}
textarea {
min-height: 88px;
height: auto;
padding: 10px;
resize: vertical;
line-height: 1.5;
}
.grid {
display: grid;
gap: 10px;
}
.grid.two {
grid-template-columns: 1fr 1fr;
}
.grid.one {
grid-template-columns: 1fr;
}
.full {
grid-column: 1 / -1;
}
.check-grid, .radio-grid {
display: grid;
gap: 8px;
}
.check-grid label {
display: flex;
gap: 8px;
align-items: flex-start;
}
.check-grid input[type="checkbox"] {
width: 16px;
height: 16px;
margin-top: 2px;
}
.radio-grid > div {
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 10px;
display: grid;
grid-template-columns: 1fr auto auto;
align-items: center;
column-gap: 12px;
}
.radio-grid > div > label {
display: inline-flex;
align-items: center;
gap: 6px;
white-space: nowrap;
}
.radio-grid input[type="radio"] {
width: 16px;
height: 16px;
margin: 0;
}
.score-box {
display: flex;
align-items: center;
font-weight: 700;
color: #0f766e;
}
.btn {
border: 0;
border-radius: 8px;
height: 38px;
padding: 0 14px;
background: #334155;
color: #fff;
cursor: pointer;
}
.btn:disabled {
opacity: .45;
cursor: not-allowed;
}
.btn-soft-disabled {
opacity: .5;
cursor: pointer;
}
.btn-primary {
width: 100%;
background: #2563eb;
margin-top: 12px;
}
.btn-link {
height: auto;
padding: 0;
border-radius: 0;
background: transparent;
color: #1d4ed8;
text-decoration: underline;
}
.form-actions {
margin-top: 12px;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.status {
min-height: 20px;
margin-top: 8px;
color: #334155;
font-size: 13px;
}
.field-tip {
margin-top: 6px;
color: #64748b;
font-size: 12px;
line-height: 1.5;
}
.field-tip-rich {
display: grid;
gap: 6px;
padding: 10px 12px;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #f8fafc;
}
.field-tip-title {
color: #334155;
font-size: 13px;
font-weight: 700;
}
.field-tip-current {
color: #334155;
font-size: 12px;
font-weight: 600;
}
.field-tip-list {
margin: 0;
padding-left: 18px;
}
.field-tip-list li {
margin: 0 0 4px;
}
.field-tip-empty {
color: #64748b;
}
.decision {
border: 1px dashed #cbd5e1;
border-radius: 8px;
padding: 12px;
min-height: 80px;
}
.decision.pass {
border-color: #16a34a;
background: #f0fdf4;
}
.decision.reject {
border-color: #dc2626;
background: #fef2f2;
}
.decision.wait {
border-color: #2563eb;
background: #eff6ff;
}
.stats {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
margin-bottom: 10px;
}
.stats .item {
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 8px;
text-align: center;
}
.stats .value {
font-size: 18px;
font-weight: 700;
}
.list {
display: grid;
gap: 8px;
}
.row {
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 10px;
display: grid;
gap: 8px;
}
.row-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.row .meta {
font-size: 13px;
color: #475569;
}
.row .meta .tag-low {
display: inline-block;
padding: 1px 6px;
border-radius: 999px;
border: 1px solid #ef4444;
color: #b91c1c;
background: #fef2f2;
font-size: 12px;
}
.row .meta .tag-archived {
display: inline-block;
padding: 1px 6px;
border-radius: 999px;
border: 1px solid #94a3b8;
color: #475569;
background: #f8fafc;
font-size: 12px;
}
.row .meta.cooling-tip {
color: #1d4ed8;
font-weight: 600;
}
.row .actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.history-toolbar {
display: grid;
grid-template-columns: 2fr 1fr 1fr auto auto;
gap: 8px;
margin-bottom: 10px;
align-items: end;
}
.history-check {
display: inline-flex;
align-items: center;
gap: 8px;
white-space: nowrap;
}
.history-check input[type="checkbox"] {
width: 16px;
height: 16px;
}
.history-pager {
margin-top: 10px;
display: flex;
justify-content: flex-end;
align-items: center;
gap: 8px;
}
.warn {
background: #fee2e2;
border: 1px solid #ef4444;
color: #991b1b;
border-radius: 8px;
padding: 10px;
font-size: 13px;
}
.risk-lights {
display: flex;
gap: 8px;
margin: 6px 0 10px;
}
.risk-light {
width: 14px;
height: 14px;
border-radius: 50%;
background: #d1d5db;
border: 1px solid #9ca3af;
box-shadow: inset 0 1px 1px rgba(255,255,255,.45);
}
.risk-light.on {
background: #22c55e;
border-color: #16a34a;
box-shadow: 0 0 6px rgba(34,197,94,.55), inset 0 1px 1px rgba(255,255,255,.45);
}
.modal-mask {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.45);
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
z-index: 1000;
}
.modal-panel {
width: min(92vw, 460px);
background: #fff;
border-radius: 12px;
box-shadow: 0 10px 40px rgba(15, 23, 42, 0.25);
padding: 14px;
display: grid;
gap: 12px;
}
.modal-panel.wide {
width: min(92vw, 760px);
}
.modal-title {
font-size: 17px;
font-weight: 700;
color: #0f172a;
}
.modal-message {
font-size: 14px;
color: #334155;
line-height: 1.55;
white-space: pre-wrap;
max-height: 62vh;
overflow: auto;
}
.modal-message.is-rich {
white-space: normal;
}
.detail-section-title {
margin: 10px 0 6px;
font-size: 14px;
font-weight: 700;
color: #0f172a;
}
.detail-row {
display: grid;
grid-template-columns: 130px 1fr;
gap: 8px;
align-items: start;
margin-bottom: 6px;
}
.detail-label {
color: #0f172a;
font-weight: 600;
}
.detail-value {
color: #1d4ed8;
white-space: pre-wrap;
word-break: break-word;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.modal-actions .btn-primary {
width: auto;
margin-top: 0;
}
.busy-mask {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.38);
display: flex;
align-items: center;
justify-content: center;
z-index: 1100;
padding: 16px;
}
.busy-panel {
background: #fff;
border-radius: 12px;
min-width: min(92vw, 280px);
padding: 16px;
display: grid;
justify-items: center;
gap: 12px;
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.24);
}
.busy-spinner {
width: 32px;
height: 32px;
border-radius: 50%;
border: 3px solid #dbeafe;
border-top-color: #2563eb;
animation: spin .9s linear infinite;
}
.busy-text {
color: #334155;
font-size: 14px;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@media (max-width: 700px) {
.hero-main {
flex-direction: column;
}
.hero-auth {
min-width: 0;
width: 100%;
justify-items: stretch;
}
.auth-login-inline {
display: grid;
grid-template-columns: 1fr 1fr auto;
}
.auth-row {
justify-content: space-between;
}
.hero-auth .status {
text-align: left;
}
.grid.two {
grid-template-columns: 1fr;
}
.stats {
grid-template-columns: 1fr 1fr;
}
.history-toolbar {
grid-template-columns: 1fr;
}
.row .actions .btn {
flex: 1 1 calc(50% - 8px);
min-width: 120px;
}
.history-pager {
justify-content: space-between;
}
.detail-row {
grid-template-columns: 1fr;
gap: 4px;
}
.hero h1 {
font-size: 19px;
}
}
File diff suppressed because it is too large Load Diff
+303
View File
@@ -0,0 +1,303 @@
(() => {
const API_BASE = '/api/rational_lock'
const DEFAULT_USERNAME = 'admin'
const $ = id => document.getElementById(id)
const now = () => Date.now()
const uid = () => `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
const emptyDB = () => ({ items: [], applications: [] })
const state = {
db: emptyDB(),
auth: { currentUser: '' },
ui: { editingId: '' }
}
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 setAuthStatus = text => {
$('auth-status').textContent = text || ''
}
const setFormStatus = text => {
$('form-status').textContent = text || ''
}
const openModal = ({ title, message, okText = '知道了', cancelText = '', tone = 'normal' }) => {
return new Promise(resolve => {
const mask = $('app-modal')
const titleBox = $('modal-title')
const msgBox = $('modal-message')
const actions = $('modal-actions')
actions.innerHTML = ''
titleBox.textContent = title || ''
msgBox.textContent = message || ''
const close = val => {
mask.style.display = 'none'
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 = ''
})
}
const modalAlert = (title, message) => openModal({ title, message, okText: '我知道了' })
const modalConfirm = (title, message, okText = '继续', cancelText = '取消') => openModal({ title, message, okText, cancelText, tone: 'primary' })
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 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 loadDB = async () => {
if (!state.auth.currentUser) {
state.db = emptyDB()
return
}
const payload = await apiRequest('/data')
state.db = normalizeDB(payload.db)
}
const saveDB = async () => {
if (!state.auth.currentUser) return
await apiRequest('/data', { method: 'PUT', body: { db: state.db } })
}
const upsertItem = (name, category, note) => {
const normalizedName = String(name || '').trim()
if (!normalizedName) return null
const normalizedCategory = String(category || '其他').trim() || '其他'
const idx = state.db.items.findIndex(it => String(it.name || '').trim().toLowerCase() === normalizedName.toLowerCase() && String(it.category || '').trim() === normalizedCategory)
if (idx >= 0) {
const next = {
...state.db.items[idx],
name: normalizedName,
category: normalizedCategory,
note: String(note || '').trim(),
updatedAt: now()
}
state.db.items[idx] = next
return next
}
const row = {
id: uid(),
name: normalizedName,
category: normalizedCategory,
note: String(note || '').trim(),
createdAt: now()
}
state.db.items.unshift(row)
return row
}
const renderLibrary = () => {
$('btn-lib-add').textContent = state.ui.editingId ? '保存修改' : '加入物品库'
$('btn-lib-cancel-edit').style.display = state.ui.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}" type="button">修改</button>
<button class="btn" data-lib-del="${it.id}" type="button">删除</button>
</div>
`
box.appendChild(row)
})
}
const renderAll = () => {
renderAuth()
renderLibrary()
}
const bindLibrary = () => {
$('btn-lib-add').addEventListener('click', async () => {
if (!state.auth.currentUser) {
await modalAlert('请先登录', '请先输入账号和密码登录后再操作。')
return
}
const name = $('lib-name').value.trim()
const category = $('lib-category').value
const note = $('lib-note').value.trim()
if (!name) {
await modalAlert('无法保存', '请先填写物品名称。')
return
}
if (state.ui.editingId) {
const idx = state.db.items.findIndex(it => it.id === state.ui.editingId)
if (idx >= 0) {
state.db.items[idx] = { ...state.db.items[idx], name, category, note, updatedAt: now() }
}
state.ui.editingId = ''
await modalAlert('✅ 保存成功!', `你改的【${name}】信息已经存好啦,不会丢的~`)
} else {
upsertItem(name, category, note)
await modalAlert('🎉 新增成功!', `你添加的【${name}】已经入库啦,后续会用于同类提醒。`)
}
await saveDB()
$('lib-name').value = ''
$('lib-note').value = ''
renderLibrary()
setFormStatus('物品库已更新')
})
$('btn-lib-cancel-edit').addEventListener('click', () => {
state.ui.editingId = ''
$('lib-name').value = ''
$('lib-note').value = ''
renderLibrary()
})
$('lib-list').addEventListener('click', async e => {
if (!state.auth.currentUser) {
await modalAlert('请先登录', '请先输入账号和密码登录后再操作。')
return
}
const target = e.target
const editId = target && target.getAttribute('data-lib-edit')
if (editId) {
const it = state.db.items.find(x => x.id === editId)
if (!it) return
state.ui.editingId = editId
$('lib-name').value = it.name || ''
$('lib-category').value = it.category || '其他'
$('lib-note').value = it.note || ''
renderLibrary()
setFormStatus(`已加载物品库【${it.name}】到编辑区,修改后点击“保存修改”`)
return
}
const id = target && 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.editingId === id) state.ui.editingId = ''
await saveDB()
renderLibrary()
setFormStatus('物品库已更新')
await modalAlert('✅ 删除成功!', `已删除【${it ? it.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 = ''
await loadDB()
renderAll()
setAuthStatus(`登录成功,欢迎你:${username}`)
setFormStatus('可以开始维护物品库')
}
$('btn-login').addEventListener('click', tryLogin)
$('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.ui.editingId = ''
$('lib-name').value = ''
$('lib-note').value = ''
setFormStatus('')
setAuthStatus('已退出登录')
renderAll()
})
}
const bindNav = () => {
$('btn-back-main').addEventListener('click', () => {
window.location.href = '/tools/rational_lock/index.html'
})
}
const init = async () => {
bindAuth()
bindLibrary()
bindNav()
try {
const payload = await apiRequest('/auth/me')
state.auth.currentUser = String(payload.username || DEFAULT_USERNAME)
await loadDB()
setAuthStatus(`已登录:${state.auth.currentUser}`)
setFormStatus('可以开始维护物品库')
} catch {
state.auth.currentUser = ''
state.db = emptyDB()
setAuthStatus('请先登录')
}
renderAll()
}
init()
})()