2025-12-21 01:47:38 +08:00
<!DOCTYPE html>
< html lang = "zh-CN" >
< head >
< meta charset = "UTF-8" >
< meta name = "viewport" content = "width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" >
< title > 小白板< / title >
< link rel = "stylesheet" href = "https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" >
< style >
/* ================== 基础重置 ================== */
*{margin:0;padding:0;box-sizing:border-box}
:root{--bg:#f7f7f7;--bg2:#fff;--bg3:#f0f0f0;--c:#222;--c2:#666;--c3:#999;--bd:#ddd}
html,body{width:100%;height:100%;overflow:hidden;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:var(--bg);color:var(--c)}
/* ================== 工具类 ================== */
.fc{display:flex;align-items:center}
.fcc{display:flex;align-items:center;justify-content:center}
.col{flex-direction:column}
.g4{gap:4px}.g6{gap:6px}.g8{gap:8px}.g10{gap:10px}.g12{gap:12px}
.p12{padding:12px}.p14{padding:14px}
.r4{border-radius:4px}.r6{border-radius:6px}.r50{border-radius:50%}
.bd{border:1px solid var(--bd)}
.bg2{background:var(--bg2)}.bg3{background:var(--bg3)}
.fs11{font-size:11px}.fs12{font-size:12px}.fs13{font-size:13px}.fs14{font-size:14px}
.fw5{font-weight:500}.fw6{font-weight:600}
.c2{color:var(--c2)}.c3{color:var(--c3)}
.flex1{flex:1}.shrink0{flex-shrink:0}
.ofy{overflow-y:auto}.usn{user-select:none}
.trans{transition:all .15s}
/* ================== 按钮 ================== */
.btn{padding:8px 16px;background:var(--bg2);font-size:13px;font-weight:500;cursor:pointer;transition:all .15s;border:1px solid var(--bd);border-radius:4px;color:var(--c)}
.btn:hover{border-color:var(--c);background:var(--bg3)}
.btn:disabled{opacity:.5;cursor:not-allowed}
.btn-p{background:var(--c);color:#fff;border-color:var(--c)}
.btn-s{padding:6px 12px;font-size:12px}
.btn-c{width:28px;height:28px;padding:0;background:#888;border-color:#777;color:#fff;border-radius:50%}
.btn-add{width:32px;height:32px;padding:0;border-radius:50%;flex-shrink:0}
/* ================== 折叠面板 ================== */
.fold{background:var(--bg2);border:1px solid var(--bd);border-radius:6px;margin-bottom:8px;overflow:hidden}
.fold-h{padding:12px 14px;cursor:pointer;display:flex;justify-content:space-between;align-items:center}
.fold-h:hover{background:var(--bg3)}
.fold-a{transition:transform .2s;color:var(--c3);font-size:10px}
.fold.exp .fold-a{transform:rotate(180deg)}
.fold-b{max-height:0;overflow:hidden;transition:all .25s}
.fold.exp .fold-b{max-height:300px}
/* ================== 侧边导航 ================== */
.side-nav-wrap{position:fixed;left:10px;top:50%;transform:translateY(-50%);z-index:500;display:flex;flex-direction:column;gap:6px}
.side-glass{background:rgba(255,255,255,.3);backdrop-filter:blur(4px);box-shadow:0 2px 12px rgba(0,0,0,.05);border:1px solid rgba(221,221,221,.3);opacity:.4;transition:opacity .2s,background .2s}
.side-glass:hover{opacity:1;background:rgba(255,255,255,.85);backdrop-filter:blur(8px)}
.side-nav,.side-menu{display:flex;flex-direction:column;border-radius:20px}
.side-nav{gap:4px;padding:8px 5px}
.side-menu{position:relative;padding:6px 5px;align-items:center}
.side-menu-btn,.nav-i{display:flex;align-items:center;justify-content:center;cursor:pointer;border-radius:6px;transition:all .15s;color:var(--c3)}
.side-menu-btn{width:28px;height:28px;font-size:11px}
.nav-i{width:32px;height:32px;border-radius:50%}
.nav-i i{font-size:13px}
.side-menu-btn:hover,.nav-i:hover{background:var(--bg3);color:var(--c2)}
.side-menu-btn.act,.nav-i.act{background:var(--c);color:#fff}
.side-menu-panel{position:absolute;left:100%;top:50%;transform:translateY(-50%);margin-left:8px;display:none;flex-direction:column;gap:4px;padding:6px;background:rgba(255,255,255,.95);backdrop-filter:blur(8px);border-radius:8px;box-shadow:0 2px 12px rgba(0,0,0,.15);border:1px solid var(--bd);white-space:nowrap;z-index:600}
.side-menu-panel.show{display:flex}
.side-menu-panel .btn{font-size:11px;padding:5px 10px}
/* ================== 工具栏 ================== */
.toolbar{height:44px;background:var(--bg2);border-bottom:1px solid var(--bd);padding:0 12px 0 56px;position:relative;z-index:200}
.toolbar-t{font-size:14px;font-weight:600;margin-right:auto}
.toolbar-t span{font-weight:400;color:var(--c3);margin-left:8px;font-size:12px}
/* ================== 页面容器 ================== */
.main-wrap{width:100%;height:calc(100% - 44px);position:relative}
.page{position:absolute;inset:0;background:var(--bg);display:none;overflow:hidden}
.page.act{display:block}
.page-pad{padding:20px 20px 20px 56px;overflow-y:auto;height:100%}
/* ================== 横幅 ================== */
.banner{width:100%;height:160px;border-radius:8px;overflow:hidden;margin-bottom:20px;position:relative;background:var(--bg3)}
.banner img{width:100%;height:100%;object-fit:cover;filter:grayscale(30%)}
.banner-ov{position:absolute;inset:0;background:linear-gradient(transparent 40%,rgba(0,0,0,.5));display:flex;align-items:flex-end;padding:16px}
.banner-ov div{color:#fff;font-size:13px}
/* ================== 章节标题 ================== */
.sec-t{font-size:12px;color:var(--c3);margin-bottom:10px;text-transform:uppercase;letter-spacing:1px}
/* ================== 新闻 ================== */
.news-sec{margin-bottom:24px}
.news-t{font-size:13px;font-weight:500}
.news-time{font-size:11px;color:var(--c3)}
.fold.exp .news-b{padding:0 14px 14px}
.news-b p{font-size:12px;color:var(--c2);line-height:1.7}
/* ================== 用户指南 ================== */
.user-guide,.prog{padding:14px;background:var(--bg2);border-radius:6px;border:1px solid var(--bd)}
.user-guide{margin-bottom:20px}
.user-guide-state{font-size:13px;font-weight:500;margin-bottom:8px}
.user-guide-actions{display:flex;flex-direction:column;gap:6px}
.user-guide-action{padding:8px 12px;background:var(--bg);border:1px solid var(--bd);border-radius:4px;font-size:12px;color:var(--c2);cursor:pointer;transition:all .15s}
.user-guide-action:hover{border-color:var(--c);background:var(--bg3)}
/* ================== 进度条 ================== */
.prog-bar{height:6px;background:var(--bg3);border-radius:3px;overflow:hidden}
.prog-fill{height:100%;background:var(--c);border-radius:3px}
.prog-txt{font-size:11px;color:var(--c3);margin-top:8px;text-align:right}
/* ================== 地图 ================== */
#mapWrap{width:100%;height:100%;background:var(--bg);cursor:grab;overflow:hidden;position:relative;touch-action:none}
#inner{position:absolute;width:4000px;height:4000px;transform-origin:0 0}
#lines{position:absolute;width:4000px;height:4000px;pointer-events:none}
.item{position:absolute;padding:8px 14px;background:var(--bg3);border:1px solid var(--bd);border-radius:6px;font-size:12px;font-weight:500;white-space:nowrap;cursor:pointer;user-select:none;box-shadow:0 1px 4px rgba(0,0,0,.06);transition:all .15s}
.item:hover{transform:scale(1.05);box-shadow:0 3px 12px rgba(0,0,0,.1);z-index:10}
.item.node-main{background:var(--c);color:#fff;border-color:#111}
.item.node-home{background:#4a5568;color:#fff;border-color:#2d3748}
.item.node-sub{background:var(--bg2)}
.item.hl{border-color:#666;box-shadow:0 0 0 3px rgba(0,0,0,.2);z-index:20}
/* ================== 地图操作 ================== */
.map-act{position:absolute;top:10px;right:10px;z-index:100}
#btn-goto{display:none}
#btn-goto.show{display:flex}
.map-lbl{position:absolute;top:10px;left:56px;z-index:100;background:var(--bg2);border:1px solid var(--bd);border-radius:4px;padding:6px 12px;font-size:11px;color:var(--c2);cursor:pointer;transition:all .15s;display:flex;align-items:center}
.map-lbl:hover{border-color:var(--c);background:var(--bg3)}
.map-lbl i:first-child{margin-right:6px}
.map-lbl-sel{position:absolute;opacity:0;cursor:pointer;inset:0;border:none;background:transparent;-webkit-appearance:none;appearance:none}
/* ================== 面板 ================== */
.panel{position:fixed;background:var(--bg2);padding:6px 12px;border-radius:4px;font-size:11px;color:var(--c3);border:1px solid var(--bd)}
#zoom-ind{bottom:12px;right:12px}
#tip{bottom:12px;left:56px;max-width:240px;padding:12px 14px;line-height:1.6;max-height:180px;overflow-y:auto;display:none}
#tip .desc{display:none}
#tip .info-w{display:block}
#tip.show{display:block;border-color:var(--c)}
.loc-lk{color:var(--c);font-weight:500;cursor:pointer;border-bottom:1px dashed var(--c3)}
.loc-lk:hover{background:var(--bg3);border-radius:2px}
.local-map-title{font-size:14px;font-weight:600;color:var(--c);margin-bottom:12px;padding-bottom:8px;border-bottom:1px solid var(--bd)}
.info-h{display:flex;justify-content:space-between;align-items:center;margin-bottom:6px}
.info-t{font-weight:600;color:var(--c);font-size:13px}
.info-bk{font-size:11px;color:var(--c3);cursor:pointer;padding:2px 6px;border-radius:3px}
.info-bk:hover{background:var(--bg3)}
.info-c{color:var(--c2);font-size:12px;line-height:1.6}
/* ================== 通讯录 ================== */
.comm-hd{display:flex;align-items:center;gap:10px;margin-bottom:16px}
.comm-tabs{display:flex;flex:1}
.comm-tab{flex:1;padding:10px;text-align:center;background:var(--bg2);border:1px solid var(--bd);cursor:pointer;font-size:12px;font-weight:500;transition:all .15s}
.comm-tab:first-child{border-radius:6px 0 0 6px}
.comm-tab:last-child{border-radius:0 6px 6px 0;border-left:none}
.comm-tab:hover{background:var(--bg3)}
.comm-tab.act{background:var(--c);color:#fff;border-color:var(--c)}
.comm-sec{display:none}
.comm-sec.act{display:block}
/* ================== 联系人卡片 ================== */
.ct-hd{gap:10px}
.ct-av,.chat-av{width:36px;height:36px;border-radius:50%;display:flex;align-items:center;justify-content:center;color:#fff;font-weight:600;font-size:13px;flex-shrink:0}
.ct-info{flex:1}
.ct-name{font-size:13px;font-weight:500}
.ct-st{font-size:11px;color:var(--c3)}
.ct-det{padding:0 14px 14px}
.ct-info-text{font-size:12px;color:var(--c2);line-height:1.7;margin:0 0 12px}
.ct-acts{display:flex;gap:8px}
.ct-acts .btn{flex:1;justify-content:center;font-size:12px}
.empty{text-align:center;padding:40px 20px;color:var(--c3);font-size:13px}
/* ================== 弹窗 ================== */
.modal{position:fixed;inset:0;z-index:10000;display:none;justify-content:center;align-items:center}
.modal.act{display:flex}
.modal-bd{position:absolute;inset:0;background:rgba(0,0,0,.4);backdrop-filter:blur(2px)}
.modal-p{position:relative;width:90%;max-width:700px;max-height:85vh;background:var(--bg2);border:1px solid var(--bd);border-radius:8px;overflow:hidden;display:flex;flex-direction:column}
.modal-p.sm{max-width:360px}
.modal-p.lg{max-width:560px}
.modal-p.xl{max-width:800px}
.modal-hd,.modal-ft{padding:14px 18px}
.modal-hd{justify-content:space-between;border-bottom:1px solid var(--bd)}
.modal-hd h2{font-size:14px;font-weight:600}
.modal-ft{justify-content:flex-end;gap:10px;border-top:1px solid var(--bd)}
.modal-x{width:28px;height:28px;background:transparent;cursor:pointer;border:1px solid var(--bd);border-radius:4px}
.modal-x:hover{background:var(--bg3)}
.modal-by{flex:1;overflow-y:auto;padding:18px}
/* ================== 编辑器 ================== */
.ed-ta{width:100%;min-height:200px;padding:12px;background:var(--bg);border:none;border-top:1px solid var(--bd);font-family:'SF Mono',Monaco,Consolas,monospace;font-size:11px;line-height:1.5;color:var(--c);resize:none;outline:none}
.ed-preview{margin-top:10px;padding:10px;background:var(--bg3);border:1px solid var(--bd);border-radius:4px;font-size:11px;font-family:'SF Mono',Monaco,Consolas,monospace;white-space:pre-wrap;display:none;color:var(--c2)}
.ed-err{padding:10px;background:#fef2f2;border:1px solid #fecaca;border-radius:4px;color:#b91c1c;font-size:12px;margin-top:10px;display:none}
.ed-err.vis{display:block}
/* ================== 表单 ================== */
.form-g{margin-bottom:14px}
.form-l{display:block;font-size:12px;font-weight:500;margin-bottom:6px;color:var(--c2)}
.form-in{width:100%;padding:10px 12px;border:1px solid var(--bd);border-radius:4px;font-size:13px;outline:none;background:var(--bg2)}
.form-ta{min-height:80px;resize:vertical;font-family:inherit}
.goto-d{font-size:13px;color:var(--c);font-weight:500;padding:10px 14px;background:var(--bg3);border-radius:4px;margin-bottom:14px}
/* ================== 聊天 ================== */
.chat{position:fixed;top:0;right:-400px;width:300px;height:100%;background:var(--bg2);border-left:1px solid var(--bd);z-index:600;display:flex;flex-direction:column;transition:right .3s}
.chat.act{right:0}
.chat-hd{padding:14px 18px;border-bottom:1px solid var(--bd);display:flex;align-items:center;gap:12px;flex-shrink:0}
.chat-t{flex:1}
.chat-nm{font-size:14px;font-weight:600}
.chat-st{font-size:11px;color:var(--c3)}
.chat-hd-acts{display:flex;align-items:center;gap:4px}
.chat-compress,.chat-clr,.chat-x{width:32px;height:32px;border:none;background:transparent;cursor:pointer;border-radius:50%;display:flex;align-items:center;justify-content:center;color:var(--c3)}
.chat-compress:hover,.chat-clr:hover,.chat-x:hover{background:var(--bg3);color:var(--c)}
.chat-compress:hover{color:#3498db}
.chat-compress:disabled{opacity:.4;cursor:not-allowed}
.chat-clr:hover{color:#e74c3c}
.chat-divider{width:100%;text-align:center;padding:8px 0;color:var(--c3);font-size:11px;position:relative}
.chat-divider::before,.chat-divider::after{content:'';position:absolute;top:50%;width:30%;height:1px;background:var(--bd)}
.chat-divider::before{left:0}
.chat-divider::after{right:0}
.chat-msgs{flex:1;overflow-y:auto;padding:16px;display:flex;flex-direction:column;gap:10px}
.chat-msg{max-width:80%;padding:10px 14px;border-radius:12px;font-size:13px;line-height:1.5;white-space:pre-wrap}
.chat-msg.sent{align-self:flex-end;background:var(--c);color:#fff;border-bottom-right-radius:4px}
.chat-msg.recv{align-self:flex-start;background:var(--bg3);border-bottom-left-radius:4px}
.chat-msg.typing{font-style:italic;opacity:.7}
.chat-in-w{padding:12px;border-top:1px solid var(--bd);display:flex;gap:10px;flex-shrink:0}
.chat-in{flex:1;padding:10px 14px;border:1px solid var(--bd);border-radius:20px;font-size:13px;outline:none;background:var(--bg)}
.chat-in:focus{border-color:var(--c)}
.chat-in:disabled,.chat-send:disabled{opacity:.5;cursor:not-allowed}
.chat-send{width:36px;height:36px;border:none;background:var(--c);color:#fff;border-radius:50%;cursor:pointer;display:flex;align-items:center;justify-content:center}
.chat-send:hover{opacity:.9}
.chat-emp{text-align:center;color:var(--c3);font-size:12px;padding:40px 20px}
/* ================== 地点列表 ================== */
.loc-list{max-height:300px;overflow-y:auto}
.loc-i{padding:12px 14px;border:1px solid var(--bd);border-radius:6px;margin-bottom:8px;cursor:pointer;transition:all .15s}
.loc-i:hover{border-color:var(--c);background:var(--bg3)}
.loc-i.sel{border-color:var(--c);background:var(--c);color:#fff}
.loc-i-nm{font-size:13px;font-weight:500}
.loc-i-info{font-size:11px;opacity:.7;margin-top:2px}
/* ================== 底部弹窗 ================== */
.mob-pop{position:fixed;bottom:0;left:0;right:0;background:var(--bg2);border-top:1px solid var(--bd);border-radius:12px 12px 0 0;box-shadow:0 -2px 16px rgba(0,0,0,.1);z-index:101;display:none;flex-direction:column}
.mob-pop.act{display:flex}
.mob-pop.drag{transition:none!important}
.mob-pop:not(.drag){transition:height .2s ease-out}
.pop-hd{padding:8px;cursor:grab;touch-action:none;flex-shrink:0}
.pop-handle{width:36px;height:4px;background:var(--bd);border-radius:2px;margin:0 auto}
.pop-ct{flex:1;overflow-y:auto;padding:0 20px 20px;-webkit-overflow-scrolling:touch}
.pop-desc{display:none}
.pop-info{display:block}
.pop-info-h{display:flex;justify-content:space-between;align-items:center;margin-bottom:8px}
.pop-info-t{font-weight:600;font-size:14px}
.pop-info-bk{font-size:12px;color:var(--c3);cursor:pointer;padding:4px 8px;border-radius:4px}
.pop-h-ind{position:absolute;top:50%;right:6px;transform:translateY(-50%);display:flex;flex-direction:column;gap:3px;opacity:0;transition:opacity .15s}
.mob-pop.drag .pop-h-ind{opacity:1}
.pop-h-ind span{width:3px;height:8px;background:var(--bd);border-radius:1px}
.pop-h-ind span.act{background:var(--c)}
/* ================== 右侧面板 ================== */
.side-pop{position:fixed;top:44px;right:0;bottom:0;background:var(--bg2);border-left:1px solid var(--bd);z-index:90;display:none;width:8px}
.side-pop.show{display:flex}
.side-pop:not(.drag){transition:width .2s ease-out}
.side-pop.drag{transition:none}
.side-pop-handle{width:8px;background:var(--bg3);cursor:ew-resize;flex-shrink:0;display:flex;align-items:center;justify-content:center;touch-action:none}
.side-pop-bar{width:3px;height:36px;background:var(--bd);border-radius:2px;transition:background .15s}
.side-pop-handle:hover .side-pop-bar{background:var(--c3)}
.side-pop-ct{flex:1;overflow-y:auto;overflow-x:hidden;padding:16px;margin-bottom:30vh;min-width:0}
.side-pop-hd{font-size:11px;color:var(--c3);margin-bottom:10px;text-transform:uppercase;letter-spacing:1px}
.side-pop-desc{font-size:13px;color:var(--c2);line-height:1.7}
/* ================== 设置 ================== */
.set-sec{margin-bottom:16px}
.set-sec-t{font-size:13px;font-weight:600;color:var(--c);margin-bottom:12px;padding-bottom:8px;border-bottom:1px solid var(--bd)}
.set-row{display:flex;align-items:center;gap:8px;margin-bottom:10px}
.set-row .form-in{flex:1}
.set-row .btn{flex-shrink:0}
.set-hint{font-size:11px;color:var(--c3);margin-top:4px}
.set-test{display:flex;align-items:center;gap:8px;margin-top:8px}
.set-test-res{font-size:12px;padding:6px 10px;border-radius:4px;display:none}
.set-test-res.ok{display:block;background:#dcfce7;color:#166534;border:1px solid #86efac}
.set-test-res.err{display:block;background:#fef2f2;color:#b91c1c;border:1px solid #fecaca}
/* ================== 数据项 ================== */
.data-item{display:flex;align-items:flex-start;gap:10px;padding:12px;background:var(--bg);border:1px solid var(--bd);border-radius:6px;margin-bottom:8px;cursor:pointer;transition:all .15s}
.data-item:hover{border-color:var(--c2);background:var(--bg3)}
.data-item.sel{border-color:var(--c);background:rgba(34,34,34,.05)}
.data-ck{width:18px;height:18px;border:2px solid var(--bd);border-radius:4px;display:flex;align-items:center;justify-content:center;flex-shrink:0;transition:all .15s;margin-top:2px}
.data-item.sel .data-ck{background:var(--c);border-color:var(--c);color:#fff}
.data-ck i{font-size:10px;opacity:0}
.data-item.sel .data-ck i{opacity:1}
.data-info{flex:1;min-width:0}
.data-nm{font-size:13px;font-weight:500;margin-bottom:4px}
.data-desc{font-size:11px;color:var(--c3);line-height:1.4}
.data-edit{width:28px;height:28px;border:1px solid var(--bd);border-radius:4px;background:var(--bg2);cursor:pointer;display:flex;align-items:center;justify-content:center;flex-shrink:0;transition:all .15s;color:var(--c3)}
.data-edit:hover{border-color:var(--c);color:var(--c);background:var(--bg3)}
/* ================== 提示词编辑器 ================== */
.prompt-sec{margin-bottom:16px}
.prompt-lbl{font-size:12px;font-weight:600;color:var(--c2);margin-bottom:6px;display:flex;align-items:center;gap:8px}
.prompt-lbl::before{content:'';display:inline-block;width:8px;height:8px;border-radius:50%;background:var(--c3)}
.prompt-lbl.u::before{background:#3b82f6}
.prompt-lbl.a::before{background:#10b981}
.prompt-ta{width:100%;padding:12px;background:var(--bg);border:1px solid var(--bd);border-radius:6px;font-size:12px;line-height:1.7;color:var(--c);resize:vertical;font-family:inherit;min-height:80px}
.prompt-ta:focus{outline:none;border-color:var(--c)}
.prompt-ta.mono{font-family:'SF Mono',Monaco,Consolas,monospace;font-size:11px;line-height:1.5}
.prompt-acts{display:flex;gap:8px;margin-top:12px}
.prompt-help{margin-top:16px;padding:14px;background:var(--bg3);border-radius:8px;border:1px solid var(--bd)}
.prompt-help-t{font-size:12px;font-weight:600;color:var(--c2);margin-bottom:10px}
.prompt-help-c{font-size:11px;color:var(--c3);line-height:2}
.prompt-help-c b{color:var(--c2);font-weight:500}
.prompt-help-c code{background:var(--bg2);padding:2px 6px;border-radius:3px;font-family:'SF Mono',Monaco,Consolas,monospace;font-size:10px}
/* ================== 响应式 ================== */
@media(max-width:550px){
.chat{width:100%;right:-100%}
.side-pop{bottom:0}
.side-nav-wrap{left:6px}
.side-nav{padding:6px 4px}
.side-menu{padding:5px 4px}
.nav-i{width:28px;height:28px}
.nav-i i{font-size:11px}
.toolbar{height:44px;padding:0 12px 0 56px;}
.toolbar-t span{display:none}
.btn{padding:6px 10px;font-size:11px}
.modal-p{max-width:100%;max-height:100%;border-radius:0}
#tip{display:none!important}
.page-pad{padding:14px 14px 14px 48px}
.map-lbl{left:48px}
}
< / style >
< / head >
< body >
<!-- ================== 侧边导航 ================== -->
< div class = "side-nav-wrap" >
< div class = "side-menu side-glass" >
< div class = "side-menu-btn" id = "btn-side-menu-toggle" title = "快捷操作" > < i class = "fa-solid fa-ellipsis" > < / i > < / div >
< div class = "side-menu-panel" id = "side-menu-panel" >
< button class = "btn btn-s fc g4" id = "btn-gen-local-map" > < i class = "fa-solid fa-plus" > < / i > 局部地图< / button >
< button class = "btn btn-s fc g4" id = "btn-simulate" > < i class = "fa-solid fa-rotate" > < / i > 推演< / button >
< button class = "btn btn-s fc g4" id = "btn-gen-local-scene" > < i class = "fa-solid fa-feather-pointed" > < / i > 局部剧情< / button >
< / div >
< / div >
< nav class = "side-nav side-glass" >
< div class = "nav-i" data-p = "world" > < i class = "fa-solid fa-earth-americas" > < / i > < / div >
< div class = "nav-i act" data-p = "map" > < i class = "fa-solid fa-map" > < / i > < / div >
< div class = "nav-i" data-p = "comm" > < i class = "fa-solid fa-phone" > < / i > < / div >
< / nav >
< / div >
<!-- ================== 工具栏 ================== -->
< div class = "toolbar fc g10 usn" >
< div class = "toolbar-t" > 小白板< span > 预测试< / span > < / div >
< button class = "btn btn-s fc g6" id = "btn-deduce" > < i class = "fa-solid fa-wand-magic-sparkles" > < / i > 生成< / button >
< button class = "btn btn-s fc g6" id = "btn-settings" > < i class = "fa-solid fa-gear" > < / i > 设置< / button >
< button class = "btn btn-c fcc" id = "btn-close" > ✕< / button >
< / div >
<!-- ================== 主内容区 ================== -->
< div class = "main-wrap" >
< div class = "page page-pad" id = "page-world" >
< div class = "banner" > < img src = "https://picsum.photos/800/300" alt = "" > < div class = "banner-ov" > < div > 探索未知的世界...< / div > < / div > < / div >
< div class = "news-sec" > < h3 class = "sec-t" > 最新消息< / h3 > < div id = "news-list" > < / div > < / div >
< div class = "user-guide" id = "user-guide" >
< h3 class = "sec-t" > 当前状态< / h3 >
< div class = "user-guide-state" id = "ug-state" > 尚未生成世界数据...< / div >
< h3 class = "sec-t" style = "margin-top:12px" > 行动指南< / h3 >
< div class = "user-guide-actions" id = "ug-actions" > < div class = "user-guide-action" > 等待世界生成...< / div > < / div >
< / div >
< / div >
< div class = "page act" id = "page-map" >
< div id = "mapWrap" >
< div class = "map-lbl" id = "map-lbl" >
< i class = "fa-solid fa-map-location-dot" > < / i >
< span id = "map-lbl-t" > 大地图< / span >
< i class = "fa-solid fa-chevron-down" style = "margin-left:6px;font-size:9px" > < / i >
< select id = "map-lbl-select" class = "map-lbl-sel" > < / select >
< / div >
< div class = "map-act" > < button class = "btn btn-s btn-p fc g6" id = "btn-goto" > < i class = "fa-solid fa-location-arrow" > < / i > < span id = "goto-t" > 前往< / span > < / button > < / div >
< div id = "inner" > < svg id = "lines" > < / svg > < / div >
< / div >
< div class = "panel" id = "zoom-ind" > 100%< / div >
< div class = "panel" id = "tip" >
< div class = "desc" id = "desc" > < / div >
< div class = "info-w" >
< div class = "info-h" > < div class = "info-t" id = "info-t" > < / div > < div class = "info-bk" id = "info-bk" > ← 返回< / div > < / div >
< div class = "info-c" id = "info-c" > < / div >
< / div >
< / div >
< / div >
< div class = "page page-pad" id = "page-comm" >
< div class = "comm-hd" >
< div class = "comm-tabs" >
< div class = "comm-tab act" data-t = "stranger" > 陌路人< / div >
< div class = "comm-tab" data-t = "contact" > 联络人< / div >
< / div >
< button class = "btn btn-add fcc" id = "btn-refresh-strangers" title = "摇一摇" > < i class = "fa-solid fa-rotate" > < / i > < / button >
< button class = "btn btn-add fcc" id = "btn-add-ct" > < i class = "fa-solid fa-plus" > < / i > < / button >
< / div >
< div id = "sec-stranger" class = "comm-sec act" > < / div >
< div id = "sec-contact" class = "comm-sec" > < / div >
< / div >
< / div >
<!-- ================== 聊天面板 ================== -->
< div class = "chat" id = "chat" >
< div class = "chat-hd" >
< div class = "chat-av" id = "chat-av" > < / div >
< div class = "chat-t" > < div class = "chat-nm" id = "chat-nm" > < / div > < div class = "chat-st" id = "chat-st" > < / div > < / div >
< div class = "chat-hd-acts" >
< button class = "chat-compress" id = "chat-compress" title = "压缩总结" > < i class = "fa-solid fa-compress" > < / i > < / button >
< button class = "chat-clr" id = "chat-clr" title = "清空记录" > < i class = "fa-solid fa-trash" > < / i > < / button >
< button class = "chat-x" id = "chat-x" > < i class = "fa-solid fa-xmark" > < / i > < / button >
< / div >
< / div >
< div class = "chat-msgs" id = "chat-msgs" > < / div >
< div class = "chat-in-w" >
< button class = "chat-send" id = "chat-back" title = "回退" > < i class = "fa-solid fa-rotate-left" > < / i > < / button >
< input type = "text" class = "chat-in" id = "chat-in" placeholder = "输入消息..." >
< button class = "chat-send" id = "chat-send" > < i class = "fa-solid fa-paper-plane" > < / i > < / button >
< / div >
< / div >
<!-- ================== 右侧描述面板 ================== -->
< div class = "side-pop" id = "side-pop" >
< div class = "side-pop-handle" id = "side-pop-handle" > < div class = "side-pop-bar" > < / div > < / div >
< div class = "side-pop-ct" >
< div class = "side-pop-hd fc" style = "justify-content:space-between" >
< span > 场景描述< / span >
< button class = "btn btn-s fc g4" id = "btn-refresh-local-map" > < i class = "fa-solid fa-rotate-right" > < / i > 刷新< / button >
< / div >
< div class = "side-pop-desc" id = "side-desc" > < / div >
< / div >
< / div >
<!-- ================== 移动端底部弹窗 ================== -->
< div class = "mob-pop" id = "mob-pop" >
< div class = "pop-h-ind" > < span data-l = "2" > < / span > < span data-l = "1" > < / span > < span data-l = "0" > < / span > < / div >
< div class = "pop-hd" id = "pop-hd" > < div class = "pop-handle" > < / div > < / div >
< div class = "pop-ct" >
< div class = "pop-desc" id = "mob-desc" > < / div >
< div class = "pop-info" >
< div class = "pop-info-h" > < div class = "pop-info-t" id = "mob-info-t" > < / div > < div class = "pop-info-bk" id = "mob-info-bk" > ← 返回< / div > < / div >
< div id = "mob-info-c" > < / div >
< / div >
< / div >
< / div >
<!-- ================== 设置弹窗 ================== -->
< div class = "modal" id = "m-settings" >
< div class = "modal-bd" > < / div >
< div class = "modal-p xl" >
< div class = "modal-hd fc" > < h2 > 设置< / h2 > < button class = "modal-x fcc" > ✕< / button > < / div >
< div class = "modal-by" >
< div class = "set-sec" >
< div class = "set-sec-t" > 剧情状态< / div >
< div class = "set-row" style = "gap:20px" >
< div class = "form-g" style = "flex:1" > < label class = "form-l" > 当前阶段< / label > < input type = "number" class = "form-in" id = "set-stage" min = "0" max = "10" value = "0" > < div class = "set-hint" > 决定可见的洋葱层级< / div > < / div >
< div class = "form-g" style = "flex:1" > < label class = "form-l" > 偏离分数< / label > < input type = "number" class = "form-in" id = "set-deviation" min = "0" max = "100" value = "0" > < div class = "set-hint" > 玩家行为对世界的影响< / div > < / div >
< / div >
< div class = "set-row" style = "gap:20px" >
< div class = "form-g" style = "flex:1" > < label class = "form-l" > 推演进度< / label > < input type = "number" class = "form-in" id = "set-sim-progress" min = "0" value = "0" > < div class = "set-hint" > 当前累计推演点数< / div > < / div >
< div class = "form-g" style = "flex:1" > < label class = "form-l" > 推演目标< / label > < input type = "number" class = "form-in" id = "set-sim-target" min = "1" value = "5" > < div class = "set-hint" > 达到目标自动推演< / div > < / div >
< / div >
< / div >
< div class = "set-sec" >
< div class = "set-sec-t" > 模式设置< / div >
< div class = "form-g" > < label class = "form-l" > 运行模式< / label >
< select class = "form-in" id = "set-mode" >
< option value = "story" > 故事模式(包含世界大纲与时间线)< / option >
< option value = "assist" > 辅助模式(只生成地图/新闻/轻松小剧情)< / option >
< / select >
< div class = "set-hint" > 可在游玩过程中自由切换< / div >
< / div >
< / div >
< div class = "set-sec" >
< div class = "set-sec-t" > 全局设定< / div >
< div class = "form-g" > < label class = "form-l" > API端点< / label > < input type = "text" class = "form-in" id = "set-api-url" placeholder = "留空则使用当前酒馆的API" > < div class = "set-hint" > 例如: https://api.openai.com/v1< / div > < / div >
< div class = "form-g" > < label class = "form-l" > API密钥< / label > < input type = "password" class = "form-in" id = "set-api-key" placeholder = "输入API密钥" > < / div >
< div class = "form-g" > < label class = "form-l" > 模型< / label >
< div class = "set-row" > < input type = "text" class = "form-in" id = "set-model" placeholder = "输入模型名称" > < button class = "btn btn-s" id = "btn-fetch-models" > 获取< / button > < button class = "btn btn-s btn-p" id = "btn-test-conn" > 测试连接< / button > < / div >
< select class = "form-in" id = "set-model-list" style = "display:none;margin-top:8px" > < / select >
< / div >
< div class = "set-test" > < label class = "form-l" > 聊天历史楼层数< / label > < input type = "number" class = "form-in" id = "set-history-count" min = "0" max = "200" value = "50" style = "width:100px" > < div class = "set-test-res" id = "test-res" > < / div > < / div >
< div class = "set-sec-t" style = "margin-top:16px" > NPC 世界书条目< / div >
< div class = "set-row" style = "gap:20px" >
< div class = "form-g" style = "flex:1" > < label class = "form-l" > 插入位置< / label >
< select class = "form-in" id = "set-npc-position" >
< option value = "0" > ↑Char 角色定义前< / option > < option value = "1" > ↓Char 角色定义后< / option >
< option value = "2" > ↑AN 作者注释前< / option > < option value = "3" > ↓AN 作者注释后< / option >
< option value = "5" > ↑EM 增强定义前< / option > < option value = "6" > ↓EM 增强定义后< / option >
< / select >
< div class = "set-hint" > 生成的NPC条目插入位置< / div >
< / div >
< div class = "form-g" style = "flex:1" > < label class = "form-l" > 条目顺序< / label > < input type = "number" class = "form-in" id = "set-npc-order" min = "0" max = "1000" value = "100" > < div class = "set-hint" > 数值越小越靠前< / div > < / div >
< / div >
< / div >
< div class = "set-sec" > < div class = "set-sec-t" > 预设 Story Outline 数据< / div > < div class = "set-hint" style = "margin-bottom:12px" > 勾选的条目将写入预设< / div > < div id = "data-list" > < / div > < / div >
< div class = "set-sec" >
< div class = "set-sec-t" > 模板编辑器< / div >
< div class = "form-g" >
< label class = "form-l" > 选择模板< / label >
< select class = "form-in" id = "template-type-select" >
< optgroup label = "短信功能" >
< option value = "sms" > 短信回复< / option >
< option value = "summary" > 总结压缩< / option >
< option value = "invite" > 邀请回复< / option >
< / optgroup >
< optgroup label = "NPC管理" >
< option value = "npc" > NPC 生成< / option >
< option value = "stranger" > 提取陌路人< / option >
< / optgroup >
< optgroup label = "世界生成 (故事模式)" >
< option value = "worldGenStep1" > 世界大纲 Step1< / option >
< option value = "worldGenStep2" > 世界细节 Step2< / option >
< option value = "worldSim" > 世界推演< / option >
< option value = "sceneSwitch" > 场景切换< / option >
< / optgroup >
< optgroup label = "世界生成 (辅助模式)" >
< option value = "worldGenAssist" > 世界生成 (辅助)< / option >
< option value = "worldSimAssist" > 世界推演 (辅助)< / option >
< option value = "sceneSwitchAssist" > 场景切换 (辅助)< / option >
< / optgroup >
< optgroup label = "局部地图" >
< option value = "localMapGen" > 局部地图生成< / option >
< option value = "localMapRefresh" > 局部地图刷新< / option >
< option value = "localSceneGen" > 局部剧情生成< / option >
< / optgroup >
< / select >
< / div >
< div id = "template-editor-wrap" >
< div class = "prompt-sec" >
< div class = "prompt-lbl u" > USER< / div >
< textarea class = "prompt-ta" id = "tpl-u1" rows = "6" > < / textarea >
< / div >
< div class = "prompt-sec" >
< div class = "prompt-lbl a" > ASSISTANT< / div >
< textarea class = "prompt-ta" id = "tpl-a1" rows = "2" > < / textarea >
< / div >
< div class = "prompt-sec" >
< div class = "prompt-lbl u" > USER< / div >
< textarea class = "prompt-ta" id = "tpl-u2" rows = "6" > < / textarea >
< / div >
< div class = "prompt-sec" >
< div class = "prompt-lbl a" > ASSISTANT< / div >
< textarea class = "prompt-ta" id = "tpl-a2" rows = "2" > < / textarea >
< / div >
< div class = "prompt-sec" style = "margin-top:16px" >
< div class = "prompt-lbl" style = "color:var(--c2)" > < i class = "fa-solid fa-code" > < / i > JSON 输出格式< / div >
< textarea class = "prompt-ta mono" id = "tpl-json" rows = "10" > < / textarea >
< / div >
< div class = "prompt-acts" >
< button class = "btn btn-s" id = "tpl-restore" > < i class = "fa-solid fa-rotate-left" > < / i > 恢复默认< / button >
< button class = "btn btn-s btn-p" id = "tpl-save" > < i class = "fa-solid fa-check" > < / i > 保存< / button >
< / div >
< / div >
< div class = "prompt-help" >
< div class = "prompt-help-t" > < i class = "fa-solid fa-circle-info" > < / i > 占位符说明< / div >
< div class = "prompt-help-c" >
< div style = "margin-bottom:8px" > < b > 角色变量< / b > < / div >
< code > {{user}}< / code > 玩家名称< br >
< code > {{char}}< / code > 角色卡名称< br > < br >
< div style = "margin-bottom:8px" > < b > 场景变量< / b > < / div >
< code > {{CONTACT_NAME}}< / code > 当前聊天对象名称< br >
< code > {{USER_MESSAGE}}< / code > 玩家发送的短信内容< br >
< code > {{TARGET_LOCATION}}< / code > 目标地点名称< br >
< code > {{STRANGER_NAME}}< / code > 陌生人名称< br >
< code > {{PLAYER_REQUESTS}}< / code > 玩家的特殊需求文本< br > < br >
< div style = "margin-bottom:8px" > < b > 内容块< / b > < / div >
< code > {{WORLD_INFO}}< / code > 世界设定(角色描述+世界书+人格)< br >
< code > {{HISTORY}}< / code > 最近N条聊天记录< br >
< code > {{HISTORY_50}}< / code > 指定获取最近50条记录< br >
< code > {{STORY_OUTLINE}}< / code > 剧情大纲(仅故事模式)< br >
< code > {{SMS_HISTORY}}< / code > 短信聊天记录< br >
< code > {{CHARACTER_CONTENT}}< / code > 联系人的世界书人设< br > < br >
< div style = "margin-bottom:8px" > < b > JSON模板引用< / b > < / div >
< code > {{JSON:sms}}< / code > 引用当前模板的JSON格式定义
< / div >
< / div >
< / div >
< / div >
< div class = "modal-ft fc" > < button class = "btn btn-s m-cancel" > 取消< / button > < button class = "btn btn-s btn-p" id = "set-save" > 保存< / button > < / div >
< / div >
< / div >
<!-- ================== 数据编辑弹窗 ================== -->
< div class = "modal" id = "m-data-edit" >
< div class = "modal-bd" > < / div >
< div class = "modal-p" >
< div class = "modal-hd fc" > < h2 id = "data-edit-title" > 编辑数据< / h2 > < button class = "modal-x fcc" > ✕< / button > < / div >
< div class = "modal-by" >
< textarea class = "ed-ta" id = "data-edit-ta" style = "min-height:300px" > < / textarea >
< pre class = "ed-preview" id = "data-edit-preview" > < / pre >
< div class = "ed-err" id = "data-edit-err" > < / div >
< / div >
< div class = "modal-ft fc" > < button class = "btn btn-s m-cancel" > 取消< / button > < button class = "btn btn-s btn-p" id = "data-edit-save" > 保存< / button > < / div >
< / div >
< / div >
<!-- ================== 前往确认弹窗 ================== -->
< div class = "modal" id = "m-goto" >
< div class = "modal-bd" > < / div >
< div class = "modal-p sm" >
< div class = "modal-hd fc" > < h2 > 确认前往< / h2 > < button class = "modal-x fcc" > ✕< / button > < / div >
< div class = "modal-by" >
< div class = "goto-d" id = "goto-d" > < / div >
< div class = "form-g" > < label class = "form-l" > 任务目标(可选)< / label > < textarea class = "form-in form-ta" id = "goto-task" placeholder = "描述你要做什么..." > < / textarea > < / div >
< / div >
< div class = "modal-ft fc" > < button class = "btn btn-s m-cancel" > 取消< / button > < button class = "btn btn-s btn-p" id = "goto-ok" > 确认前往< / button > < / div >
< / div >
< / div >
<!-- ================== 邀请弹窗 ================== -->
< div class = "modal" id = "m-invite" >
< div class = "modal-bd" > < / div >
< div class = "modal-p sm" >
< div class = "modal-hd fc" > < h2 > 邀请前往< / h2 > < button class = "modal-x fcc" > ✕< / button > < / div >
< div class = "modal-by" >
< div class = "goto-d" id = "inv-t" > < / div >
< div class = "form-g" > < label class = "form-l" > 选择地点< / label > < div class = "loc-list" id = "loc-list" > < / div > < / div >
< / div >
< div class = "modal-ft fc" > < button class = "btn btn-s m-cancel" > 取消< / button > < button class = "btn btn-s btn-p" id = "inv-ok" > 发送邀请< / button > < / div >
< / div >
< / div >
<!-- ================== 世界生成弹窗 ================== -->
< div class = "modal" id = "m-world-gen" >
< div class = "modal-bd" > < / div >
< div class = "modal-p" >
< div class = "modal-hd fc" > < h2 > 世界生成< / h2 > < button class = "modal-x fcc" > ✕< / button > < / div >
< div class = "modal-by" >
< div class = "form-g" > < label class = "form-l" > 玩家特殊需求(可选)< / label > < textarea class = "form-in form-ta" id = "world-gen-req" rows = "4" placeholder = "例如:希望有更多悬疑元素、增加一个神秘组织..." > < / textarea > < div class = "set-hint" > AI 会根据当前世界观和你的需求生成完整的世界数据< / div > < / div >
< div id = "world-gen-status" class = "set-hint" style = "color:#4a9;display:none" > < / div >
< / div >
< div class = "modal-ft fc" > < button class = "btn btn-s m-cancel" > 取消< / button > < button class = "btn btn-s btn-p" id = "world-gen-ok" > < i class = "fa-solid fa-wand-magic-sparkles" > < / i > 开始生成< / button > < / div >
< / div >
< / div >
<!-- ================== 世界推演弹窗 ================== -->
< div class = "modal" id = "m-world-sim" >
< div class = "modal-bd" > < / div >
< div class = "modal-p" >
< div class = "modal-hd fc" > < h2 > 世界推演< / h2 > < button class = "modal-x fcc" > ✕< / button > < / div >
< div class = "modal-by" >
< div class = "form-g" >
< div class = "set-hint" style = "margin-bottom:12px" > < strong > 推演模式< / strong > :根据玩家行为和时间流逝,演化世界状态。
< ul style = "margin:8px 0;padding-left:20px;font-size:12px" > < li > L1/L2 将大幅更新< / li > < li > L3/L4 适度调整< / li > < li > L5 保持不变< / li > < li > Stage 将推进到下一阶段< / li > < / ul >
< / div >
< div class = "set-hint" style = "color:#f80" > ⚠️ 此操作会覆盖当前世界数据,建议先备份< / div >
< / div >
< div id = "world-sim-status" class = "set-hint" style = "color:#4a9;display:none" > < / div >
< / div >
< div class = "modal-ft fc" > < button class = "btn btn-s m-cancel" > 取消< / button > < button class = "btn btn-s btn-p" id = "world-sim-ok" > < i class = "fa-solid fa-rotate" > < / i > 开始推演< / button > < / div >
< / div >
< / div >
<!-- ================== 添加联络人弹窗 ================== -->
< div class = "modal" id = "m-add-ct" >
< div class = "modal-bd" > < / div >
< div class = "modal-p sm" >
< div class = "modal-hd fc" > < h2 > 添加联络人< / h2 > < button class = "modal-x fcc" > ✕< / button > < / div >
< div class = "modal-by" >
< div class = "form-g" > < label class = "form-l" > UID( 世界书条目UID) < / label >
< div class = "set-row" > < input type = "text" class = "form-in" id = "add-uid" placeholder = "请输入世界书条目UID" > < button class = "btn btn-s" id = "btn-check-uid" > < i class = "fa-solid fa-search" > < / i > 检查< / button > < / div >
< div class = "set-hint" > 输入角色卡绑定世界书的条目UID< / div >
< / div >
< div class = "form-g" id = "name-select-group" style = "display:none" > < label class = "form-l" > 选择名字< / label > < select class = "form-in" id = "add-name" > < option value = "" > -- 请选择 --< / option > < / select > < / div >
< div id = "uid-check-err" class = "ed-err" > < / div >
< / div >
< div class = "modal-ft fc" > < button class = "btn btn-s m-cancel" > 取消< / button > < button class = "btn btn-s btn-p" id = "add-ct-ok" disabled > 确定< / button > < / div >
< / div >
< / div >
<!-- ================== 结果弹窗 ================== -->
< div class = "modal" id = "m-result" >
< div class = "modal-bd" > < / div >
< div class = "modal-p sm" >
< div class = "modal-hd fc" > < h2 id = "res-title" > 操作结果< / h2 > < button class = "modal-x fcc" > ✕< / button > < / div >
< div class = "modal-by" >
< div id = "res-msg" style = "margin-bottom:10px;line-height:1.5" > < / div >
< div id = "res-record-box" style = "display:none" >
< div class = "set-hint" style = "margin-bottom:4px" > 详细记录:< / div >
< pre id = "res-record" style = "max-height:200px;overflow-y:auto;background:var(--bg3);padding:8px;border-radius:4px;font-size:12px;white-space:pre-wrap;word-break:break-all" > < / pre >
< / div >
< / div >
< div class = "modal-ft fc" > < button class = "btn btn-s" id = "res-action" style = "display:none" > < / button > < button class = "btn btn-s btn-p m-cancel" > 确定< / button > < / div >
< / div >
< / div >
< script >
/* ================== 数据状态 ================== */
const D = {
stage: 0, deviationScore: 0, simulationProgress: 0, simulationTarget: 5,
meta: { truth: null, onion_layers: null, timeline: null, user_guide: null },
world: {}, maps: { outdoor: { nodes: [] }, indoor: null }, sceneSetup: null,
contacts: { strangers: [], contacts: [{ name: '{{characterName}}', avatar: '', color: '#555', location: '在线', info: '角色卡联络人', online: true, worldbookUid: '__CHARACTER_CARD__', messages: [], summarizedCount: 0 }] }
};
let charSmsHistory = { messages: [], summarizedCount: 0, summaries: {} };
/* ================== 工具函数 ================== */
const $ = id => document.getElementById(id);
const $$ = s => document.querySelectorAll(s);
const isMob = () => innerWidth < = 550;
const escHtml = s => s.replace(/[& < >"']/g, c => ({ '& ': '& ', '< ': '< ', '>': '> ', '"': '" ', "'": '' ' })[c]);
const stripXml = s => s ? s.replace(/< (\w+)[^>]*>[\s\S]*?< \/\1>/g, '').replace(/< [^>]+\/?>/g, '').trim() : '';
const parseLinks = t => t.replace(/\*\*([^*]+)\*\*/g, '< span class = "loc-lk" data-loc = "$1" > $1< / span > ');
const post = (type, data = {}) => parent.postMessage({ source: 'LittleWhiteBox-OutlineFrame', type, ...data }, '*');
const BtnState = {
load: (btn, t) => { btn.disabled = true; btn._o = btn.innerHTML; btn.innerHTML = `< i class = "fa-solid fa-spinner fa-spin" > < / i > ${t}` },
reset: (btn, t) => { btn.disabled = false; btn.innerHTML = t || btn._o }
};
const Req = {
_p: {}, _id: 0,
create(k) { const id = k + '_' + (++this._id); this._p[k] = { id }; return id },
get(k) { return this._p[k] },
match(id) { const k = id?.split('_')[0]; return this._p[k]?.id === id ? this._p[k] : null },
set(k, d) { Object.assign(this._p[k] || {}, d) },
clear(k) { delete this._p[k] }
};
const openM = id => $(id).classList.add('act');
const closeM = id => $(id).classList.remove('act');
/* ================== 模板编辑器状态 ================== */
let templateState = {
currentType: 'sms',
prompts: {},
jsonTemplates: {},
defaults: { prompts: {}, jsonTemplates: {} }
};
function loadTemplate(type) {
templateState.currentType = type;
const p = templateState.prompts[type] || templateState.defaults.prompts[type] || {};
$('tpl-u1').value = p.u1 || '';
$('tpl-a1').value = p.a1 || '';
$('tpl-u2').value = p.u2 || '';
$('tpl-a2').value = p.a2 || '';
const j = templateState.jsonTemplates[type] || templateState.defaults.jsonTemplates[type] || '';
$('tpl-json').value = j;
autoResizeAll();
}
function saveCurrentTemplate() {
const type = templateState.currentType;
templateState.prompts[type] = {
u1: $('tpl-u1').value,
a1: $('tpl-a1').value,
u2: $('tpl-u2').value,
a2: $('tpl-a2').value
};
templateState.jsonTemplates[type] = $('tpl-json').value;
post('SAVE_PROMPTS', { promptConfig: { prompts: templateState.prompts, jsonTemplates: templateState.jsonTemplates } });
const btn = $('tpl-save');
const orig = btn.innerHTML;
btn.innerHTML = '< i class = "fa-solid fa-check" > < / i > 已保存';
btn.disabled = true;
setTimeout(() => { btn.innerHTML = orig; btn.disabled = false; }, 1500);
}
function restoreCurrentTemplate() {
const type = templateState.currentType;
if (!confirm(`确定要恢复「${type}」为默认模板吗?`)) return;
delete templateState.prompts[type];
delete templateState.jsonTemplates[type];
loadTemplate(type);
post('SAVE_PROMPTS', { promptConfig: { prompts: templateState.prompts, jsonTemplates: templateState.jsonTemplates } });
}
function autoResizeAll() {
['tpl-u1', 'tpl-a1', 'tpl-u2', 'tpl-a2'].forEach(id => {
const ta = $(id);
if (!ta) return;
ta.style.height = 'auto';
ta.style.height = Math.max(ta.scrollHeight, 60) + 'px';
});
}
/* ================== 地图状态 ================== */
const dirMap = { north: [0, -1], south: [0, 1], east: [1, 0], west: [-1, 0], northeast: [1, -1], northwest: [-1, -1], southeast: [1, 1], southwest: [-1, 1] };
let playerLocation = '未知', curNode = null, drag = false, scale = 1, offX = 0, offY = 0, seed = 123456789, nodes = [], lines = [], anim = false;
let chatTgt = null, invTgt = null, selLoc = null, smsGen = false, selectedMapValue = 'current';
const inner = $("inner"), svg = $("lines"), mapWrap = $("mapWrap"), popup = $('mob-pop'), chat = $('chat'), sidePop = $('side-pop');
const rand = () => (seed = (seed * 9301 + 49297) % 233280) / 233280;
const getCurInside = () => D.maps?.indoor?.[playerLocation] || null;
/* ================== 弹窗拖拽 ================== */
const snaps = () => [($('pop-hd')?.offsetHeight || 0), innerHeight * .30, innerHeight * .65];
let popH = 0, popDrag = false, popSY = 0, popSH = 0, popLv = 1;
const inds = popup.querySelectorAll('.pop-h-ind span');
const setPopH = h => {
const s = snaps();
popH = Math.max(s[0], Math.min(innerHeight * .85, h));
popup.style.height = popH + 'px';
popLv = s.map((v, i) => [i, Math.abs(popH - v)]).sort((a, b) => a[1] - b[1])[0][0];
inds.forEach((x, i) => x.classList.toggle('act', i === popLv));
};
const snapTo = l => { popLv = Math.max(0, Math.min(2, l)); setPopH(snaps()[popLv]); };
const openPop = (l = 1) => { popup.classList.add('act'); snapTo(l); };
const sideMinW = 8, sideMaxW = () => Math.floor(innerWidth * (isMob() ? 0.8 : 1 / 3));
let sideW = sideMinW, sideDrag = false, sideSX = 0, sideSW = 0;
const setSideW = w => { sideW = Math.max(sideMinW, Math.min(sideMaxW(), w)); sidePop.style.width = sideW + 'px'; };
$('pop-hd').onmousedown = e => { popDrag = true; popSY = e.clientY; popSH = popH || snaps()[1]; popup.classList.add('drag'); e.preventDefault(); };
$('pop-hd').ontouchstart = e => { e.preventDefault(); popDrag = true; popSY = e.touches[0].clientY; popSH = popH || snaps()[1]; popup.classList.add('drag'); };
$('side-pop-handle').onmousedown = e => { sideDrag = true; sideSX = e.clientX; sideSW = sideW; sidePop.classList.add('drag'); e.preventDefault(); };
$('side-pop-handle').ontouchstart = e => { e.preventDefault(); sideDrag = true; sideSX = e.touches[0].clientX; sideSW = sideW; sidePop.classList.add('drag'); };
document.onmousemove = e => { if (popDrag) setPopH(popSH + popSY - e.clientY); if (sideDrag) setSideW(sideSW + (sideSX - e.clientX)); };
document.ontouchmove = e => {
if (popDrag & & e.touches.length) { e.preventDefault(); setPopH(popSH + popSY - e.touches[0].clientY); }
if (sideDrag & & e.touches.length) { e.preventDefault(); setSideW(sideSW + (sideSX - e.touches[0].clientX)); }
};
const endDrag = () => {
if (popDrag) { popDrag = false; popup.classList.remove('drag'); snapTo(popLv); }
if (sideDrag) { sideDrag = false; sidePop.classList.remove('drag'); }
};
document.onmouseup = endDrag;
document.ontouchend = endDrag;
document.ontouchcancel = endDrag;
/* ================== 链接绑定 ================== */
const bindLinks = el => el.querySelectorAll('.loc-lk').forEach(l => l.onclick = e => {
e.stopPropagation();
const locName = l.dataset.loc;
const outdoorNode = nodes.find(x => x.name === locName);
if (outdoorNode) { panTo(outdoorNode); showInfo(outdoorNode); return; }
const inside = getCurInside();
const insideNode = inside?.nodes?.find(x => x.name === locName);
if (insideNode) {
$('info-t').textContent = insideNode.name;
$('info-c').textContent = insideNode.info || '暂无信息...';
$('tip').classList.add('show');
$('btn-goto').classList.remove('show');
if (isMob()) { $('mob-info-t').textContent = insideNode.name; $('mob-info-c').textContent = insideNode.info || '暂无信息...'; if (!popup.classList.contains('act')) openPop(1); }
}
});
const bindFold = el => el.querySelector('.fold-h').onclick = () => el.classList.toggle('exp');
$$('.modal-bd,.modal-x,.m-cancel').forEach(el => el.onclick = () => el.closest('.modal').classList.remove('act'));
/* ================== 聊天功能 ================== */
const openChat = c => {
chatTgt = c;
$('chat-av').textContent = c.avatar;
$('chat-av').style.background = c.color;
$('chat-nm').textContent = c.name;
$('chat-st').textContent = c.online ? '● 在线' : c.location;
if (c.worldbookUid & & (!c.messages || !c.messages.length)) post('LOAD_SMS_HISTORY', { worldbookUid: c.worldbookUid });
else renderMsgs();
chat.classList.add('act');
$('chat-in').focus();
};
const closeChat = () => { chat.classList.remove('act'); chatTgt = null; smsGen = false; };
const renderMsgs = () => {
if (!chatTgt) return;
const m = chatTgt.messages || [], sc = chatTgt.summarizedCount || 0;
let h = '';
if (m.length) {
m.forEach((x, i) => {
if (sc > 0 & & i === sc) h += '< div class = "chat-divider" > —— 以上为已总结消息 ——< / div > ';
h += `< div class = "chat-msg ${x.type === 'sent' ? 'sent' : 'recv'}${x.typing ? ' typing' : ''}" > < div > ${escHtml(stripXml(x.text))}< / div > < / div > `;
});
} else h = '< div class = "chat-emp" > 暂无消息,开始聊天吧< / div > ';
$('chat-msgs').innerHTML = h;
$('chat-msgs').scrollTop = $('chat-msgs').scrollHeight;
$('chat-compress').disabled = (m.length - sc) < 2 | | smsGen ;
$('chat-back').disabled = !m.length || smsGen;
};
const sendMsg = () => {
const t = $('chat-in').value.trim();
if (!t || !chatTgt || smsGen) return;
chatTgt.messages = chatTgt.messages || [];
chatTgt.messages.push({ type: 'sent', text: t });
$('chat-in').value = '';
renderMsgs();
smsGen = true;
$('chat-in').disabled = $('chat-send').disabled = $('chat-back').disabled = true;
chatTgt.messages.push({ type: 'received', text: '正在输入...', typing: true });
renderMsgs();
const id = Req.create('sms');
Req.set('sms', { tgt: chatTgt });
post('SEND_SMS', { requestId: id, contactName: chatTgt.name, worldbookUid: chatTgt.worldbookUid, userMessage: t, chatHistory: chatTgt.messages.filter(m => !m.typing).slice(-20), summarizedCount: chatTgt.summarizedCount || 0 });
};
const isCharCardContact = c => c?.worldbookUid === '__CHARACTER_CARD__';
const contactsForSave = () => (D.contacts.contacts || []).filter(c => !isCharCardContact(c));
const saveCt = () => post('SAVE_CONTACTS', { contacts: contactsForSave(), strangers: D.contacts.strangers });
const saveChat = c => post('SAVE_SMS_HISTORY', { worldbookUid: c.worldbookUid, contactName: c.name, messages: c.messages.filter(m => !m.typing), summarizedCount: c.summarizedCount || 0 });
$('chat-x').onclick = closeChat;
$('chat-back').onclick = () => {
if (!chatTgt || smsGen) return;
const m = chatTgt.messages || [];
if (!m.length) return;
m.pop();
while (m[m.length - 1]?.typing) m.pop();
chatTgt.summarizedCount = Math.min(chatTgt.summarizedCount || 0, m.length);
renderMsgs();
saveCt();
if (chatTgt.worldbookUid) saveChat(chatTgt);
};
$('chat-clr').onclick = () => {
if (!chatTgt || !confirm(`确定要清空与 ${chatTgt.name} 的所有聊天记录吗?`)) return;
chatTgt.messages = [];
chatTgt.summarizedCount = 0;
renderMsgs();
saveCt();
if (chatTgt.worldbookUid) saveChat(chatTgt);
};
$('chat-compress').onclick = () => {
if (!chatTgt || smsGen) return;
const m = chatTgt.messages || [], sc = chatTgt.summarizedCount || 0, um = m.slice(sc);
if (um.length < 2 ) { alert ( ' 至少需要2条未总结的消息才能压缩 ' ) ; return ; }
if (!confirm(`确定要压缩总结 ${um.length} 条消息吗?`)) return;
smsGen = true;
$('chat-compress').disabled = $('chat-in').disabled = $('chat-send').disabled = $('chat-back').disabled = true;
const id = Req.create('compress');
Req.set('compress', { tgt: chatTgt });
post('COMPRESS_SMS', { requestId: id, contactName: chatTgt.name, worldbookUid: chatTgt.worldbookUid, messages: m.filter(x => !x.typing), summarizedCount: sc });
};
$('chat-send').onclick = sendMsg;
const chatIn = $('chat-in');
['keydown', 'keypress', 'keyup'].forEach(e => chatIn.addEventListener(e, ev => ev.stopPropagation()));
chatIn.addEventListener('keydown', e => { if (e.key === 'Enter' & & !e.shiftKey) { e.preventDefault(); sendMsg(); } });
/* ================== 邀请功能 ================== */
const openInv = c => {
invTgt = c; selLoc = null;
$('inv-t').textContent = `邀请:${c.name}`;
$('loc-list').innerHTML = D.maps.outdoor.nodes.map(l => `< div class = "loc-i" data-n = "${l.name}" > < div class = "loc-i-nm" > ${l.name}< / div > < div class = "loc-i-info" > ${l.info || ''}< / div > < / div > `).join('');
$$('#loc-list .loc-i').forEach(i => i.onclick = () => { $$('#loc-list .loc-i').forEach(x => x.classList.remove('sel')); i.classList.add('sel'); selLoc = i.dataset.n; });
openM('m-invite');
};
$('inv-ok').onclick = () => {
if (!selLoc || !invTgt) return;
const btn = $('inv-ok');
BtnState.load(btn, '询问中...');
const id = Req.create('invite');
const loc = canonicalLoc(selLoc);
Req.set('invite', { contact: invTgt, loc, btn });
post('SEND_INVITE', { requestId: id, contactName: invTgt.name, contactUid: invTgt.worldbookUid, targetLocation: loc, smsHistory: (invTgt.messages || []).map(m => m.type === 'sent' ? `{{user}}: ${m.text}` : `${invTgt.name}: ${m.text}`).join('\n') });
};
/* ================== 添加联络人 ================== */
let addCtState = { uid: '', name: '', keys: [] };
const resetAddCt = () => {
addCtState = { uid: '', name: '', keys: [] };
$('add-uid').value = '';
$('add-name').value = '';
$('add-name').innerHTML = '< option value = "" > -- 请选择 --< / option > ';
$('name-select-group').style.display = 'none';
$('uid-check-err').classList.remove('vis');
$('add-ct-ok').disabled = true;
BtnState.reset($('btn-check-uid'), '< i class = "fa-solid fa-search" > < / i > 检查');
};
const showUidErr = m => { $('uid-check-err').textContent = m; $('uid-check-err').classList.add('vis'); };
$('btn-check-uid').onclick = () => {
const uid = $('add-uid').value.trim();
if (!uid) { showUidErr('请输入UID'); return; }
$('uid-check-err').classList.remove('vis');
BtnState.load($('btn-check-uid'), '检查中');
$('name-select-group').style.display = 'none';
$('add-ct-ok').disabled = true;
addCtState.uid = uid;
post('CHECK_WORLDBOOK_UID', { uid, requestId: Req.create('uidck') });
};
$('btn-add-ct').onclick = () => { resetAddCt(); openM('m-add-ct'); };
$('add-ct-ok').onclick = () => {
const uid = addCtState.uid || $('add-uid').value.trim(), name = addCtState.name || $('add-name').value.trim();
if (!uid || !name) return;
if (D.contacts.contacts.some(c => c.worldbookUid === uid)) { showUidErr('该联络人已存在'); return; }
D.contacts.contacts.push({ name, avatar: name[0], color: '#' + Math.floor(Math.random() * 0xffffff).toString(16).padStart(6, '0'), location: '未知', worldbookUid: uid, messages: [] });
saveCt();
closeM('m-add-ct');
render();
};
/* ================== 陌路人生成NPC ================== */
const genAddCt = (name, info, btn) => {
BtnState.load(btn, '检查中');
const id = Req.create('stgwb');
Req.set('stgwb', { name, info, btn });
post('CHECK_STRANGER_WORLDBOOK', { requestId: id, strangerName: name });
};
$('btn-refresh-strangers').onclick = () => {
const btn = $('btn-refresh-strangers');
BtnState.load(btn, '');
const id = Req.create('extract');
Req.set('extract', { btn });
post('EXTRACT_STRANGERS', { requestId: id, existingContacts: D.contacts.contacts, existingStrangers: D.contacts.strangers });
};
/* ================== 世界生成与推演 ================== */
$('world-gen-ok').onclick = () => {
const btn = $('world-gen-ok'), st = $('world-gen-status');
BtnState.load(btn, '生成中');
st.style.display = 'block';
st.textContent = '正在生成世界数据,请稍候...';
post('GENERATE_WORLD', { requestId: Req.create('wgen'), playerRequests: $('world-gen-req').value.trim() });
};
$('world-sim-ok').onclick = () => {
if (!D.meta & & !D.timeline & & !D.maps?.outdoor) { alert('请先生成世界数据,再进行推演'); return; }
const btn = $('world-sim-ok'), st = $('world-sim-status');
BtnState.load(btn, '推演中');
st.style.display = 'block';
st.style.color = '#4a9';
st.textContent = '正在分析玩家行为并推演世界变化...';
post('SIMULATE_WORLD', { requestId: Req.create('wsim'), currentData: JSON.stringify({ meta: D.meta, timeline: D.timeline, world: D.world, maps: D.maps }, null, 2) });
};
$('btn-deduce').onclick = () => openM('m-world-gen');
$('btn-simulate').onclick = () => openM('m-world-sim');
/* ================== 侧边菜单 ================== */
$('btn-side-menu-toggle').onclick = () => {
const p = $('side-menu-panel'), btn = $('btn-side-menu-toggle');
p.classList.toggle('show');
btn.classList.toggle('act', p.classList.contains('show'));
};
document.addEventListener('click', e => {
if (e.target.closest('.side-menu')) return;
$('side-menu-panel')?.classList.remove('show');
$('btn-side-menu-toggle')?.classList.remove('act');
});
/* ================== 场景切换 ================== */
function canonicalLoc(s) { return String(s || '').trim().replace(/^\u90ae\u8f6e/, ''); }
const getWaitingContacts = loc => {
const target = canonicalLoc(loc);
if (!target) return [];
return (D.contacts.contacts || []).filter(c => c?.waitingAt & & canonicalLoc(c.waitingAt) === target);
};
const finishTravel = (targetName, prevName) => {
playerLocation = targetName;
selectedMapValue = 'current';
const wc = getWaitingContacts(targetName);
wc.forEach(c => delete c.waitingAt);
saveAll();
render();
hideInfo();
const action = $('goto-task')?.value;
let sm = `{{user}}离开了${prevName || '上一地点'},来到${targetName}。${action ? '意图:' + action : ''}`;
if (wc.length) sm += ` ${wc.map(c => c.name).join('、')}已经在这里等你了。`;
post('EXECUTE_SLASH_COMMAND', { command: `/sendas name="剧情任务" ${sm}` });
};
$('goto-ok').onclick = () => {
if (!curNode) return;
const btn = $('goto-ok');
const prevNode = D.maps?.outdoor?.nodes?.find(n => n.name === playerLocation);
const prev = { name: playerLocation, info: prevNode?.info || '' };
const targetName = curNode.name;
const indoorTarget = D.maps?.indoor?.[targetName];
const hasExistingLocal = !!(indoorTarget?.description);
if (hasExistingLocal) {
BtnState.reset(btn, '确认前往');
closeM('m-goto');
finishTravel(targetName, prev.name);
switchMapView('current');
return;
}
let tt = curNode.type === 'main' ? 'main' : curNode.type === 'home' ? 'home' : 'sub';
const id = Req.create('scene');
Req.set('scene', { node: curNode, prev: prev.name });
BtnState.load(btn, '生成中');
post('SCENE_SWITCH', { requestId: id, prevLocationName: prev.name, prevLocationInfo: prev.info, targetLocationName: curNode.name, targetLocationType: tt, targetLocationInfo: curNode.data?.info || '', playerAction: $('goto-task').value || '' });
};
/* ================== 局部地图生成/刷新 ================== */
$('btn-gen-local-map').onclick = () => {
const btn = $('btn-gen-local-map');
BtnState.load(btn, '生成中');
const id = Req.create('localmap');
Req.set('localmap', { btn });
post('GENERATE_LOCAL_MAP', { requestId: id, outdoorDescription: D.maps?.outdoor?.description || '' });
};
$('btn-refresh-local-map').onclick = () => {
if (!playerLocation) { showResultModal('提示', '请先生成世界数据', true); return; }
const btn = $('btn-refresh-local-map');
const indoor = D.maps?.indoor?.[playerLocation];
const current = indoor;
if (!current) { showResultModal('提示', '当前区域没有局部地图,请先生成', true); return; }
BtnState.load(btn, '刷新中');
const id = Req.create('localmaprf');
Req.set('localmaprf', { btn, loc: playerLocation });
post('REFRESH_LOCAL_MAP', { requestId: id, locationName: playerLocation, currentLocalMap: current, outdoorDescription: D.maps?.outdoor?.description || '' });
};
$('btn-gen-local-scene').onclick = () => {
if (!playerLocation || playerLocation === '未知') { showResultModal('提示', '请先生成世界数据', true); return; }
const btn = $('btn-gen-local-scene');
BtnState.load(btn, '生成中');
const outdoorNode = D.maps?.outdoor?.nodes?.find(n => n.name === playerLocation);
const indoorNode = D.maps?.indoor?.[playerLocation];
const locationInfo = indoorNode?.description || outdoorNode?.info || outdoorNode?.data?.info || '';
const id = Req.create('localscene');
Req.set('localscene', { btn, loc: playerLocation });
post('GENERATE_LOCAL_SCENE', { requestId: id, locationName: playerLocation, locationInfo });
};
/* ================== 保存数据 ================== */
const saveAll = () => post('SAVE_ALL_DATA', { allData: { meta: D.meta, world: D.world, outdoor: D.maps.outdoor, indoor: D.maps.indoor, sceneSetup: D.sceneSetup, strangers: D.contacts.strangers, contacts: contactsForSave() }, playerLocation });
/* ================== 设置相关 ================== */
const dataKeys = [['meta', '大纲', '核心真相、洋葱结构、时间线、用户指南', () => D.meta, v => D.meta = v], ['world', '世界资讯', '世界新闻等信息', () => D.world, v => D.world = v], ['outdoor', '大地图', '室外区域的地点和路线', () => D.maps.outdoor, v => D.maps.outdoor = v], ['indoor', '局部地图', '隐藏的室内/局部场景地图', () => D.maps.indoor, v => D.maps.indoor = v], ['sceneSetup', '区域剧情', '当前区域的 Side Story', () => D.sceneSetup, v => D.sceneSetup = v], ['characterContactSms', '角色卡短信', '角色卡联络人的短信记录', () => ({ messages: charSmsHistory?.messages || [], summarizedCount: charSmsHistory?.summarizedCount || 0, summaries: charSmsHistory?.summaries || {} }), v => { if (v & & typeof v === 'object') charSmsHistory = { messages: charSmsHistory?.messages || [], summarizedCount: charSmsHistory?.summarizedCount || 0, ...(v || {}) }; }], ['strangers', '陌路人', '已遇见但未建立联系的角色', () => D.contacts.strangers, v => D.contacts.strangers = v], ['contacts', '联络人', '已添加的联系人', () => contactsForSave(), v => { const keep = (D.contacts.contacts || []).find(isCharCardContact); D.contacts.contacts = (keep ? [keep] : []).concat(Array.isArray(v) ? v : []); }]];
let gSet = { apiUrl: '', apiKey: '', model: '', mode: 'assist' }, dataCk = {}, editCtx = null, commSet = { historyCount: 50, npcPosition: 0, npcOrder: 100 };
const reqSet = () => post('GET_SETTINGS');
const renderDataList = () => {
$('data-list').innerHTML = dataKeys.map(([k, t, d]) => `< div class = "data-item ${dataCk[k] ? 'sel' : ''}" data-k = "${k}" > < div class = "data-ck" > < i class = "fa-solid fa-check" > < / i > < / div > < div class = "data-info" > < div class = "data-nm" > ${t}< / div > < div class = "data-desc" > ${d}< / div > < / div > < button class = "data-edit" data-k = "${k}" title = "编辑" > < i class = "fa-solid fa-pen" > < / i > < / button > < / div > `).join('');
$$('#data-list .data-item').forEach(i => i.onclick = e => { if (e.target.closest('.data-edit')) return; const k = i.dataset.k; dataCk[k] = !dataCk[k]; i.classList.toggle('sel', dataCk[k]); });
$$('#data-list .data-edit').forEach(b => b.onclick = e => { e.stopPropagation(); openDataEdit(b.dataset.k); });
};
const parseJsonLoose = (input) => {
const str = String(input ?? '').trim();
if (!str) throw new Error('空内容');
try { return JSON.parse(str); } catch { }
const fenced = str.match(/```[^\n]*\n([\s\S]*?)\n```/);
if (fenced?.[1]) { try { return JSON.parse(fenced[1].trim()); } catch { } }
const sliceBetween = (open, close) => { const s = str.indexOf(open); const e = str.lastIndexOf(close); if (s === -1 || e === -1 || e < = s) return null; return str.slice(s, e + 1); };
const objStr = sliceBetween('{', '}') ?? sliceBetween('[', ']');
if (objStr) return JSON.parse(objStr);
return JSON.parse(str);
};
const setEditContent = (title, val) => { $('data-edit-title').textContent = title; $('data-edit-ta').value = val; $('data-edit-err').classList.remove('vis'); openM('m-data-edit'); };
const openDataEdit = k => { const i = dataKeys.find(([x]) => x === k); if (!i) return; editCtx = { type: k === 'characterContactSms' ? 'charSms' : 'data', key: k }; setEditContent(`编辑 - ${i[1]}`, JSON.stringify(i[3](), null, 2)); };
$('data-edit-save').onclick = () => {
if (!editCtx) return;
try {
const parsed = parseJsonLoose($('data-edit-ta').value);
if (editCtx.type === 'data') {
const i = dataKeys.find(([k]) => k === editCtx.key);
if (!i) return;
i[4](parsed);
render();
saveAll();
} else if (editCtx.type === 'charSms') {
const sums = parsed?.summaries ?? parsed;
if (!sums || typeof sums !== 'object' || Array.isArray(sums)) throw new Error('需要 summaries 对象');
charSmsHistory.summaries = sums;
post('SAVE_CHAR_SMS_HISTORY', { summaries: sums });
}
closeM('m-data-edit');
editCtx = null;
} catch (e) { $('data-edit-err').textContent = `JSON错误: ${e.message}`; $('data-edit-err').classList.add('vis'); }
};
const showTestRes = (ok, m) => { const r = $('test-res'); r.textContent = m; r.className = 'set-test-res ' + (ok ? 'ok' : 'err'); };
const showResultModal = (title, msg, isError = false, record = null) => {
$('res-title').textContent = title;
$('res-title').style.color = isError ? '#ff6b6b' : '';
$('res-msg').textContent = msg;
const box = $('res-record-box'), pre = $('res-record');
if (record) { box.style.display = 'block'; pre.textContent = typeof record === 'object' ? JSON.stringify(record, null, 2) : String(record); }
else { box.style.display = 'none'; pre.textContent = ''; }
const actBtn = $('res-action');
actBtn.style.display = 'none';
actBtn.textContent = '';
actBtn.onclick = null;
openM('m-result');
};
$('btn-settings').onclick = () => {
reqSet();
$('set-api-url').value = gSet.apiUrl || '';
$('set-api-key').value = gSet.apiKey || '';
$('set-model').value = gSet.model || '';
$('set-model-list').style.display = 'none';
$('test-res').className = 'set-test-res';
$('set-stage').value = D.stage || 0;
$('set-deviation').value = D.deviationScore || 0;
$('set-sim-progress').value = D.simulationProgress || 0;
$('set-sim-target').value = D.simulationTarget || 5;
$('set-mode').value = gSet.mode || 'story';
$('set-history-count').value = commSet.historyCount || 50;
$('set-npc-position').value = commSet.npcPosition || 0;
$('set-npc-order').value = commSet.npcOrder || 100;
renderDataList();
loadTemplate(templateState.currentType);
openM('m-settings');
};
$('btn-fetch-models').onclick = () => { BtnState.load($('btn-fetch-models'), '加载'); post('FETCH_MODELS', { apiUrl: $('set-api-url').value.trim(), apiKey: $('set-api-key').value.trim() }); };
$('btn-test-conn').onclick = () => { $('test-res').className = 'set-test-res'; BtnState.load($('btn-test-conn'), '测试'); post('TEST_CONNECTION', { apiUrl: $('set-api-url').value.trim(), apiKey: $('set-api-key').value.trim(), model: $('set-model').value.trim() }); };
$('set-save').onclick = () => {
const type = templateState.currentType;
templateState.prompts[type] = {
u1: $('tpl-u1').value,
a1: $('tpl-a1').value,
u2: $('tpl-u2').value,
a2: $('tpl-a2').value
};
templateState.jsonTemplates[type] = $('tpl-json').value;
post('SAVE_PROMPTS', { promptConfig: { prompts: templateState.prompts, jsonTemplates: templateState.jsonTemplates } });
gSet = { apiUrl: $('set-api-url').value.trim(), apiKey: $('set-api-key').value.trim(), model: $('set-model').value.trim(), mode: $('set-mode').value || 'assist' };
D.stage = Math.max(0, Math.min(10, parseInt($('set-stage').value, 10) || 0));
D.deviationScore = Math.max(0, Math.min(100, parseInt($('set-deviation').value, 10) || 0));
D.simulationProgress = Math.max(0, parseInt($('set-sim-progress').value, 10) || 0);
D.simulationTarget = Math.max(1, parseInt($('set-sim-target').value, 10) || 5);
commSet = { historyCount: Math.max(0, Math.min(200, parseInt($('set-history-count').value, 10) || 50)), npcPosition: parseInt($('set-npc-position').value, 10) || 0, npcOrder: Math.max(0, Math.min(1000, parseInt($('set-npc-order').value, 10) || 100)) };
const od = {};
dataKeys.forEach(([k, , , get]) => { if (dataCk[k]) od[k] = get(); });
post('SAVE_SETTINGS', { globalSettings: gSet, commSettings: commSet, stage: D.stage, deviationScore: D.deviationScore, simulationProgress: D.simulationProgress, simulationTarget: D.simulationTarget, playerLocation, dataChecked: dataCk, outlineData: od, allData: { meta: D.meta, timeline: D.timeline, world: D.world, outdoor: D.maps.outdoor, indoor: D.maps.indoor, strangers: D.contacts.strangers, contacts: contactsForSave() } });
closeM('m-settings');
};
$('btn-close').onclick = () => post('CLOSE_PANEL');
/* ================== 消息处理 ================== */
window.addEventListener('message', e => {
if (e.data?.source !== 'LittleWhiteBox') return;
const d = e.data, t = d.type;
if (t === 'LOAD_SETTINGS') {
if (d.globalSettings) gSet = d.globalSettings;
if (d.stage !== undefined) D.stage = d.stage;
if (d.deviationScore !== undefined) D.deviationScore = d.deviationScore;
if (d.simulationProgress !== undefined) D.simulationProgress = d.simulationProgress;
if (d.simulationTarget !== undefined) D.simulationTarget = d.simulationTarget;
if (d.playerLocation) playerLocation = d.playerLocation;
if (d.commSettings) commSet = { historyCount: d.commSettings.historyCount ?? 50, npcPosition: d.commSettings.npcPosition ?? 0, npcOrder: d.commSettings.npcOrder ?? 100 };
if (d.dataChecked) dataCk = d.dataChecked;
if (d.promptConfig) {
templateState.prompts = d.promptConfig.current?.prompts || {};
templateState.jsonTemplates = d.promptConfig.current?.jsonTemplates || {};
templateState.defaults = d.promptConfig.defaults || { prompts: {}, jsonTemplates: {} };
}
if (d.outlineData) {
const o = d.outlineData;
if (o.meta) D.meta = o.meta;
if (o.world) D.world = o.world;
if (o.outdoor) D.maps.outdoor = o.outdoor;
if (o.indoor) D.maps.indoor = o.indoor;
if (o.sceneSetup) D.sceneSetup = o.sceneSetup;
if (o.strangers) D.contacts.strangers = o.strangers;
if (o.contacts) D.contacts.contacts = o.contacts;
}
{
const h = d.characterContactSmsHistory || {};
charSmsHistory = { messages: Array.isArray(h.messages) ? h.messages : [], summarizedCount: h.summarizedCount || 0, summaries: h.summaries || {} };
}
let charContact = D.contacts.contacts.find(c => c.worldbookUid === '__CHARACTER_CARD__');
if (!charContact) {
charContact = D.contacts.contacts.find(c => !c.worldbookUid & & c.name === '炒饭智能');
if (charContact) {
charContact.worldbookUid = '__CHARACTER_CARD__';
charContact.info = '角色卡联络人';
charContact.location = '在线';
charContact.online = true;
} else {
D.contacts.contacts.unshift({ name: d.characterCardName || '{{characterName}}', avatar: '', color: '#555', location: '在线', info: '角色卡联络人', online: true, worldbookUid: '__CHARACTER_CARD__', messages: [], summarizedCount: 0 });
charContact = D.contacts.contacts[0];
}
}
if (charContact & & d.characterCardName) {
charContact.name = d.characterCardName;
charContact.avatar = (d.characterCardName || '')[0] || charContact.avatar || '';
}
render();
if ($('m-settings').classList.contains('act')) {
$('set-api-url').value = gSet.apiUrl || '';
$('set-api-key').value = gSet.apiKey || '';
$('set-model').value = gSet.model || '';
$('set-stage').value = D.stage;
$('set-deviation').value = D.deviationScore;
$('set-sim-progress').value = D.simulationProgress || 0;
$('set-sim-target').value = D.simulationTarget || 5;
$('set-mode').value = gSet.mode || 'story';
$('set-history-count').value = commSet.historyCount;
$('set-npc-position').value = commSet.npcPosition;
$('set-npc-order').value = commSet.npcOrder;
renderDataList();
loadTemplate(templateState.currentType);
}
} else if (t === 'PROMPT_CONFIG_UPDATED') {
if (d.promptConfig) {
templateState.prompts = d.promptConfig.current?.prompts || {};
templateState.jsonTemplates = d.promptConfig.current?.jsonTemplates || {};
templateState.defaults = d.promptConfig.defaults || templateState.defaults;
if ($('m-settings').classList.contains('act')) {
loadTemplate(templateState.currentType);
}
}
} else if (t === 'FETCH_MODELS_RESULT') {
BtnState.reset($('btn-fetch-models'), '获取');
const s = $('set-model-list');
if (d.error) { s.style.display = 'none'; showTestRes(false, '获取模型失败: ' + d.error); return; }
if (!d.models?.length) { s.style.display = 'none'; showTestRes(false, '未找到可用模型'); return; }
s.innerHTML = '< option value = "" > -- 选择模型 --< / option > ' + d.models.map(m => `< option value = "${m}" > ${m}< / option > `).join('');
s.style.display = 'block';
s.onchange = () => { if (s.value) $('set-model').value = s.value; };
showTestRes(true, `找到 ${d.models.length} 个模型`);
} else if (t === 'TEST_CONN_RESULT') {
BtnState.reset($('btn-test-conn'), '测试连接');
showTestRes(d.success, d.message);
} else if (t === 'CHECK_WORLDBOOK_UID_RESULT') {
BtnState.reset($('btn-check-uid'), '< i class = "fa-solid fa-search" > < / i > 检查');
if (!Req.match(d.requestId)) return;
if (d.error) { showUidErr(d.error); return; }
if (!d.primaryKeys?.length) { showUidErr('该条目没有主要关键字'); return; }
addCtState.keys = d.primaryKeys;
const sel = $('add-name');
sel.innerHTML = '< option value = "" > -- 请选择 --< / option > ' + d.primaryKeys.map(k => `< option value = "${k}" > ${k}< / option > `).join('');
sel.onchange = () => { addCtState.name = sel.value; $('add-ct-ok').disabled = !sel.value; };
$('name-select-group').style.display = 'block';
if (d.primaryKeys.length === 1) { addCtState.name = d.primaryKeys[0]; sel.value = addCtState.name; $('add-ct-ok').disabled = false; }
} else if (t === 'SMS_RESULT') {
const r = Req.get('sms');
if (!r || r.id !== d.requestId) return;
Req.clear('sms');
smsGen = false;
$('chat-in').disabled = $('chat-send').disabled = $('chat-back').disabled = false;
if (!chatTgt) return;
chatTgt.messages = chatTgt.messages.filter(m => !m.typing);
if (d.error) chatTgt.messages.push({ type: 'received', text: `[错误] ${d.error}` });
else if (d.reply) chatTgt.messages.push({ type: 'received', text: stripXml(d.reply) });
renderMsgs();
saveCt();
if (chatTgt.worldbookUid) saveChat(chatTgt);
} else if (t === 'SMS_STREAM') {
const r = Req.get('sms');
if (!r || r.id !== d.requestId || !chatTgt) return;
const tm = chatTgt.messages.find(m => m.typing);
if (tm & & d.text) { tm.text = d.text; renderMsgs(); }
} else if (t === 'LOAD_SMS_HISTORY_RESULT') {
if (!chatTgt || chatTgt.worldbookUid !== d.worldbookUid) return;
if (d.messages?.length) { chatTgt.messages = d.messages; chatTgt.summarizedCount = d.summarizedCount || 0; saveCt(); }
renderMsgs();
} else if (t === 'COMPRESS_SMS_RESULT') {
const r = Req.get('compress');
if (!r || r.id !== d.requestId) return;
Req.clear('compress');
smsGen = false;
$('chat-compress').disabled = $('chat-in').disabled = $('chat-send').disabled = $('chat-back').disabled = false;
if (!chatTgt) return;
if (d.error) { alert(`压缩失败: ${d.error}`); return; }
if (d.newSummarizedCount !== undefined) { chatTgt.summarizedCount = d.newSummarizedCount; renderMsgs(); saveCt(); }
} else if (t === 'CHECK_STRANGER_WORLDBOOK_RESULT') {
const r = Req.get('stgwb');
if (!r || r.id !== d.requestId) return;
const { name, info, btn } = r;
Req.clear('stgwb');
if (d.found & & d.worldbookUid) {
BtnState.reset(btn, '< i class = "fa-solid fa-user-plus" > < / i > 添加');
const i = D.contacts.strangers.findIndex(s => s.name === name);
if (i > -1) {
const st = D.contacts.strangers.splice(i, 1)[0];
D.contacts.contacts.push({ name: st.name, avatar: st.avatar || st.name[0], color: st.color || '#' + Math.floor(Math.random() * 0xffffff).toString(16).padStart(6, '0'), location: st.location || '未知', info: st.info || '', worldbookUid: d.worldbookUid, messages: [] });
saveCt();
render();
}
} else {
BtnState.load(btn, '生成中');
const id = Req.create('npcgen');
Req.set('npcgen', { name, info, btn });
post('GENERATE_NPC', { requestId: id, strangerName: name, strangerInfo: info });
}
} else if (t === 'GENERATE_NPC_RESULT') {
const r = Req.get('npcgen');
if (!r || r.id !== d.requestId) return;
const { name, btn } = r;
Req.clear('npcgen');
BtnState.reset(btn, '< i class = "fa-solid fa-user-plus" > < / i > 添加');
if (d.error) { showResultModal('生成角色失败', '生成 NPC 失败', true, d.error); return; }
if (d.success & & d.worldbookUid) {
const i = D.contacts.strangers.findIndex(s => s.name === name);
if (i > -1) {
const st = D.contacts.strangers.splice(i, 1)[0], np = d.npcData || {};
D.contacts.contacts.push({ name: np.name || st.name, avatar: (np.name || st.name)[0], color: st.color || '#' + Math.floor(Math.random() * 0xffffff).toString(16).padStart(6, '0'), location: st.location || '未知', info: np.intro || st.info || '', worldbookUid: d.worldbookUid, messages: [] });
saveCt();
render();
showResultModal('生成成功', `NPC ${name} 已生成并添加到联络人`, false, d.npcData);
}
}
} else if (t === 'EXTRACT_STRANGERS_RESULT') {
const r = Req.get('extract');
if (!r || r.id !== d.requestId) return;
const { btn } = r;
Req.clear('extract');
BtnState.reset(btn, '< i class = "fa-solid fa-rotate" > < / i > ');
if (d.error) { showResultModal('提取失败', '提取陌路人失败', true, d.error); return; }
if (d.success & & Array.isArray(d.strangers)) {
if (!d.strangers.length) { showResultModal('提取结果', '没有发现新的陌路人'); return; }
const ex = [...D.contacts.contacts.map(c => c.name), ...D.contacts.strangers.map(s => s.name)];
const nw = d.strangers.filter(s => !ex.includes(s.name));
if (!nw.length) { showResultModal('提取结果', '提取到的角色都已存在'); return; }
D.contacts.strangers = D.contacts.strangers.concat(nw);
saveCt();
render();
showResultModal('提取成功', `成功提取 ${nw.length} 个新陌路人`, false, nw);
}
} else if (t === 'GENERATE_WORLD_STATUS') {
if (!Req.match(d.requestId)) return;
const st = $('world-gen-status');
st.style.display = 'block';
st.style.color = '#4a9';
st.textContent = d.message;
} else if (t === 'GENERATE_WORLD_RESULT') {
if (!Req.match(d.requestId)) return;
Req.clear('wgen');
const btn = $('world-gen-ok'), st = $('world-gen-status');
BtnState.reset(btn, '< i class = "fa-solid fa-wand-magic-sparkles" > < / i > 开始生成');
if (d.error) {
st.style.display = 'none';
showResultModal('生成失败', '世界生成失败', true, d.error);
if (String(d.error || '').includes('Step 2')) {
const actBtn = $('res-action');
actBtn.style.display = 'inline-block';
actBtn.textContent = '重试 Step2';
actBtn.onclick = () => {
closeM('m-result');
const rid = Req.create('wgen');
BtnState.load(btn, '重试中');
st.style.display = 'block';
st.style.color = '#4a9';
st.textContent = '准备重试 Step 2/2...';
post('RETRY_WORLD_GEN_STEP2', { requestId: rid });
};
}
return;
}
if (d.success & & d.worldData) {
st.style.color = '#4a9';
st.textContent = '生成成功!正在应用数据...';
const wd = d.worldData;
if (wd.meta) D.meta = wd.meta;
if (wd.world) D.world = wd.world;
if (wd.maps?.outdoor) D.maps.outdoor = wd.maps.outdoor;
D.stage = 0;
D.deviationScore = 0;
if (wd.playerLocation) playerLocation = wd.playerLocation;
else {
const homeNode = D.maps?.outdoor?.nodes?.find(n => n.type === 'home');
playerLocation = homeNode?.name || D.maps?.outdoor?.nodes?.[0]?.name || '未知';
}
if (wd.maps?.inside & & playerLocation) { D.maps.indoor = D.maps.indoor || {}; D.maps.indoor[playerLocation] = wd.maps.inside; }
selectedMapValue = 'current';
saveAll();
render();
setTimeout(() => { closeM('m-world-gen'); st.style.display = 'none'; $('world-gen-req').value = ''; showResultModal('生成成功', '世界数据生成完成! Stage 和 Deviation 已重置为 0', false, d.worldData); }, 500);
}
} else if (t === 'SIMULATE_WORLD_RESULT') {
if (!d.isAuto & & !Req.match(d.requestId)) return;
if (!d.isAuto) {
Req.clear('wsim');
const btn = $('world-sim-ok'), st = $('world-sim-status');
BtnState.reset(btn, '< i class = "fa-solid fa-rotate" > < / i > 开始推演');
if (d.error) { st.style.display = 'none'; showResultModal('推演失败', '世界推演失败', true, d.error); return; }
}
if (d.success & & d.simData) {
if (!d.isAuto) { const st = $('world-sim-status'); st.style.color = '#4a9'; st.textContent = '推演成功!正在应用数据...'; }
const sd = d.simData;
if (sd.meta) D.meta = sd.meta;
if (sd.world) D.world = sd.world;
if (sd.maps?.outdoor) D.maps.outdoor = sd.maps.outdoor;
D.stage = (D.stage || 0) + 1;
saveAll();
render();
if (!d.isAuto) setTimeout(() => { closeM('m-world-sim'); $('world-sim-status').style.display = 'none'; showResultModal('推演成功', `世界推演完成! Stage 已推进到 ${D.stage}`, false, d.simData); }, 500);
}
} else if (t === 'SCENE_SWITCH_RESULT') {
const r = Req.get('scene');
if (!r || r.id !== d.requestId) return;
const { node, prev } = r;
Req.clear('scene');
BtnState.reset($('goto-ok'), '确认前往');
closeM('m-goto');
if (d.error) { showResultModal('切换失败', '场景切换失败', true, d.error); return; }
if (d.success & & d.sceneData) {
const sc = d.sceneData;
if (typeof sc.newScore === 'number') D.deviationScore = sc.newScore;
if (sc.localMap) { D.maps.indoor = D.maps.indoor || {}; D.maps.indoor[node.name] = sc.localMap; }
if (sc.strangers?.length) {
const ex = new Set((D.contacts.strangers || []).map(s => s.name));
const nw = sc.strangers.filter(s => !ex.has(s.name));
D.contacts.strangers = [...(D.contacts.strangers || []), ...nw];
}
finishTravel(node.name, prev);
if (sc.scoreDelta !== 0) showResultModal('切换成功', `场景切换完成!\n偏差值变化: ${sc.scoreDelta > 0 ? '+' : ''}${sc.scoreDelta} (当前: ${sc.newScore})`, false, d.sceneData);
}
} else if (t === 'REFRESH_LOCAL_MAP_RESULT') {
const r = Req.get('localmaprf');
if (!r || r.id !== d.requestId) return;
const { btn, loc } = r;
Req.clear('localmaprf');
BtnState.reset(btn, '< i class = "fa-solid fa-rotate-right" > < / i > 刷新');
if (d.error) { showResultModal('刷新失败', '刷新局部地图失败', true, d.error); return; }
if (d.success & & d.localMapData) {
const lm = d.localMapData;
const locName = lm.name || loc || playerLocation || '当前位置';
D.maps.indoor = D.maps.indoor || {};
D.maps.indoor[locName] = lm;
playerLocation = locName;
selectedMapValue = 'current';
saveAll();
render();
if (lm.description) { $('side-desc').innerHTML = `< div class = "local-map-title" > 📍 ${locName}< / div > ` + parseLinks(lm.description); bindLinks($('side-desc')); }
showResultModal('刷新成功', `局部地图已刷新!当前位置: ${locName}`, false, d.localMapData);
}
} else if (t === 'GENERATE_LOCAL_SCENE_RESULT') {
const r = Req.get('localscene');
if (!r || r.id !== d.requestId) return;
const { btn, loc } = r;
Req.clear('localscene');
BtnState.reset(btn, '< i class = "fa-solid fa-feather-pointed" > < / i > 局部剧情');
if (d.error) { showResultModal('生成失败', '局部剧情生成失败', true, d.error); return; }
if (d.success & & d.sceneSetup) {
D.sceneSetup = { ...(D.sceneSetup || {}), ...(d.sceneSetup || {}) };
saveAll();
render();
const intro = String(d.introduce || '').replace(/^\s*(?:\/?\s*(?:sendas|as)\s+name\s*=\s*(?:"[^"]*"|'[^']*'|\S+)\s+)/i, '').replace(/\s+/g, ' ').trim();
if (intro) post('EXECUTE_SLASH_COMMAND', { command: `/sendas name="剧情任务" ${intro}` });
showResultModal('生成成功', `局部剧情已生成:${loc || playerLocation}`, false, d.sceneSetup);
}
} else if (t === 'SEND_INVITE_RESULT') {
const r = Req.get('invite');
if (!r || r.id !== d.requestId) return;
const { contact, loc, btn } = r;
Req.clear('invite');
BtnState.reset(btn, '发送邀请');
if (d.error) { showResultModal('邀请失败', '邀请发送失败', true, d.error); return; }
if (d.success & & d.inviteData) {
const inv = d.inviteData;
const targetLoc = canonicalLoc(inv.targetLocation || loc || '');
const live = D.contacts.contacts.find(c => c & & contact & & c.worldbookUid & & contact.worldbookUid & & c.worldbookUid === contact.worldbookUid) || D.contacts.contacts.find(c => c & & contact & & c.name === contact.name) || contact;
live.messages = live.messages || [];
live.messages.push({ type: 'sent', text: `我邀请你前往「${targetLoc}」` });
live.messages.push({ type: 'received', text: inv.reply });
if (inv.accepted) {
const here = canonicalLoc(playerLocation);
live.location = targetLoc;
if (here & & targetLoc & & here === targetLoc) {
delete live.waitingAt;
post('EXECUTE_SLASH_COMMAND', { command: `/sendas name="剧情任务" ${live.name}过来了。` });
} else {
live.waitingAt = targetLoc;
}
showResultModal('邀请成功', `${live.name} 接受了邀请!\n回复: ${inv.reply}`, false, inv);
}
else showResultModal('邀请被拒', `${live.name} 拒绝了邀请。\n回复: ${inv.reply}`, false, inv);
saveAll();
if (live.worldbookUid) saveChat(live);
closeM('m-invite');
render();
openChat(live);
}
} else if (t === 'GENERATE_LOCAL_MAP_RESULT') {
const r = Req.get('localmap');
if (!r || r.id !== d.requestId) return;
const { btn } = r;
Req.clear('localmap');
BtnState.reset(btn, '< i class = "fa-solid fa-plus" > < / i > 局部地图');
if (d.error) { showResultModal('生成失败', '局部地图生成失败', true, d.error); return; }
if (d.success & & d.localMapData) {
const lm = d.localMapData;
const locName = lm.name || '当前位置';
D.sceneSetup = null;
D.maps.indoor = D.maps.indoor || {};
D.maps.indoor[locName] = lm;
playerLocation = locName;
selectedMapValue = 'current';
saveAll();
render();
if (lm.description) { $('side-desc').innerHTML = `< div class = "local-map-title" > 📍 ${locName}< / div > ` + parseLinks(lm.description); bindLinks($('side-desc')); }
showResultModal('生成成功', `局部地图生成完成!当前位置: ${locName}`, false, lm);
}
}
});
/* ================== 渲染 ================== */
function render() {
const news = D.world?.news || [];
$('news-list').innerHTML = news.length ? news.map(n => `< div class = "fold" > < div class = "fold-h" > < div > < div class = "news-t" > ${n.title}< / div > < div class = "news-time" > ${n.time || ''}< / div > < / div > < i class = "fa-solid fa-chevron-down fold-a" > < / i > < / div > < div class = "fold-b news-b" > < p > ${n.content}< / p > < / div > < / div > `).join('') : '< div class = "empty" > 暂无新闻< / div > ';
$$('#news-list .fold').forEach(bindFold);
const ug = D.meta?.user_guide;
if (ug) {
$('ug-state').textContent = ug.current_state || '未知状态';
$('ug-actions').innerHTML = (ug.guides || []).map((g, i) => `< div class = "user-guide-action" data-idx = "${i}" > ${i + 1}. ${g}< / div > `).join('') || '< div class = "user-guide-action" > 暂无行动指南< / div > ';
}
const renderCt = (list, isS) => (list || []).length ? list.map(p => `< div class = "fold" data-name = "${p.name || ''}" data-info = "${(p.info || '').replace(/" / g , ' & quot ; ' ) } " data-uid = "${p.worldbookUid || ''}" > < div class = "fold-h ct-hd fc" > < div class = "ct-av" style = "background:${p.color}" > ${p.avatar}< / div > < div class = "ct-info" > < div class = "ct-name" > ${p.name}< / div > < div class = "ct-st" > ${p.online ? '● 在线' : p.location}< / div > < / div > < i class = "fa-solid fa-chevron-down fold-a" > < / i > < / div > < div class = "fold-b" > < div class = "ct-det" > ${p.info ? `< div class = "ct-info-text" > ${p.info}< / div > ` : ''}< div class = "ct-acts" > ${isS ? `< button class = "btn btn-s btn-p fc add-btn" data-name = "${p.name || ''}" data-info = "${(p.info || '').replace(/" / g , ' & quot ; ' ) } " > < i class = "fa-solid fa-user-plus" > < / i > 添加< / button > < button class = "btn btn-s fc ignore-btn" data-name = "${p.name || ''}" > < i class = "fa-solid fa-eye-slash" > < / i > 忽略< / button > ` : `< button class = "btn btn-s fc msg-btn" data-uid = "${p.worldbookUid || ''}" > < i class = "fa-solid fa-message" > < / i > 短信< / button > < button class = "btn btn-s btn-p fc inv-btn" data-uid = "${p.worldbookUid || ''}" > < i class = "fa-solid fa-paper-plane" > < / i > 邀请< / button > `}< / div > < / div > < / div > < / div > `).join('') : '< div class = "empty" > 暂无< / div > ';
$('sec-stranger').innerHTML = renderCt(D.contacts.strangers, true);
$('sec-contact').innerHTML = renderCt(D.contacts.contacts, false);
$$('.comm-sec .fold').forEach(bindFold);
$$('.add-btn').forEach(b => b.onclick = e => { e.stopPropagation(); genAddCt(b.dataset.name, b.dataset.info || '', b); });
$$('.ignore-btn').forEach(b => b.onclick = e => { e.stopPropagation(); const i = D.contacts.strangers.findIndex(s => s.name === b.dataset.name); if (i > -1) { D.contacts.strangers.splice(i, 1); saveCt(); render(); } });
$$('.msg-btn').forEach(b => b.onclick = e => { e.stopPropagation(); const c = D.contacts.contacts.find(x => x.worldbookUid === b.dataset.uid); if (c) openChat(c); });
$$('.inv-btn').forEach(b => b.onclick = e => { e.stopPropagation(); const c = D.contacts.contacts.find(x => x.worldbookUid === b.dataset.uid); if (c) openInv(c); });
if (selectedMapValue === 'current') {
const inside = getCurInside();
if (inside?.description) {
$('side-desc').innerHTML = `< div class = "local-map-title" > 📍 ${playerLocation}< / div > ` + parseLinks(inside.description);
} else {
$('side-desc').innerHTML = parseLinks(D.maps?.outdoor?.description || '');
}
} else {
$('side-desc').innerHTML = parseLinks(D.maps?.outdoor?.description || '');
}
bindLinks($('side-desc'));
$('mob-desc').innerHTML = '';
renderMapSelector();
renderMap();
}
function renderMap() {
const map = D.maps?.outdoor;
seed = 123456789;
inner.querySelectorAll('.item').forEach(e => e.remove());
svg.innerHTML = '';
nodes = [];
lines = [];
if (!map?.nodes?.length) return;
map.nodes.forEach((n, i) => {
const d = dirMap[n.position] || [0, 0], len = Math.hypot(d[0], d[1]) || 1, dist = (n.distant || 1) * 120;
nodes.push({ id: 'n' + i, name: n.name, type: n.type || 'sub', pos: n.position, distant: n.distant || 1, x: d[0] / len * dist, y: d[1] / len * dist, data: n });
});
const groups = {};
nodes.forEach(n => (groups[n.pos] = groups[n.pos] || []).push(n));
for (let p in groups) {
const arr = groups[p];
if (arr.length < = 1) continue;
const d = dirMap[p] || [0, 0], len = Math.hypot(d[0], d[1]) || 1, px = -d[1] / len, py = d[0] / len, mid = (arr.length - 1) / 2;
arr.sort((a, b) => a.distant - b.distant).forEach((n, i) => { n.x += px * (i - mid) * 50; n.y += py * (i - mid) * 50; });
}
for (let t = 0; t < 15 ; t + + )
for (let i = 0; i < nodes.length ; i + + )
for (let j = i + 1; j < nodes.length ; j + + ) {
const a = nodes[i], b = nodes[j], dx = a.x - b.x, dy = a.y - b.y, d = Math.hypot(dx, dy);
if (d < 80 & & d > 0) { const p = (80 - d) / d * .5; a.x += dx * p; a.y += dy * p; b.x -= dx * p; b.y -= dy * p; }
}
nodes.forEach(n => {
const el = document.createElement('div');
el.className = `item node-${n.type}`;
el.id = n.id;
el.textContent = n.type === 'home' ? '🏠 ' + n.name : n.name;
el.style.cssText = `left:${n.x + 2000}px;top:${n.y + 2000}px`;
el.onclick = e => { e.stopPropagation(); curNode?.id === n.id ? hideInfo() : showInfo(n); };
inner.appendChild(el);
});
for (let g in groups) {
const arr = groups[g].sort((a, b) => a.distant - b.distant);
for (let i = 0; i < arr.length - 1 ; i + + ) lines . push ( [ arr [ i ] , arr [ i + 1 ] ] ) ;
}
const anchors = Object.values(groups).map(a => a.sort((x, y) => x.distant - y.distant)[0]).sort((a, b) => Math.atan2(a.y, a.x) - Math.atan2(b.y, b.x));
anchors.forEach((a, i) => lines.push([a, anchors[(i + 1) % anchors.length]]));
const exists = (a, b) => lines.some(([x, y]) => (x === a & & y === b) || (x === b & & y === a));
for (let i = 0, c = 0; c < Math.floor ( nodes . length / 5 ) & & i < 200 ; i + + ) {
const a = nodes[Math.floor(rand() * nodes.length)], b = nodes[Math.floor(rand() * nodes.length)];
if (a !== b & & !exists(a, b)) { lines.push([a, b]); c++; }
}
drawLines();
}
function drawLines() {
svg.innerHTML = '';
const rect = mapWrap.getBoundingClientRect();
lines.forEach(([a, b]) => {
const elA = $(a.id), elB = $(b.id);
if (!elA || !elB) return;
const rA = elA.getBoundingClientRect(), rB = elB.getBoundingClientRect();
const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
line.setAttribute('x1', (rA.left + rA.width / 2 - rect.left) / scale - offX / scale);
line.setAttribute('y1', (rA.top + rA.height / 2 - rect.top) / scale - offY / scale);
line.setAttribute('x2', (rB.left + rB.width / 2 - rect.left) / scale - offX / scale);
line.setAttribute('y2', (rB.top + rB.height / 2 - rect.top) / scale - offY / scale);
const main = (a.type === 'main' & & b.type === 'main') || a.type === 'home' || b.type === 'home';
line.setAttribute('stroke', main ? '#333' : '#aaa');
line.setAttribute('stroke-width', main ? '2' : '1');
if (!main) line.setAttribute('stroke-dasharray', '4 3');
svg.appendChild(line);
});
}
const updateTf = () => { inner.style.transform = `translate(${offX}px,${offY}px) scale(${scale})`; $('zoom-ind').textContent = Math.round(scale * 100) + '%'; requestAnimationFrame(drawLines); };
const initPos = () => { offX = -2000 + mapWrap.clientWidth / 2; offY = -2000 + mapWrap.clientHeight / 2; scale = 1; updateTf(); };
function panTo(node, dur = 350) {
if (!node || anim) return;
const el = $(node.id);
if (!el) return;
const tX = -(node.x + 2000) * scale + mapWrap.clientWidth / 2, tY = -(node.y + 2000) * scale + mapWrap.clientHeight * .25;
const sX = offX, sY = offY, st = performance.now();
anim = true;
(function step(now) {
const p = Math.min((now - st) / dur, 1), e = 1 - Math.pow(1 - p, 3);
offX = sX + (tX - sX) * e;
offY = sY + (tY - sY) * e;
updateTf();
p < 1 ? requestAnimationFrame ( step ) : anim = false;
})(st);
}
function showInfo(n) {
if (!n?.data) return;
curNode = n;
inner.querySelectorAll('.item').forEach(e => e.classList.remove('hl'));
$(n.id)?.classList.add('hl');
const isCurrentLoc = n.name === playerLocation;
$('btn-goto').classList.toggle('show', !isCurrentLoc);
if (!isCurrentLoc) $('goto-t').textContent = `前往 ${n.name}`;
const inside = D.maps?.indoor?.[n.name];
if (isCurrentLoc & & inside?.description) {
$('side-desc').innerHTML = `< div class = "local-map-title" > 📍 ${n.name}< / div > ` + parseLinks(inside.description);
bindLinks($('side-desc'));
} else {
$('side-desc').innerHTML = parseLinks(D.maps?.outdoor?.description || '');
bindLinks($('side-desc'));
}
if (isMob()) {
$('mob-info-t').textContent = n.name;
$('mob-info-c').textContent = isCurrentLoc ? (inside?.description || n.data.info || '暂无信息...') : (n.data.info || '暂无信息...');
if (!popup.classList.contains('act')) openPop(1);
} else {
$('info-t').textContent = n.name;
$('info-c').textContent = n.data.info || '暂无信息...';
$('tip').classList.add('show');
}
}
const hideInfo = () => { curNode = null; inner.querySelectorAll('.item').forEach(e => e.classList.remove('hl')); $('btn-goto').classList.remove('show'); $('tip').classList.remove('show'); };
$('info-bk').onclick = hideInfo;
$('mob-info-bk').onclick = () => popup.classList.remove('act');
function renderMapSelector() {
const sel = $('map-lbl-select');
sel.innerHTML = '< option value = "overview" > 🗺️ 大地图< / option > ';
const curIdx = D.maps?.outdoor?.nodes?.findIndex(n => n.name === playerLocation);
const isInIndoorMap = D.maps?.indoor & & D.maps.indoor[playerLocation];
if (curIdx >= 0 || isInIndoorMap) sel.innerHTML += `< option value = "current" > 📍 ${playerLocation}(你)< / option > `;
sel.innerHTML += '< option disabled > ──────────< / option > ';
if (D.maps?.outdoor?.nodes?.length) D.maps.outdoor.nodes.forEach((n, i) => { if (n.name !== playerLocation) sel.innerHTML += `< option value = "node:${i}" > ${n.name}< / option > `; });
if (D.maps?.indoor) {
const indoorKeys = Object.keys(D.maps.indoor).filter(k => k !== playerLocation & & !D.maps?.outdoor?.nodes?.some(n => n.name === k));
if (indoorKeys.length) {
sel.innerHTML += '< option disabled > ── 隐藏地图 ──< / option > ';
indoorKeys.forEach(k => sel.innerHTML += `< option value = "indoor:${k}" > 🏠 ${k}< / option > `);
}
}
sel.value = selectedMapValue;
updateMapLabel();
}
function updateMapLabel() {
const v = $('map-lbl-select').value;
if (v === 'overview') $('map-lbl-t').textContent = '大地图';
else if (v === 'current') $('map-lbl-t').textContent = playerLocation + '(你)';
else if (v.startsWith('node:')) { const idx = parseInt(v.split(':')[1]); $('map-lbl-t').textContent = D.maps?.outdoor?.nodes?.[idx]?.name || '未知'; }
else if (v.startsWith('indoor:')) $('map-lbl-t').textContent = v.replace('indoor:', '');
}
function switchMapView(value) {
selectedMapValue = value;
hideInfo();
if (value === 'overview') {
$('btn-goto').classList.remove('show');
$('side-desc').innerHTML = parseLinks(D.maps?.outdoor?.description || '');
bindLinks($('side-desc'));
initPos();
} else if (value === 'current') {
$('btn-goto').classList.remove('show');
const inside = getCurInside();
if (inside?.description) {
$('side-desc').innerHTML = `< div class = "local-map-title" > 📍 ${playerLocation}< / div > ` + parseLinks(inside.description);
bindLinks($('side-desc'));
} else {
$('side-desc').innerHTML = parseLinks(D.maps?.outdoor?.description || '');
bindLinks($('side-desc'));
}
const n = nodes.find(x => x.name === playerLocation);
if (n) { panTo(n); showInfo(n); }
} else if (value.startsWith('node:')) {
const idx = parseInt(value.split(':')[1]);
const node = D.maps?.outdoor?.nodes?.[idx];
$('side-desc').innerHTML = parseLinks(D.maps?.outdoor?.description || '');
bindLinks($('side-desc'));
const n = nodes.find(x => x.name === node?.name);
if (n) { panTo(n); showInfo(n); }
} else if (value.startsWith('indoor:')) {
const name = value.replace('indoor:', '');
const indoorMap = D.maps?.indoor?.[name];
if (name !== playerLocation) {
$('side-desc').innerHTML = parseLinks(D.maps?.outdoor?.description || '');
bindLinks($('side-desc'));
curNode = { name, type: 'sub', data: { info: stripXml(indoorMap?.description || '') }, isIndoor: true };
$('btn-goto').classList.add('show');
$('goto-t').textContent = `前往 ${name}`;
} else {
$('btn-goto').classList.remove('show');
if (indoorMap?.description) {
$('side-desc').innerHTML = `< div class = "local-map-title" > 🏠 ${name}< / div > ` + parseLinks(indoorMap.description);
bindLinks($('side-desc'));
}
}
}
updateMapLabel();
}
$('map-lbl-select').onchange = e => switchMapView(e.target.value);
/* ================== 地图交互 ================== */
let sx, sy, lastDist = 0, lastCX = 0, lastCY = 0;
mapWrap.onmousedown = e => { if (anim || e.target.closest('.map-act,.map-lbl')) return; if (!e.target.classList.contains('item')) hideInfo(); drag = true; sx = e.clientX; sy = e.clientY; mapWrap.style.cursor = 'grabbing'; };
mapWrap.onmousemove = e => { if (!drag) return; offX += e.clientX - sx; offY += e.clientY - sy; sx = e.clientX; sy = e.clientY; updateTf(); };
mapWrap.onmouseup = mapWrap.onmouseleave = () => { drag = false; mapWrap.style.cursor = 'grab'; };
mapWrap.onwheel = e => { if (anim) return; e.preventDefault(); const ns = Math.max(.3, Math.min(3, scale + (e.deltaY > 0 ? -.1 : .1))), rect = mapWrap.getBoundingClientRect(), mx = e.clientX - rect.left, my = e.clientY - rect.top, r = ns / scale; offX = mx - (mx - offX) * r; offY = my - (my - offY) * r; scale = ns; updateTf(); };
mapWrap.ontouchstart = e => {
if (anim || e.target.closest('.map-act,.map-lbl')) return;
const it = e.target.closest('.item');
if (it) { it._ts = { x: e.touches[0].clientX, y: e.touches[0].clientY, t: Date.now() }; return; }
hideInfo();
if (e.touches.length === 1) { drag = true; sx = e.touches[0].clientX; sy = e.touches[0].clientY; }
else if (e.touches.length === 2) { drag = false; lastDist = Math.hypot(e.touches[0].clientX - e.touches[1].clientX, e.touches[0].clientY - e.touches[1].clientY); lastCX = (e.touches[0].clientX + e.touches[1].clientX) / 2; lastCY = (e.touches[0].clientY + e.touches[1].clientY) / 2; }
};
mapWrap.ontouchmove = e => {
const it = e.target.closest('.item');
if (it & & it._ts) { if (Math.hypot(e.touches[0].clientX - it._ts.x, e.touches[0].clientY - it._ts.y) > 10) { delete it._ts; drag = true; sx = e.touches[0].clientX; sy = e.touches[0].clientY; } return; }
if (e.touches.length === 1 & & drag) { e.preventDefault(); offX += e.touches[0].clientX - sx; offY += e.touches[0].clientY - sy; sx = e.touches[0].clientX; sy = e.touches[0].clientY; updateTf(); }
else if (e.touches.length === 2) {
e.preventDefault();
const d = Math.hypot(e.touches[0].clientX - e.touches[1].clientX, e.touches[0].clientY - e.touches[1].clientY);
const cx = (e.touches[0].clientX + e.touches[1].clientX) / 2, cy = (e.touches[0].clientY + e.touches[1].clientY) / 2;
const ns = Math.max(.3, Math.min(3, scale * (d / lastDist))), rect = mapWrap.getBoundingClientRect(), mx = cx - rect.left, my = cy - rect.top, r = ns / scale;
offX = mx - (mx - offX) * r; offY = my - (my - offY) * r; offX += cx - lastCX; offY += cy - lastCY;
scale = ns; lastDist = d; lastCX = cx; lastCY = cy; updateTf();
}
};
mapWrap.ontouchend = e => {
const it = e.target.closest('.item');
if (it & & it._ts) { const el = Date.now() - it._ts.t; delete it._ts; if (el < 300 ) { const n = nodes.find(n = > n.id === it.id); if (n) { e.preventDefault(); curNode?.id === n.id ? hideInfo() : showInfo(n); } } }
drag = false;
};
/* ================== 导航 ================== */
$$('.nav-i').forEach(i => i.onclick = () => {
$$('.nav-i').forEach(n => n.classList.remove('act'));
$$('.page').forEach(p => p.classList.remove('act'));
i.classList.add('act');
$(`page-${i.dataset.p}`).classList.add('act');
const isMap = i.dataset.p === 'map';
sidePop.classList.toggle('show', isMap);
if (isMob()) isMap ? openPop(1) : popup.classList.remove('act');
if (isMap) setTimeout(() => { initPos(); drawLines(); }, 50);
});
$$('.comm-tab').forEach(t => t.onclick = () => {
$$('.comm-tab').forEach(x => x.classList.remove('act'));
$$('.comm-sec').forEach(s => s.classList.remove('act'));
t.classList.add('act');
$(`sec-${t.dataset.t}`).classList.add('act');
});
$('btn-goto').onclick = e => { e.stopPropagation(); if (curNode) { $('goto-d').textContent = `目的地:${curNode.name}`; $('goto-task').value = ''; openM('m-goto'); } };
addEventListener('resize', () => requestAnimationFrame(drawLines));
/* ================== 初始化 ================== */
document.addEventListener('DOMContentLoaded', () => {
render();
initPos();
sidePop.classList.add('show');
setSideW(sideMaxW());
if (isMob()) openPop(1);
$('template-type-select').onchange = e => loadTemplate(e.target.value);
$('tpl-save').onclick = saveCurrentTemplate;
$('tpl-restore').onclick = restoreCurrentTemplate;
['tpl-u1', 'tpl-a1', 'tpl-u2', 'tpl-a2'].forEach(id => {
const ta = $(id);
if (ta) ta.oninput = function() { this.style.height = 'auto'; this.style.height = Math.max(this.scrollHeight, 60) + 'px'; };
});
post('FRAME_READY');
setTimeout(() => { if (selectedMapValue === 'current') switchMapView('current'); }, 100);
});
< / script >
< / body >
< / html >