feat: 世界聊天敏感词过滤+菜单整合, 部署脚本绑定 yt_game

This commit is contained in:
李鹏宇
2026-08-14 15:40:56 +08:00
parent 73d764938d
commit ec2f07dfab
13 changed files with 242 additions and 27 deletions
+1
View File
@@ -2,5 +2,6 @@ from . import game_employee
from . import game_achievement
from . import game_mail
from . import chat
from . import sensitive_word
from . import page
from . import asset
+28 -1
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
import re
from odoo import models, fields
from odoo import api, models, fields
# 游戏助手的展示昵称(前后端共用,落库消息的作者名)
GUIDE_NAME = '游戏助手'
@@ -29,6 +29,24 @@ class GameChatChannel(models.Model):
creator_id = fields.Many2one('res.users', string='创建者')
member_ids = fields.Many2many('res.users', string='成员')
message_ids = fields.One2many('game.chat.message', 'channel_id', string='消息')
message_count = fields.Integer(string='消息数量', compute='_compute_message_count')
@api.depends('message_ids')
def _compute_message_count(self):
for rec in self:
rec.message_count = len(rec.message_ids)
def action_view_messages(self):
"""从频道列表跳转查看该频道全部消息。"""
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': '频道消息',
'res_model': 'game.chat.message',
'view_mode': 'list,form',
'domain': [('channel_id', '=', self.id)],
'context': dict(self.env.context),
}
def generate_guide_reply(self, question):
"""内置游戏知识引擎(零外部依赖):按关键词检索 game.rule / game.rule.category 作答。
@@ -104,3 +122,12 @@ class GameChatMessage(models.Model):
author_id = fields.Many2one('res.users', string='发送者')
author_name = fields.Char(string='昵称')
body = fields.Text(string='内容', required=True)
@api.model_create_multi
def create(self, vals_list):
"""创建消息时自动过滤敏感词。"""
sanitize = self.env['game.sensitive.word'].sanitize
for vals in vals_list:
if vals.get('body'):
vals['body'] = sanitize(vals['body'])
return super(GameChatMessage, self).create(vals_list)
+32
View File
@@ -0,0 +1,32 @@
# -*- coding: utf-8 -*-
import re
from odoo import api, models, fields
class GameSensitiveWord(models.Model):
_name = 'game.sensitive.word'
_description = '聊天敏感词'
_order = 'name'
name = fields.Char(string='敏感词', required=True, index=True)
active = fields.Boolean(string='启用', default=True)
replacement = fields.Char(string='替换为', default='**')
_sql_constraints = [
('name_uniq', 'unique(name)', '敏感词不能重复'),
]
@api.model
def sanitize(self, text):
"""将文本中的敏感词替换为对应替换字符(默认 **)。"""
if not text:
return text
words = self.search([('active', '=', True)])
if not words:
return text
result = text
for w in words:
pat = re.escape(w.name)
repl = w.replacement or '**'
result = re.sub(pat, repl, result, flags=re.IGNORECASE)
return result
@@ -9,6 +9,8 @@ access_game_chat_channel,game.chat.channel public,model_game_chat_channel,base.g
access_game_chat_channel_user,game.chat.channel user,model_game_chat_channel,base.group_user,1,1,1,1
access_game_chat_message,game.chat.message public,model_game_chat_message,base.group_public,1,0,0,0
access_game_chat_message_user,game.chat.message user,model_game_chat_message,base.group_user,1,1,1,1
access_game_sensitive_word,game.sensitive.word public,model_game_sensitive_word,base.group_public,1,0,0,0
access_game_sensitive_word_user,game.sensitive.word user,model_game_sensitive_word,base.group_user,1,1,1,1
access_game_page,game.page public,model_game_page,base.group_public,1,0,0,0
access_game_page_user,game.page user,model_game_page,base.group_user,1,1,1,1
access_game_page_item,game.page.item public,model_game_page_item,base.group_public,1,0,0,0
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
9 access_game_chat_channel_user game.chat.channel user model_game_chat_channel base.group_user 1 1 1 1
10 access_game_chat_message game.chat.message public model_game_chat_message base.group_public 1 0 0 0
11 access_game_chat_message_user game.chat.message user model_game_chat_message base.group_user 1 1 1 1
12 access_game_sensitive_word game.sensitive.word public model_game_sensitive_word base.group_public 1 0 0 0
13 access_game_sensitive_word_user game.sensitive.word user model_game_sensitive_word base.group_user 1 1 1 1
14 access_game_page game.page public model_game_page base.group_public 1 0 0 0
15 access_game_page_user game.page user model_game_page base.group_user 1 1 1 1
16 access_game_page_item game.page.item public model_game_page_item base.group_public 1 0 0 0
+45 -3
View File
@@ -1,10 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- ============ 世界聊天 · 顶级菜单 ============ -->
<!-- ============ 世界聊天 · 并入宇森游戏根菜单 ============ -->
<record id="menu_game_chat_root" model="ir.ui.menu">
<field name="name">世界聊天</field>
<field name="sequence">10</field>
<field name="parent_id" ref="game_base.menu_game_root"/>
<field name="sequence">95</field>
</record>
<!-- ============ 世界聊天 · 频道 ============ -->
@@ -28,7 +29,8 @@
<list>
<field name="name"/>
<field name="channel_type"/>
<field name="sequence"/>
<field name="message_count"/>
<button name="action_view_messages" type="object" string="消息" icon="fa-comments"/>
</list>
</field>
</record>
@@ -108,4 +110,44 @@
<field name="sequence">20</field>
</record>
<!-- ============ 世界聊天 · 敏感词 ============ -->
<record id="action_game_sensitive_word" model="ir.actions.act_window">
<field name="name">敏感词</field>
<field name="res_model">game.sensitive.word</field>
<field name="view_mode">list,form</field>
</record>
<record id="view_game_sensitive_word_list" model="ir.ui.view">
<field name="name">game.sensitive.word.list</field>
<field name="model">game.sensitive.word</field>
<field name="arch" type="xml">
<list editable="bottom">
<field name="name"/>
<field name="replacement"/>
<field name="active"/>
</list>
</field>
</record>
<record id="view_game_sensitive_word_form" model="ir.ui.view">
<field name="name">game.sensitive.word.form</field>
<field name="model">game.sensitive.word</field>
<field name="arch" type="xml">
<form>
<group>
<field name="name"/>
<field name="replacement"/>
<field name="active"/>
</group>
</form>
</field>
</record>
<record id="menu_game_sensitive_word" model="ir.ui.menu">
<field name="name">敏感词</field>
<field name="parent_id" ref="menu_game_chat_root"/>
<field name="action" ref="action_game_sensitive_word"/>
<field name="sequence">30</field>
</record>
</odoo>
+33
View File
@@ -124,6 +124,39 @@ class YtWorldApi(http.Controller):
tile.micro_world_id.build_items = items
return _json({'ok': True, 'count': len(items)})
@http.route('/yt_world/api/micro/name', type='http', auth='user',
methods=['POST', 'OPTIONS'], csrf=False)
def rename_micro(self, **kw):
"""修改某地块微观世界的名称。body: {"q":int,"r":int,"name":"..."}
找不到地块时自动建(保证种子地块也能落库记忆);与 rename_world 同样的校验。"""
user = request.env.user
world = request.env['yt.world'].sudo().search(
[('user_id', '=', user.id)], limit=1)
if not world:
return _json({'ok': False, 'error': 'no_world'})
body = _post_body()
q, r, name = body.get('q'), body.get('r'), (body.get('name') or '').strip()
if q is None or r is None:
return _json({'ok': False, 'error': 'bad_params'})
if not name:
return _json({'ok': False, 'error': 'empty'})
if len(name) > 24:
return _json({'ok': False, 'error': 'too_long'})
if re.search(r'[\x00-\x1f\x7f]', name):
return _json({'ok': False, 'error': 'invalid_char'})
tile = request.env['yt.world.tile'].sudo().search(
[('world_id', '=', world.id), ('q', '=', q), ('r', '=', r)], limit=1)
if not tile:
tile = request.env['yt.world.tile'].sudo().create(
{'world_id': world.id, 'q': q, 'r': r})
if not tile.micro_world_id:
tile._ensure_micro()
old = tile.micro_world_id.name
tile.micro_world_id.name = name
_logger.info('yt.world.tile.micro rename uid=%s (%s,%s) %r -> %r',
user.id, q, r, old, name)
return _json({'ok': True, 'name': name, 'q': q, 'r': r})
@http.route('/yt_world/api/micro/catalog', type='http', auth='user',
methods=['GET', 'OPTIONS'], csrf=False)
def micro_catalog(self, **kw):
+1
View File
@@ -84,6 +84,7 @@ class YtWorld(models.Model):
'height': t.height or None,
'flags': t.flags or {},
'micro_world_id': t.micro_world_id.id if t.micro_world_id else None,
'micro_name': (t.micro_world_id.name or '') if t.micro_world_id else '',
'build_items': (t.micro_world_id.build_items or []) if t.micro_world_id else [],
})
return {
-5
View File
@@ -56,9 +56,4 @@
parent="menu_website_config"
action="game_base.action_game_achievement_template" sequence="93"/>
<!-- 8. 世界聊天 -->
<menuitem id="menu_website_world_chat" name="世界聊天"
parent="game_base.menu_game_root"
action="game_base.action_game_chat_channel" sequence="95"/>
</odoo>
+17
View File
@@ -125,6 +125,23 @@ const API = {
body: JSON.stringify({ name }),
})).json();
},
// 微观世界命名(按地块坐标):命名后由后端持久化到 yt.world.tile.micro.name
async renameMicro(q, r, name) {
try {
const r0 = await fetch(_worldUrl('/yt_world/api/micro/name'), {
method: 'POST', credentials: _wcred,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ q, r, name }),
});
if (r0.redirected || (r0.url && r0.url.indexOf('/web/login') >= 0)) {
return { ok: false, error: 'auth' };
}
const j = await r0.json();
return j && j.ok === false ? j : (j || { ok: true });
} catch (e) {
return { ok: false, error: 'network', detail: String(e && e.message || e) };
}
},
// 微观建造布置保存(按地块坐标)
// 返回结构化结果:{ok:true,...} 或 {ok:false, error:'auth'|'network', detail}
// —— auth='user' 路由未登录会 303 跳转到 /web/login,fetch 跟随后得到 HTML,
+2 -2
View File
@@ -6,7 +6,7 @@ set -e
# ===== 按实际情况修改这几个变量 =====
REPO_DIR="/opt/yuthon/game" # git 仓库目录(含 world.html 与 addons/)
ODOO_CONF="/etc/odoo/odoo.conf" # odoo.conf 路径
ODOO_DB="game" # 线上数据库名(务必与前端 ?db= 一致)
ODOO_DB="yt_game" # 线上数据库名(game.pengyuthon.cn 对应 yt_game;勿改,避免影响 school)
ODOO_SERVICE="odoo" # systemctl 服务名
ODOO_BIN="/usr/bin/odoo" # odoo-bin 路径(或 which odoo)
# ===================================
@@ -36,5 +36,5 @@ sleep 3
systemctl is-active "$ODOO_SERVICE" || { echo "Odoo 未起来,检查: journalctl -u $ODOO_SERVICE -n 50"; exit 1; }
echo "== [5/5] 冒烟验证 =="
curl -s -o /dev/null -w "codex 接口 HTTP %{http_code}\n" "https://game.pengyuthon.cn/game/api/codex?db=game"
curl -s -o /dev/null -w "codex 接口 HTTP %{http_code}\n" "https://game.pengyuthon.cn/game/api/codex?db=yt_game"
echo "完成。前端为静态文件,nginx 指向 $REPO_DIR 即可,无需构建。"
+2
View File
@@ -47,6 +47,8 @@
.sl-arrow:hover{background:rgba(139,92,246,.25);border-color:rgba(139,92,246,.4);transform:translateY(-50%) scale(1.08);}
.sl-arrow.prev{left:16px;}
.sl-arrow.next{right:16px;}
/* 隐藏左右翻页与轮播点,底层自动轮播/键盘/滑动切换逻辑保留 */
.sl-arrow, .sl-dots{ display:none !important; }
/* 起源历史(右上竖排) */
.slide-origin{position:absolute;right:72px;top:48px;z-index:4;pointer-events:none;writing-mode:vertical-rl;text-orientation:upright;
+8 -7
View File
@@ -31,17 +31,18 @@
.wrap{position:relative;z-index:2;width:min(420px,92vw);}
.brand{text-align:center;margin-bottom:26px;}
.brand .cn{font-family:"PingFang SC","Microsoft YaHei",system-ui,sans-serif;font-weight:900;font-size:42px;letter-spacing:8px;color:#fff;text-shadow:0 0 18px rgba(139,92,246,.6);}
.brand .en{font-size:16px;color:var(--magenta);letter-spacing:5px;margin-top:8px;font-weight:600;text-shadow:0 1px 6px rgba(0,0,0,.7);}
.brand .en{font-size:18px;color:#fff;letter-spacing:6px;margin-top:8px;font-weight:700;text-shadow:0 2px 10px rgba(0,0,0,.9),0 0 18px rgba(139,92,246,.45);}
.brand .tag{font-size:13px;color:var(--text-dim);margin-top:10px;}
.card{background:var(--card);border:1px solid var(--card-bd);border-radius:18px;padding:26px 26px 30px;backdrop-filter:blur(10px);box-shadow:var(--glow);}
.card{background:rgba(8,12,26,.78);border:1px solid rgba(255,255,255,.18);border-radius:18px;padding:26px 26px 30px;backdrop-filter:blur(14px);box-shadow:0 8px 32px rgba(0,0,0,.45),var(--glow);}
.tabs{display:flex;gap:6px;margin-bottom:20px;background:rgba(8,12,26,.5);border-radius:12px;padding:5px;}
.tabs button{flex:1;font-family:inherit;font-size:14px;font-weight:500;color:var(--text-dim);background:transparent;border:none;padding:9px;border-radius:9px;cursor:pointer;transition:.2s;}
.tabs button.active{color:#fff;background:rgba(139,92,246,.28);box-shadow:var(--glow);}
.tabs button{flex:1;font-family:inherit;font-size:15px;font-weight:600;color:#c4cde5;background:transparent;border:none;padding:10px;border-radius:9px;cursor:pointer;transition:.2s;}
.tabs button.active{color:#fff;background:rgba(139,92,246,.35);box-shadow:var(--glow);}
.field{margin-bottom:14px;}
.field label{display:block;font-size:12px;color:var(--text-dim);margin-bottom:6px;letter-spacing:1px;}
.field input{width:100%;background:rgba(8,12,26,.6);border:1px solid var(--card-bd);border-radius:10px;color:var(--text);padding:11px 13px;font-family:inherit;font-size:14px;outline:none;transition:.2s;}
.field{margin-bottom:16px;}
.field label{display:block;font-size:14px;color:#fff;margin-bottom:8px;letter-spacing:1px;font-weight:600;text-shadow:0 1px 4px rgba(0,0,0,.8);}
.field input{width:100%;background:rgba(0,0,0,.45);border:1px solid rgba(255,255,255,.22);border-radius:10px;color:var(--text);padding:12px 14px;font-family:inherit;font-size:15px;outline:none;transition:.2s;}
.field input::placeholder{color:rgba(233,237,246,.55);}
.field input:focus{border-color:var(--violet);box-shadow:0 0 0 3px rgba(139,92,246,.18);}
.pw-wrap{position:relative;}
.pw-wrap input{padding-right:40px;}
+71 -9
View File
@@ -42,7 +42,7 @@ body { background: #0a0e17; overflow: hidden; font-family: 'Microsoft YaHei', sa
}
#topBar .title .worldName.editable { cursor: pointer; padding: 2px 8px; border-radius: 5px; transition: background .15s; }
#topBar .title .worldName.editable:hover { background: rgba(255,255,255,.08); }
#topBar .title .worldName-input {
.worldName-input {
background: transparent; border: 1px solid rgba(124,199,255,.6); color: #cfe1f3;
font-size: 15px; font-weight: 600; padding: 2px 6px; border-radius: 5px; width: 180px;
font-family: inherit; outline: none;
@@ -172,7 +172,7 @@ body { background: #0a0e17; overflow: hidden; font-family: 'Microsoft YaHei', sa
/* 世界等级经验条(位于 bottomBar 内最左侧,加大加清晰) */
#worldExpBarWrap{
flex:0 0 auto; width:380px; height:100%;
flex:0 0 auto; width:520px; height:100%;
display:flex; flex-direction:column; justify-content:center; gap:7px;
padding:0 16px 0 0; background:transparent; border:none;
}
@@ -180,8 +180,9 @@ body { background: #0a0e17; overflow: hidden; font-family: 'Microsoft YaHei', sa
.we-lv{font-size:16px; font-weight:800; color:#c4b5fd; text-shadow:0 0 10px rgba(139,92,246,.5);}
.we-title{font-size:13px; color:#9fb0c4; max-width:130px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;}
.we-exp-txt{margin-left:auto; font-size:13px; font-weight:600; color:#cfe1f3;}
.we-bar{width:100%; height:12px; background:rgba(255,255,255,.10); border-radius:6px; overflow:hidden;}
.we-fill{height:100%; width:0%; background:linear-gradient(90deg,#8b5cf6,#4af); border-radius:6px; transition:width .3s ease; box-shadow:0 0 12px rgba(139,92,246,.45);}
.we-pct{margin-left:2px; font-size:13px; font-weight:700; color:#c4b5fd; min-width:34px; text-align:right;}
.we-bar{width:100%; height:14px; background:rgba(255,255,255,.10); border-radius:7px; overflow:hidden;}
.we-fill{height:100%; width:0%; background:linear-gradient(90deg,#8b5cf6,#4af); border-radius:7px; transition:width .3s ease; box-shadow:0 0 12px rgba(139,92,246,.45);}
/* 主沙盘底部功能框架(预留区:放功能按钮 / 素材槽,比微观边框更高) */
#bottomBar {
@@ -238,6 +239,8 @@ body { background: #0a0e17; overflow: hidden; font-family: 'Microsoft YaHei', sa
}
#microHeader .backBtn:hover { background: rgba(100,200,255,0.25); }
#microHeader .microTitle { margin-left: 16px; font-size: 16px; font-weight: bold; color: #e0e0e0; }
#microHeader .microTitle.editable { cursor: pointer; padding: 2px 8px; border-radius: 5px; transition: background .15s; }
#microHeader .microTitle.editable:hover { background: rgba(255,255,255,.08); }
#microHeader .microSubtitle { display: none; }
#microBody { margin-top: 96px; flex: 1; display: flex; flex-direction: column; position: relative; overflow: hidden; }
@@ -715,7 +718,7 @@ body { background: #0a0e17; overflow: hidden; font-family: 'Microsoft YaHei', sa
@media (max-width:420px){
#topBar .title .worldName{display:none;}
#bottomBar .bb-btn{padding:7px 9px;font-size:11px;}
#worldExpBarWrap{width:220px;gap:5px;}
#worldExpBarWrap{width:280px;gap:5px;}
#worldExpBarWrap .we-title{display:none;}
#sceneToolbar{right:8px;gap:5px;padding:6px 7px;top:calc(58px + env(safe-area-inset-top) + 6px);}
#sceneToolbar .top-icon{width:34px;height:34px;font-size:16px;}
@@ -818,6 +821,7 @@ body { background: #0a0e17; overflow: hidden; font-family: 'Microsoft YaHei', sa
<span class="we-lv">世界 Lv.<b id="weLv">--</b></span>
<span class="we-title" id="weTitle">--</span>
<span class="we-exp-txt" id="weExpTxt">EXP -- / --</span>
<span class="we-pct" id="weExpPct">--%</span>
</div>
<div class="we-bar"><div class="we-fill" id="weExpFill" style="width:0%"></div></div>
</div>
@@ -1690,6 +1694,7 @@ let ambParticles = [];
// 微观世界
let microState = null;
let microNames = {}; // 地块 key("q,r") -> 该微观世界自定义名称(后端持久化,记忆)
let microBuildRevealAt = 0; // 建筑显示时间戳:过渡结束 + 200ms 后才浮现
// 游戏化系统全局状态(与 Odoo 后端打通)
let microChar = null; // 玩家角色:{name,level,exp,gold,inventory,equip}
@@ -2972,6 +2977,15 @@ function pickTransitionImage() {
return TRANSITION_IMAGES[idx];
}
// ============ 微观世界标题:有自定义名显示「层 · 自定义名」,否则默认「层 · 地形村落」 ============
function microTitleText(tile) {
const key = tile.q + ',' + tile.r;
const custom = microNames[key];
const layerLabel = { earth: '🌍 大地', under: '🕳 地下', core: '🔥 地心', sky: '🌤 天空', star: '✦ 星空' }[tile.layer] || '';
if (custom) return `${layerLabel} · ${custom}`;
return `${layerLabel} · ${TERRAIN[tile.terrain].name}村落`;
}
// ============ 进入微观世界 ============
function enterMicroWorld(tile) {
if (!tile) return;
@@ -3012,8 +3026,7 @@ function enterMicroWorld(tile) {
microZoom = 1; microPanX = 0; microPanY = 0;
microViewOpen = true; // 暂停主世界重绘
microView.classList.add('active'); // 入场动画(缩放+去模糊)在遮罩掩护下播放,揭幕时早已归位
const layerLabel = { earth:'🌍 大地', under:'🕳 地下', core:'🔥 地心', sky:'🌤 天空', star:'✦ 星空' }[tile.layer] || '';
document.getElementById('microTitle').textContent = `${layerLabel} · ${TERRAIN[tile.terrain].name}村落`;
document.getElementById('microTitle').textContent = microTitleText(tile);
document.getElementById('microSubtitle').textContent = `坐标(${tile.q}, ${tile.r}) · ${microState.units.length}位居民`;
applyMicroAccent(tile.terrain);
updateSidebar();
@@ -3682,8 +3695,7 @@ function setMicroTile(tile) {
? [{ ...BUILDINGS[tile.buildingType], type: tile.buildingType, level: tile.buildingLevel, hp: 80 + tile.buildingLevel * 10 }]
: [];
microState.resources = calcResources(tile);
const layerLabel = { earth: '🌍 大地', under: '🕳 地下', core: '🔥 地心', sky: '🌤 天空', star: '✦ 星空' }[tile.layer] || '';
document.getElementById('microTitle').textContent = `${layerLabel} · ${TERRAIN[tile.terrain].name}村落`;
document.getElementById('microTitle').textContent = microTitleText(tile);
document.getElementById('microSubtitle').textContent = `坐标(${tile.q}, ${tile.r}) · ${microState.units.length}位居民`;
updateSidebar();
microAutoRoadCells(); // 切地块时重新计算自动道路贴图
@@ -5044,6 +5056,7 @@ function applyWorldState(ws) {
if (ov.terrain) t.terrain = ov.terrain;
if (ov.height != null) { t.height = ov.height; t.tier = heightToTier(ov.height); }
if (ov.flags) t.flags = ov.flags;
if (ov.micro_name != null) microNames[ov.q + ',' + ov.r] = ov.micro_name; // 恢复微观世界命名记忆
}
// 微观建造布置:按 'q,r' 恢复(覆盖所有层,进入地块时即显示)
for (const ov of ws.tiles) {
@@ -5946,6 +5959,51 @@ function setupWorldNameEdit() {
});
}
// ============ 微观世界标题:点击进入编辑,回车/失焦保存到后端,Esc 取消 ============
function setupMicroNameEdit() {
const mt = document.getElementById('microTitle');
if (!mt) return;
mt.classList.add('editable');
mt.title = '点击重命名此微观世界';
mt.addEventListener('click', () => {
if (!microState || !microState.tile) return; // 仅在微观视图内可编辑
if (mt.querySelector('input')) return; // 已在编辑
const tile = microState.tile;
const key = tile.q + ',' + tile.r;
const current = microNames[key] || '';
const input = document.createElement('input');
input.type = 'text';
input.value = current;
input.placeholder = '输入此微观世界名称';
input.maxLength = 24;
input.className = 'worldName-input';
input.spellcheck = false;
mt.textContent = '';
mt.appendChild(input);
input.focus();
input.select();
let done = false;
const restore = () => { if (done) return; done = true; mt.textContent = microTitleText(tile); };
const commit = async () => {
if (done) return;
const next = input.value.trim();
if (!next || next === current) { restore(); return; }
restore(); // 先本地显示,再异步写后端
microNames[key] = next;
try {
const r = await API.renameMicro(tile.q, tile.r, next);
if (r && r.ok && r.name) { microNames[key] = r.name; }
else console.warn('微观世界重命名后端未响应,已本地保存', r);
} catch (e) { console.warn('微观世界重命名失败,已本地保存', e); }
};
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') { e.preventDefault(); commit(); }
else if (e.key === 'Escape') { e.preventDefault(); restore(); }
});
input.addEventListener('blur', commit);
});
}
function init() {
W = canvas.width = window.innerWidth;
H = canvas.height = window.innerHeight - BOTTOM_BAR_H;
@@ -5960,6 +6018,8 @@ function init() {
setupBuildUI();
// 顶栏世界名称点击编辑
setupWorldNameEdit();
// 微观世界标题点击编辑(按地块独立命名,持久化到后端)
setupMicroNameEdit();
// 钟表已移走;时间文字由 syncTimeUI 每 100ms 节流更新
// 世界切换导航
document.querySelectorAll('#worldNav .wn-item').forEach(el => {
@@ -6245,6 +6305,8 @@ if (typeof fitWorld === 'function') fitWorld();
if (weExpFill) weExpFill.style.width = worldExpPct + '%';
const weExpTxt = document.getElementById('weExpTxt');
if (weExpTxt) weExpTxt.textContent = 'EXP ' + worldExp + ' / ' + worldExpMax;
const weExpPctEl = document.getElementById('weExpPct');
if (weExpPctEl) weExpPctEl.textContent = Math.floor(worldExpPct) + '%';
// 邮件未读角标(顶部图标栏)
const topMailBadge = document.getElementById('topMailBadge');
if (topMailBadge) {