fix merge conflicts

This commit is contained in:
李鹏宇 2026-07-28 21:46:38 +08:00
commit d103dd9ca1
29 changed files with 1437 additions and 92 deletions

View File

@ -1,14 +1,15 @@
{
'name': '宇森游戏基础数据',
'summary': '宇森游戏基础数据:图鉴 / 规则 / 论坛 / 公告',
'version': '1.2',
'summary': '宇森游戏基础数据:图鉴 / 规则 / 论坛 / 公告 / 玩家员工',
'version': '1.3',
'category': 'Game',
'sequence': 10,
'depends': ['base'],
'depends': ['base', 'hr'],
'data': [
'security/ir.model.access.csv',
'data/game_base_data.xml',
'data/story_cron.xml',
'data/game_achievement_data.xml',
'views/codex_views.xml',
'views/rule_category_views.xml',
'views/rule_views.xml',
@ -16,10 +17,13 @@
'views/forum_views.xml',
'views/announcement_views.xml',
'views/story_views.xml',
'views/game_employee_views.xml',
'views/game_achievement_views.xml',
'views/game_mail_views.xml',
'views/menu.xml',
],
'application': True,
'license': 'LGPL-3',
'pre_init_hook': 'pre_init_hook',
'description': '基于官网内容沉淀的游戏基础数据模型,供后台维护、前端经 /game/api/* 读取;并提供与 Odoo 用户打通的登录/注册接口。',
'description': '基于官网内容沉淀的游戏基础数据模型,供后台维护、前端经 /game/api/* 读取;并提供与 Odoo 用户打通的登录/注册接口。注册时自动创建 hr.employee 并初始化游戏数据。',
}

View File

@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
import json
from odoo import http
from odoo import http, fields
from odoo.http import request, Response
@ -309,12 +309,52 @@ class GameApiController(http.Controller):
'groups_id': [(6, 0, [portal.id])],
})
request.env.cr.commit() # 让新用户立即对其他游标可见,便于随后自动登录
# === 自动创建 hr.employee 并初始化游戏数据 ===
Emp = request.env['hr.employee'].sudo()
emp = Emp.create({
'name': name or login,
'user_id': user.id,
'work_email': email or False,
'game_title': '萌新',
'game_level': 1,
'game_exp': 0,
'game_exp_max': 100,
'game_coins': 0,
'game_energy': 0,
'game_wood': 0,
'game_stone': 0,
'game_reg_date': fields.Date.today(),
})
# 生成游戏 UID格式 10086 + 4位员工ID
emp.game_uid = '10086%04d' % emp.id
# 给新玩家解锁第一个成就"开天辟地"
tmpl = request.env['game.achievement.template'].sudo().search(
[('code', '=', 'first_land')], limit=1)
if tmpl:
request.env['game.player.achievement'].sudo().create({
'employee_id': emp.id,
'template_id': tmpl.id,
'progress': tmpl.target,
'unlocked': True,
'unlock_date': fields.Datetime.now(),
})
# 给新玩家发一封欢迎邮件
request.env['game.player.mail'].sudo().create({
'employee_id': emp.id,
'category': 'system',
'subject': '欢迎来到宇森',
'sender': '系统',
'body': '<p>欢迎来到宇森!请查收新手礼包与世界探索指南。</p>',
})
request.env.cr.commit()
db = request.session.db or 'game'
auth_info = request.session.authenticate(db, {'type': 'password', 'login': login, 'password': password})
uid = auth_info['uid']
if uid:
return _json({'ok': True, 'uid': uid, 'name': user.name, 'login': user.login})
return _json({'ok': True, 'uid': user.id, 'name': user.name, 'login': user.login})
return _json({'ok': True, 'uid': uid, 'name': user.name, 'login': user.login,
'emp_id': emp.id, 'game_uid': emp.game_uid})
return _json({'ok': True, 'uid': user.id, 'name': user.name, 'login': user.login,
'emp_id': emp.id, 'game_uid': emp.game_uid})
except Exception as e:
return _json({'ok': False, 'error': '注册失败:%s' % str(e)})
@ -324,9 +364,123 @@ class GameApiController(http.Controller):
if uid:
user = request.env['res.users'].sudo().browse(uid)
if user.exists():
return _json({'uid': uid, 'name': user.name, 'login': user.login})
data = {'uid': uid, 'name': user.name, 'login': user.login,
'email': user.email or ''}
emp = request.env['hr.employee'].sudo().search(
[('user_id', '=', uid)], limit=1)
if emp:
data.update(emp.export_game_data())
return _json(data)
return _json({'uid': None})
@http.route('/game/api/achievements', type='http', auth='public',
methods=['GET'], csrf=False)
def achievements(self, **kw):
"""返回当前登录玩家的成就列表(含模板+进度)。"""
uid = request.session.uid
if not uid:
return _json({'error': '未登录'})
emp = request.env['hr.employee'].sudo().search(
[('user_id', '=', uid)], limit=1)
if not emp:
return _json({'error': '玩家不存在', 'items': [], 'total_points': 0})
# 取所有启用的成就模板
tmpl_model = request.env['game.achievement.template'].sudo()
tier_labels = dict(tmpl_model._fields['tier'].selection)
templates = tmpl_model.search_read(
[('active', '=', True)],
['id', 'name', 'icon', 'tier', 'desc', 'points', 'target', 'sequence'],
order='sequence, id')
# 取该玩家已解锁的成就
player_recs = request.env['game.player.achievement'].sudo().search_read(
[('employee_id', '=', emp.id)],
['template_id', 'progress', 'unlocked', 'unlock_date'])
progress_map = {
r['template_id'][0]: r for r in player_recs
}
items = []
total_points = 0
unlocked_count = 0
for t in templates:
pr = progress_map.get(t['id'])
unlocked = pr and pr['unlocked']
progress = pr['progress'] if pr else 0
if unlocked:
total_points += t['points']
unlocked_count += 1
items.append({
'id': t['id'],
'name': t['name'],
'icon': t.get('icon') or '',
'tier': t['tier'],
'tier_label': tier_labels.get(t['tier'], t['tier']),
'desc': t.get('desc') or '',
'points': t['points'],
'target': t['target'],
'progress': progress,
'unlocked': bool(unlocked),
'unlock_date': str(pr['unlock_date']) if pr and pr['unlock_date'] else None,
})
return _json({
'items': items,
'total_points': total_points,
'unlocked_count': unlocked_count,
'total_count': len(items),
})
@http.route('/game/api/mails', type='http', auth='public',
methods=['GET'], csrf=False)
def mails(self, **kw):
"""返回当前登录玩家的邮件列表。"""
uid = request.session.uid
if not uid:
return _json({'error': '未登录', 'items': []})
emp = request.env['hr.employee'].sudo().search(
[('user_id', '=', uid)], limit=1)
if not emp:
return _json({'error': '玩家不存在', 'items': []})
cat_labels = dict(
request.env['game.player.mail'].sudo()._fields['category'].selection)
recs = request.env['game.player.mail'].sudo().search_read(
[('employee_id', '=', emp.id)],
['id', 'category', 'subject', 'body', 'date', 'is_read',
'is_pinned', 'sender'],
order='is_pinned desc, date desc')
items = [{
'id': r['id'],
'category': r['category'],
'category_label': cat_labels.get(r['category'], r['category']),
'subject': r['subject'],
'body': r.get('body') or '',
'date': str(r['date']) if r['date'] else '',
'is_read': r['is_read'],
'is_pinned': r['is_pinned'],
'sender': r.get('sender') or '',
} for r in recs]
unread = len([r for r in items if not r['is_read']])
return _json({'items': items, 'unread': unread})
@http.route('/game/api/mail/read', type='http', auth='public',
methods=['POST', 'OPTIONS'], csrf=False)
def mail_read(self, **kw):
"""标记邮件为已读。"""
uid = request.session.uid
if not uid:
return _json({'ok': False, 'error': '未登录'})
data = _post_body()
mail_id = data.get('mail_id')
if not mail_id:
return _json({'ok': False, 'error': '缺少 mail_id'})
emp = request.env['hr.employee'].sudo().search(
[('user_id', '=', uid)], limit=1)
if not emp:
return _json({'ok': False, 'error': '玩家不存在'})
mail = request.env['game.player.mail'].sudo().browse(mail_id)
if not mail.exists() or mail.employee_id != emp:
return _json({'ok': False, 'error': '邮件不存在或无权操作'})
mail.write({'is_read': True})
return _json({'ok': True})
@http.route('/game/api/logout', type='http', auth='public',
methods=['POST', 'OPTIONS'], csrf=False)
def logout(self, **kw):

View File

@ -0,0 +1,95 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<!-- 成就模板初始数据 -->
<record model="game.achievement.template" id="ach_first_land">
<field name="code">first_land</field>
<field name="sequence">10</field>
<field name="name">开天辟地</field>
<field name="icon">star</field>
<field name="tier">gold</field>
<field name="points">50</field>
<field name="target">1</field>
<field name="desc">已点亮第一块领土,文明之火开始燃烧。</field>
</record>
<record model="game.achievement.template" id="ach_civilization_cradle">
<field name="code">civilization_cradle</field>
<field name="sequence">20</field>
<field name="name">文明摇篮</field>
<field name="icon">sprout</field>
<field name="tier">silver</field>
<field name="points">30</field>
<field name="target">5</field>
<field name="desc">建立 5 个聚落,文明初步成形。</field>
</record>
<record model="game.achievement.template" id="ach_sky_tower">
<field name="code">sky_tower</field>
<field name="sequence">30</field>
<field name="name">通天塔</field>
<field name="icon">lock</field>
<field name="tier">platinum</field>
<field name="points">100</field>
<field name="target">10</field>
<field name="desc">建造 10 座奇观,文明疆域遍布穹苍。</field>
</record>
<record model="game.achievement.template" id="ach_world_explorer">
<field name="code">world_explorer</field>
<field name="sequence">40</field>
<field name="name">世界探索者</field>
<field name="icon">compass</field>
<field name="tier">bronze</field>
<field name="points">10</field>
<field name="target">10</field>
<field name="desc">探索 10 块未知领土。</field>
</record>
<record model="game.achievement.template" id="ach_resource_master">
<field name="code">resource_master</field>
<field name="sequence">50</field>
<field name="name">资源大亨</field>
<field name="icon">gem</field>
<field name="tier">silver</field>
<field name="points">30</field>
<field name="target">1000</field>
<field name="desc">累计获得 1000 金币。</field>
</record>
<record model="game.achievement.template" id="ach_spirit_awakening">
<field name="code">spirit_awakening</field>
<field name="sequence">60</field>
<field name="name">灵气觉醒</field>
<field name="icon">sparkle</field>
<field name="tier">gold</field>
<field name="points">50</field>
<field name="target">1</field>
<field name="desc">世界阶段跃迁至「灵启」。</field>
</record>
<record model="game.achievement.template" id="ach_mystic_realm">
<field name="code">mystic_realm</field>
<field name="sequence">70</field>
<field name="name">玄界之主</field>
<field name="icon">crown</field>
<field name="tier">legend</field>
<field name="points">200</field>
<field name="target">1</field>
<field name="desc">世界阶段跃迁至「玄界」。</field>
</record>
<record model="game.achievement.template" id="ach_eternal">
<field name="code">eternal</field>
<field name="sequence">80</field>
<field name="name">永恒之锚</field>
<field name="icon">infinity</field>
<field name="tier">legend</field>
<field name="points">500</field>
<field name="target">1</field>
<field name="desc">世界阶段达到「溯元」,触及世界本源。</field>
</record>
</data>
</odoo>

View File

@ -6,3 +6,6 @@ from . import rule
from . import page
from . import story
from . import ir_http
from . import game_employee
from . import game_achievement
from . import game_mail

View File

@ -0,0 +1,85 @@
# -*- coding: utf-8 -*-
from odoo import api, fields, models
class GameAchievementTemplate(models.Model):
"""全局成就模板:后台定义,所有玩家共用同一套成就定义。"""
_name = 'game.achievement.template'
_description = '成就模板'
_order = 'sequence, id'
_rec_name = 'name'
TIER = [
('bronze', ''),
('silver', ''),
('gold', ''),
('platinum', '铂金'),
('legend', '传奇'),
]
sequence = fields.Integer('排序', default=10, index=True)
code = fields.Char('标识', index=True,
help='唯一标识,用于代码引用,如 first_land')
name = fields.Char('成就名称', required=True, translate=True)
icon = fields.Char('图标', help='emoji 或符号,如 star / sprout / lock')
tier = fields.Selection(TIER, '品质', default='bronze', index=True)
desc = fields.Text('描述', help='解锁条件/说明')
target = fields.Integer('目标值', default=1,
help='解锁所需进度值,如 10 表示需完成 10 次')
points = fields.Integer('成就点数', default=10)
active = fields.Boolean('启用', default=True)
player_achievement_ids = fields.One2many(
'game.player.achievement', 'template_id', '玩家解锁记录')
class GamePlayerAchievement(models.Model):
"""玩家已解锁/进行中的成就,关联到 hr.employee。"""
_name = 'game.player.achievement'
_description = '玩家成就'
_order = 'template_id, id'
_rec_name = 'name'
template_id = fields.Many2one(
'game.achievement.template', '成就模板',
required=True, ondelete='cascade', index=True)
employee_id = fields.Many2one(
'hr.employee', '所属玩家',
required=True, ondelete='cascade', index=True)
name = fields.Char('名称', related='template_id.name', store=True)
icon = fields.Char('图标', related='template_id.icon')
tier = fields.Selection(
related='template_id.tier', store=True)
desc = fields.Text('描述', related='template_id.desc')
points = fields.Integer('点数', related='template_id.points')
progress = fields.Integer('进度', default=0)
target = fields.Integer('目标值', related='template_id.target')
unlocked = fields.Boolean('已解锁', default=False)
unlock_date = fields.Datetime('解锁时间')
@api.model
def _update_progress(self, employee_id, template_key, progress):
"""更新某玩家某成就的进度,达标自动解锁。"""
tmpl = self.env['game.achievement.template'].search(
[('id', '=', template_key)], limit=1)
if not tmpl:
return False
rec = self.search([
('employee_id', '=', employee_id),
('template_id', '=', tmpl.id),
], limit=1)
if not rec:
rec = self.create({
'employee_id': employee_id,
'template_id': tmpl.id,
'progress': min(progress, tmpl.target),
'unlocked': progress >= tmpl.target,
'unlock_date': fields.Datetime.now() if progress >= tmpl.target else False,
})
elif not rec.unlocked:
rec.write({
'progress': min(progress, tmpl.target),
'unlocked': progress >= tmpl.target,
})
if rec.unlocked:
rec.unlock_date = fields.Datetime.now()
return True

View File

@ -0,0 +1,74 @@
# -*- coding: utf-8 -*-
from odoo import api, fields, models
class GameEmployee(models.Model):
"""在 hr.employee 上扩展游戏数据字段。"""
_inherit = 'hr.employee'
# === 等级系统 ===
game_level = fields.Integer('游戏等级', default=1)
game_exp = fields.Integer('经验值', default=0)
game_exp_max = fields.Integer('升级经验', default=100)
game_title = fields.Char('称号', default='萌新')
# === 资源系统 ===
game_coins = fields.Integer('金币', default=0)
game_energy = fields.Integer('灵气', default=0)
game_wood = fields.Integer('木材', default=0)
game_stone = fields.Integer('石料', default=0)
# === 世界数据 ===
game_world_name = fields.Char('世界名')
game_era = fields.Char('历元', default='星元1年·春')
game_tiles = fields.Integer('疆域地块', default=0)
game_population = fields.Integer('人口', default=0)
game_civ_level = fields.Integer('文明度', default=0)
# === 身份 ===
game_uid = fields.Char('游戏UID', index=True)
game_reg_date = fields.Date('注册日期', default=fields.Date.today)
# === 关联 ===
achievement_ids = fields.One2many('game.player.achievement', 'employee_id', '成就')
mail_ids = fields.One2many('game.player.mail', 'employee_id', '邮件')
game_achievement_count = fields.Integer(
'已解锁成就数', compute='_compute_achievement_count')
game_unread_mail = fields.Integer(
'未读邮件数', compute='_compute_unread_mail')
@api.depends('achievement_ids.unlocked')
def _compute_achievement_count(self):
for rec in self:
rec.game_achievement_count = len(
rec.achievement_ids.filtered('unlocked'))
@api.depends('mail_ids.is_read')
def _compute_unread_mail(self):
for rec in self:
rec.game_unread_mail = len(
rec.mail_ids.filtered(lambda m: not m.is_read))
def export_game_data(self):
"""将游戏数据导出为前端可用的字典。"""
self.ensure_one()
return {
'emp_id': self.id,
'level': self.game_level,
'exp': self.game_exp,
'exp_max': self.game_exp_max,
'title': self.game_title,
'coins': self.game_coins,
'energy': self.game_energy,
'wood': self.game_wood,
'stone': self.game_stone,
'world_name': self.game_world_name or '',
'era': self.game_era or '',
'tiles': self.game_tiles,
'population': self.game_population,
'civ_level': self.game_civ_level,
'game_uid': self.game_uid or '',
'reg_date': str(self.game_reg_date) if self.game_reg_date else None,
'achievement_count': self.game_achievement_count,
'unread_mail': self.game_unread_mail,
}

View File

@ -0,0 +1,28 @@
# -*- coding: utf-8 -*-
from odoo import api, fields, models
class GamePlayerMail(models.Model):
"""玩家邮件:后台可发送系统邮件给指定玩家(员工)。"""
_name = 'game.player.mail'
_description = '玩家邮件'
_order = 'date desc, id desc'
_rec_name = 'subject'
CATEGORY = [
('system', '系统'),
('activity', '活动'),
('reward', '奖励'),
('announcement', '公告'),
]
employee_id = fields.Many2one(
'hr.employee', '收件人',
required=True, ondelete='cascade', index=True)
category = fields.Selection(CATEGORY, '分类', default='system', index=True)
subject = fields.Char('标题', required=True)
body = fields.Html('正文')
date = fields.Datetime('发送时间', default=fields.Datetime.now)
is_read = fields.Boolean('已读', default=False)
is_pinned = fields.Boolean('置顶', default=False)
sender = fields.Char('发件人', default='系统')

View File

@ -19,3 +19,9 @@ access_game_story_volume,game.story.volume public,model_game_story_volume,base.g
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
access_game_achievement_template,game.achievement.template public,model_game_achievement_template,base.group_public,1,0,0,0
access_game_achievement_template_user,game.achievement.template user,model_game_achievement_template,base.group_user,1,1,1,1
access_game_player_achievement,game.player.achievement public,model_game_player_achievement,base.group_public,1,0,0,0
access_game_player_achievement_user,game.player.achievement user,model_game_player_achievement,base.group_user,1,1,1,1
access_game_player_mail,game.player.mail public,model_game_player_mail,base.group_public,1,0,0,0
access_game_player_mail_user,game.player.mail user,model_game_player_mail,base.group_user,1,1,1,1

1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
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
22 access_game_achievement_template game.achievement.template public model_game_achievement_template base.group_public 1 0 0 0
23 access_game_achievement_template_user game.achievement.template user model_game_achievement_template base.group_user 1 1 1 1
24 access_game_player_achievement game.player.achievement public model_game_player_achievement base.group_public 1 0 0 0
25 access_game_player_achievement_user game.player.achievement user model_game_player_achievement base.group_user 1 1 1 1
26 access_game_player_mail game.player.mail public model_game_player_mail base.group_public 1 0 0 0
27 access_game_player_mail_user game.player.mail user model_game_player_mail base.group_user 1 1 1 1

View File

@ -28,7 +28,7 @@
</group>
<field name="body_html"/>
<field name="post_ids">
<tree><field name="author_name"/><field name="create_date"/></tree>
<list><field name="author_name"/><field name="create_date"/></list>
<form><field name="author_name"/><field name="body_html"/></form>
</field>
</form>

View File

@ -0,0 +1,81 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- 成就模板列表 -->
<record model="ir.ui.view" id="view_game_achievement_template_tree">
<field name="name">game.achievement.template.tree</field>
<field name="model">game.achievement.template</field>
<field name="arch" type="xml">
<list>
<field name="sequence" widget="handle"/>
<field name="code"/>
<field name="name"/>
<field name="icon"/>
<field name="tier"/>
<field name="points"/>
<field name="target"/>
<field name="active" widget="boolean_toggle"/>
</list>
</field>
</record>
<!-- 成就模板表单 -->
<record model="ir.ui.view" id="view_game_achievement_template_form">
<field name="name">game.achievement.template.form</field>
<field name="model">game.achievement.template</field>
<field name="arch" type="xml">
<form>
<sheet>
<group>
<group>
<field name="code"/>
<field name="name"/>
<field name="icon" placeholder="emoji 或符号"/>
<field name="tier"/>
</group>
<group>
<field name="points"/>
<field name="target"/>
<field name="sequence"/>
<field name="active"/>
</group>
</group>
<group>
<field name="desc" placeholder="解锁条件/说明"/>
</group>
</sheet>
</form>
</field>
</record>
<!-- 动作 -->
<record model="ir.actions.act_window" id="action_game_achievement_template">
<field name="name">成就模板</field>
<field name="res_model">game.achievement.template</field>
<field name="view_mode">tree,form</field>
</record>
<!-- 玩家成就列表(只读查看) -->
<record model="ir.ui.view" id="view_game_player_achievement_tree">
<field name="name">game.player.achievement.tree</field>
<field name="model">game.player.achievement</field>
<field name="arch" type="xml">
<list>
<field name="name"/>
<field name="employee_id"/>
<field name="tier"/>
<field name="points"/>
<field name="progress"/>
<field name="unlocked"/>
<field name="unlock_date"/>
</list>
</field>
</record>
<record model="ir.actions.act_window" id="action_game_player_achievement">
<field name="name">玩家成就</field>
<field name="res_model">game.player.achievement</field>
<field name="view_mode">tree,form</field>
</record>
</odoo>

View File

@ -0,0 +1,60 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- 继承 hr.employee 表单视图,添加「游戏数据」页签 -->
<record model="ir.ui.view" id="view_employee_game_form">
<field name="name">hr.employee.game.form</field>
<field name="model">hr.employee</field>
<field name="inherit_id" ref="hr.view_employee_form"/>
<field name="arch" type="xml">
<xpath expr="//notebook" position="inside">
<page string="游戏数据" name="game_data">
<group>
<group string="等级系统">
<field name="game_uid"/>
<field name="game_level"/>
<field name="game_exp"/>
<field name="game_exp_max"/>
<field name="game_title"/>
<field name="game_reg_date"/>
</group>
<group string="资源">
<field name="game_coins"/>
<field name="game_energy"/>
<field name="game_wood"/>
<field name="game_stone"/>
</group>
</group>
<group>
<group string="世界数据">
<field name="game_world_name"/>
<field name="game_era"/>
<field name="game_tiles"/>
<field name="game_population"/>
<field name="game_civ_level"/>
</group>
<group string="统计">
<field name="game_achievement_count"/>
<field name="game_unread_mail"/>
</group>
</group>
</page>
</xpath>
</field>
</record>
<!-- 员工列表:增加游戏 UID/等级/称号 列 -->
<record model="ir.ui.view" id="view_employee_game_tree">
<field name="name">hr.employee.game.tree</field>
<field name="model">hr.employee</field>
<field name="inherit_id" ref="hr.view_employee_tree"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='name']" position="after">
<field name="game_uid" string="游戏UID"/>
<field name="game_level" string="等级"/>
<field name="game_title" string="称号"/>
</xpath>
</field>
</record>
</odoo>

View File

@ -0,0 +1,56 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- 玩家邮件列表 -->
<record model="ir.ui.view" id="view_game_player_mail_tree">
<field name="name">game.player.mail.tree</field>
<field name="model">game.player.mail</field>
<field name="arch" type="xml">
<list>
<field name="is_pinned" widget="boolean_toggle"/>
<field name="subject"/>
<field name="employee_id" string="收件人"/>
<field name="category"/>
<field name="sender"/>
<field name="date"/>
<field name="is_read" widget="boolean_toggle"/>
</list>
</field>
</record>
<!-- 玩家邮件表单 -->
<record model="ir.ui.view" id="view_game_player_mail_form">
<field name="name">game.player.mail.form</field>
<field name="model">game.player.mail</field>
<field name="arch" type="xml">
<form>
<sheet>
<group>
<group>
<field name="employee_id" string="收件人"/>
<field name="category"/>
<field name="sender"/>
</group>
<group>
<field name="date"/>
<field name="is_pinned"/>
<field name="is_read"/>
</group>
</group>
<group>
<field name="subject"/>
<field name="body" widget="html"/>
</group>
</sheet>
</form>
</field>
</record>
<!-- 动作 -->
<record model="ir.actions.act_window" id="action_game_player_mail">
<field name="name">玩家邮件</field>
<field name="res_model">game.player.mail</field>
<field name="view_mode">tree,form</field>
</record>
</odoo>

View File

@ -26,6 +26,11 @@
<!-- 公告 -->
<menuitem id="menu_game_announcement" name="公告" parent="menu_game_root" action="action_game_announcement" sequence="40"/>
<!-- 玩家 -->
<menuitem id="menu_game_player" name="玩家" parent="menu_game_root" sequence="45"/>
<menuitem id="menu_game_player_achievement" name="玩家成就" parent="menu_game_player" action="action_game_player_achievement" sequence="46"/>
<menuitem id="menu_game_player_mail" name="玩家邮件" parent="menu_game_player" action="action_game_player_mail" sequence="47"/>
<!-- 小说(剧情) -->
<menuitem id="menu_game_story" name="小说" parent="menu_game_root" sequence="35"/>
<menuitem id="menu_game_story_volume" name="卷" parent="menu_game_story" action="action_game_story_volume" sequence="36"/>
@ -37,5 +42,6 @@
<field name="action" eval="False"/>
</record>
<menuitem id="menu_game_rule_category" name="规则类别" parent="menu_game_config" action="action_game_rule_category" sequence="51"/>
<menuitem id="menu_game_achievement_template" name="成就模板" parent="menu_game_config" action="action_game_achievement_template" sequence="52"/>
</odoo>

View File

@ -59,6 +59,19 @@ const API = {
method: 'POST', credentials: _cred,
})).json();
},
async achievements() {
return (await fetch(_url('/game/api/achievements'), { credentials: _cred })).json();
},
async mails() {
return (await fetch(_url('/game/api/mails'), { credentials: _cred })).json();
},
async mailRead(mailId) {
return (await fetch(_url('/game/api/mail/read'), {
method: 'POST', credentials: _cred,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mail_id: mailId }),
})).json();
},
async codex() {
return (await fetch(_url('/game/api/codex'), { credentials: _cred })).json();
},
@ -136,4 +149,6 @@ const API = {
async codexRaces() {
return (await fetch(_worldUrl('/yt_world/api/codex/races'), { credentials: _wcred })).json();
},
// 暴露后端 base供图片等静态资源 URL 拼接本地跨域→8069线上同域→''
base: API_CONFIG.base,
};

View File

@ -53,6 +53,102 @@ async function loadRules(){
} catch (e) { /* 保留静态兜底内容 */ }
}
// ---------- 图鉴 ----------
async function loadCodex(){
const grid = document.getElementById('codexGrid');
const catsEl = document.getElementById('codexCats');
const subsEl = document.getElementById('codexSubs');
const searchEl= document.getElementById('codexSearch');
const countEl = document.getElementById('codexCount');
if (!grid) return;
let data;
try { data = await API.codex(); }
catch (e) { return; } // 失败时保留静态兜底内容
const groups = (data && data.groups) || [];
if (!groups.length){
grid.innerHTML = '<div style="grid-column:1/-1;text-align:center;color:var(--text-dim);padding:60px 0">暂无图鉴数据</div>';
return;
}
// 选中状态
let curGroup = groups[0].key;
let curSub = 'all';
let curQ = '';
function renderCats(){
if (!catsEl) return;
catsEl.innerHTML = groups.map(g =>
`<button class="${g.key === curGroup ? 'active' : ''}" data-gk="${g.key}">${g.label}</button>`
).join('');
catsEl.querySelectorAll('button').forEach(b => b.addEventListener('click', () => {
curGroup = b.dataset.gk; curSub = 'all'; curQ = '';
if (searchEl) searchEl.value = '';
renderCats(); renderSubs(); renderGrid();
}));
}
function renderSubs(){
if (!subsEl) return;
const g = groups.find(x => x.key === curGroup);
const subs = (g && g.subs) || [];
subsEl.innerHTML = `<button class="${curSub === 'all' ? 'active' : ''}" data-sn="all">全部</button>` +
subs.map(s => `<button class="${curSub === s.name ? 'active' : ''}" data-sn="${s.name}">${s.name}</button>`).join('');
subsEl.querySelectorAll('button').forEach(b => b.addEventListener('click', () => {
curSub = b.dataset.sn; renderSubs(); renderGrid();
}));
}
function renderGrid(){
const g = groups.find(x => x.key === curGroup);
if (!g){ grid.innerHTML = ''; return; }
// 按子分类筛选
let items = [];
(g.subs || []).forEach(s => {
(s.items || []).forEach(it => {
if (curSub !== 'all' && it.sub !== curSub) return;
items.push(it);
});
});
// 搜索过滤
if (curQ){
const q = curQ.toLowerCase();
items = items.filter(it =>
(it.name || '').toLowerCase().includes(q) ||
(it.desc || '').replace(/<[^>]+>/g, '').toLowerCase().includes(q)
);
}
if (countEl) countEl.textContent = items.length + ' 项';
if (!items.length){
grid.innerHTML = '<div style="grid-column:1/-1;text-align:center;color:var(--text-dim);padding:60px 0">未找到匹配项</div>';
return;
}
const base = API.base || '';
grid.innerHTML = items.map(it => {
const thumb = it.image
? `<div class="thumb" style="background:url('${base}${it.image}') center/cover"></div>`
: `<div class="thumb"></div>`;
return `<div class="cx">
${thumb}
<div class="body">
<div class="nm">${it.name || ''}</div>
<div class="desc">${it.desc || ''}</div>
<div class="meta"><span class="sub">${it.sub || ''}</span><span class="tier">${it.tier || ''}</span></div>
</div>
</div>`;
}).join('');
}
if (searchEl){
searchEl.addEventListener('input', () => { curQ = searchEl.value.trim(); renderGrid(); });
}
renderCats();
renderSubs();
renderGrid();
}
// ---------- 论坛 ----------
async function loadForum(){
const threadsEl = document.getElementById('threads');
@ -155,6 +251,7 @@ async function loadStory(){
document.addEventListener('DOMContentLoaded', () => {
loadPages();
loadRules();
loadCodex();
loadForum();
loadAnnounce();
loadStory();

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

BIN
assets/slides/02_sky.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

BIN
assets/slides/03_earth.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

BIN
assets/slides/05_core.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

BIN
assets/slides/07_sky_b.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

BIN
assets/slides/10_core_b.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

BIN
assets/slides/login_bg.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

View File

@ -15,6 +15,65 @@
/* 首页特有:全屏 hero 轮播 */
html,body{overflow:hidden;}
.content{flex:1;display:flex;flex-direction:column;position:relative;overflow:hidden;}
:root{
--bg-0:#080c1a; --bg-1:#0e1730; --bg-2:#15213f;
--violet:#8b5cf6; --magenta:#c77dff; --cyan:#4cc9f0; --gold:#e7b85c;
--text:#e9edf6; --text-dim:#9aa6c4;
--card:rgba(255,255,255,.045); --card-bd:rgba(255,255,255,.10);
--glow:0 0 22px rgba(139,92,246,.28);
}
*{box-sizing:border-box;margin:0;padding:0;}
html,body{height:100%;width:100%;overflow:hidden;}
body{
font-family:"Noto Sans SC",system-ui,sans-serif;color:var(--text);
background:
radial-gradient(1000px 620px at 82% -8%, rgba(139,92,246,.14), transparent 60%),
radial-gradient(820px 560px at 8% 112%, rgba(76,201,240,.10), transparent 55%),
linear-gradient(160deg, var(--bg-0) 0%, var(--bg-1) 48%, var(--bg-2) 100%);
position:relative;
}
body::before,body::after{content:"";position:fixed;border-radius:50%;filter:blur(80px);z-index:0;pointer-events:none;}
body::before{width:380px;height:380px;background:rgba(139,92,246,.20);top:-110px;right:-70px;animation:fl1 20s ease-in-out infinite;}
body::after{width:340px;height:340px;background:rgba(76,201,240,.12);bottom:-110px;left:-60px;animation:fl2 24s ease-in-out infinite;}
@keyframes fl1{0%,100%{transform:translate(0,0)}50%{transform:translate(-36px,36px)}}
@keyframes fl2{0%,100%{transform:translate(0,0)}50%{transform:translate(36px,-28px)}}
.app{position:relative;z-index:2;height:100vh;display:flex;flex-direction:column;}
.topbar{height:64px;flex:0 0 auto;display:flex;align-items:center;gap:22px;padding:0 30px;border-bottom:1px solid var(--card-bd);background:rgba(8,12,26,.55);backdrop-filter:blur(10px);}
.logo{display:flex;align-items:baseline;gap:9px;cursor:pointer;}
.logo .cn{font-family:"ZCOOL QingKe HuangYou",sans-serif;font-size:28px;letter-spacing:4px;color:#fff;text-shadow:0 0 16px rgba(139,92,246,.55);}
.logo .en{font-family:"Press Start 2P",monospace;font-size:9px;color:var(--magenta);letter-spacing:1px;}
.nav{display:flex;gap:6px;margin-left:12px;}
.nav button{font-family:"Noto Sans SC",sans-serif;font-size:15px;font-weight:500;color:var(--text-dim);background:transparent;border:1px solid transparent;padding:8px 15px;border-radius:10px;cursor:pointer;transition:.22s;}
.nav button:hover{color:var(--text);background:rgba(139,92,246,.12);}
.nav button.active{color:#fff;background:rgba(139,92,246,.22);border-color:var(--card-bd);box-shadow:var(--glow);}
.spacer{flex:1;}
.user-box{display:flex;align-items:center;gap:10px;font-size:14px;color:var(--text);}
.user-box .ub-name{color:#fff;font-weight:500;}
.user-box .ub-logout{font-family:inherit;font-size:12px;color:var(--text-dim);background:rgba(255,255,255,.06);border:1px solid var(--card-bd);padding:5px 12px;border-radius:8px;cursor:pointer;transition:.2s;}
.user-box .ub-logout:hover{color:#fff;background:rgba(139,92,246,.18);}
.user-box .auth-link{color:var(--text-dim);text-decoration:none;font-size:14px;transition:.2s;padding:5px 8px;}
.user-box .auth-link:hover{color:#fff;}
.user-box .auth-btn{font-family:inherit;font-size:13px;color:#0b1020;background:linear-gradient(120deg,var(--gold),#f0d49a);border:none;padding:6px 16px;border-radius:20px;cursor:pointer;font-weight:600;transition:.2s;}
.user-box .auth-btn:hover{filter:brightness(1.1);box-shadow:0 0 16px rgba(231,184,92,.4);}
.nav .auth-only{display:none;} /* 登录专属 tab未登录时隐藏 */
.nav .auth-only.visible{display:inline-block;}
.cta:hover{filter:brightness(1.08);}
.content{flex:1;position:relative;overflow:hidden;}
.panel{position:absolute;inset:0;display:none;flex-direction:column;padding:28px 48px;overflow:hidden;}
.panel.active{display:flex;}
.panel-scroll{flex:1;min-height:0;overflow-y:auto;padding-right:8px;}
/* 隐藏内部滚动条但保留滚轮/触控滑动(规则/世界数据/公告/论坛/图鉴/剧情等) */
.rules-scroll,#worldDataContent,#announce .notes,.threads,.codex-grid,.story-reader,.panel-scroll,.toc-list{
scrollbar-width:none; -ms-overflow-style:none;
}
.rules-scroll::-webkit-scrollbar,#worldDataContent::-webkit-scrollbar,#announce .notes::-webkit-scrollbar,
.threads::-webkit-scrollbar,.codex-grid::-webkit-scrollbar,.story-reader::-webkit-scrollbar,
.panel-scroll::-webkit-scrollbar,.toc-list::-webkit-scrollbar{display:none;width:0;height:0;}
/* ===== 介绍(图片轮播主页) ===== */
#intro{padding:0;}
.hero{position:relative;flex:1;border-radius:0;overflow:hidden;}
.hero .slideshow{position:absolute;inset:0;overflow:hidden;}
.hero .slide-track{position:absolute;inset:0;display:flex;flex-direction:column;transition:transform 1.25s cubic-bezier(0.4,0,0.2,1);will-change:transform;}
@ -28,6 +87,7 @@
.hero .copy .tag{font-size:20px;font-weight:500;color:#e8ecf6;margin-top:4px;text-shadow:0 1px 10px rgba(0,0,0,.85);}
/* 轮播标签 */
/* 轮播左下角特色文案 */
.slide-label{position:absolute;left:56px;bottom:72px;z-index:4;pointer-events:none;opacity:0;transform:translateY(16px);transition:opacity .8s ease, transform .8s ease;}
.slide.active .slide-label{opacity:1;transform:translateY(0);}
.slide-label .sl-en{font-family:"Press Start 2P",monospace;font-size:13px;color:var(--cyan);letter-spacing:3px;margin-bottom:10px;display:block;text-shadow:0 1px 8px rgba(0,0,0,.9);}
@ -35,6 +95,7 @@
.slide-label .sl-desc{font-size:16px;color:#c8d2ec;margin-top:12px;max-width:500px;line-height:1.85;text-shadow:0 1px 8px rgba(0,0,0,.88);}
/* 指示器 */
/* 轮播指示器(竖排右侧) */
.sl-dots{position:absolute;right:40px;bottom:96px;top:auto;transform:none;z-index:5;display:flex;flex-direction:column;gap:14px;pointer-events:auto;}
.sl-dot{width:12px;height:12px;border-radius:50%;background:rgba(255,255,255,.22);border:1px solid rgba(255,255,255,.4);cursor:pointer;transition:.3s;flex:0 0 auto;}
.sl-dot:hover{background:rgba(255,255,255,.5);}
@ -47,6 +108,7 @@
.sl-arrow.next{right:16px;}
/* 起源文案 */
/* 轮播右上角竖排「起源历史」 */
.slide-origin{position:absolute;right:72px;top:48px;z-index:4;pointer-events:none;writing-mode:vertical-rl;text-orientation:upright;
font-family:"Noto Sans SC",sans-serif;font-size:16px;font-weight:500;letter-spacing:3px;line-height:1.55;color:rgba(240,244,252,.94);
text-shadow:0 1px 3px rgba(0,0,0,.92);max-height:28vh;overflow:hidden;text-rendering:optimizeLegibility;-webkit-font-smoothing:antialiased;
@ -59,11 +121,244 @@
.hero .enter-btn{font-family:"Noto Sans SC",sans-serif;font-size:17px;font-weight:700;color:#0b1020;background:linear-gradient(120deg,var(--gold),#f0d49a);padding:13px 42px;border-radius:30px;cursor:pointer;box-shadow:0 0 26px rgba(231,184,92,.5);letter-spacing:3px;transition:.25s;border:none;}
.hero .enter-btn:hover{filter:brightness(1.1);transform:translateY(-2px);box-shadow:0 0 40px rgba(231,184,92,.75);}
/* ===== 特色 ===== */
#features{align-items:center;justify-content:center;gap:22px;}
.grid{display:grid;grid-template-columns:repeat(3,1fr);gap:16px;width:min(1060px,100%);}
.card{background:var(--card);border:1px solid var(--card-bd);border-radius:14px;padding:18px 20px;transition:.26s;min-height:132px;}
.card:hover{transform:translateY(-4px);border-color:var(--violet);box-shadow:var(--glow);background:rgba(139,92,246,.12);}
.card .ic{font-size:28px;margin-bottom:8px;filter:drop-shadow(0 0 6px rgba(199,125,255,.6));}
.card h3{font-size:17px;color:#fff;margin-bottom:7px;letter-spacing:1px;}
.card p{font-size:13px;color:var(--text-dim);line-height:1.6;}
/* ===== 背景 ===== */
#background{flex-direction:column;align-items:stretch;gap:0;overflow:hidden;padding:28px 48px;}
.bg-page{max-width:1080px;width:100%;margin:0 auto;padding:24px 0;border-top:1px solid var(--card-bd);}
.bg-page:first-child{border-top:none;padding-top:0;}
.bg-page-head{text-align:center;margin-bottom:28px;}
.bg-page-title{font-family:"Noto Serif SC",serif;font-weight:800;font-size:40px;letter-spacing:3px;line-height:1.2;margin:0 0 10px;
background:linear-gradient(120deg,#ffffff 0%,#f0d49a 48%,#c79bff 100%);
-webkit-background-clip:text;background-clip:text;-webkit-text-fill-color:transparent;}
.bg-page-sub{font-size:13px;letter-spacing:7px;color:var(--text-dim);text-transform:uppercase;}
.bg-page-body{font-size:17px;line-height:2.05;color:#e2e8f6;max-width:840px;margin:0 auto 34px;}
.bg-page-body p{margin:0 0 18px;}
.bg-page-items{display:grid;grid-template-columns:repeat(auto-fill,minmax(262px,1fr));gap:18px;}
.bg-card{display:flex;gap:15px;align-items:flex-start;background:linear-gradient(135deg,rgba(139,92,246,.10),rgba(76,201,240,.05));
border:1px solid var(--card-bd);border-radius:16px;padding:18px 20px;transition:.26s;}
.bg-card:hover{transform:translateY(-4px);border-color:var(--violet);box-shadow:var(--glow);background:rgba(139,92,246,.16);}
.bg-card-ic{font-size:27px;line-height:1.15;filter:drop-shadow(0 0 9px rgba(139,92,246,.55));flex:0 0 auto;}
.bg-card-title{font-size:17px;font-weight:700;color:#fff;margin-bottom:6px;}
.bg-card-desc{font-size:14.5px;line-height:1.66;color:var(--text-dim);}
.bg-empty{text-align:center;color:var(--text-dim);padding:70px 0;font-size:16px;}
.layers{flex:0 0 270px;display:flex;flex-direction:column;gap:8px;justify-content:center;}
.layer{border-radius:12px;padding:11px 15px;border:1px solid var(--card-bd);position:relative;}
.layer .lt{font-size:15px;color:#fff;font-weight:500;letter-spacing:1px;}
.layer .ld{font-size:12px;color:var(--text-dim);margin-top:4px;line-height:1.5;}
.layer.l1{background:linear-gradient(100deg,rgba(76,201,240,.18),rgba(139,92,246,.08));}
.layer.l2{background:linear-gradient(100deg,rgba(199,125,255,.18),rgba(139,92,246,.07));}
.layer.l3{background:linear-gradient(100deg,rgba(139,92,246,.24),rgba(76,201,240,.06));border-color:var(--violet);}
.layer.l4{background:linear-gradient(100deg,rgba(139,92,246,.14),rgba(8,12,26,.18));}
.layer.l5{background:linear-gradient(100deg,rgba(231,184,92,.14),rgba(8,12,26,.26));}
.layer .star{position:absolute;right:12px;top:9px;font-size:11px;color:var(--cyan);}
.bg-text{flex:1;display:flex;flex-direction:column;justify-content:center;overflow-y:auto;}
.bg-text h2{font-family:"ZCOOL QingKe HuangYou",sans-serif;font-size:30px;letter-spacing:3px;color:#fff;text-shadow:0 0 16px rgba(139,92,246,.5);margin-bottom:16px;}
.bg-text p{font-size:14.5px;color:var(--text-dim);line-height:1.95;max-width:640px;}
/* ===== 规则 ===== */
#rules{flex-direction:column;gap:0;}
.rules-scroll{flex:1;min-height:0;overflow-y:auto;display:flex;flex-direction:column;gap:22px;padding-right:8px;}
.rule-block h3{font-family:"ZCOOL QingKe HuangYou",sans-serif;font-size:22px;color:#fff;letter-spacing:2px;margin-bottom:6px;}
.rule-intro{font-size:13.5px;color:var(--text-dim);margin-bottom:12px;line-height:1.7;max-width:780px;}
.tier-list{display:flex;flex-direction:column;gap:8px;}
.tier-row{display:grid;grid-template-columns:96px 116px 1fr;gap:12px;align-items:center;background:var(--card);border:1px solid var(--card-bd);border-radius:10px;padding:10px 14px;transition:.2s;}
.tier-row:hover{border-color:var(--violet);background:rgba(139,92,246,.10);}
.tier-row .tn{font-family:"Press Start 2P",monospace;font-size:10px;color:var(--cyan);}
.tier-row .tname{font-size:15px;color:#fff;font-weight:500;}
.tier-row .tdesc{font-size:13px;color:var(--text-dim);line-height:1.5;}
/* ===== 图鉴 ===== */
#codex{padding:28px 48px;gap:0;}
.codex-wrap{display:flex;flex-direction:column;gap:13px;flex:1;min-height:0;}
.codex-cats{display:flex;gap:6px;flex-wrap:wrap;}
.codex-cats button{font-family:"Noto Sans SC",sans-serif;font-size:14px;color:var(--text-dim);background:var(--card);border:1px solid var(--card-bd);padding:8px 15px;border-radius:11px;cursor:pointer;transition:.2s;}
.codex-cats button:hover{color:#fff;}
.codex-cats button.active{color:#fff;background:rgba(139,92,246,.24);border-color:var(--magenta);box-shadow:var(--glow);}
.codex-subrow{display:flex;align-items:center;gap:12px;}
.codex-subs{display:flex;gap:6px;flex-wrap:wrap;flex:1;}
.codex-subs button{font-size:12px;color:var(--text-dim);background:var(--card);border:1px solid var(--card-bd);padding:5px 12px;border-radius:14px;cursor:pointer;transition:.2s;}
.codex-subs button:hover{color:#fff;}
.codex-subs button.active{color:#fff;background:rgba(76,201,240,.22);border-color:var(--cyan);}
.codex-search{background:rgba(8,12,26,.6);border:1px solid var(--card-bd);border-radius:18px;color:var(--text);padding:7px 14px;font-size:13px;outline:none;width:200px;flex:0 0 auto;}
.codex-search:focus{border-color:var(--violet);}
.codex-count{font-size:12px;color:var(--text-dim);white-space:nowrap;flex:0 0 auto;}
.codex-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:16px;overflow-y:auto;padding:4px 6px 4px 0;align-content:start;flex:1;min-height:0;}
.cx{background:var(--card);border:1px solid var(--card-bd);border-radius:13px;overflow:hidden;transition:.24s;display:flex;flex-direction:column;cursor:default;}
.cx:hover{transform:translateY(-3px);border-color:var(--violet);box-shadow:var(--glow);}
.cx .thumb{width:100%;aspect-ratio:1/1;display:block;overflow:hidden;background:linear-gradient(135deg,rgba(139,92,246,.22),rgba(76,201,240,.10));}
.cx .thumb svg{width:100%;height:100%;display:block;}
.cx .body{padding:10px 12px;}
.cx .nm{font-size:14.5px;color:#fff;font-weight:500;}
.cx .meta{display:flex;justify-content:space-between;margin-top:5px;font-size:11px;}
.cx .meta .sub{color:var(--cyan);}
.cx .meta .tier{color:var(--gold);}
.cx .desc{font-size:12px;color:var(--text-dim);line-height:1.5;margin-top:4px;max-height:60px;overflow:hidden;}
.cx .ds{font-size:11.5px;color:var(--text-dim);line-height:1.5;margin-top:6px;}
/* ===== 论坛 ===== */
#forum{flex-direction:row;gap:28px;}
.f-cats{flex:0 0 200px;display:flex;flex-direction:column;gap:12px;}
.f-cats .ch{font-size:15.5px;color:var(--text-dim);background:var(--card);border:1px solid var(--card-bd);padding:13px 18px;border-radius:12px;cursor:pointer;transition:.22s;letter-spacing:.5px;}
.f-cats .ch:hover{color:#fff;background:rgba(139,92,246,.12);}
.f-cats .ch.active{color:#fff;background:linear-gradient(120deg,rgba(139,92,246,.28),rgba(199,125,255,.16));border-color:var(--magenta);box-shadow:var(--glow);}
.f-main{flex:1;display:flex;flex-direction:column;min-width:0;}
.threads{flex:1;overflow-y:auto;display:grid;grid-template-columns:repeat(auto-fill,minmax(340px,1fr));gap:16px;padding-right:8px;align-content:start;}
.th{display:flex;align-items:center;gap:16px;background:var(--card);border:1px solid var(--card-bd);border-radius:14px;padding:16px 20px;transition:.22s;cursor:pointer;}
.th:hover{border-color:var(--violet);background:rgba(139,92,246,.12);transform:translateY(-2px);box-shadow:0 8px 24px rgba(0,0,0,.25);}
.th .pin{font-size:12px;font-weight:700;letter-spacing:1px;color:var(--gold);background:rgba(231,184,92,.16);border:1px solid rgba(231,184,92,.45);border-radius:6px;padding:2px 8px;}
.th .tt{flex:1;font-size:16.5px;color:#fff;letter-spacing:.3px;}
.th .meta{font-size:13px;color:var(--text-dim);white-space:nowrap;}
.th .rp{color:var(--cyan);font-weight:600;}
.post-form{display:none;flex-direction:column;gap:10px;background:var(--card);border:1px solid var(--magenta);border-radius:14px;padding:16px;margin-bottom:14px;}
.post-form.show{display:flex;}
.post-form input,.post-form textarea{background:rgba(8,12,26,.6);border:1px solid var(--card-bd);border-radius:10px;color:var(--text);padding:10px 14px;font-family:inherit;font-size:14px;outline:none;}
.post-form textarea{resize:vertical;min-height:72px;}
.post-form .row{display:flex;gap:10px;justify-content:flex-end;}
.post-form .row button{font-size:14px;padding:8px 18px;border-radius:10px;cursor:pointer;border:1px solid var(--card-bd);background:var(--card);color:var(--text);}
.post-form .row .ok{background:linear-gradient(120deg,var(--gold),#f0d49a);color:#0b1020;font-weight:700;border:none;}
/* ===== 公告 ===== */
#announce{gap:0;align-items:stretch;}
#announce .notes{flex:1;min-height:0;width:100%;display:grid;grid-template-columns:repeat(auto-fill,minmax(360px,1fr));gap:18px;overflow-y:auto;padding-right:8px;align-content:start;}
#announce .notes{grid-template-columns:repeat(auto-fill,minmax(380px,1fr));gap:20px;}
.note{display:flex;flex-direction:column;overflow:hidden;background:linear-gradient(135deg,rgba(139,92,246,.10),rgba(76,201,240,.04));border:1px solid var(--card-bd);border-left:4px solid var(--magenta);border-radius:16px;transition:.24s;}
.note:hover{transform:translateY(-3px);border-color:var(--violet);background:linear-gradient(135deg,rgba(139,92,246,.16),rgba(76,201,240,.07));box-shadow:var(--glow);}
.note.pin{border-left-color:var(--gold);background:linear-gradient(135deg,rgba(231,184,92,.12),rgba(139,92,246,.06));}
.note.pin:hover{background:linear-gradient(135deg,rgba(231,184,92,.18),rgba(139,92,246,.08));}
.note.feat{grid-column:1 / -1;flex-direction:row;align-items:stretch;}
.note.feat .note-cover{flex:0 0 44%;width:44%;min-height:260px;}
.note.feat .note-body{flex:1;justify-content:center;}
.note.feat .note-title{font-size:27px;}
.note.feat .body{font-size:16px;}
.note-cover{height:168px;flex:0 0 auto;background-size:cover;background-position:center;background-repeat:no-repeat;border-bottom:1px solid var(--card-bd);position:relative;}
.note-cover.empty{background:linear-gradient(135deg,rgba(139,92,246,.24),rgba(76,201,240,.10));}
.note-cover.empty::after{content:'封面图 · 后台配置';position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-size:12px;letter-spacing:2px;color:rgba(255,255,255,.5);}
.note-body{padding:18px 22px;display:flex;flex-direction:column;min-width:0;}
.note .date{font-size:13px;font-weight:600;color:var(--cyan);letter-spacing:1px;margin-bottom:8px;}
.note-title{font-family:"ZCOOL QingKe HuangYou",sans-serif;font-size:21px;color:#fff;letter-spacing:1px;margin-bottom:10px;line-height:1.32;text-shadow:0 0 14px rgba(139,92,246,.4);}
.note .body{font-size:15px;color:var(--text);line-height:1.9;}
.note .body p{margin:0 0 11px;}
.note .body p:last-child{margin-bottom:0;}
/* ===== 致玩家 ===== */
#letter{align-items:center;justify-content:center;}
#letter .letter-wrap{max-width:680px;width:100%;overflow-y:auto;padding:10px 20px;}
#letter .letter-wrap h2{font-family:"ZCOOL QingKe HuangYou",sans-serif;font-size:30px;letter-spacing:4px;color:#fff;text-shadow:0 0 18px rgba(139,92,246,.5);margin-bottom:6px;text-align:center;}
#letter .letter-wrap .lt-sub{text-align:center;font-family:"Press Start 2P",monospace;font-size:9px;color:var(--magenta);letter-spacing:1px;margin-bottom:28px;}
#letter .letter-wrap .lt-body{font-size:15px;color:var(--text-dim);line-height:2.1;}
#letter .letter-wrap .lt-body p{margin-bottom:16px;}
#letter .letter-wrap .lt-body p:first-child{font-size:17px;color:#fff;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;}
/* ===== 论坛(竖排:顶部分类条 + 下方纵向帖子流) ===== */
#forum{flex-direction:column;gap:20px;}
.f-cats{flex:0 0 auto;flex-direction:row;flex-wrap:wrap;gap:10px;}
.f-cats .ch{flex:0 0 auto;}
.f-main{flex:1;min-height:0;}
.threads{width:100%;}
/* ===== 公告(内容居中) ===== */
#announce .notes{margin:0;}
/* ===== 剧情(小说阅读器:左目录 + 右正文,竖排阅读感) ===== */
#story{padding:28px 48px;}
.story-wrap{display:flex;flex-direction:row;width:100%;flex:1;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:24px;letter-spacing:3px;color:#fff;padding:24px 0 12px;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:14px;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:15px;color:var(--text-dim);background:transparent;border:none;border-left:2px solid transparent;padding:10px 12px;cursor:pointer;transition:.18s;border-radius:0 8px 8px 0;line-height:1.45;}
.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:840px;margin:0 auto;padding:48px 52px 100px;}
.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:32px;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:17.5px;color:#eef2fb;line-height:2.0;text-rendering:optimizeLegibility;-webkit-font-smoothing:antialiased;}
.reader-inner .body p{margin-bottom:20px;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;}
/* ===== 世界数据报表 ===== */
#worlddata{overflow:hidden;padding:28px 48px;}
#worldDataContent{flex:1;min-height:0;overflow-y:auto;width:100%;padding-right:8px;}
.wd-tag{display:inline-block;font-size:12px;letter-spacing:2px;color:var(--gold);background:rgba(231,184,92,.12);border:1px solid rgba(231,184,92,.38);border-radius:6px;padding:3px 11px;margin-bottom:22px;}
.wd-h{font-family:"ZCOOL QingKe HuangYou",sans-serif;font-size:22px;letter-spacing:3px;color:#fff;margin:38px 0 18px;text-shadow:0 0 14px rgba(139,92,246,.45);display:flex;align-items:center;gap:10px;}
.wd-h::before{content:"";width:4px;height:22px;border-radius:3px;background:linear-gradient(180deg,var(--violet),var(--cyan));box-shadow:0 0 10px rgba(139,92,246,.6);}
.wd-h small{font-family:"Press Start 2P",monospace;font-size:8px;color:var(--magenta);letter-spacing:1px;}
.wd-kpi{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:20px;}
.wd-kpi-card{position:relative;overflow:hidden;background:linear-gradient(135deg,rgba(139,92,246,.12),rgba(76,201,240,.05));border:1px solid var(--card-bd);border-radius:16px;padding:22px 26px;transition:.26s;}
.wd-kpi-card:hover{transform:translateY(-4px);border-color:var(--violet);box-shadow:var(--glow);}
.wd-kpi-card::after{content:"";position:absolute;right:-26px;top:-26px;width:88px;height:88px;border-radius:50%;background:radial-gradient(circle,rgba(199,125,255,.22),transparent 70%);}
.wd-kpi-ic{font-size:22px;filter:drop-shadow(0 0 7px rgba(139,92,246,.55));}
.wd-kpi-num{font-family:"ZCOOL QingKe HuangYou",sans-serif;font-size:40px;line-height:1.1;color:#fff;margin:10px 0 4px;}
.wd-kpi-num .u{font-size:14px;color:var(--text-dim);margin-left:4px;letter-spacing:1px;}
.wd-kpi-label{font-size:13px;color:var(--text-dim);letter-spacing:1px;}
.wd-kpi-delta{position:absolute;right:14px;top:16px;font-size:11px;font-weight:700;padding:1px 7px;border-radius:5px;}
.wd-up{color:#7CFC9B;background:rgba(124,252,155,.12);}
.wd-down{color:#ff8585;background:rgba(255,133,133,.12);}
.wd-bars{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:14px 26px;}
.wd-bar-row{margin-bottom:4px;}
.wd-bar-top{display:flex;justify-content:space-between;font-size:13.5px;margin-bottom:6px;}
.wd-bar-top .k{color:var(--text);}
.wd-bar-top .v{color:var(--gold);font-weight:700;}
.wd-track{height:9px;border-radius:6px;background:rgba(255,255,255,.07);overflow:hidden;}
.wd-fill{height:100%;border-radius:6px;background:linear-gradient(90deg,var(--cyan),var(--violet));box-shadow:0 0 9px rgba(139,92,246,.55);}
.wd-table{width:100%;border-collapse:separate;border-spacing:0;font-size:14px;background:rgba(255,255,255,.02);border:1px solid var(--card-bd);border-radius:14px;overflow:hidden;}
.wd-table th{font-family:"ZCOOL QingKe HuangYou",sans-serif;font-size:14px;letter-spacing:2px;color:#fff;background:linear-gradient(135deg,rgba(139,92,246,.22),rgba(76,201,240,.12));text-align:left;padding:13px 16px;border-bottom:1px solid var(--card-bd);}
.wd-table td{padding:12px 16px;border-bottom:1px solid rgba(255,255,255,.06);color:var(--text-dim);line-height:1.5;}
.wd-table tr:last-child td{border-bottom:none;}
.wd-table tr:hover td{background:rgba(139,92,246,.08);color:#eaf0ff;}
.wd-table .lead{color:#fff;font-weight:600;}
.wd-table .pill{display:inline-block;font-size:12px;padding:2px 9px;border-radius:20px;background:rgba(76,201,240,.14);color:var(--cyan);border:1px solid rgba(76,201,240,.34);}
.wd-rank{display:flex;flex-direction:column;gap:12px;}
.wd-rank-row{display:flex;align-items:center;gap:14px;background:linear-gradient(135deg,rgba(139,92,246,.08),rgba(255,255,255,.02));border:1px solid var(--card-bd);border-radius:13px;padding:12px 16px;transition:.22s;}
.wd-rank-row:hover{border-color:var(--violet);transform:translateX(4px);}
.wd-rank-no{font-family:"ZCOOL QingKe HuangYou",sans-serif;font-size:22px;width:34px;text-align:center;color:var(--gold);flex:0 0 auto;}
.wd-rank-row:nth-child(1) .wd-rank-no{color:#ffd56b;}
.wd-rank-row:nth-child(2) .wd-rank-no{color:#cfd8e8;}
.wd-rank-row:nth-child(3) .wd-rank-no{color:#e0a86b;}
.wd-rank-main{flex:1;min-width:0;}
.wd-rank-name{font-size:15px;color:#fff;margin-bottom:5px;display:flex;justify-content:space-between;}
.wd-rank-name .s{color:var(--gold);font-weight:700;font-size:13px;}
.wd-rank-track{height:8px;border-radius:5px;background:rgba(255,255,255,.07);overflow:hidden;}
.wd-rank-fill{height:100%;border-radius:5px;background:linear-gradient(90deg,var(--magenta),var(--violet));box-shadow:0 0 8px rgba(199,125,255,.5);}
@media (max-width:900px){
.hero .copy h1{font-size:42px;}
}
@media (max-width:768px){
.hero .copy{left:18px;right:18px;top:28px;}
html,body{height:100%;}
body{height:100dvh;}
.app{height:100dvh;}
.topbar{height:auto;min-height:54px;flex-wrap:wrap;align-items:center;padding:8px 14px;gap:10px;}
.logo .cn{font-size:22px;letter-spacing:2px;}
.logo .en{display:none;}
.spacer{display:none;}
.user-box{margin-left:auto;font-size:13px;gap:8px;}
.user-box .auth-btn{padding:6px 13px;font-size:12px;}
.nav{order:3;flex:1 0 100%;width:100%;margin-left:0;gap:4px;padding-top:6px;border-top:1px solid var(--card-bd);}
.nav button{font-size:13px;padding:7px 9px;white-space:nowrap;}
.hero .copy{left:18px;right:18px;bottom:82px;}
.hero .copy{left:18px;top:28px;}
.hero .copy h1{font-size:32px;letter-spacing:4px;}
.hero .copy .tag{font-size:16px;}
.slide-label{left:18px;bottom:78px;}
@ -74,6 +369,10 @@
.hero .enter-btn{font-size:15px;padding:11px 30px;}
}
@media (max-width:480px){
.topbar{padding:8px 10px;gap:8px;}
.logo .cn{font-size:20px;}
.nav button{font-size:12px;padding:6px 7px;}
.user-box .auth-btn{padding:6px 11px;}
.hero .copy{left:14px;top:20px;}
.hero .copy h1{font-size:26px;letter-spacing:2px;}
.hero .copy .tag{font-size:14px;}
@ -81,6 +380,14 @@
.slide-label .sl-cn{font-size:20px;}
.slide-label .sl-desc{font-size:12.5px;}
.sl-dots{right:12px;gap:10px;}
.grid{grid-template-columns:1fr;}
.panel{padding:14px 14px;}
.codex-search{width:118px;flex:1 1 100%;}
.codex-subrow{flex-wrap:wrap;}
.reader-inner{padding:22px 14px 70px;}
#story{padding:14px 14px;}
.tier-row{grid-template-columns:60px 76px 1fr;gap:6px;}
.codex-cats button,.codex-subs button{padding:7px 11px;font-size:13px;}
}
</style>
</head>
@ -114,6 +421,87 @@
</div>
<button class="sl-arrow prev" onclick="window.slPrev && window.slPrev()"></button>
<button class="sl-arrow next" onclick="window.slNext && window.slNext()"></button>
<!-- ===== 介绍(视频主页) ===== -->
<section class="panel active" id="intro">
<div class="hero">
<div class="slideshow" id="slideshow">
<!-- 五层世界轮播,由 JS 渲染 -->
</div>
<div class="enter-wrap">
<button class="enter-btn" id="enterGame">进入游戏</button>
</div>
</div>
</section>
<!-- ===== 背景(特色 + 世界背景 + 致玩家 合并平铺) ===== -->
<section class="panel" id="background">
<div class="panel-scroll" id="bgBody"></div>
</section>
<!-- ===== 规则 ===== -->
<section class="panel" id="rules">
<div class="rules-scroll">
<!-- 静态兜底数据已注释 — 验证 API 是否取到后台数据 -->
<!-- 如页面此处为空白 = Odoo API 未返回规则数据 -->
</div>
</section>
<!-- ===== 图鉴 ===== -->
<section class="panel" id="codex">
<div class="codex-wrap">
<div class="codex-cats" id="codexCats"></div>
<div class="codex-subrow">
<div class="codex-subs" id="codexSubs"></div>
<input class="codex-search" id="codexSearch" placeholder="搜索名称 / 描述…" />
<span class="codex-count" id="codexCount"></span>
</div>
<div class="codex-grid" id="codexGrid"></div>
</div>
</section>
<!-- ===== 论坛 ===== -->
<section class="panel" id="forum">
<div class="f-cats">
<!-- 静态分类已注释 — 验证 API 是否取到后台数据 -->
<!-- 如页面此处为空白 = Odoo API 未返回论坛分类 -->
</div>
<div class="f-main">
<div class="post-form" id="postForm">
<input id="pfTitle" placeholder="标题" />
<textarea id="pfBody" placeholder="说点什么…(演示:仅本次会话内可见)"></textarea>
<div class="row"><button id="pfCancel">取消</button><button class="ok" id="pfOk">发布</button></div>
</div>
<div class="threads" id="threads">
<!-- 静态兜底数据已注释 — 验证 API 是否取到后台数据 -->
<!-- 如页面此处为空白 = Odoo API 未返回论坛数据 -->
</div>
</div>
</section>
<!-- ===== 公告 ===== -->
<section class="panel" id="announce">
<div class="notes">
<!-- 静态兜底数据已注释 — 验证 API 是否取到后台数据 -->
<!-- 如页面此处为空白 = Odoo API 未返回公告数据 -->
</div>
</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">
<div id="worldDataContent"></div>
</section>
</main>
</div>
@ -217,6 +605,86 @@
// —— 暴露给箭头按钮 ——
window.slPrev=prev; window.slNext=next;
// ============ 五层世界图片轮播 ============
(function(){
const slides = [
{ img:'assets/slides/01_starry_sky.png', en:'STARRY SKY', cn:'星空', desc:'陨石星空带悬浮于世界最外层,是文明触及维度的起点。星轨连通各浮空岛,穹鎏晶体自星尘中凝结。',
origin:'世界未形,星尘先于万物浮游于虚无。首缕维度自星隙坍缩成形,穹鎏晶体由尘凝就,文明自此仰望苍穹之始。' },
{ img:'assets/slides/02_sky.png', en:'SKY', cn:'天空', desc:'云朵地基块托起浮空岛群,灵气复苏的第一缕风拂过。各族初代聚落,在此仰望也更在此扎根。',
origin:'云基初结,浮空岛群自苍穹垂落凡尘。灵气复苏,初民循风而居,于云上筑城,仰望亦扎根,道统由此萌发。' },
{ img:'assets/slides/03_earth.png', en:'EARTH', cn:'大地', desc:'平原与森林铺展成万物生灵的摇篮,玄荒文明于斯鼎盛。城邦林立,征伐与道统在沃土上并行。',
origin:'沃野铺展为万灵摇篮,玄荒先族自林泉走出。城邦并起,征伐与道统并驰沃土之上,文明于此鼎盛绵延。' },
{ img:'assets/slides/04_underground.png',en:'UNDERGROUND',cn:'地下', desc:'溶洞晶矿与暗河在深处低语,冥玉生于幽暗玉石之中。地心工坊调度浅层资源,灯火不熄。',
origin:'溶洞深开,晶矿与暗河在幽处低语。冥玉生于幽玉深心,地心工坊取浅层之资,灯火长明,匠魂不熄。' },
{ img:'assets/slides/05_core.png', en:'CORE', cn:'地心', desc:'熔岩奔流的地心,是世界诞生的本源。元玺在此凝聚,追溯天地初开的那一瞬永恒。',
origin:'熔岩奔流处,乃世界本源所钟。元玺于亿载炽火中凝聚成形,溯天地初开一瞬,万物归处,亦是起始。' }
];
const container = document.getElementById('slideshow');
if(!container) return;
let current = 0, timer = null;
const INTERVAL = 5500;
// 竖向轨道5 张图上下滑动
const track = document.createElement('div');
track.className='slide-track';
container.appendChild(track);
// 渲染 slide + label
slides.forEach((s,i)=>{
const el = document.createElement('div');
el.className='slide'+(i===0?' active':'');
el.style.backgroundImage="url('"+s.img+"')";
el.innerHTML='<div class="slide-label"><span class="sl-en">'+s.en+'</span><div class="sl-cn">'+s.cn+'</div><div class="sl-desc">'+s.desc+'</div></div>'
+'<div class="slide-origin"><span class="so-tag">起源</span>'+s.origin+'</div>';
track.appendChild(el);
});
const all = track.querySelectorAll('.slide');
// 指示器
const dotsWrap = document.createElement('div');
dotsWrap.className='sl-dots';
slides.forEach((_,i)=>{
const d=document.createElement('div');
d.className='sl-dot'+(i===0?' active':'');
d.addEventListener('click',()=>go(i));
dotsWrap.appendChild(d);
});
document.querySelector('.hero').appendChild(dotsWrap);
const dots = dotsWrap.querySelectorAll('.sl-dot');
function go(idx){
if(idx<0)idx=slides.length-1;if(idx>=slides.length)idx=0;
if(idx===current) return;
all[current].classList.remove('active');dots[current].classList.remove('active');
current=idx;
track.style.transform='translateY('+(-current*100)+'%)';
all[current].classList.add('active');dots[current].classList.add('active');
resetTimer();
}
function next(){ go((current+1)%slides.length); }
function prev(){ go((current-1+slides.length)%slides.length); }
function resetTimer(){ clearInterval(timer); timer=setInterval(next,INTERVAL); }
// 鼠标滚轮上下滑动
let lock=false,wAccum=0;
document.querySelector('.hero').addEventListener('wheel',function(e){
wAccum+=e.deltaY;
if(lock)return;
if(Math.abs(wAccum)>50){
lock=true;
if(wAccum>0)next();else prev();
wAccum=0;
setTimeout(function(){lock=false;},1250);
}
},{passive:true});
resetTimer();
})();
// ============ 程序化矢量缩略图(零 AI 生图,不消耗积分) ============
function hashStr(s){let h=2166136261;for(let i=0;i<s.length;i++){h^=s.charCodeAt(i);h=Math.imul(h,16777619);}return h>>>0;}
function rng(seed){let s=seed>>>0;return function(){s=(Math.imul(s,1664525)+1013904223)>>>0;return s/4294967296;};}
const TIERCOL={'普通':'#9aa6c4','精良':'#5fd38a','稀有':'#4cc9f0','史诗':'#c77dff','传说':'#e7b85c'};
resetTimer();
})();

View File

@ -26,6 +26,7 @@
/* 登录页背景(固定单图 login_bg原图直出不加特效 */
.bg-slides{position:fixed;inset:0;z-index:0;background:url('assets/slides/login_bg.webp') center/cover no-repeat;}
.bg-slides{position:fixed;inset:0;z-index:0;background:url('assets/slides/login_bg.png') center/cover no-repeat;}
.wrap{position:relative;z-index:2;width:min(420px,92vw);}
.brand{text-align:center;margin-bottom:26px;}

View File

@ -619,51 +619,49 @@ body { background: #0a0e17; overflow: hidden; font-family: 'Microsoft YaHei', sa
</div>
<div class="ua-scroll">
<!-- 缔造者等级卡 -->
<!-- 缔造者等级卡(数据从 me() 动态填充) -->
<section class="ua-card">
<div class="ua-card-top">
<span class="ua-lv">Lv.<b id="uaLv">12</b></span>
<span class="ua-title">星界旅人</span>
<span class="ua-lv">Lv.<b id="uaLv">--</b></span>
<span class="ua-title" id="uaTitle">--</span>
</div>
<div class="ua-exp"><div class="ua-exp-fill" style="width:64%"></div></div>
<div class="ua-exp-txt">EXP 640 / 1000 · 距下一级还差 360</div>
<div class="ua-exp"><div class="ua-exp-fill" id="uaExpBar" style="width:0%"></div></div>
<div class="ua-exp-txt" id="uaExpTxt">EXP -- / --</div>
</section>
<!-- 我的世界 概览 -->
<!-- 我的世界 概览(数据从 me() 动态填充) -->
<section class="ua-card">
<div class="ua-card-h">🌍 我的世界</div>
<div class="ua-world-name" id="uaWorldName">我的世界</div>
<div class="ua-world-name" id="uaWorldName">--</div>
<div class="ua-grid">
<div class="ua-stat"><div class="ua-stat-v" id="uaEra">星元1年·春</div><div class="ua-stat-l">历元</div></div>
<div class="ua-stat"><div class="ua-stat-v" id="uaTiles">0</div><div class="ua-stat-l">疆域地块</div></div>
<div class="ua-stat"><div class="ua-stat-v">382</div><div class="ua-stat-l">人口</div></div>
<div class="ua-stat"><div class="ua-stat-v">68%</div><div class="ua-stat-l">文明度</div></div>
<div class="ua-stat"><div class="ua-stat-v" id="uaEra">--</div><div class="ua-stat-l">历元</div></div>
<div class="ua-stat"><div class="ua-stat-v" id="uaTiles">--</div><div class="ua-stat-l">疆域地块</div></div>
<div class="ua-stat"><div class="ua-stat-v" id="uaPop">--</div><div class="ua-stat-l">人口</div></div>
<div class="ua-stat"><div class="ua-stat-v" id="uaCiv">--</div><div class="ua-stat-l">文明度</div></div>
</div>
<div class="ua-res">
<div class="ua-res-row"><span>🪙 金币</span><span class="ua-res-bar"><i style="width:80%"></i></span><b>1280</b></div>
<div class="ua-res-row"><span>✨ 灵气</span><span class="ua-res-bar"><i style="width:45%"></i></span><b>540</b></div>
<div class="ua-res-row"><span>🪵 木材</span><span class="ua-res-bar"><i style="width:60%"></i></span><b>360</b></div>
<div class="ua-res-row"><span>🪨 石料</span><span class="ua-res-bar"><i style="width:30%"></i></span><b>180</b></div>
<div class="ua-res-row"><span>🪙 金币</span><span class="ua-res-bar"><i id="uaCoinBar" style="width:0%"></i></span><b id="uaCoins">--</b></div>
<div class="ua-res-row"><span>✨ 灵气</span><span class="ua-res-bar"><i id="uaEnergyBar" style="width:0%"></i></span><b id="uaEnergy">--</b></div>
<div class="ua-res-row"><span>🪵 木材</span><span class="ua-res-bar"><i id="uaWoodBar" style="width:0%"></i></span><b id="uaWood">--</b></div>
<div class="ua-res-row"><span>🪨 石料</span><span class="ua-res-bar"><i id="uaStoneBar" style="width:0%"></i></span><b id="uaStone">--</b></div>
</div>
</section>
<!-- 成就 -->
<!-- 成就预览(数据从 API.achievements() 动态填充) -->
<section class="ua-card">
<div class="ua-card-h">🏆 成就</div>
<div class="ua-ach">
<div class="ua-ach-item done"><span class="ua-ach-ic">🌟</span><div><div class="ua-ach-t">开天辟地</div><div class="ua-ach-d">已点亮第一块领土</div></div></div>
<div class="ua-ach-item"><span class="ua-ach-ic">🌱</span><div><div class="ua-ach-t">文明摇篮</div><div class="ua-ach-bar"><i style="width:60%"></i></div></div></div>
<div class="ua-ach-item locked"><span class="ua-ach-ic">🔒</span><div><div class="ua-ach-t">通天塔</div><div class="ua-ach-d">建造 10 座奇观解锁</div></div></div>
<div class="ua-card-h">🏆 成就 <span id="uaAchSummary" style="font-size:11px;color:var(--text-dim);float:right;">-- / --</span></div>
<div class="ua-ach" id="uaAchPreview">
<!-- 成就预览项由 JS 动态渲染 -->
</div>
</section>
<!-- 快捷操作 -->
<div class="ua-actions">
<button class="ua-item" id="uaProfile" type="button">👤 个人信息</button>
<button class="ua-item" id="uaProfile" type="button">👤 个人</button>
<button class="ua-item" id="uaAchievements" type="button">🏆 成就</button>
<button class="ua-item" id="uaMessages" type="button">✉️ 邮箱 <span class="ua-badge" id="uaMailBadge" style="display:none;">0</span></button>
<button class="ua-item" id="uaSettings" type="button">⚙️ 设置</button>
<button class="ua-item" id="uaMessages" type="button">✉️ 邮件 <span class="ua-badge">3</span></button>
<button class="ua-item" id="uaAchievements" type="button">🏆 成就墙</button>
<button class="ua-item" id="uaWebsite" type="button">🌐 官网 / 社区</button>
<button class="ua-item" id="uaWebsite" type="button">🌐 官网</button>
</div>
</div>
@ -4364,17 +4362,94 @@ if (typeof fitWorld === 'function') fitWorld();
trigger.setAttribute('aria-expanded', open ? 'true' : 'false');
};
// 面板内容(游戏视角 demo同步真实可读字段
// === 面板数据填充(从 me() 返回值动态渲染) ===
const cachedMe = me; // 保存供弹窗复用
function fillPanelData(d) {
const name = d.name || d.login || '';
const uaName2 = document.getElementById('uaName2');
if (uaName2) uaName2.textContent = name;
const uaAv = document.getElementById('uaAvatarLg');
const wnEl2 = document.getElementById('worldName');
const eraEl = document.getElementById('calendarLabel');
const tilesEl = document.getElementById('uaTiles');
if (uaName2) uaName2.textContent = me.name || me.login;
if (uaAv) uaAv.textContent = (me.name || me.login || 'M').trim().charAt(0).toUpperCase();
if (wnEl2) { const wn = document.getElementById('uaWorldName'); if (wn) wn.textContent = wnEl2.textContent; }
if (eraEl) { const er = document.getElementById('uaEra'); if (er) er.textContent = eraEl.textContent; }
if (tilesEl && typeof tiles !== 'undefined' && Array.isArray(tiles)) tilesEl.textContent = tiles.length;
if (uaAv) uaAv.textContent = (name || 'M').trim().charAt(0).toUpperCase();
const uaUid = document.getElementById('uaUid');
if (uaUid) uaUid.textContent = d.game_uid || d.uid || '--';
// 等级卡
const uaLv = document.getElementById('uaLv');
if (uaLv) uaLv.textContent = d.level || 1;
const uaTitle = document.getElementById('uaTitle');
if (uaTitle) uaTitle.textContent = d.title || '萌新';
const expMax = d.exp_max || 100;
const expPct = expMax > 0 ? Math.min(100, (d.exp || 0) / expMax * 100) : 0;
const uaExpBar = document.getElementById('uaExpBar');
if (uaExpBar) uaExpBar.style.width = expPct + '%';
const uaExpTxt = document.getElementById('uaExpTxt');
if (uaExpTxt) uaExpTxt.textContent = 'EXP ' + (d.exp || 0) + ' / ' + expMax;
// 世界概览
const uaWorldName = document.getElementById('uaWorldName');
if (uaWorldName) uaWorldName.textContent = d.world_name || '我的世界';
const uaEra = document.getElementById('uaEra');
if (uaEra) uaEra.textContent = d.era || '--';
const uaTiles = document.getElementById('uaTiles');
if (uaTiles) uaTiles.textContent = d.tiles || 0;
const uaPop = document.getElementById('uaPop');
if (uaPop) uaPop.textContent = d.population || 0;
const uaCiv = document.getElementById('uaCiv');
if (uaCiv) uaCiv.textContent = d.civ_level || 0;
// 资源条(按上限折算宽度)
function setRes(barId, valId, val, max) {
const el = document.getElementById(valId);
if (el) el.textContent = val || 0;
const bar = document.getElementById(barId);
if (bar) bar.style.width = (max > 0 ? Math.min(100, (val || 0) / max * 100) : 0) + '%';
}
setRes('uaCoinBar', 'uaCoins', d.coins, 10000);
setRes('uaEnergyBar', 'uaEnergy', d.energy, 1000);
setRes('uaWoodBar', 'uaWood', d.wood, 1000);
setRes('uaStoneBar', 'uaStone', d.stone, 1000);
// 邮件未读角标
const uaMailBadge = document.getElementById('uaMailBadge');
if (uaMailBadge) {
const n = d.unread_mail || 0;
uaMailBadge.textContent = n;
uaMailBadge.style.display = n > 0 ? '' : 'none';
}
}
fillPanelData(me);
// === 异步加载成就预览 + 邮件未读数 ===
(async () => {
try {
const ach = await API.achievements();
if (ach && ach.items) {
const summary = document.getElementById('uaAchSummary');
if (summary) summary.textContent = (ach.unlocked_count || 0) + ' / ' + (ach.total_count || 0);
const preview = document.getElementById('uaAchPreview');
if (preview) {
const top3 = ach.items.slice(0, 3);
preview.innerHTML = top3.length ? top3.map(a => {
const icon = a.unlocked ? (a.icon || '🏆') : '🔒';
const pct = a.target > 0 ? Math.min(100, (a.progress || 0) / a.target * 100) : (a.unlocked ? 100 : 0);
return `<div class="ua-ach-item${a.unlocked ? ' done' : ''}">
<span class="ua-ach-ic">${icon}</span>
<div style="flex:1;min-width:0;">
<div class="ua-ach-t">${a.name}</div>
<div class="ua-ach-bar"><i style="width:${pct}%"></i></div>
</div>
</div>`;
}).join('') : '<div style="font-size:12px;color:#8899aa;text-align:center;padding:12px;">暂无成就数据</div>';
}
}
} catch (e) { console.warn('加载成就预览失败', e); }
try {
const ml = await API.mails();
if (ml && typeof ml.unread === 'number') {
const badge = document.getElementById('uaMailBadge');
if (badge) {
badge.textContent = ml.unread;
badge.style.display = ml.unread > 0 ? '' : 'none';
}
}
} catch (e) { console.warn('加载邮件数失败', e); }
})();
trigger.addEventListener('click', (ev) => {
ev.stopPropagation();
@ -4418,27 +4493,40 @@ if (typeof fitWorld === 'function') fitWorld();
if (e.target === appModal) closeAppModal();
});
// 个人信息
// 个人信息(从 cachedMe 动态渲染)
const uaProfile = document.getElementById('uaProfile');
if (uaProfile) uaProfile.addEventListener('click', () => {
const d = cachedMe;
const name = d.name || d.login || '';
const avatarLetter = (name || 'M').trim().charAt(0).toUpperCase();
const expMax = d.exp_max || 100;
const expPct = expMax > 0 ? Math.min(100, (d.exp || 0) / expMax * 100) : 0;
openAppModal('👤 个人信息', `
<div style="display:flex;align-items:center;gap:14px;margin-bottom:14px;">
<div style="width:54px;height:54px;border-radius:50%;background:linear-gradient(135deg,#c77dff,#4af);display:flex;align-items:center;justify-content:center;font-size:22px;font-weight:700;color:#fff;box-shadow:0 0 14px rgba(199,125,255,.5);">M</div>
<div style="width:54px;height:54px;border-radius:50%;background:linear-gradient(135deg,#c77dff,#4af);display:flex;align-items:center;justify-content:center;font-size:22px;font-weight:700;color:#fff;box-shadow:0 0 14px rgba(199,125,255,.5);">${avatarLetter}</div>
<div>
<div style="font-size:16px;font-weight:700;color:#e0e0e0;">Mitchell</div>
<div style="font-size:12px;color:#8899aa;">UID: 10086001</div>
<div style="font-size:16px;font-weight:700;color:#e0e0e0;">${name}</div>
<div style="font-size:12px;color:#8899aa;">UID: ${d.game_uid || d.uid || '--'}</div>
</div>
</div>
<div class="infoRow"><span class="infoLabel">等级</span><span class="infoValue">Lv.12</span></div>
<div class="infoRow"><span class="infoLabel">等级</span><span class="infoValue">Lv.${d.level || 1}</span></div>
<div style="margin:6px 0 10px;">
<div style="height:8px;background:rgba(255,255,255,.08);border-radius:4px;overflow:hidden;">
<div style="height:100%;width:64%;background:linear-gradient(90deg,#8b5cf6,#4af);border-radius:4px;"></div>
<div style="height:100%;width:${expPct}%;background:linear-gradient(90deg,#8b5cf6,#4af);border-radius:4px;"></div>
</div>
<div style="font-size:11px;color:#8899aa;text-align:right;margin-top:3px;">EXP 640 / 1000</div>
<div style="font-size:11px;color:#8899aa;text-align:right;margin-top:3px;">EXP ${d.exp || 0} / ${expMax}</div>
</div>
<div class="infoRow"><span class="infoLabel">金币</span><span class="infoValue">🪙 1280</span></div>
<div class="infoRow"><span class="infoLabel">世界</span><span class="infoValue">星元 1年 · 春</span></div>
<div class="infoRow"><span class="infoLabel">注册</span><span class="infoValue">2026-07-20</span></div>
<div class="infoRow"><span class="infoLabel">称号</span><span class="infoValue">${d.title || '萌新'}</span></div>
<div class="infoRow"><span class="infoLabel">金币</span><span class="infoValue">🪙 ${d.coins || 0}</span></div>
<div class="infoRow"><span class="infoLabel">灵气</span><span class="infoValue">✨ ${d.energy || 0}</span></div>
<div class="infoRow"><span class="infoLabel">木材</span><span class="infoValue">🪵 ${d.wood || 0}</span></div>
<div class="infoRow"><span class="infoLabel">石料</span><span class="infoValue">🪨 ${d.stone || 0}</span></div>
<div class="infoRow"><span class="infoLabel">世界</span><span class="infoValue">${d.world_name || '我的世界'}</span></div>
<div class="infoRow"><span class="infoLabel">历元</span><span class="infoValue">${d.era || '--'}</span></div>
<div class="infoRow"><span class="infoLabel">疆域</span><span class="infoValue">${d.tiles || 0} 块</span></div>
<div class="infoRow"><span class="infoLabel">人口</span><span class="infoValue">${d.population || 0}</span></div>
<div class="infoRow"><span class="infoLabel">文明度</span><span class="infoValue">${d.civ_level || 0}</span></div>
<div class="infoRow"><span class="infoLabel">注册</span><span class="infoValue">${d.reg_date || '--'}</span></div>
`);
});
@ -4475,57 +4563,81 @@ if (typeof fitWorld === 'function') fitWorld();
};
});
// 邮件
// 邮件(从 API.mails() 动态取值)
const uaMessages = document.getElementById('uaMessages');
if (uaMessages) uaMessages.addEventListener('click', () => {
const mails = [
{ cat: '系统', title: '欢迎来到宇森', date: '2026-07-26', body: '请查收新手礼包与世界探索指南。', unread: true },
{ cat: '系统', title: '版本更新 v0.2', date: '2026-07-25', body: '新增世界导航与微观视图功能。', unread: false },
{ cat: '活动', title: '夏日创作大赛', date: '2026-07-24', body: '上传你的世界截图,赢限定头像框。', unread: true }
];
const cats = ['全部', '系统', '活动'];
if (uaMessages) uaMessages.addEventListener('click', async () => {
openAppModal('✉️ 邮件', '<div style="text-align:center;padding:20px;color:#8899aa;font-size:13px;">加载中…</div>');
let mails = [];
try {
const resp = await API.mails();
mails = (resp && resp.items) || [];
} catch (e) {
if (appModalBody) appModalBody.innerHTML = '<div class="mailEmpty">邮件加载失败</div>';
return;
}
const cats = ['全部', ...new Set(mails.map(m => m.category_label).filter(Boolean))];
function renderMail(cat) {
const list = cat === '全部' ? mails : mails.filter(m => m.cat === cat);
const list = cat === '全部' ? mails : mails.filter(m => m.category_label === cat);
const tabs = cats.map(c => `<span class="mailTab${c === cat ? ' active' : ''}" data-cat="${c}">${c}</span>`).join('');
const items = list.length
? list.map(m => `<div class="mailItem"><div class="mailTitle">${m.unread ? '● ' : ''}${m.title}</div><div class="mailMeta">${m.cat} · ${m.date}</div><div>${m.body}</div></div>`).join('')
? list.map(m => `<div class="mailItem" data-id="${m.id}" style="cursor:pointer;${!m.is_read ? 'border-left:3px solid #8b5cf6;' : ''}">
<div class="mailTitle">${!m.is_read ? '● ' : ''}${m.subject || '(无标题)'}</div>
<div class="mailMeta">${m.category_label || ''} · ${m.date || ''}${m.sender ? ' · ' + m.sender : ''}</div>
<div>${m.body || ''}</div>
</div>`).join('')
: '<div class="mailEmpty">暂无邮件</div>';
if (appModalBody) appModalBody.innerHTML = `<div class="mailTabs">${tabs}</div>${items}`;
appModalBody.querySelectorAll('.mailTab').forEach(t => { t.onclick = () => renderMail(t.dataset.cat); });
appModalBody.querySelectorAll('.mailItem').forEach(item => {
item.onclick = async () => {
const id = parseInt(item.dataset.id);
if (!id) return;
try { await API.mailRead(id); } catch (e) {}
const m = mails.find(x => x.id === id);
if (m) m.is_read = true;
const badge = document.getElementById('uaMailBadge');
const unread = mails.filter(x => !x.is_read).length;
if (badge) { badge.textContent = unread; badge.style.display = unread > 0 ? '' : 'none'; }
const activeTab = appModalBody.querySelector('.mailTab.active');
renderMail(activeTab ? activeTab.dataset.cat : '全部');
};
});
}
openAppModal('✉️ 邮件', '');
renderMail('全部');
});
// 成就
// 成就(从 API.achievements() 动态取值)
const uaAchievements = document.getElementById('uaAchievements');
if (uaAchievements) uaAchievements.addEventListener('click', () => {
openAppModal('🏆 成就', `
if (uaAchievements) uaAchievements.addEventListener('click', async () => {
openAppModal('🏆 成就', '<div style="text-align:center;padding:20px;color:#8899aa;font-size:13px;">加载中…</div>');
let ach = { items: [], total_points: 0, unlocked_count: 0, total_count: 0 };
try {
ach = await API.achievements();
} catch (e) {
if (appModalBody) appModalBody.innerHTML = '<div style="text-align:center;padding:20px;color:#8899aa;">成就加载失败</div>';
return;
}
const items = ach.items || [];
const html = `
<div style="display:flex;justify-content:space-between;margin-bottom:14px;">
<div><div style="font-size:22px;font-weight:700;color:#e0e0e0;">320</div><div style="font-size:11px;color:#8899aa;">成就点数</div></div>
<div style="text-align:right;"><div style="font-size:22px;font-weight:700;color:#e0e0e0;">8 / 20</div><div style="font-size:11px;color:#8899aa;">已解锁</div></div>
<div><div style="font-size:22px;font-weight:700;color:#e0e0e0;">${ach.total_points || 0}</div><div style="font-size:11px;color:#8899aa;">成就点数</div></div>
<div style="text-align:right;"><div style="font-size:22px;font-weight:700;color:#e0e0e0;">${ach.unlocked_count || 0} / ${ach.total_count || 0}</div><div style="font-size:11px;color:#8899aa;">已解锁</div></div>
</div>
<div class="achItem done">
<div class="achIcon">🏆</div>
<div class="achMain"><div class="achName">初临世界</div><div class="achDesc">创建你的第一个世界</div></div>
<div class="achState">已达成</div>
${items.length ? items.map(a => {
const pct = a.target > 0 ? Math.min(100, (a.progress || 0) / a.target * 100) : (a.unlocked ? 100 : 0);
const icon = a.unlocked ? (a.icon || '🏆') : '🔒';
return `<div class="achItem${a.unlocked ? ' done' : ''}">
<div class="achIcon${a.unlocked ? '' : ' locked'}">${icon}</div>
<div class="achMain">
<div class="achName">${a.name}</div>
<div class="achDesc">${a.desc || ''}${a.tier_label ? ' [' + a.tier_label + ']' : ''} · ${a.points}点</div>
${a.unlocked ? '' : `<div class="achBar"><div class="achFill" style="width:${pct}%"></div></div>`}
</div>
<div class="achItem done">
<div class="achIcon">🌍</div>
<div class="achMain"><div class="achName">五界行者</div><div class="achDesc">解锁全部五层世界</div></div>
<div class="achState">已达成</div>
</div>
<div class="achItem">
<div class="achIcon locked">🔒</div>
<div class="achMain"><div class="achName">大地建筑师</div><div class="achDesc">在大地世界放置 50 个地块</div><div class="achBar"><div class="achFill" style="width:40%"></div></div></div>
<div class="achState">40%</div>
</div>
<div class="achItem">
<div class="achIcon locked">🔒</div>
<div class="achMain"><div class="achName">熔岩征服者</div><div class="achDesc">在地心世界停留 10 分钟</div><div class="achBar"><div class="achFill" style="width:15%"></div></div></div>
<div class="achState">15%</div>
</div>
`);
<div class="achState">${a.unlocked ? '已达成' : Math.round(pct) + '%'}</div>
</div>`;
}).join('') : '<div style="text-align:center;padding:20px;color:#8899aa;font-size:13px;">暂无成就数据</div>'}
`;
if (appModalBody) appModalBody.innerHTML = html;
});
} catch (e) {
location.replace('login.html?next=world.html'); return;