打开/关闭菜单
打开/关闭外观设置菜单
打开/关闭个人菜单
未登录
未登录用户的IP地址会在进行任意编辑后公开展示。

模块:CardData

来自夜幕之下
Rin留言 | 贡献2026年3月11日 (三) 00:13的版本 (修复:用 frame:callParserFunction("#external_value") 替换不存在的 mw.ext.externalData.getVar (via update-page on MediaWiki MCP Server))

此模块的文档可以在模块:CardData/doc创建

-- Module:CardData
-- 用法:{{#invoke:CardData|render|复仇童谣}}
-- 通过 ExternalData 扩展从 Directus API 拉取数据,在服务端渲染完整 HTML
-- 小传、邀约、映像、认知、属性等 API 暂无数据,保留骨架占位

local p = {}

-- ══════════════════════════════════════════════
-- 工具函数
-- ══════════════════════════════════════════════

-- 安全取值:若为 nil 则返回 fallback(默认 "—")
local function val(v, fallback)
    if v == nil or v == "" then
        return fallback or "—"
    end
    return v
end

-- 将稀有度数字转为星号字符串,例如 6 → "★★★★★★"
local function rarityStars(n)
    n = tonumber(n) or 0
    return string.rep("★", n)
end

-- 解析 JSON 字符串,失败时返回 nil
local function parseJson(str)
    if not str or str == "" then return nil end
    local ok, result = pcall(mw.text.jsonDecode, str)
    if ok then return result else return nil end
end

-- ──────────────────────────────────────────────
-- 辅助:段落标题(如「战斗技能 Tactical」)
-- ──────────────────────────────────────────────
local function makeTitle(titleText, subtitleText, extraClass)
    local div = mw.html.create("div")
        :addClass("card_content-item-title")
        :addClass("card_content-item-title--mt")
    if extraClass then div:addClass(extraClass) end
    div:wikitext(titleText)
    div:tag("span")
        :addClass("card_content-item-subtitle")
        :wikitext(subtitleText)
    return div
end

-- ──────────────────────────────────────────────
-- 辅助:分隔线
-- ──────────────────────────────────────────────
local function makeHr(extraClass)
    local div = mw.html.create("div")
        :addClass("card_content-item-hr")
    if extraClass then div:addClass(extraClass) end
    return div
end

-- ──────────────────────────────────────────────
-- 辅助:被动触发条件文字
-- trigger_type: "normal_attack" 等
-- trigger_value: 数字
-- ──────────────────────────────────────────────
local function makeTriggerLabel(triggerType, triggerValue)
    local span = mw.html.create("span")
        :addClass("card_content_skill-meta-val")
    if triggerType == "normal_attack" then
        span:wikitext("每进行")
        span:tag("span"):addClass("card_accent"):wikitext(tostring(triggerValue or "?"))
        span:wikitext("次普攻")
    else
        span:wikitext(val(triggerType))
    end
    return span
end

-- ──────────────────────────────────────────────
-- 辅助:技能 tags 行
-- tagsJson: JSON 字符串数组,如 '["持续伤害","群攻"]'
-- ──────────────────────────────────────────────
local function makeTagsRow(tagsJson)
    local div = mw.html.create("div"):addClass("card_content_skill-tags")
    local tags = parseJson(tagsJson)
    if tags and type(tags) == "table" then
        for _, tag in ipairs(tags) do
            div:tag("div"):addClass("card_content_skill-tag"):wikitext(tag)
        end
    end
    return div
end

-- ──────────────────────────────────────────────
-- 辅助:技能升级表格
-- levelsJson: JSON 数组,每项 { name, levels: [...] }
-- 列数根据第一行 levels 数组长度 +1 决定(+1 是标签列)
-- ──────────────────────────────────────────────
local function makeUpgradeTable(levelsJson)
    local wrap = mw.html.create("div"):addClass("card_content_skill-upgrade")
    local rows = parseJson(levelsJson)

    -- 无数据时返回空占位
    if not rows or type(rows) ~= "table" or #rows == 0 then
        return wrap
    end

    -- 计算列数:第一行的 levels 数组长度(即升级档位数)
    local firstRow = rows[1]
    local colCount = 0
    if firstRow and firstRow.levels then
        colCount = #firstRow.levels
    end
    -- 总格数 = 标签列 + 数据列,对应 grid-template-columns
    local gridClass = "card_content_skill-upgrade-grid"
    if colCount == 9 then
        -- 终结技 9 档(1~9级)
        gridClass = gridClass  -- 默认样式
    elseif colCount >= 9 then
        -- 普攻/被动 10 档(1~10级,但第1格是标签)
        gridClass = gridClass .. " card_content_skill-upgrade-grid--10"
    end

    local grid = wrap:tag("div"):addClass(gridClass)

    -- 表头行:空格 + 2~N 级
    grid:tag("div") -- 左上角空格
    local startLv = 2
    if colCount == 9 then startLv = 2 end -- 终结技从 Lv2 开始
    for i = startLv, colCount do
        grid:tag("div"):addClass("card_content_skill-upgrade-lv"):wikitext(tostring(i))
    end

    -- 数据行
    for idx, row in ipairs(rows) do
        -- 标签列:序号徽章 + 名称 + 箭头
        local labelCell = grid:tag("div"):addClass("card_content_skill-upgrade-row-label")
        labelCell:tag("span"):addClass("card_content_skill-upgrade-badge"):wikitext(tostring(idx))
        labelCell:tag("span"):addClass("card_content_skill-upgrade-badge-text"):wikitext(val(row.name, ""))
        labelCell:tag("span"):addClass("card_content_skill-upgrade-arrow"):wikitext("→")

        -- 数据列(从第 2 个值开始,第 1 个是基础值显示在描述里)
        if row.levels and type(row.levels) == "table" then
            for _, v in ipairs(row.levels) do
                local cell = grid:tag("div"):addClass("card_content_skill-upgrade-val")
                if v == nil or v == "" or v == "—" then
                    cell:addClass("card_content_skill-upgrade-val--null"):wikitext("—")
                else
                    cell:addClass("card_accent"):wikitext(tostring(v))
                end
            end
        end
    end

    return wrap
end

-- ──────────────────────────────────────────────
-- 辅助:生成一张技能卡
-- skillData: Lua table,包含技能字段
-- skillType: "normal_attack" | "passive" | "ultimate"
-- ──────────────────────────────────────────────
local function makeSkillCard(skillData, skillType)
    local card = mw.html.create("div"):addClass("card_content_skill-card")
    local left = card:tag("div"):addClass("card_content_skill-left")

    -- 标题行
    local titleRow = left:tag("div"):addClass("card_content_skill-title")
    titleRow:tag("div"):addClass("card_content_skill-icon") -- 图标占位

    local titleText = titleRow:tag("div"):addClass("card_content_skill-title-text")

    -- 技能名 + 类型
    local nameRow = titleText:tag("div")
    nameRow:tag("span")
        :addClass("card_content_skill-name")
        :wikitext(val(skillData.name))
    nameRow:tag("span")
        :addClass("card_content_skill-type card_accent--type")
        :wikitext("「" .. val(skillData.type) .. "」")

    -- meta 行
    local metaRow = titleText:tag("div"):addClass("card_content_skill-meta")
    if skillType == "normal_attack" then
        metaRow:wikitext("使用武器 ")
        metaRow:tag("span")
            :addClass("card_content_skill-meta-val card_accent")
            :wikitext(val(skillData.weapon))
    elseif skillType == "passive" then
        metaRow:wikitext("触发条件 ")
        metaRow:node(makeTriggerLabel(skillData.trigger_type, skillData.trigger_value))
    elseif skillType == "ultimate" then
        metaRow:wikitext("欲火消耗 ")
        local costSpan = metaRow:tag("span"):addClass("card_content_skill-meta-val")
        costSpan:tag("span"):addClass("card_accent"):wikitext(val(skillData.desire_cost))
    end

    -- tags(被动和终结技才有)
    if skillType == "passive" or skillType == "ultimate" then
        left:node(makeTagsRow(skillData.tags))
    end

    -- 技能描述
    left:tag("div")
        :addClass("card_content_skill_effect")
        :wikitext(val(skillData.description))

    -- 升级表格
    card:node(makeUpgradeTable(skillData.levels))

    return card
end

-- ──────────────────────────────────────────────
-- 辅助:觉醒列表
-- stagesJson: JSON 字符串,feats[0].stages 数组
-- ──────────────────────────────────────────────
local function makeFeatList(stagesJson)
    local ul = mw.html.create("ul"):addClass("card_content_feat-ul")
    local stages = parseJson(stagesJson)

    if not stages or type(stages) ~= "table" then
        ul:tag("li"):wikitext("—")
        return ul
    end

    for _, stage in ipairs(stages) do
        local li = ul:tag("li"):addClass("card_content_feat-list")
        local row = li:tag("div"):addClass("card_content_feat-row")

        -- 左侧:STAGE + 序号
        local stageWrap = row:tag("div"):addClass("card_content_feat-stage-wrap")
        stageWrap:tag("p"):tag("span"):addClass("card_content_feat-stage"):wikitext("STAGE")
        local stageNum = stage.stage or 0
        stageWrap:tag("div"):addClass("card_content_feat-number")
            :wikitext(string.format("%02d", stageNum))

        -- 右侧:效果文字 + 属性加成 + 觉醒名
        local rightDiv = row:tag("div")

        -- 特殊效果(value[].description),用 card_accent 高亮
        if stage.value and type(stage.value) == "table" and #stage.value > 0 then
            for _, v in ipairs(stage.value) do
                if v.description then
                    rightDiv:tag("span"):addClass("card_accent"):wikitext(v.description)
                    rightDiv:tag("br")
                end
            end
        end

        -- 属性加成(stat_boosts[])
        if stage.stat_boosts and type(stage.stat_boosts) == "table" then
            for _, boost in ipairs(stage.stat_boosts) do
                rightDiv:wikitext((boost.type or "") .. "提升" .. (boost.value or ""))
            end
        end

        -- 觉醒名(extra_name)
        if stage.extra_name then
            rightDiv:tag("span")
                :addClass("card_content_feat-stage-cn")
                :wikitext("「" .. stage.extra_name .. "」")
        end
    end

    return ul
end

-- ══════════════════════════════════════════════
-- 主渲染函数
-- ══════════════════════════════════════════════
function p.render(frame)
    local name = mw.text.trim(frame.args[1] or frame.args.name or "")

    if name == "" then
        return '<span class="error">错误:请提供卡片名称,例如 {{#invoke:CardData|render|复仇童谣}}</span>'
    end

    -- ── 1. 用 ExternalData 拉取数据 ──
    local fields = table.concat({
        "stylename", "rarity",
        "character.name",
        "profession.name",
        "desire.name",
        "skill_normal_attack.name",
        "skill_normal_attack.type",
        "skill_normal_attack.weapon",
        "skill_normal_attack.description",
        "skill_normal_attack.levels.*",
        "skill_passive.name",
        "skill_passive.type",
        "skill_passive.trigger_type",
        "skill_passive.trigger_value",
        "skill_passive.tags",
        "skill_passive.description",
        "skill_passive.levels.*",
        "skill_ultimate.name",
        "skill_ultimate.type",
        "skill_ultimate.desire_cost",
        "skill_ultimate.tags",
        "skill_ultimate.description",
        "skill_ultimate.levels.*",
        "feats.stages"
    }, ",")

    local encodedName = mw.uri.encode(name, "QUERY")
    local apiUrl = "https://data.saltedkiss.org/items/cards"
        .. "?fields=" .. mw.uri.encode(fields, "QUERY")
        .. "&filter[stylename][_eq]=" .. encodedName

    -- 调用 #get_web_data 拉取并存入变量 cardJson
    -- 第一个无名参数是占位符(callParserFunction 要求至少一个无名参数)
    frame:callParserFunction("#get_web_data", {
        "",
        url    = apiUrl,
        format = "JSON",
        data   = "cardJson=data.0"
    })

    -- 用 #external_value 读回变量(ExternalData 没有 Lua API,只能通过 parser function 读取)
    local rawJson = frame:callParserFunction("#external_value", { "cardJson" }) or ""

    -- ── 2. 解析 JSON ──
    local card = parseJson(rawJson)

    if not card then
        return '<span class="error">⚠ 无法加载卡片数据:' .. mw.text.nowiki(name) .. '</span>'
    end

    -- 取各子对象(可能是 table 或 nil)
    local charData    = card.character or {}
    local profData    = card.profession or {}
    local desireData  = card.desire or {}
    local skillNA     = card.skill_normal_attack or {}
    local skillPA     = card.skill_passive or {}
    local skillUL     = card.skill_ultimate or {}

    -- feats 是数组,取第一项的 stages
    local featsStagesJson = "[]"
    if card.feats and type(card.feats) == "table" and card.feats[1] then
        local stagesRaw = card.feats[1].stages
        if type(stagesRaw) == "table" then
            featsStagesJson = mw.text.jsonEncode(stagesRaw)
        elseif type(stagesRaw) == "string" then
            featsStagesJson = stagesRaw
        end
    end

    -- 技能 levels:ExternalData 可能把它解析成 table,转回 JSON 字符串给 makeUpgradeTable
    local function levelsToJson(skill)
        if not skill.levels then return "[]" end
        if type(skill.levels) == "string" then return skill.levels end
        if type(skill.levels) == "table" then return mw.text.jsonEncode(skill.levels) end
        return "[]"
    end
    skillNA.levels = levelsToJson(skillNA)
    skillPA.levels = levelsToJson(skillPA)
    skillUL.levels = levelsToJson(skillUL)

    -- 技能 tags 同理
    local function tagsToJson(skill)
        if not skill.tags then return "[]" end
        if type(skill.tags) == "string" then return skill.tags end
        if type(skill.tags) == "table" then return mw.text.jsonEncode(skill.tags) end
        return "[]"
    end
    skillPA.tags = tagsToJson(skillPA)
    skillUL.tags = tagsToJson(skillUL)

    -- ── 3. 组装 HTML ──
    local rarity    = tonumber(card.rarity) or 0
    local stylename = val(card.stylename)
    local charName  = val(charData.name)
    local profName  = val(profData.name)
    local desName   = val(desireData.name)

    local root = mw.html.create("div")
        :addClass("ron-card")
        :addClass("ron-card--rarity-" .. rarity)
        :attr("data-rarity", rarity)

    root:tag("div"):addClass("card_fullscreen-img")
    root:tag("div"):addClass("card_hero"):css("height", "100vh")

    local container = root:tag("div"):addClass("card_content-container")
    local bg        = container:tag("div"):addClass("card_content-background")
    local inner     = bg:tag("div"):addClass("card_content-inner")
    local layout    = inner:tag("div"):addClass("card_content card_content--layout")

    -- ════════════════════════════════
    -- 左栏
    -- ════════════════════════════════
    local leftCol = layout:tag("div"):addClass("card_content_left")

    local nameContainer = leftCol:tag("div"):addClass("card_content_name-container")
    local nameRow = nameContainer:tag("div"):addClass("card_content_name-row")

    nameRow:tag("div"):addClass("card_content_name-icon")
    nameRow:tag("div"):addClass("card_content_name-classtext"):wikitext(profName)
    nameRow:tag("div"):addClass("card_content_name-icon card_content_name-icon--ml")
    nameRow:tag("div"):addClass("card_content_name-classtext"):wikitext(desName)

    local nameText = nameContainer:tag("div"):addClass("card_content_name-text")
    nameText:wikitext(stylename .. " ")
    nameText:tag("span"):addClass("card_accent"):wikitext("·")
    nameText:wikitext(" " .. charName)

    leftCol:node(makeTitle("信息", "Info"))
    leftCol:node(makeHr())
    local infoGrid = leftCol:tag("div"):addClass("card_content_info")
    infoGrid:tag("div"):addClass("card_content_info-item")
        :tag("div"):wikitext("稀有度"):done()
        :tag("div"):wikitext(rarityStars(rarity))

    leftCol:node(makeTitle("等级 100", "Lv. Max"))
    leftCol:node(makeHr())
    leftCol:tag("div"):addClass("card_content_attribute-item"):wikitext("(数据待补充)")

    leftCol:node(makeTitle("映像", "Reflection"))
    leftCol:node(makeHr())
    leftCol:tag("div"):addClass("card_content_attribute-item"):wikitext("(数据待补充)")

    leftCol:node(makeTitle("认知", "Cognition"))
    leftCol:node(makeHr())
    leftCol:tag("div"):addClass("card_content_attribute-item"):wikitext("(数据待补充)")

    -- ════════════════════════════════
    -- 右栏
    -- ════════════════════════════════
    local rightCol = layout:tag("div"):addClass("card_content_right")

    rightCol:node(makeTitle("战斗技能", "Tactical"))
    rightCol:node(makeHr("card_content-item-hr--mb12"))

    rightCol:node(makeSkillCard(skillNA, "normal_attack"))
    rightCol:tag("div"):addClass("card_content_skill-gap")

    rightCol:node(makeSkillCard(skillPA, "passive"))
    rightCol:tag("div"):addClass("card_content_skill-gap")

    rightCol:node(makeSkillCard(skillUL, "ultimate"))

    rightCol:node(makeTitle("觉醒", "Feat"))
    rightCol:node(makeHr("card_content-item-hr--mb12"))
    rightCol:node(makeFeatList(featsStagesJson))

    local storySection = rightCol:tag("div"):addClass("card_content_story")
    local storyHeader  = storySection:tag("div"):addClass("card_content_story-header")
    local storyLeft    = storyHeader:tag("div"):addClass("card_content_story-header-left")

    storyLeft:node(makeTitle("小传", "Story"))
    storyLeft:node(makeHr("card_content-item-hr--mb12"))
    storyLeft:tag("div"):addClass("card_content_story-toggle")
        :attr("data-collapsed", "false")
        :tag("span"):addClass("story-toggle-text"):wikitext("收起"):done()
        :tag("span"):addClass("story-toggle-icon"):wikitext("-")

    storyHeader:tag("div"):addClass("card_content_story-header-spacer")

    local timeline = storyHeader:tag("div")
        :addClass("card_timeline card_content_story-timeline")
    timeline:tag("div"):addClass("card_timeline-line")
    timeline:tag("div"):addClass("card_timeline-item"):wikitext("(小传数据待补充)")

    rightCol:tag("div"):css("height", "12px"):css("width", "100%")
    rightCol:node(makeTitle("邀约", "Date"))
    rightCol:node(makeHr("card_content-item-hr--mb12"))

    local dateWrap = rightCol:tag("div"):addClass("card_content_date")
    dateWrap:tag("div"):addClass("card_content_date-track")
        :tag("ul"):addClass("card_content_date-list")
        :tag("li"):addClass("card_content_date-item")
        :wikitext("(邀约数据待补充)")

    return tostring(root) .. frame:expandTemplate{ title = "cardcss" }
end

return p