Files
pixelheros/put/web/src/components/TemplateManager.vue
pan cd9e7296a1 feat(trigger-editor): 添加触发组概率与保底配置功能
1. 新增触发组条目字段t_chance(触发概率)和pity_add(保底叠加),仅攻击/受击组生效
2. 为TriggerGroupEditor传入分组标识,动态显示概率配置表单项
3. 新增条目创建时继承同组末档概率参数的逻辑
4. 更新常量定义与文档说明,补充概率参数使用规则
5. 在前端展示和服务端导出中添加概率相关信息展示
2026-09-21 16:01:41 +08:00

422 lines
16 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="tpl-manager">
<div class="tpl-toolbar">
<div class="tpl-toolbar-left">
<span class="tpl-title">预设库</span>
<span class="muted">触发/光环预设是跨职业复用的资产,新英雄向导中勾选即可套用</span>
</div>
<div class="tpl-toolbar-actions">
<el-button size="small" type="success" @click="doSave">保存预设</el-button>
<el-button size="small" type="primary" @click="addPreset">+ 新建{{ activeTab === 'trigger' ? '触发' : '光环' }}预设</el-button>
</div>
</div>
<el-tabs v-model="activeTab" class="tpl-tabs">
<!-- 触发预设 -->
<el-tab-pane label="触发预设" name="trigger">
<div class="preset-sub-tabs">
<el-radio-group v-model="triggerGroup" size="small">
<el-radio-button v-for="g in TRIGGER_GROUPS" :key="g.key" :value="g.key">{{ g.label }}</el-radio-button>
</el-radio-group>
<span class="muted">共 {{ filteredTriggerPresets.length }} 个</span>
</div>
<div class="tpl-scroll">
<div class="tpl-cards">
<div v-for="p in filteredTriggerPresets" :key="p.id" class="tpl-card" @click="openEditTrigger(p)">
<div class="tpl-card-head">
<span class="tpl-name">{{ p.label || '(未命名)' }}</span>
<el-button type="danger" size="small" text @click.stop="removeTrigger(p)">删除</el-button>
</div>
<div class="tpl-meta">
<span class="tag">{{ p.group }}</span>
<span class="tpl-meta-item">{{ p.uuid }} {{ skillName(p.uuid) }}</span>
<span class="tpl-meta-sep">·</span>
<span class="tpl-meta-item">t_num {{ p.entry.t_num }}</span>
<span class="tpl-meta-sep" v-if="p.entry.t_chance != null && p.entry.t_chance < 100">·</span>
<span class="tpl-meta-item" v-if="p.entry.t_chance != null && p.entry.t_chance < 100">概率 {{ p.entry.t_chance }}%</span>
<span class="tpl-meta-sep">·</span>
<span class="tpl-meta-item">lv{{ p.entry.lv }}</span>
</div>
<div class="tpl-chips" v-if="overrideSummary(p.entry.overrides)">
<span class="chip">{{ overrideSummary(p.entry.overrides) }}</span>
</div>
<div class="tpl-chips" v-if="p.tags?.length">
<span v-for="t in p.tags" :key="t" class="chip tag-chip">{{ t }}</span>
</div>
</div>
<div v-if="!filteredTriggerPresets.length" class="tpl-empty-role">
<span class="muted">该组暂无预设</span>
<el-button size="small" text type="primary" @click="addTriggerFor(triggerGroup)">+ 补一个</el-button>
</div>
</div>
</div>
</el-tab-pane>
<!-- 光环预设 -->
<el-tab-pane label="光环预设" name="field">
<div class="preset-sub-tabs">
<span class="muted">共 {{ fieldPresets.length }} 个</span>
</div>
<div class="tpl-scroll">
<div class="tpl-cards">
<div v-for="p in fieldPresets" :key="p.id" class="tpl-card" @click="openEditField(p)">
<div class="tpl-card-head">
<span class="tpl-name">{{ p.label || '(未命名)' }}</span>
<el-button type="danger" size="small" text @click.stop="removeField(p)">删除</el-button>
</div>
<div class="tpl-meta">
<span class="tag">lv{{ p.lv }}</span>
<span class="tpl-meta-item">{{ p.uuids.length }} 个光环</span>
</div>
<div class="tpl-chips">
<span v-for="(u, i) in p.uuids" :key="i" class="chip aura">
{{ u.name || fieldName(u.uuid) }} {{ u.value }}
</span>
</div>
<div class="tpl-chips" v-if="p.tags?.length">
<span v-for="t in p.tags" :key="t" class="chip tag-chip">{{ t }}</span>
</div>
</div>
<div v-if="!fieldPresets.length" class="tpl-empty-role">
<span class="muted">暂无光环预设</span>
<el-button size="small" text type="primary" @click="addField">+ 补一个</el-button>
</div>
</div>
</div>
</el-tab-pane>
</el-tabs>
<!-- 触发预设编辑弹窗 -->
<el-dialog
v-model="editTriggerVisible"
:title="`编辑触发预设 ${currentTrigger?.label || ''}`"
width="70%"
top="4vh"
:close-on-click-modal="false"
>
<template v-if="currentTrigger">
<div class="panel">
<h3>基础</h3>
<div class="form-grid">
<el-form-item label="预设名">
<el-input v-model="currentTrigger.label" placeholder="如:受击3次·全队护盾" />
</el-form-item>
<el-form-item label="触发组">
<el-select v-model="currentTrigger.group">
<el-option v-for="g in TRIGGER_GROUPS" :key="g.key" :label="g.label" :value="g.key" />
</el-select>
</el-form-item>
<el-form-item label="技能底座">
<el-select v-model="currentTrigger.uuid" filterable placeholder="选择技能">
<el-option v-for="s in skillOptions" :key="s.uuid" :label="`${s.uuid} ${s.name}`" :value="s.uuid" />
</el-select>
</el-form-item>
<el-form-item label="标签(逗号分隔)">
<el-input v-model="triggerTagsInput" placeholder="伤害,团队" />
</el-form-item>
</div>
</div>
<div class="panel">
<h3>触发参数(entry)</h3>
<div class="form-grid">
<el-form-item label="解锁等级 lv">
<el-input-number v-model="currentTrigger.entry.lv" :min="1" :max="20" :controls="false" style="width: 100%" />
</el-form-item>
<el-form-item label="SkillLv">
<el-select v-model="currentTrigger.entry.s_lv">
<el-option v-for="n in 5" :key="n" :label="`Lv${n}`" :value="n" />
</el-select>
</el-form-item>
<el-form-item label="t_num">
<el-input-number v-model="currentTrigger.entry.t_num" :min="1" :controls="false" style="width: 100%" />
</el-form-item>
<template v-if="currentTrigger.group === 'atking' || currentTrigger.group === 'atked'">
<el-form-item label="t_chance 触发概率%(空=100)">
<el-input-number v-model="currentTrigger.entry.t_chance" :min="0" :max="100" :controls="false" style="width: 100%" />
</el-form-item>
<el-form-item label="pity_add 保底叠加%(空=0)">
<el-input-number v-model="currentTrigger.entry.pity_add" :min="0" :max="100" :controls="false" style="width: 100%" />
</el-form-item>
</template>
<el-form-item label="组名 name">
<el-input v-model="currentTrigger.entry.name" placeholder="留空回退底座名" />
</el-form-item>
</div>
<el-form-item label="overrides">
<OverrideEditor v-model="currentTrigger.entry.overrides" :skill-options="skillOptions" :buff-options="buffOptions" :base-skill-uuid="currentTrigger.uuid || 0" />
</el-form-item>
<el-form-item label="描述 desc">
<el-input v-model="currentTrigger.entry.desc" type="textarea" :rows="2" />
</el-form-item>
</div>
</template>
<template #footer>
<el-button size="small" @click="editTriggerVisible = false">关闭</el-button>
</template>
</el-dialog>
<!-- 光环预设编辑弹窗 -->
<el-dialog
v-model="editFieldVisible"
:title="`编辑光环预设 ${currentField?.label || ''}`"
width="60%"
top="6vh"
:close-on-click-modal="false"
>
<template v-if="currentField">
<div class="panel">
<h3>基础</h3>
<div class="form-grid">
<el-form-item label="预设名">
<el-input v-model="currentField.label" placeholder="如:暴击双光环" />
</el-form-item>
<el-form-item label="解锁等级 lv">
<el-input-number v-model="currentField.lv" :min="1" :max="20" :controls="false" style="width: 100%" />
</el-form-item>
<el-form-item label="标签(逗号分隔)" class="grid-span">
<el-input v-model="fieldTagsInput" placeholder="暴击,增伤" />
</el-form-item>
</div>
</div>
<div class="panel">
<h3>光环词条</h3>
<div v-for="(u, i) in currentField.uuids" :key="i" style="display: flex; gap: 8px; margin-bottom: 6px">
<el-select v-model="u.uuid" placeholder="光环" filterable style="width: 220px">
<el-option v-for="fs in fieldOptions" :key="fs.uuid" :label="`${fs.uuid} ${fs.name}`" :value="fs.uuid" />
</el-select>
<el-input-number v-model="u.value" :step="0.01" :controls="false" style="width: 120px" placeholder="value" />
<el-input v-model="u.name" placeholder="英雄语境名(可选)" style="width: 200px" />
<el-button type="danger" size="small" text @click="currentField.uuids.splice(i, 1)">删除</el-button>
</div>
<el-button size="small" @click="currentField.uuids.push({ uuid: null, value: 0, name: '' })">+ 添加词条</el-button>
</div>
</template>
<template #footer>
<el-button size="small" @click="editFieldVisible = false">关闭</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
import { ElMessage } from 'element-plus'
import { api } from '../api'
import { TRIGGER_GROUPS, defaultTriggerPreset, defaultFieldPreset } from '../constants'
import OverrideEditor from './OverrideEditor.vue'
const props = defineProps({
// 由 App 传入并持有,本组件直接原地修改,保存时整体提交
triggerPresets: { type: Array, required: true },
fieldPresets: { type: Array, required: true },
skillOptions: { type: Array, default: () => [] },
skillMap: { type: Object, default: () => ({}) },
fieldOptions: { type: Array, default: () => [] },
buffOptions: { type: Array, default: () => [] },
})
const activeTab = ref('trigger')
const triggerGroup = ref('atking')
const editTriggerVisible = ref(false)
const editFieldVisible = ref(false)
const activeTriggerId = ref('')
const activeFieldId = ref('')
const currentTrigger = computed(() => props.triggerPresets.find((p) => p.id === activeTriggerId.value) || null)
const currentField = computed(() => props.fieldPresets.find((p) => p.id === activeFieldId.value) || null)
const filteredTriggerPresets = computed(() => props.triggerPresets.filter((p) => p.group === triggerGroup.value))
// tags 数组 ↔ 输入框字符串双向桥接
const triggerTagsInput = computed({
get: () => (currentTrigger.value?.tags || []).join(','),
set: (v) => { if (currentTrigger.value) currentTrigger.value.tags = v.split(/[,,]/).map((s) => s.trim()).filter(Boolean) },
})
const fieldTagsInput = computed({
get: () => (currentField.value?.tags || []).join(','),
set: (v) => { if (currentField.value) currentField.value.tags = v.split(/[,,]/).map((s) => s.trim()).filter(Boolean) },
})
function skillName(uuid) { return props.skillMap[uuid]?.name || String(uuid ?? '') }
function fieldName(uuid) { return props.fieldOptions.find((f) => f.uuid === uuid)?.name || String(uuid ?? '') }
// overrides 摘要(卡片展示用):ap100 / buff 等关键键
function overrideSummary(ov) {
if (!ov) return ''
const keys = ['ap', 'crt', 'stun', 'frz', 'para', 'bck', 'timed_buff_id', 'timed_debuff_id', 'proc_skill']
const parts = []
for (const k of keys) {
const v = ov[k]
if (v == null) continue
if (k === 'proc_skill') parts.push(`proc:${v.s_uuid}×${v.chance ?? 100}%`)
else parts.push(`${k}${v}`)
}
return parts.join(' ')
}
function openEditTrigger(p) { activeTriggerId.value = p.id; editTriggerVisible.value = true }
function openEditField(p) { activeFieldId.value = p.id; editFieldVisible.value = true }
function addPreset() {
if (activeTab.value === 'trigger') addTriggerFor(triggerGroup.value)
else addField()
}
function addTriggerFor(group) {
const p = defaultTriggerPreset(group)
props.triggerPresets.push(p)
openEditTrigger(p)
}
function addField() {
const p = defaultFieldPreset()
props.fieldPresets.push(p)
openEditField(p)
}
function removeTrigger(p) {
const i = props.triggerPresets.findIndex((x) => x.id === p.id)
if (i >= 0) props.triggerPresets.splice(i, 1)
}
function removeField(p) {
const i = props.fieldPresets.findIndex((x) => x.id === p.id)
if (i >= 0) props.fieldPresets.splice(i, 1)
}
async function doSave() {
try {
const r = await api.saveTemplates({
triggerPresets: props.triggerPresets,
fieldPresets: props.fieldPresets,
})
ElMessage.success(`已保存:触发 ${r.triggerCount} / 光环 ${r.fieldCount} → ${r.path}`)
} catch (e) {
ElMessage.error('保存失败: ' + (e?.response?.data?.error || e.message))
}
}
</script>
<style scoped>
.tpl-manager {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* 顶部工具栏 */
.tpl-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 16px;
background: var(--panel);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
gap: 12px;
}
.tpl-toolbar-left { display: flex; align-items: baseline; gap: 10px; min-width: 0; }
.tpl-title { font-size: 13px; font-weight: 600; color: var(--text); }
.tpl-toolbar-actions { display: flex; gap: 8px; flex-shrink: 0; }
/* tabs 撑满剩余高度 */
.tpl-tabs {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
padding: 0 16px;
}
.tpl-tabs :deep(.el-tabs__header) { margin-bottom: 4px; flex-shrink: 0; }
.tpl-tabs :deep(.el-tabs__content) { flex: 1; overflow: hidden; display: flex; flex-direction: column; }
.tpl-tabs :deep(.el-tab-pane) { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
/* 触发组二级筛选 */
.preset-sub-tabs {
display: flex;
align-items: center;
gap: 12px;
padding: 4px 0 8px;
flex-shrink: 0;
}
/* 滚动区 */
.tpl-scroll {
flex: 1;
overflow-y: auto;
padding-bottom: 40px;
}
/* 预设卡片网格 */
.tpl-cards {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 10px;
}
.tpl-card {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 8px;
padding: 10px 12px;
cursor: pointer;
transition: box-shadow 0.15s, border-color 0.15s;
display: flex;
flex-direction: column;
gap: 6px;
}
.tpl-card:hover {
box-shadow: 0 3px 12px rgba(46, 91, 160, 0.15);
border-color: var(--brand-light);
}
.tpl-card-head {
display: flex;
justify-content: space-between;
align-items: center;
}
.tpl-name { font-size: 13px; font-weight: 600; }
.tpl-meta { display: flex; align-items: center; gap: 6px; font-size: 11px; color: var(--text-light); flex-wrap: wrap; }
.tpl-meta-sep { color: var(--border); }
.tpl-chips { display: flex; flex-wrap: wrap; gap: 4px; }
.chip {
background: #eef4fc;
color: var(--brand);
border-radius: 4px;
padding: 0 6px;
font-size: 11px;
line-height: 18px;
}
.chip.aura { background: #f4f0fa; color: #7c5cbf; }
.chip.tag-chip { background: var(--el-fill-color); color: var(--text-light); }
.tag {
display: inline-block;
background: #eef4fc;
color: var(--brand);
border-radius: 4px;
padding: 0 5px;
font-size: 11px;
line-height: 18px;
font-family: Menlo, Consolas, monospace;
}
.tpl-empty-role {
border: 1px dashed var(--border);
border-radius: 8px;
padding: 14px;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
grid-column: 1 / -1;
}
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0 16px; }
.form-grid .grid-span { grid-column: 1 / -1; }
@media (max-width: 600px) {
.tpl-cards { grid-template-columns: 1fr; }
.form-grid { grid-template-columns: 1fr; }
}
</style>