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

模块:CardData:修订间差异

来自夜幕之下
Rin留言 | 贡献
无编辑摘要
Rin留言 | 贡献
修复:加 deep filter 绕过 draft 过滤;修正小传结构解析和 CSS 类名以匹配 sandbox/2.0;移除 cardcss 模板引用 (via update-page on MediaWiki MCP Server)
第10行: 第10行:


local function val(v, fallback)
local function val(v, fallback)
     if v == nil or v == "" then
     if v == nil or v == "" then return fallback or "—" end
        return fallback or "—"
    end
     return v
     return v
end
end
第36行: 第34行:
     if extraClass then div:addClass(extraClass) end
     if extraClass then div:addClass(extraClass) end
     div:wikitext(titleText)
     div:wikitext(titleText)
     div:tag("span")
     div:tag("span"):addClass("card_content-item-subtitle"):wikitext(subtitleText)
        :addClass("card_content-item-subtitle")
        :wikitext(subtitleText)
     return div
     return div
end
end


local function makeHr(extraClass)
local function makeHr(extraClass)
     local div = mw.html.create("div")
     local div = mw.html.create("div"):addClass("card_content-item-hr")
        :addClass("card_content-item-hr")
     if extraClass then div:addClass(extraClass) end
     if extraClass then div:addClass(extraClass) end
     return div
     return div
第50行: 第45行:


local function makeTriggerLabel(triggerType, triggerValue)
local function makeTriggerLabel(triggerType, triggerValue)
     local span = mw.html.create("span")
     local span = mw.html.create("span"):addClass("card_content_skill-meta-val")
        :addClass("card_content_skill-meta-val")
     if triggerType == "normal_attack" then
     if triggerType == "normal_attack" then
         span:wikitext("每进行")
         span:wikitext("每进行")
第76行: 第70行:
     local wrap = mw.html.create("div"):addClass("card_content_skill-upgrade")
     local wrap = mw.html.create("div"):addClass("card_content_skill-upgrade")
     local rows = parseJson(levelsJson)
     local rows = parseJson(levelsJson)
     if not rows or type(rows) ~= "table" or #rows == 0 then
     if not rows or type(rows) ~= "table" or #rows == 0 then return wrap end
        return wrap
    end


    local firstRow = rows[1]
     local colCount = 0
     local colCount = 0
     if firstRow and firstRow.levels then
     if rows[1] and rows[1].levels then colCount = #rows[1].levels end
        colCount = #firstRow.levels
    end


     local gridClass = "card_content_skill-upgrade-grid"
     local gridClass = "card_content_skill-upgrade-grid"
     if colCount >= 10 then
     if colCount >= 10 then gridClass = gridClass .. " card_content_skill-upgrade-grid--10" end
        gridClass = gridClass .. " card_content_skill-upgrade-grid--10"
    end


     local grid = wrap:tag("div"):addClass(gridClass)
     local grid = wrap:tag("div"):addClass(gridClass)
第102行: 第89行:
         labelCell:tag("span"):addClass("card_content_skill-upgrade-badge-text"):wikitext(val(row.name, ""))
         labelCell:tag("span"):addClass("card_content_skill-upgrade-badge-text"):wikitext(val(row.name, ""))
         labelCell:tag("span"):addClass("card_content_skill-upgrade-arrow"):wikitext("→")
         labelCell:tag("span"):addClass("card_content_skill-upgrade-arrow"):wikitext("→")
         if row.levels and type(row.levels) == "table" then
         if row.levels and type(row.levels) == "table" then
             for _, v in ipairs(row.levels) do
             for _, v in ipairs(row.levels) do
第114行: 第100行:
         end
         end
     end
     end
     return wrap
     return wrap
end
end
第128行: 第113行:
     local nameRow = titleText:tag("div")
     local nameRow = titleText:tag("div")
     nameRow:tag("span"):addClass("card_content_skill-name"):wikitext(val(skillData.name))
     nameRow:tag("span"):addClass("card_content_skill-name"):wikitext(val(skillData.name))
     nameRow:tag("span")
     nameRow:tag("span"):addClass("card_content_skill-type card_accent--type")
        :addClass("card_content_skill-type card_accent--type")
         :wikitext("「" .. val(skillData.type) .. "」")
         :wikitext("「" .. val(skillData.type) .. "」")


第135行: 第119行:
     if skillType == "normal_attack" then
     if skillType == "normal_attack" then
         metaRow:wikitext("使用武器 ")
         metaRow:wikitext("使用武器 ")
         metaRow:tag("span")
         metaRow:tag("span"):addClass("card_content_skill-meta-val card_accent"):wikitext(val(skillData.weapon))
            :addClass("card_content_skill-meta-val card_accent")
            :wikitext(val(skillData.weapon))
     elseif skillType == "passive" then
     elseif skillType == "passive" then
         metaRow:wikitext("触发条件 ")
         metaRow:wikitext("触发条件 ")
第150行: 第132行:
         left:node(makeTagsRow(skillData.tags))
         left:node(makeTagsRow(skillData.tags))
     end
     end
 
     left:tag("div"):addClass("card_content_skill_effect"):wikitext(val(skillData.description))
     left:tag("div")
        :addClass("card_content_skill_effect")
        :wikitext(val(skillData.description))
 
     card:node(makeUpgradeTable(skillData.levels))
     card:node(makeUpgradeTable(skillData.levels))
     return card
     return card
第162行: 第140行:
     local ul = mw.html.create("ul"):addClass("card_content_feat-ul")
     local ul = mw.html.create("ul"):addClass("card_content_feat-ul")
     local stages = parseJson(stagesJson)
     local stages = parseJson(stagesJson)
     if not stages or type(stages) ~= "table" then
     if not stages or type(stages) ~= "table" then
         ul:tag("li"):wikitext("—")
         ul:tag("li"):wikitext("—")
         return ul
         return ul
     end
     end
     for _, stage in ipairs(stages) do
     for _, stage in ipairs(stages) do
         local li = ul:tag("li"):addClass("card_content_feat-list")
         local li = ul:tag("li"):addClass("card_content_feat-list")
         local row = li:tag("div"):addClass("card_content_feat-row")
         local row = li:tag("div"):addClass("card_content_feat-row")
         local stageWrap = row:tag("div"):addClass("card_content_feat-stage-wrap")
         local stageWrap = row:tag("div"):addClass("card_content_feat-stage-wrap")
         stageWrap:tag("p"):tag("span"):addClass("card_content_feat-stage"):wikitext("STAGE")
         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")
         stageWrap:tag("div"):addClass("card_content_feat-number")
             :wikitext(string.format("%02d", stageNum))
             :wikitext(string.format("%02d", stage.stage or 0))
 
         local rightDiv = row:tag("div")
         local rightDiv = row:tag("div")
 
         if stage.value and type(stage.value) == "table" then
         if stage.value and type(stage.value) == "table" and #stage.value > 0 then
             for _, v in ipairs(stage.value) do
             for _, v in ipairs(stage.value) do
                 if v.description then
                 if v.description then
第188行: 第160行:
             end
             end
         end
         end
         if stage.stat_boosts and type(stage.stat_boosts) == "table" then
         if stage.stat_boosts and type(stage.stat_boosts) == "table" then
             for _, boost in ipairs(stage.stat_boosts) do
             for _, boost in ipairs(stage.stat_boosts) do
第194行: 第165行:
             end
             end
         end
         end
         if stage.extra_name then
         if stage.extra_name then
             rightDiv:tag("span")
             rightDiv:tag("span"):addClass("card_content_feat-stage-cn")
                :addClass("card_content_feat-stage-cn")
                 :wikitext("「" .. stage.extra_name .. "」")
                 :wikitext("「" .. stage.extra_name .. "」")
         end
         end
     end
     end
     return ul
     return ul
end
end
第207行: 第175行:
-- ──────────────────────────────────────────────
-- ──────────────────────────────────────────────
-- 辅助:小传时间线
-- 辅助:小传时间线
-- stories: card.stories 数组
-- 数据结构:card.stories = [ { sort, story: [ { id, text, title? }, ... ] } ]
-- 每项结构:{ sort, story: [ { id, text }, ... ] }
-- 每条 stories 记录对应一张卡,其 story 数组里每一项是一个章节。
-- ──────────────────────────────────────────────
-- ──────────────────────────────────────────────
local function makeStoryTimeline(stories)
local function makeStoryTimeline(stories)
第215行: 第183行:
     timeline:tag("div"):addClass("card_timeline-line")
     timeline:tag("div"):addClass("card_timeline-line")


     -- 没有小传数据时显示占位
     -- 取第一条 stories 记录(一张卡只有一条)
     if not stories or type(stories) ~= "table" or #stories == 0 then
     local storiesEntry = stories and type(stories) == "table" and stories[1]
    local chapters = storiesEntry and type(storiesEntry.story) == "table" and storiesEntry.story or {}
 
    if #chapters == 0 then
         timeline:tag("div"):addClass("card_timeline-item"):wikitext("(小传数据待补充)")
         timeline:tag("div"):addClass("card_timeline-item"):wikitext("(小传数据待补充)")
         return timeline
         return timeline
     end
     end


     -- 按 sort 字段排序(sort 为 null 时按原始顺序)
     local romans      = { "Ⅰ", "Ⅱ", "Ⅲ", "Ⅳ", "Ⅴ" }
     local sorted = {}
     local unlockLevels = { "Lv.20 解锁", "Lv.40 解锁", "Lv.60 解锁", "Lv.80 解锁", "Lv.100 解锁" }
     for _, s in ipairs(stories) do
 
         table.insert(sorted, s)
     for idx, chapter in ipairs(chapters) do
    end
         local isLast = (idx == #chapters)
    table.sort(sorted, function(a, b)
         local item  = timeline:tag("div"):addClass("card_timeline-item")
         local sa = tonumber(a.sort) or 0
         if isLast then item:addClass("card_timeline-item--last") end
         local sb = tonumber(b.sort) or 0
        return sa < sb
    end)


    for idx, storyEntry in ipairs(sorted) do
        item:tag("div"):addClass("card_timeline-dot")
         local item = timeline:tag("div"):addClass("card_timeline-item")
         item:tag("div"):addClass("card_timeline-unlock")
            :wikitext(unlockLevels[idx] or ("Lv." .. (idx * 20) .. " 解锁"))


         -- 序号标记
         -- 标题行:罗马数字 + 章节名(有则显示)+ "/ Story"
         item:tag("div")
         local titleDiv = item:tag("div"):addClass("card_timeline-title")
            :addClass("card_timeline-badge")
        titleDiv:tag("span"):addClass("card_timeline-numeral")
             :wikitext(tostring(idx))
             :wikitext(romans[idx] or tostring(idx))
        if chapter.title and chapter.title ~= "" then
            titleDiv:tag("span"):addClass("card_timeline-chapter"):wikitext(chapter.title)
        end
        titleDiv:tag("span"):addClass("card_timeline-label"):wikitext("/ Story")


         -- 正文:story 是子数组,取所有 text 段落
         -- 正文:\n 转 <br>
         local bodyDiv = item:tag("div"):addClass("card_timeline-body")
         local bodyDiv = item:tag("div"):addClass("card_timeline-body")
         if storyEntry.story and type(storyEntry.story) == "table" then
         local text = chapter.text or ""
            for _, para in ipairs(storyEntry.story) do
        if text ~= "" then
                if para.text and para.text ~= "" then
            local lines = mw.text.split(text, "\n")
                    -- 将换行符转为 <br>,保留原文段落感
            for i, line in ipairs(lines) do
                    local lines = mw.text.split(para.text, "\n")
                if line ~= "" then bodyDiv:wikitext(line) end
                    for i, line in ipairs(lines) do
                if i < #lines then bodyDiv:tag("br") end
                        if line ~= "" then
                            bodyDiv:wikitext(line)
                        end
                        if i < #lines then
                            bodyDiv:tag("br")
                        end
                    end
                end
             end
             end
         end
         end
第268行: 第233行:
function p.render(frame)
function p.render(frame)
     local name = mw.text.trim(frame.args[1] or frame.args.name or "")
     local name = mw.text.trim(frame.args[1] or frame.args.name or "")
     if name == "" then
     if name == "" then
         return '<span class="error">错误:请提供卡片名称,例如 {{#invoke:CardData|render|复仇童谣}}</span>'
         return '<span class="error">错误:请提供卡片名称,例如 {{#invoke:CardData|render|复仇童谣}}</span>'
第276行: 第240行:
     local fields = table.concat({
     local fields = table.concat({
         "stylename", "rarity",
         "stylename", "rarity",
         "character.name",
         "character.name", "profession.name", "desire.name",
        "profession.name",
         "skill_normal_attack.name", "skill_normal_attack.type",
        "desire.name",
         "skill_normal_attack.weapon", "skill_normal_attack.description",
         "skill_normal_attack.name",
        "skill_normal_attack.type",
         "skill_normal_attack.weapon",
        "skill_normal_attack.description",
         "skill_normal_attack.levels.*",
         "skill_normal_attack.levels.*",
         "skill_passive.name",
         "skill_passive.name", "skill_passive.type",
        "skill_passive.type",
         "skill_passive.trigger_type", "skill_passive.trigger_value",
         "skill_passive.trigger_type",
         "skill_passive.tags", "skill_passive.description",
        "skill_passive.trigger_value",
         "skill_passive.tags",
        "skill_passive.description",
         "skill_passive.levels.*",
         "skill_passive.levels.*",
         "skill_ultimate.name",
         "skill_ultimate.name", "skill_ultimate.type",
        "skill_ultimate.type",
         "skill_ultimate.desire_cost", "skill_ultimate.tags",
         "skill_ultimate.desire_cost",
         "skill_ultimate.description", "skill_ultimate.levels.*",
        "skill_ultimate.tags",
         "skill_ultimate.description",
        "skill_ultimate.levels.*",
         "feats.stages",
         "feats.stages",
         "stories.sort",
         "stories.sort",
         "stories.story.text"
         "stories.story.*"   -- 取 story 数组里所有字段(含 title、text、id)
     }, ",")
     }, ",")


第306行: 第260行:
         .. "?fields=" .. mw.uri.encode(fields, "QUERY")
         .. "?fields=" .. mw.uri.encode(fields, "QUERY")
         .. "&filter[stylename][_eq]=" .. encodedName
         .. "&filter[stylename][_eq]=" .. encodedName
        -- deep filter:让 Directus 不过滤掉 draft 状态的 stories 关联记录
        .. "&deep[stories][_filter][status][_neq]=archived"


     -- ── 2. 拉取数据 ──
     -- ── 2. 拉取原始 JSON ──
     local rawResponse = frame:preprocess(
     local rawResponse = frame:preprocess(
         '{{#get_web_data:url=' .. apiUrl
         '{{#get_web_data:url=' .. apiUrl
         .. '|format=text'
         .. '|format=text|data=responseText=__text}}'
        .. '|data=responseText=__text}}'
         .. '{{#external_value:responseText}}'
         .. '{{#external_value:responseText}}'
     )
     )
     rawResponse = mw.text.trim(rawResponse or "")
     rawResponse = mw.text.trim(rawResponse or "")


     -- ── 3. 解析 ──
     -- ── 3. 解析 ──
     local parsed = parseJson(rawResponse)
     local parsed = parseJson(rawResponse)
     local card = parsed and type(parsed.data) == "table" and parsed.data[1] or nil
     local card   = parsed and type(parsed.data) == "table" and parsed.data[1] or nil


     if not card then
     if not card then
第336行: 第290行:
     local featsStagesJson = "[]"
     local featsStagesJson = "[]"
     if card.feats and type(card.feats) == "table" and card.feats[1] then
     if card.feats and type(card.feats) == "table" and card.feats[1] then
         local stagesRaw = card.feats[1].stages
         local sr = card.feats[1].stages
         if type(stagesRaw) == "table" then
         featsStagesJson = type(sr) == "table" and mw.text.jsonEncode(sr)
            featsStagesJson = mw.text.jsonEncode(stagesRaw)
                      or type(sr) == "string" and sr or "[]"
        elseif type(stagesRaw) == "string" then
            featsStagesJson = stagesRaw
        end
     end
     end


第347行: 第298行:
         if not skill.levels then return "[]" end
         if not skill.levels then return "[]" end
         if type(skill.levels) == "string" then return skill.levels end
         if type(skill.levels) == "string" then return skill.levels end
         if type(skill.levels) == "table" then return mw.text.jsonEncode(skill.levels) end
         if type(skill.levels) == "table" then return mw.text.jsonEncode(skill.levels) end
         return "[]"
         return "[]"
     end
     end
第357行: 第308行:
         if not skill.tags then return "[]" end
         if not skill.tags then return "[]" end
         if type(skill.tags) == "string" then return skill.tags end
         if type(skill.tags) == "string" then return skill.tags end
         if type(skill.tags) == "table" then return mw.text.jsonEncode(skill.tags) end
         if type(skill.tags) == "table" then return mw.text.jsonEncode(skill.tags) end
         return "[]"
         return "[]"
     end
     end
第371行: 第322行:


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


第378行: 第328行:
     root:tag("div"):addClass("card_hero"):css("height", "100vh")
     root:tag("div"):addClass("card_hero"):css("height", "100vh")


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


     -- 左栏
     -- ── 左栏 ──
     local leftCol = layout:tag("div"):addClass("card_content_left")
     local leftCol = layout:tag("div"):addClass("card_content_left")
     local nameContainer = leftCol:tag("div"):addClass("card_content_name-container")
     local nc = leftCol:tag("div"):addClass("card_content_name-container")
     local nameRow = nameContainer:tag("div"):addClass("card_content_name-row")
     local nr = nc:tag("div"):addClass("card_content_name-row")
 
     nr:tag("div"):addClass("card_content_name-icon")
     nameRow:tag("div"):addClass("card_content_name-icon")
     nr:tag("div"):addClass("card_content_name-classtext"):wikitext(profName)
     nameRow:tag("div"):addClass("card_content_name-classtext"):wikitext(profName)
     nr:tag("div"):addClass("card_content_name-icon card_content_name-icon--ml")
     nameRow:tag("div"):addClass("card_content_name-icon card_content_name-icon--ml")
     nr:tag("div"):addClass("card_content_name-classtext"):wikitext(desName)
     nameRow:tag("div"):addClass("card_content_name-classtext"):wikitext(desName)
     local nt = nc:tag("div"):addClass("card_content_name-text")
 
     nt:wikitext(stylename .. " ")
     local nameText = nameContainer:tag("div"):addClass("card_content_name-text")
     nt:tag("span"):addClass("card_accent"):wikitext("·")
     nameText:wikitext(stylename .. " ")
     nt:wikitext(" " .. charName)
     nameText:tag("span"):addClass("card_accent"):wikitext("·")
     nameText:wikitext(" " .. charName)


     leftCol:node(makeTitle("信息", "Info"))
     leftCol:node(makeTitle("信息", "Info"))
     leftCol:node(makeHr())
     leftCol:node(makeHr())
     local infoGrid = leftCol:tag("div"):addClass("card_content_info")
     leftCol:tag("div"):addClass("card_content_info")
    infoGrid:tag("div"):addClass("card_content_info-item")
        :tag("div"):addClass("card_content_info-item")
         :tag("div"):wikitext("稀有度"):done()
         :tag("div"):wikitext("稀有度"):done()
         :tag("div"):wikitext(rarityStars(rarity))
         :tag("div"):wikitext(rarityStars(rarity))
第417行: 第365行:
     leftCol:tag("div"):addClass("card_content_attribute-item"):wikitext("(数据待补充)")
     leftCol:tag("div"):addClass("card_content_attribute-item"):wikitext("(数据待补充)")


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


第436行: 第384行:
     local storyHeader  = storySection:tag("div"):addClass("card_content_story-header")
     local storyHeader  = storySection:tag("div"):addClass("card_content_story-header")
     local storyLeft    = storyHeader:tag("div"):addClass("card_content_story-header-left")
     local storyLeft    = storyHeader:tag("div"):addClass("card_content_story-header-left")
     storyLeft:node(makeTitle("小传", "Story"))
     storyLeft:node(makeTitle("小传", "Story"))
     storyLeft:node(makeHr("card_content-item-hr--mb12"))
     storyLeft:node(makeHr("card_content-item-hr--mb12"))
第443行: 第390行:
         :tag("span"):addClass("story-toggle-text"):wikitext("收起"):done()
         :tag("span"):addClass("story-toggle-text"):wikitext("收起"):done()
         :tag("span"):addClass("story-toggle-icon"):wikitext("-")
         :tag("span"):addClass("story-toggle-icon"):wikitext("-")
     storyHeader:tag("div"):addClass("card_content_story-header-spacer")
     storyHeader:tag("div"):addClass("card_content_story-header-spacer")
    -- 时间线(有数据就渲染,没有就显示占位)
     storyHeader:node(makeStoryTimeline(stories))
     storyHeader:node(makeStoryTimeline(stories))


     -- ── 邀约(暂无数据) ──
     -- ── 邀约(待补充) ──
     rightCol:tag("div"):css("height", "12px"):css("width", "100%")
     rightCol:tag("div"):css("height", "12px"):css("width", "100%")
     rightCol:node(makeTitle("邀约", "Date"))
     rightCol:node(makeTitle("邀约", "Date"))
     rightCol:node(makeHr("card_content-item-hr--mb12"))
     rightCol:node(makeHr("card_content-item-hr--mb12"))
 
     rightCol:tag("div"):addClass("card_content_date")
     local dateWrap = rightCol:tag("div"):addClass("card_content_date")
        :tag("div"):addClass("card_content_date-track")
    dateWrap:tag("div"):addClass("card_content_date-track")
         :tag("ul"):addClass("card_content_date-list")
         :tag("ul"):addClass("card_content_date-list")
         :tag("li"):addClass("card_content_date-item")
         :tag("li"):addClass("card_content_date-item"):wikitext("(邀约数据待补充)")
        :wikitext("(邀约数据待补充)")


     return tostring(root)
     return tostring(root)

2026年3月11日 (三) 20:26的版本

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

-- Module:CardData
-- 用法:{{#invoke:CardData|render|复仇童谣}}
-- 通过 ExternalData 扩展从 Directus API 拉取数据,在服务端渲染完整 HTML

local p = {}

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

local function val(v, fallback)
    if v == nil or v == "" then return fallback or "—" end
    return v
end

local function rarityStars(n)
    n = tonumber(n) or 0
    return string.rep("★", n)
end

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

-- ──────────────────────────────────────────────
-- 辅助:段落标题
-- ──────────────────────────────────────────────
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

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

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

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

    local colCount = 0
    if rows[1] and rows[1].levels then colCount = #rows[1].levels end

    local gridClass = "card_content_skill-upgrade-grid"
    if colCount >= 10 then gridClass = gridClass .. " card_content_skill-upgrade-grid--10" end

    local grid = wrap:tag("div"):addClass(gridClass)
    grid:tag("div")
    for i = 2, 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("→")
        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

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) .. "」")

    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

    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

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")
        local stageWrap = row:tag("div"):addClass("card_content_feat-stage-wrap")
        stageWrap:tag("p"):tag("span"):addClass("card_content_feat-stage"):wikitext("STAGE")
        stageWrap:tag("div"):addClass("card_content_feat-number")
            :wikitext(string.format("%02d", stage.stage or 0))
        local rightDiv = row:tag("div")
        if stage.value and type(stage.value) == "table" 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
        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
        if stage.extra_name then
            rightDiv:tag("span"):addClass("card_content_feat-stage-cn")
                :wikitext("「" .. stage.extra_name .. "」")
        end
    end
    return ul
end

-- ──────────────────────────────────────────────
-- 辅助:小传时间线
-- 数据结构:card.stories = [ { sort, story: [ { id, text, title? }, ... ] } ]
-- 每条 stories 记录对应一张卡,其 story 数组里每一项是一个章节。
-- ──────────────────────────────────────────────
local function makeStoryTimeline(stories)
    local timeline = mw.html.create("div")
        :addClass("card_timeline card_content_story-timeline")
    timeline:tag("div"):addClass("card_timeline-line")

    -- 取第一条 stories 记录(一张卡只有一条)
    local storiesEntry = stories and type(stories) == "table" and stories[1]
    local chapters = storiesEntry and type(storiesEntry.story) == "table" and storiesEntry.story or {}

    if #chapters == 0 then
        timeline:tag("div"):addClass("card_timeline-item"):wikitext("(小传数据待补充)")
        return timeline
    end

    local romans      = { "Ⅰ", "Ⅱ", "Ⅲ", "Ⅳ", "Ⅴ" }
    local unlockLevels = { "Lv.20 解锁", "Lv.40 解锁", "Lv.60 解锁", "Lv.80 解锁", "Lv.100 解锁" }

    for idx, chapter in ipairs(chapters) do
        local isLast = (idx == #chapters)
        local item   = timeline:tag("div"):addClass("card_timeline-item")
        if isLast then item:addClass("card_timeline-item--last") end

        item:tag("div"):addClass("card_timeline-dot")
        item:tag("div"):addClass("card_timeline-unlock")
            :wikitext(unlockLevels[idx] or ("Lv." .. (idx * 20) .. " 解锁"))

        -- 标题行:罗马数字 + 章节名(有则显示)+ "/ Story"
        local titleDiv = item:tag("div"):addClass("card_timeline-title")
        titleDiv:tag("span"):addClass("card_timeline-numeral")
            :wikitext(romans[idx] or tostring(idx))
        if chapter.title and chapter.title ~= "" then
            titleDiv:tag("span"):addClass("card_timeline-chapter"):wikitext(chapter.title)
        end
        titleDiv:tag("span"):addClass("card_timeline-label"):wikitext("/ Story")

        -- 正文:\n 转 <br>
        local bodyDiv = item:tag("div"):addClass("card_timeline-body")
        local text = chapter.text or ""
        if text ~= "" then
            local lines = mw.text.split(text, "\n")
            for i, line in ipairs(lines) do
                if line ~= "" then bodyDiv:wikitext(line) end
                if i < #lines then bodyDiv:tag("br") end
            end
        end
    end

    return timeline
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. 构造 API URL ──
    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",
        "stories.sort",
        "stories.story.*"   -- 取 story 数组里所有字段(含 title、text、id)
    }, ",")

    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
        -- deep filter:让 Directus 不过滤掉 draft 状态的 stories 关联记录
        .. "&deep[stories][_filter][status][_neq]=archived"

    -- ── 2. 拉取原始 JSON ──
    local rawResponse = frame:preprocess(
        '{{#get_web_data:url=' .. apiUrl
        .. '|format=text|data=responseText=__text}}'
        .. '{{#external_value:responseText}}'
    )
    rawResponse = mw.text.trim(rawResponse or "")

    -- ── 3. 解析 ──
    local parsed = parseJson(rawResponse)
    local card   = parsed and type(parsed.data) == "table" and parsed.data[1] or nil

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

    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 {}
    local stories    = card.stories or {}

    local featsStagesJson = "[]"
    if card.feats and type(card.feats) == "table" and card.feats[1] then
        local sr = card.feats[1].stages
        featsStagesJson = type(sr) == "table" and mw.text.jsonEncode(sr)
                       or type(sr) == "string" and sr or "[]"
    end

    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)

    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)

    -- ── 4. 组装 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 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 layout = root:tag("div"):addClass("card_content-container")
        :tag("div"):addClass("card_content-background")
        :tag("div"):addClass("card_content-inner")
        :tag("div"):addClass("card_content card_content--layout")

    -- ── 左栏 ──
    local leftCol = layout:tag("div"):addClass("card_content_left")
    local nc = leftCol:tag("div"):addClass("card_content_name-container")
    local nr = nc:tag("div"):addClass("card_content_name-row")
    nr:tag("div"):addClass("card_content_name-icon")
    nr:tag("div"):addClass("card_content_name-classtext"):wikitext(profName)
    nr:tag("div"):addClass("card_content_name-icon card_content_name-icon--ml")
    nr:tag("div"):addClass("card_content_name-classtext"):wikitext(desName)
    local nt = nc:tag("div"):addClass("card_content_name-text")
    nt:wikitext(stylename .. " ")
    nt:tag("span"):addClass("card_accent"):wikitext("·")
    nt:wikitext(" " .. charName)

    leftCol:node(makeTitle("信息", "Info"))
    leftCol:node(makeHr())
    leftCol:tag("div"):addClass("card_content_info")
        :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")
    storyHeader:node(makeStoryTimeline(stories))

    -- ── 邀约(待补充) ──
    rightCol:tag("div"):css("height", "12px"):css("width", "100%")
    rightCol:node(makeTitle("邀约", "Date"))
    rightCol:node(makeHr("card_content-item-hr--mb12"))
    rightCol:tag("div"):addClass("card_content_date")
        :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)
end

return p