feat: 论坛/公告/剧情打通 Odoo —— 新增小说模型与阅读器

- 新增 game.story.volume / game.story.chapter 模型 + 后台菜单视图
- 新增 /game/api/story(目录) 与 /game/api/story/chapter/<id>(正文) 接口
- 三块补充 Odoo 默认种子数据(剧情 2 卷 5 章、论坛 5 帖、公告 4 条)
- 前端:剧情改造为左目录 + 右正文的小说阅读器;论坛改为登录后可见(auth-only);论坛/公告调整为竖排阅读布局
This commit is contained in:
李鹏宇 2026-07-24 15:56:42 +08:00
parent 467e260d80
commit d9269e0f56
9 changed files with 352 additions and 6 deletions

View File

@ -172,6 +172,49 @@ class GameApiController(http.Controller):
} for r in recs] } for r in recs]
return _json(out) return _json(out)
@http.route('/game/api/story', type='http', auth='public', methods=['GET'], csrf=False)
def story(self, **kw):
"""小说目录:返回 卷 -> 章节 结构,供前端左侧目录(TOC)使用。"""
Volume = request.env['game.story.volume'].sudo()
Chapter = request.env['game.story.chapter'].sudo()
vols = Volume.search_read([], ['id', 'name', 'sequence', 'intro'], order='sequence, id')
chapters = Chapter.search_read([], ['id', 'volume_id', 'name', 'sequence'],
order='volume_id, sequence, id')
by_vol = {}
for c in chapters:
vid = c['volume_id'][0] if c['volume_id'] else None
by_vol.setdefault(vid, []).append({
'id': c['id'],
'name': c['name'],
'sequence': c['sequence'],
})
out = []
for v in vols:
out.append({
'id': v['id'],
'name': v['name'],
'sequence': v['sequence'],
'intro': v.get('intro') or '',
'chapters': by_vol.get(v['id'], []),
})
return _json({'volumes': out})
@http.route('/game/api/story/chapter/<int:cid>', type='http', auth='public', methods=['GET'], csrf=False)
def story_chapter(self, cid, **kw):
"""单章正文(点击目录时按需拉取,避免一次性下发整本小说)。"""
rec = request.env['game.story.chapter'].sudo().search_read(
[('id', '=', cid)], ['id', 'volume_id', 'name', 'sequence', 'body_html'])
if not rec:
return _json({'error': '章节不存在'})
r = rec[0]
return _json({
'id': r['id'],
'volume_id': r['volume_id'][0] if r['volume_id'] else None,
'name': r['name'],
'sequence': r['sequence'],
'body_html': r['body_html'] or '',
})
# ============ 登录 / 注册 / 会话(与 Odoo res.users 打通) ============ # ============ 登录 / 注册 / 会话(与 Odoo res.users 打通) ============
@http.route('/game/api/login', type='http', auth='public', @http.route('/game/api/login', type='http', auth='public',

View File

@ -249,4 +249,112 @@
<p>欢迎播种。</p>]]></field> <p>欢迎播种。</p>]]></field>
</record> </record>
<!-- ============ 论坛种子(登录后可见) ============ -->
<record id="thread_welcome" model="game.forum.thread">
<field name="name">【公告】宇森官网第一阶段上线,欢迎园丁们入驻</field>
<field name="category">discuss</field>
<field name="author_name">官方</field>
<field name="pinned">1</field>
<field name="body_html"><![CDATA[<p>宇森官网第一阶段上线:介绍 / 规则 / 图鉴 / 论坛 / 公告 / 剧情 六块,单页切换呈现。欢迎园丁们入驻,一起把这个世界养活。</p>]]></field>
</record>
<record id="thread_newbie" model="game.forum.thread">
<field name="name">新手向:园丁视角的三种开局思路</field>
<field name="category">strategy</field>
<field name="author_name">行者</field>
<field name="body_html"><![CDATA[<p>① 静观其变:前期少干预,先把灵气浓度调匀;② 重点扶持:选一个族群定向加灵气,加速聚落成型;③ 制造意外:故意制造资源稀缺,观察文明如何应对。你更偏向哪种?</p>]]></field>
</record>
<record id="thread_lore" model="game.forum.thread">
<field name="name">世界观考据:五层世界是怎么来的</field>
<field name="category">lore</field>
<field name="author_name">雾岛</field>
<field name="body_html"><![CDATA[<p>从地心熔岩到陨石星空,五层世界是灵气自下而上攀爬的切面。凡墟→灵启→玄界→穹冥→溯元,每一层都有独立的生态与事件池,文明则是被这五层同时托举的。</p>]]></field>
</record>
<record id="thread_showcase" model="game.forum.thread">
<field name="name">手绘了我的主世界浮空岛,求轻喷</field>
<field name="category">showcase</field>
<field name="author_name">青柠</field>
<field name="body_html"><![CDATA[<p>肝了三个晚上,把我主世界那座浮空岛画出来了,云朵地基块 + 中央灵泉,欢迎来捞图也欢迎拍砖。</p>]]></field>
</record>
<record id="thread_bug" model="game.forum.thread">
<field name="name">反馈:飞入微观时偶现卡顿</field>
<field name="category">bug</field>
<field name="author_name">K</field>
<field name="body_html"><![CDATA[<p>进入微观沙盘的前一两秒偶尔会掉帧,退出再进就正常了。设备是集显笔记本,疑似首帧资源加载没预载,供开发组参考。</p>]]></field>
</record>
<!-- ============ 公告补充 ============ -->
<record id="ann_roadmap" model="game.announcement">
<field name="name">【前瞻】微观沙盘与剧情站即将开放</field>
<field name="date">2026-07-12</field>
<field name="priority">important</field>
<field name="body_html"><![CDATA[<p>下一阶段将开放「微观沙盘」与「剧情站」。前者让你亲手摆放六边形地块、引导文明生长;后者将连载《园丁手记》系列小说,记录这个世界的来龙去脉。</p>]]></field>
</record>
<record id="ann_convention" model="game.announcement">
<field name="name">【须知】社区公约与发帖规范</field>
<field name="date">2026-07-15</field>
<field name="priority">normal</field>
<field name="body_html"><![CDATA[<p>论坛仅对登录用户开放。请友善交流、尊重原创;同人创作欢迎标注「二创」,攻略与考据可自由转载,转载请注明出处。</p>]]></field>
</record>
<record id="ann_story" model="game.announcement">
<field name="name">【公告】剧情站上线:《园丁手记·凡墟篇》</field>
<field name="date">2026-07-20</field>
<field name="priority">important</field>
<field name="body_html"><![CDATA[<p>剧情站正式上线!首卷《园丁手记·凡墟篇》已开放阅读,记录灵气初醒、第一株苗与聚落第一缕光的蒙昧年代。前往导航「剧情」即可翻阅。</p>]]></field>
</record>
<!-- ============ 剧情(小说)种子 ============ -->
<record id="story_vol_fanxu" model="game.story.volume">
<field name="name">第一卷 · 园丁手记·凡墟篇</field>
<field name="sequence">10</field>
<field name="intro"><![CDATA[<p>凡墟篇·园丁手记。灵气初醒的蒙昧年代,文明的种子刚刚落进泥土。</p>]]></field>
</record>
<record id="story_ch_fx_1" model="game.story.chapter">
<field name="volume_id" ref="story_vol_fanxu"/>
<field name="sequence">10</field>
<field name="name">楔子 · 灵气初醒</field>
<field name="body_html"><![CDATA[<p>天地初开的时候,没有名字。</p>
<p>只有一团混沌的灵气,从地心最深处艰难地往上爬——它穿过滚烫的岩浆,穿过沉默的岩层,在某个谁也说不清的时刻,第一次触到了冰冷的地表。</p>
<p>那一刻,凡土醒了。</p>
<p>后来的人把它叫做「灵气初醒」。但在当时,没有谁记得这一刻。只有风,把第一缕微弱的生机,吹向了荒野里零散的族群。</p>]]></field>
</record>
<record id="story_ch_fx_2" model="game.story.chapter">
<field name="volume_id" ref="story_vol_fanxu"/>
<field name="sequence">20</field>
<field name="name">第一章 · 第一株苗</field>
<field name="body_html"><![CDATA[<p>园丁第一次伸手,是在一片荒芜的斜坡上。</p>
<p>他什么也没做,只是把掌心的灵气,轻轻按进了一粒埋在石缝里的种子。没有咒语,没有仪式——只是给予,然后等待。</p>
<p>三天后,一株嫩绿的苗破土而出。它不知道自己被谁照料,也不在乎。它只是生长,像这个世界本该有的样子。</p>
<p>园丁看着那点绿色,忽然明白了自己该做的事:不是统治,不是创造,而是——让生长发生。</p>]]></field>
</record>
<record id="story_ch_fx_3" model="game.story.chapter">
<field name="volume_id" ref="story_vol_fanxu"/>
<field name="sequence">30</field>
<field name="name">第二章 · 聚落的第一缕光</field>
<field name="body_html"><![CDATA[<p>苗活下来之后,荒野里有了第二个、第三个生命。</p>
<p>零散的族群循着灵气的痕迹聚拢。他们不再只是狩猎采集,开始用碎石垒出简陋的屋舍,用兽骨交换 surplus 的猎物。第一缕属于「文明」的光,在凡墟的夜色里亮起。</p>
<p>园丁没有现身。他只是把灵气的浓度,往聚落的方向,悄悄调浓了半分。</p>]]></field>
</record>
<record id="story_vol_lingqi" model="game.story.volume">
<field name="name">第二卷 · 园丁手记·灵启篇</field>
<field name="sequence">20</field>
<field name="intro"><![CDATA[<p>灵启篇·园丁手记。当聚落连成部族,争与和同时降临。</p>]]></field>
</record>
<record id="story_ch_lq_1" model="game.story.chapter">
<field name="volume_id" ref="story_vol_lingqi"/>
<field name="sequence">10</field>
<field name="name">第一章 · 部族初成</field>
<field name="body_html"><![CDATA[<p>灵气浓到一定程度,聚落开始兼并。</p>
<p>三个相邻的族群,在一场没有流血的议事之后,决定共用一口深井。这是凡墟历史上第一次「联盟」——没有王,只有约定。</p>
<p>园丁在云端记下了这一笔。他知道,从今往后,世界不再只是一群野生的人,而是一个会自己谈判、自己妥协的活物。</p>]]></field>
</record>
<record id="story_ch_lq_2" model="game.story.chapter">
<field name="volume_id" ref="story_vol_lingqi"/>
<field name="sequence">20</field>
<field name="name">第二章 · 交易与争端</field>
<field name="body_html"><![CDATA[<p>联盟带来了繁荣,也带来了第一场争端。</p>
<p>东边的墟族说井水该归他们,西边的人不肯。争执了七天,最后是园丁在不经意间,让井边多长出一片可食用的苔藓——谁都能采,谁都不必独占。</p>
<p>争端平息的方式,从来不在胜负,而在有没有第三种选择。这是园丁学到的第二课。</p>]]></field>
</record>
</odoo> </odoo>

View File

@ -4,3 +4,4 @@ from . import announcement
from . import rule from . import rule
from . import currency from . import currency
from . import page from . import page
from . import story

View File

@ -0,0 +1,27 @@
# -*- coding: utf-8 -*-
from odoo import models, fields
class GameStoryVolume(models.Model):
_name = 'game.story.volume'
_description = '小说卷'
_order = 'sequence, id'
_rec_name = 'name'
name = fields.Char(string='卷名', required=True, index=True)
sequence = fields.Integer(string='序号', default=10, index=True)
intro = fields.Html(string='卷首语')
chapter_ids = fields.One2many('game.story.chapter', 'volume_id', string='章节')
class GameStoryChapter(models.Model):
_name = 'game.story.chapter'
_description = '小说章节'
_order = 'volume_id, sequence, id'
_rec_name = 'name'
volume_id = fields.Many2one('game.story.volume', string='所属卷',
required=True, ondelete='cascade', index=True)
name = fields.Char(string='章名', required=True, index=True)
sequence = fields.Integer(string='序号', default=10, index=True)
body_html = fields.Html(string='正文')

View File

@ -15,3 +15,7 @@ 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_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 access_game_page_item,game.page.item public,model_game_page_item,base.group_public,1,0,0,0
access_game_page_item_user,game.page.item user,model_game_page_item,base.group_user,1,1,1,1 access_game_page_item_user,game.page.item user,model_game_page_item,base.group_user,1,1,1,1
access_game_story_volume,game.story.volume public,model_game_story_volume,base.group_public,1,0,0,0
access_game_story_volume_user,game.story.volume user,model_game_story_volume,base.group_user,1,1,1,1
access_game_story_chapter,game.story.chapter public,model_game_story_chapter,base.group_public,1,0,0,0
access_game_story_chapter_user,game.story.chapter user,model_game_story_chapter,base.group_user,1,1,1,1

1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
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
17 access_game_page_item_user game.page.item user model_game_page_item base.group_user 1 1 1 1
18 access_game_story_volume game.story.volume public model_game_story_volume base.group_public 1 0 0 0
19 access_game_story_volume_user game.story.volume user model_game_story_volume base.group_user 1 1 1 1
20 access_game_story_chapter game.story.chapter public model_game_story_chapter base.group_public 1 0 0 0
21 access_game_story_chapter_user game.story.chapter user model_game_story_chapter base.group_user 1 1 1 1

View File

@ -203,4 +203,59 @@
</field> </field>
</record> </record>
<!-- ============ 小说(剧情) ============ -->
<menuitem id="menu_game_story" name="小说" parent="menu_game_root" sequence="35"/>
<record id="action_game_story_volume" model="ir.actions.act_window">
<field name="name"></field>
<field name="res_model">game.story.volume</field>
<field name="view_mode">list,form</field>
</record>
<menuitem id="menu_game_story_volume" name="卷" parent="menu_game_story" action="action_game_story_volume" sequence="36"/>
<record id="action_game_story_chapter" model="ir.actions.act_window">
<field name="name">章节</field>
<field name="res_model">game.story.chapter</field>
<field name="view_mode">list,form</field>
</record>
<menuitem id="menu_game_story_chapter" name="章节" parent="menu_game_story" action="action_game_story_chapter" sequence="37"/>
<record id="view_game_story_volume_list" model="ir.ui.view">
<field name="name">game.story.volume.list</field>
<field name="model">game.story.volume</field>
<field name="arch" type="xml">
<list><field name="sequence"/><field name="name"/><field name="chapter_ids"/></list>
</field>
</record>
<record id="view_game_story_volume_form" model="ir.ui.view">
<field name="name">game.story.volume.form</field>
<field name="model">game.story.volume</field>
<field name="arch" type="xml">
<form>
<group><field name="name"/><field name="sequence"/></group>
<field name="intro"/>
<field name="chapter_ids">
<list editable="bottom">
<field name="sequence" widget="handle"/><field name="name"/><field name="body_html"/>
</list>
</field>
</form>
</field>
</record>
<record id="view_game_story_chapter_list" model="ir.ui.view">
<field name="name">game.story.chapter.list</field>
<field name="model">game.story.chapter</field>
<field name="arch" type="xml">
<list><field name="volume_id"/><field name="sequence"/><field name="name"/></list>
</field>
</record>
<record id="view_game_story_chapter_form" model="ir.ui.view">
<field name="name">game.story.chapter.form</field>
<field name="model">game.story.chapter</field>
<field name="arch" type="xml">
<form>
<group><field name="volume_id"/><field name="sequence"/><field name="name"/></group>
<field name="body_html"/>
</form>
</field>
</record>
</odoo> </odoo>

View File

@ -60,6 +60,12 @@ const API = {
async announcements() { async announcements() {
return (await fetch(_url('/game/api/announcements'), { credentials: _cred })).json(); return (await fetch(_url('/game/api/announcements'), { credentials: _cred })).json();
}, },
async story() {
return (await fetch(_url('/game/api/story'), { credentials: _cred })).json();
},
async storyChapter(cid) {
return (await fetch(_url('/game/api/story/chapter/' + cid), { credentials: _cred })).json();
},
// ---------- 沙盘记忆yt_world 模块) ---------- // ---------- 沙盘记忆yt_world 模块) ----------
async world() { async world() {
return (await fetch('/yt_world/api/world?db=yt_game', { credentials: 'same-origin' })).json(); return (await fetch('/yt_world/api/world?db=yt_game', { credentials: 'same-origin' })).json();
@ -78,4 +84,19 @@ const API = {
body: JSON.stringify({ settings }), body: JSON.stringify({ settings }),
})).json(); })).json();
}, },
// ---------- 世界数据 / 包裹后端接口预留yt_world 暂未实现路由,前端 mock 兜底) ----------
async worldData() {
try {
const r = await fetch('/yt_world/api/data?db=yt_game', { credentials: 'same-origin' });
if (!r.ok) return null;
return await r.json();
} catch (e) { return null; }
},
async inventory() {
try {
const r = await fetch('/yt_world/api/inventory?db=yt_game', { credentials: 'same-origin' });
if (!r.ok) return null;
return await r.json();
} catch (e) { return null; }
},
}; };

View File

@ -32,9 +32,6 @@ async function loadPages(){
else if (p.key === 'letter') html += letterHTML(p); else if (p.key === 'letter') html += letterHTML(p);
}); });
if (bg) bg.innerHTML = html; // 特色 + 背景 + 致玩家 平铺进同一页,无子菜单切换 if (bg) bg.innerHTML = html; // 特色 + 背景 + 致玩家 平铺进同一页,无子菜单切换
const story = document.getElementById('story');
if (story) story.innerHTML = '<div class="sec-title">剧情<small>STORY</small></div><div style="text-align:center;padding:80px 20px;color:#556677;font-size:15px;"><div style="font-size:48px;margin-bottom:16px;">📖</div>剧情内容正在筹备中…<div style="font-size:13px;margin-top:8px;color:#445566;">敬请期待</div></div>';
} catch (e) { /* 失败时保留 HTML 中的静态兜底内容 */ } } catch (e) { /* 失败时保留 HTML 中的静态兜底内容 */ }
} }
@ -99,9 +96,51 @@ async function loadAnnounce(){
} catch (e) { /* 保留静态兜底内容 */ } } catch (e) { /* 保留静态兜底内容 */ }
} }
// ---------- 剧情(小说阅读器:左侧目录 + 右侧正文) ----------
async function loadStory(){
const tocList = document.getElementById('storyTocList');
const reader = document.getElementById('storyReader');
if (!tocList || !reader) return;
let data;
try { data = await API.story(); }
catch (e) { tocList.innerHTML = '<div class="toc-loading">目录加载失败</div>'; return; }
const volumes = (data && data.volumes) || [];
if (!volumes.length){ tocList.innerHTML = '<div class="toc-loading">暂无内容</div>'; return; }
const volNameByCh = {};
let firstCid = null;
const html = volumes.map(v => {
const chs = (v.chapters || []).map(c => {
volNameByCh[c.id] = v.name;
if (firstCid === null) firstCid = c.id;
return '<button class="toc-ch" data-cid="' + c.id + '">' + c.name + '</button>';
}).join('');
return '<div class="toc-vol"><div class="vname">' + v.name + '</div>' +
(v.intro ? '<div class="vintro">' + v.intro + '</div>' : '') + chs + '</div>';
}).join('');
tocList.innerHTML = html;
async function openChapter(cid){
tocList.querySelectorAll('.toc-ch').forEach(b => b.classList.toggle('active', String(b.dataset.cid) === String(cid)));
reader.innerHTML = '<div class="reader-loading">加载中…</div>';
try {
const d = await API.storyChapter(cid);
if (!d || d.error){ reader.innerHTML = '<div class="reader-loading">' + ((d && d.error) || '章节加载失败') + '</div>'; return; }
reader.innerHTML =
'<div class="reader-inner"><div class="r-vol">' + (volNameByCh[cid] || '') + '</div>' +
'<h1>' + d.name + '</h1><div class="body">' + (d.body_html || '<p>(暂无正文)</p>') + '</div></div>';
reader.scrollTop = 0;
} catch (e) { reader.innerHTML = '<div class="reader-loading">章节加载失败</div>'; }
}
tocList.querySelectorAll('.toc-ch').forEach(b => b.addEventListener('click', () => openChapter(b.dataset.cid)));
if (firstCid !== null) openChapter(firstCid);
}
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
loadPages(); loadPages();
loadRules(); loadRules();
loadForum(); loadForum();
loadAnnounce(); loadAnnounce();
loadStory();
}); });

View File

@ -186,10 +186,48 @@
#letter .letter-wrap .lt-body .hl{color:var(--gold);font-weight:500;} #letter .letter-wrap .lt-body .hl{color:var(--gold);font-weight:500;}
#letter .letter-wrap .lt-sign{text-align:right;margin-top:28px;font-size:14px;color:var(--magenta);font-style:italic;} #letter .letter-wrap .lt-sign{text-align:right;margin-top:28px;font-size:14px;color:var(--magenta);font-style:italic;}
/* ===== 论坛(竖排:顶部分类条 + 下方纵向帖子流) ===== */
#forum{flex-direction:column;gap:16px;}
.f-cats{flex:0 0 auto;flex-direction:row;flex-wrap:wrap;gap:8px;}
.f-cats .ch{flex:0 0 auto;}
.f-main{flex:1;min-height:0;}
.threads{max-width:900px;width:100%;}
/* ===== 公告(居中竖排) ===== */
#announce{align-items:center;}
#announce .notes{max-width:880px;width:100%;margin:0 auto;}
/* ===== 剧情(小说阅读器:左目录 + 右正文,竖排阅读感) ===== */
#story{padding:0;}
.story-wrap{display:flex;flex-direction:row;width:100%;height:100%;min-height:0;}
.story-toc{flex:0 0 286px;display:flex;flex-direction:column;border-right:1px solid var(--card-bd);background:rgba(8,12,26,.35);min-height:0;}
.toc-head{font-family:"ZCOOL QingKe HuangYou",sans-serif;font-size:22px;letter-spacing:3px;color:#fff;padding:24px 22px 10px;text-shadow:0 0 16px rgba(139,92,246,.5);}
.toc-head small{display:block;font-family:"Press Start 2P",monospace;font-size:8px;color:var(--magenta);letter-spacing:1px;margin-top:6px;}
.toc-list{flex:1;overflow-y:auto;padding:0 12px 24px;}
.toc-loading{padding:30px 16px;color:var(--text-dim);font-size:14px;}
.toc-vol{margin-top:10px;}
.toc-vol .vname{font-size:13px;color:var(--gold);letter-spacing:1px;padding:8px 10px 4px;font-weight:500;}
.toc-vol .vintro{font-size:12px;color:var(--text-dim);line-height:1.6;padding:0 10px 8px;}
.toc-ch{display:block;width:100%;text-align:left;font-family:inherit;font-size:13.5px;color:var(--text-dim);background:transparent;border:none;border-left:2px solid transparent;padding:9px 12px;cursor:pointer;transition:.18s;border-radius:0 8px 8px 0;line-height:1.4;}
.toc-ch:hover{color:#fff;background:rgba(139,92,246,.12);}
.toc-ch.active{color:#fff;background:rgba(139,92,246,.20);border-left-color:var(--magenta);}
.story-reader{flex:1;overflow-y:auto;min-width:0;background:linear-gradient(180deg, rgba(139,92,246,.04), transparent 240px);}
.reader-loading{padding:80px 20px;text-align:center;color:var(--text-dim);font-size:15px;}
.reader-inner{max-width:760px;margin:0 auto;padding:44px 48px 90px;}
.reader-inner .r-vol{font-size:12px;color:var(--cyan);letter-spacing:2px;margin-bottom:10px;}
.reader-inner h1{font-family:"ZCOOL QingKe HuangYou",sans-serif;font-size:30px;letter-spacing:2px;color:#fff;text-shadow:0 0 16px rgba(139,92,246,.5);margin-bottom:18px;line-height:1.3;}
.reader-inner .body{font-size:15.5px;color:var(--text);line-height:2.15;}
.reader-inner .body p{margin-bottom:18px;text-indent:2em;}
.reader-inner .body p:first-child{text-indent:0;}
.reader-inner .body h2,.reader-inner .body h3{color:#fff;margin:22px 0 10px;font-size:18px;}
@media (max-width:900px){ @media (max-width:900px){
.grid{grid-template-columns:repeat(2,1fr);} .grid{grid-template-columns:repeat(2,1fr);}
#background,#forum{flex-direction:column;overflow-y:auto;} #background,#forum{flex-direction:column;overflow-y:auto;}
.layers,.f-cats{flex:none;} .layers,.f-cats{flex:none;}
#story .story-wrap{flex-direction:column;overflow-y:auto;}
.story-toc{flex:none;border-right:none;border-bottom:1px solid var(--card-bd);}
.toc-list{max-height:220px;}
.hero .copy h1{font-size:42px;} .hero .copy h1{font-size:42px;}
.nav button{padding:8px 10px;font-size:13px;} .nav button{padding:8px 10px;font-size:13px;}
.nav{overflow-x:auto;scrollbar-width:none;} .nav{overflow-x:auto;scrollbar-width:none;}
@ -206,7 +244,7 @@
<button data-tab="background">背景</button> <button data-tab="background">背景</button>
<button data-tab="rules">规则</button> <button data-tab="rules">规则</button>
<button data-tab="codex">图鉴</button> <button data-tab="codex">图鉴</button>
<button data-tab="forum">论坛</button> <button data-tab="forum" class="auth-only">论坛</button>
<button data-tab="announce">公告</button> <button data-tab="announce">公告</button>
<button data-tab="story">剧情</button> <button data-tab="story">剧情</button>
<button data-tab="worlddata" class="auth-only">世界数据</button> <button data-tab="worlddata" class="auth-only">世界数据</button>
@ -361,8 +399,18 @@
</div> </div>
</section> </section>
<!-- ===== 剧情 ===== --> <!-- ===== 剧情(小说阅读器:左目录 + 右正文) ===== -->
<section class="panel" id="story"></section> <section class="panel" id="story">
<div class="story-wrap">
<aside class="story-toc">
<div class="toc-head">目录<small>CONTENTS</small></div>
<div class="toc-list" id="storyTocList"><div class="toc-loading">加载中…</div></div>
</aside>
<article class="story-reader" id="storyReader">
<div class="reader-loading">从左侧目录选择章节开始阅读</div>
</article>
</div>
</section>
<!-- ===== 世界数据(登录后可见) ===== --> <!-- ===== 世界数据(登录后可见) ===== -->
<section class="panel" id="worlddata"> <section class="panel" id="worlddata">