bugfix250729.1

全站通知:

模块:语音映射

来自CrashFeverWIKI_BWIKI_哔哩哔哩
跳到导航 跳到搜索
按右上角“WIKI功能→编辑”即可修改页面内容。“刷新页面”即可查看最新内容。
觉得WIKI有帮助,请推荐给朋友哦~(◕ω<)☆

模块导读:语音动态映射系统

本模块(模块:语音映射)是 Wiki 语音系统的核心调度中枢。它负责从分散的数据页面中精准抓取特定角色的语音数据,进行文本清洗与格式化,并最终将数据注入到前端 Widget 中,生成带有打字机动效的交互式语音卡片。

💡 设计初衷:让路人也能参与维护

在传统的 Wiki 架构中,复杂的台词数据往往被写死在 Lua 的 Table 表中。这导致普通玩家发现翻译错误时,面对密密麻麻的代码根本无从下手。 本系统采用了“数据分片存储”架构:将海量语音数据按角色拆分到 数据:语音-xxx 等独立页面,并配合 Page Forms 提供极简的“填表式”编辑。目的是彻底降低维护门槛,让任何路过查阅 Wiki 的玩家,只需点击“编辑”按钮就能直接修正台词翻译,实现真正的社区共建。

基本调用方法

在任何角色页面或相关词条中,使用以下代码即可自动生成该角色的所有语音卡片:

{{#invoke:语音映射|render|角色ID}}

示例: {{#invoke:语音映射|render|930}} 将渲染 ID 为 930 的所有语音。


1. 底层数据结构 (Template)

为了让 Semantic MediaWiki (SMW) 能够识别这些散落的语音数据,我们使用了隐藏模板 Template:语音条目。当玩家通过表单保存数据时,实际上就是将数据包裹在这个模板中。

源码参考:

<noinclude>这是一个后端数据存储模板,请勿直接手动调用。</noinclude>{{#subobject:
 |音频文件名={{{音频文件名|}}}
 |日文内容={{{日文内容|}}}
 |中文翻译={{{中文翻译|}}}
 |所属ID={{{所属ID|}}}
 |所属数据分片页={{FULLPAGENAME}}
}}
  • 原理解析:这里使用了 SMW 的 #subobject(子对象)功能。它使得一个页面可以存储多条独立的语音数据。其中 所属数据分片页=模块:语音映射 是核心锚点,它告诉系统这条数据保存在哪个具体的页面,从而为前端生成正确的“编辑表单链接”。

2. 系统运行逻辑 (Data Flow)

本系统的运转遵循“底层存储 -> Lua 处理 -> 前端渲染 -> 表单回写”的完整闭环。按执行顺序,逻辑如下:

Step 1: 语义检索 (SMW Query)

模块接收到 角色ID 后,会在内部调用 MediaWiki 解析器函数来执行 SMW 搜索。为了追求最高性能,我们在 Lua 中是这样构建查询的:

local askResult = frame:callParserFunction{
    name = '#ask',
    args = {
        string.format("[[所属ID::%s]]", char_id), -- 查询条件:锁定当前角色
        '?音频文件名',
        '?日文内容',
        '?中文翻译',
        '?所属数据分片页',
        sort = '音频文件名',
        order = 'asc',
        format = 'array',  -- 核心:阵列格式
        sep = '@@@',       -- 条目之间的分隔符
        propsep = '###',   -- 属性之间的分隔符
        mainlabel = '-',   -- 隐藏主页面名,纯粹取数据
        limit = '100'
    }
}
  • 深度解析:通常 #ask 会返回复杂的 HTML 表格。但在 Lua 中,我们强行设置 format = 'array',并定义了罕见的分隔符(@@@###)。这样 SMW 就会返回一个纯文本的超长字符串,Lua 只需要用正则 string.gmatch 就能极速切分数据,避免了繁重的 DOM 解析,极大降低了服务器负载。

Step 2: 文本清洗与格式化

获取到长字符串后,Lua 进行拆解,并执行关键的文本转义:

  • 换行符处理:识别用户在表单填写的字面量换行符(如 \n/n),并将其转换为真实的换行符。这确保了后续前端的 white-space: pre-wrap 样式能够正确折行,而不会与 HTML 转义机制冲突。

Step 3: 智能路由构造

对于每一条语音,Lua 会动态计算两个关键 URL:

  • 音频源路径:通过 Special:FilePath 将文件名转化为真实的 .mp3 播放地址。
  • 编辑跳转链接:这是系统的亮点。Lua 会将当前的页面标题(returnto)、当前条目的文件名(focus_file)和角色ID(target_id)拼接在 URL 中,生成一个极具针对性的 Special:FormEdit 链接。

Step 4: Widget 分发

将清洗后的参数(audiourl, editurl, jp, cn)逐条传递给 Widget:语音展示,输出最终的 HTML 和 JS 代码。


3. 核心特性与体验优化 (Features)

本系统在设计时重点解决了“大页面性能崩溃”“移动端/PC端阅读体验”两大痛点,包含以下高级特性:

🎨 前端交互:内联展开与智能打字机

Widget:语音展示 实现。

  • 极致空间压缩:初始状态仅显示“胶囊形”微型播放按钮,支持横向流式排版(inline-block),极大节约了长语音列表的垂直空间。
  • 精准动效同步:通过设置 <audio preload="metadata"></audio>,浏览器能瞬间获取真实音频时长。JS 会据此精确计算打字速度(保底 40ms~300ms/字),确保文字输出速度与配音时长完美契合
  • 瞬间翻译机制:日文原文逐字打出(带闪烁光标),日文输出完毕的瞬间,中文翻译一次性全部显示,符合玩家阅读习惯。

🛡️ 数据架构:碎片化存储与绕过限制

  • 抛弃了将 8000+ 条数据塞入单一页面的传统做法。数据被分散存储在如 数据:语音-930 这样的角色专属分片页中。
  • 完美避开了 Semantic MediaWiki 单页 100 条子对象(Subobject)的索引上限,保证所有语音都能被全站搜索到。

🤖 傻瓜式表单:防呆与精准定位

表单:语音数据表单Widget:语音表单逻辑 协同实现。 当用户点击某条语音的“✏️编辑”按钮时:

  1. 自动过滤:JS 会读取 URL 中的 target_id,将表单中不属于该角色的其他行瞬间隐藏。
  2. 高亮与滚动:读取 URL 中的 focus_file,自动将页面滚动到用户刚刚点击的那一条,并加上蓝色高亮边框,实现“指哪打哪”。
  3. 数据保护:强行隐藏 Page Forms 默认的“增加/删除实例”按钮,并将文件名设为只读。普通用户只能修改中文/日文文本,绝无可能误删核心数据。
  4. 无缝返回:读取 URL 中的 returnto,用户点击“保存”后,浏览器会自动跳回他们原来所在的 Wiki 词条,而不是停留在枯燥的数据页。

4. 架构依赖清单 (Dependencies)

维护或迁移本系统时,请确保以下组件完整齐备:

组件类型 页面名称 职责说明
数据载体 语音-xxx 存放具体语音条目的页面。必须包含 {{语音条目}} 模板。
数据模板 Template:语音条目 负责将参数转化为 SMW #set 属性。
核心模块 Module:语音映射 本模块,负责检索数据并调度展示。
前端展现 Widget:语音展示 负责渲染胶囊按钮、打字机动画、CSS 样式控制。
编辑入口 表单:语音数据表单 提供用户友好的多实例编辑界面。
表单控制 Widget:语音表单逻辑 注入于表单内,负责高亮行、隐藏按钮、回跳路由等 JS 逻辑。

5. 常见问题排查 (Troubleshooting)

  • Q: 语音点击播放后,文字出得太快或太慢?
    • 检查点:确认 Widget:语音展示 中的 <audio> 标签是否包含 preload="metadata"。如果缺失,浏览器无法获取时长,会触发保底的 5 秒逻辑。
  • Q: 编辑完语音点击保存,没有跳回角色页面?
    • 检查点:检查调用模块的页面 URL 中是否丢失了 returnto 参数。或者检查 Widget:语音表单逻辑 中覆盖 form action 的 JS 代码是否正常执行。
  • Q: 页面上出现了 <br> 或者文字没有换行?
    • 检查点:确保 Lua 模块中转义 \n 的代码使用的是 jp:gsub("[/\\]n", "\n") 替换为真实换行,且 Widget 中展示文本的带有white-space: pre-wrap; 样式,并正确使用了 Smarty 过滤器。

local p = {}

function p.render(frame)
    -- 1. 接收角色 ID
    local char_id = frame.args[1] or frame:getParent().args[1]
    if not char_id then
        return ""
    end
    
    -- 获取当前页面标题,用于返回
    local current_page = mw.title.getCurrentTitle().fullText
    
    -- 2. 构造底层 #ask 语句
    local askResult = frame:callParserFunction{
        name = '#ask',
        args = {
            string.format("[[所属ID::%s]]", char_id),
            '?音频文件名',
            '?日文内容',
            '?中文翻译',
            '?所属数据分片页',
            sort = '音频文件名',
            order = 'asc',
            format = 'array',
            sep = '@@@',
            propsep = '###',
            mainlabel = '-',
            limit = '100'
        }
    }

    if not askResult or askResult == "" then
        return ""
    end

    local html_output = {}

    -- 3. 解析结果字符串并循环渲染
    for line in string.gmatch(askResult, "[^@]+") do
        line = mw.text.trim(line)
        if line ~= "" then
            local parts = {}
            for part in string.gmatch(line .. "###", "(.-)###") do
                table.insert(parts, mw.text.trim(part))
            end

            if #parts >= 4 then
                local file = parts[1]
                local jp = parts[2]
                local cn = parts[3]
                local datapage = parts[4]
				-- 【新增:处理换行符】
	    		-- 这里的 gsub 会把文本里的 \n 替换成 HTML 换行,这样在浏览器里才会换行
	    		jp = jp:gsub("[/\\]n", "\n")
	    		cn = cn:gsub("[/\\]n", "\n")
                -- 4. 计算 URL 
                local audio_url = frame:callParserFunction('filepath', file .. '.mp3')
                
                -- 【核心修改:在这里拼接多个参数】
                -- 增加 target_id 用于表单过滤,增加 returnto 用于保存后返回
                local query = string.format(
                    "focus_file=%s&target_id=%s&returnto=%s",
                    mw.uri.encode(file),
                    mw.uri.encode(char_id),
                    mw.uri.encode(current_page)
                )
                local edit_url = frame:callParserFunction('fullurl', '特殊:编辑表格/语音数据表单/' .. datapage, query)

                -- 5. 使用结构化参数调用 Widget
                local widget_call = frame:callParserFunction{
                    name = '#widget:语音展示',
                    args = {
                        ['audiourl'] = audio_url,
                        ['editurl'] = edit_url,
                        ['jp'] = jp ~= "" and jp or "",
                        ['cn'] = cn ~= "" and cn or "(暂无翻译,点击右上角补充)"
                    }
                }
                
                table.insert(html_output, widget_call)
            end
        end
    end

    -- 6. 合并输出
    return table.concat(html_output, "\n")
end

return p