feat: 新增预设库管理与一键创建英雄向导
1. 新增服务端模板持久化接口,支持触发/光环预设的读写 2. 前端新增预设库管理页面,支持增删改查触发/光环预设 3. 新增英雄创建向导,支持按职业快速生成英雄并套用预设 4. 调整本地开发端口与代理地址 5. 补充职业默认配置与攻速映射等常量定义
This commit is contained in:
@@ -3,6 +3,10 @@
|
||||
<header class="hero-header">
|
||||
<h1>英雄配置编辑器</h1>
|
||||
<div class="actions">
|
||||
<el-radio-group v-model="view" size="small">
|
||||
<el-radio-button value="heroes">英雄列表</el-radio-button>
|
||||
<el-radio-button value="templates">预设库</el-radio-button>
|
||||
</el-radio-group>
|
||||
<el-button size="small" @click="refreshMeta" :loading="loadingMeta">刷新配置数据</el-button>
|
||||
<el-button size="small" type="success" @click="doSave" :disabled="!heroes.length">保存到服务器</el-button>
|
||||
<el-button size="small" type="primary" @click="doApply" :disabled="!heroes.length">写入 heroSet.ts</el-button>
|
||||
@@ -10,6 +14,17 @@
|
||||
</div>
|
||||
</header>
|
||||
<div class="hero-main">
|
||||
<!-- 预设库视图 -->
|
||||
<TemplateManager
|
||||
v-if="view === 'templates'"
|
||||
:trigger-presets="triggerPresets"
|
||||
:field-presets="fieldPresets"
|
||||
:skill-options="allSkills"
|
||||
:skill-map="skillMap"
|
||||
:field-options="fieldOptions"
|
||||
:buff-options="buffOptions"
|
||||
/>
|
||||
<template v-else>
|
||||
<!-- 首页:英雄卡片列表 -->
|
||||
<div class="home-toolbar">
|
||||
<span class="muted">共 {{ heroes.length }} 个英雄</span>
|
||||
@@ -188,6 +203,17 @@
|
||||
<el-button size="small" @click="editVisible = false">关闭</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 新建英雄向导 -->
|
||||
<HeroWizard
|
||||
v-model:visible="wizardVisible"
|
||||
:heroes="heroes"
|
||||
:skill-map="skillMap"
|
||||
:trigger-presets="triggerPresets"
|
||||
:field-presets="fieldPresets"
|
||||
@confirm="onWizardCreate"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -199,17 +225,26 @@ import { api } from './api'
|
||||
import {
|
||||
HERO_ROLES, GROW_TYPES, H_TYPES,
|
||||
ROLE_ATK_SKILL, ROLE_ULT_SKILL, SUPPORT_ULT,
|
||||
defaultHero, defaultSkill,
|
||||
defaultSkill,
|
||||
} from './constants'
|
||||
import SkillEditor from './components/SkillEditor.vue'
|
||||
import TriggerGroupEditor from './components/TriggerGroupEditor.vue'
|
||||
import FieldEditor from './components/FieldEditor.vue'
|
||||
import BonusEditor from './components/BonusEditor.vue'
|
||||
import HeroWizard from './components/HeroWizard.vue'
|
||||
import TemplateManager from './components/TemplateManager.vue'
|
||||
|
||||
const heroes = ref([])
|
||||
// 预设库(跨职业复用资产,与职业默认解耦)
|
||||
const triggerPresets = ref([])
|
||||
const fieldPresets = ref([])
|
||||
const activeIdx = ref(-1)
|
||||
const meta = ref(null)
|
||||
const loadingMeta = ref(false)
|
||||
// 视图切换:heroes 英雄列表 / templates 模板管理
|
||||
const view = ref('heroes')
|
||||
// 新建英雄向导可见性
|
||||
const wizardVisible = ref(false)
|
||||
// 编辑弹窗可见性:首页卡片点「修改/新建」打开,关闭仅收起不丢数据
|
||||
const editVisible = ref(false)
|
||||
|
||||
@@ -389,13 +424,13 @@ function openEdit(i) {
|
||||
activeIdx.value = i
|
||||
editVisible.value = true
|
||||
}
|
||||
// 「+ 新建英雄」改为打开向导:选职业/技能底座/勾选模板预设,一键生成完整英雄
|
||||
function addHero() {
|
||||
const nh = defaultHero()
|
||||
const used = new Set(heroes.value.map((h) => h.uuid))
|
||||
let uuid = 5001
|
||||
while (used.has(uuid)) uuid++
|
||||
nh.uuid = uuid
|
||||
heroes.value.push(nh)
|
||||
wizardVisible.value = true
|
||||
}
|
||||
// 向导创建完成:追加到列表并打开编辑弹窗微调
|
||||
function onWizardCreate(h) {
|
||||
heroes.value.push(h)
|
||||
openEdit(heroes.value.length - 1)
|
||||
}
|
||||
function removeHero(i) {
|
||||
@@ -448,6 +483,16 @@ async function loadHeroes() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTemplates() {
|
||||
try {
|
||||
const r = await api.fetchTemplates()
|
||||
triggerPresets.value = Array.isArray(r.triggerPresets) ? r.triggerPresets : []
|
||||
fieldPresets.value = Array.isArray(r.fieldPresets) ? r.fieldPresets : []
|
||||
} catch (e) {
|
||||
ElMessage.error('加载预设库失败: ' + (e?.response?.data?.error || e.message))
|
||||
}
|
||||
}
|
||||
|
||||
async function doSave() {
|
||||
try {
|
||||
const r = await api.saveHeroes(heroes.value)
|
||||
@@ -492,5 +537,6 @@ async function doExportCsv() {
|
||||
onMounted(() => {
|
||||
loadMeta()
|
||||
loadHeroes()
|
||||
loadTemplates()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -7,4 +7,6 @@ export const api = {
|
||||
saveHeroes: (heroes) => axios.post('/api/heroes', { heroes }).then((r) => r.data),
|
||||
applyHeroes: (heroes) => axios.post('/api/apply', { heroes }).then((r) => r.data),
|
||||
exportCsv: (heroes) => axios.post('/api/export-csv', { heroes }).then((r) => r.data),
|
||||
fetchTemplates: () => axios.get('/api/templates').then((r) => r.data),
|
||||
saveTemplates: (templates) => axios.post('/api/templates', { templates }).then((r) => r.data),
|
||||
}
|
||||
|
||||
280
put/web/src/components/HeroWizard.vue
Normal file
280
put/web/src/components/HeroWizard.vue
Normal file
@@ -0,0 +1,280 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
:model-value="visible"
|
||||
title="新建英雄向导"
|
||||
width="64%"
|
||||
top="4vh"
|
||||
:close-on-click-modal="false"
|
||||
@update:model-value="emit('update:visible', $event)"
|
||||
>
|
||||
<div class="panel">
|
||||
<h3>职业</h3>
|
||||
<div class="role-cards">
|
||||
<div
|
||||
v-for="r in HERO_ROLES"
|
||||
:key="r.key"
|
||||
class="role-card"
|
||||
:class="{ active: form.roleKey === r.key }"
|
||||
@click="onRoleChange(r.key)"
|
||||
>
|
||||
{{ r.label }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<h3>基础</h3>
|
||||
<div class="form-grid">
|
||||
<el-form-item label="名称">
|
||||
<el-input v-model="form.name" placeholder="英雄名" />
|
||||
</el-form-item>
|
||||
<el-form-item label="稀有度">
|
||||
<el-select v-model="form.rarity">
|
||||
<el-option v-for="n in [1,2,3,4,5]" :key="n" :label="'★'.repeat(n)" :value="n" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="形态">
|
||||
<el-select v-model="form.typeKey">
|
||||
<el-option v-for="t in H_TYPES" :key="t.key" :label="`${t.key} ${t.label}`" :value="t.key" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="攻速档">
|
||||
<el-select v-model="form.atkSpeed">
|
||||
<el-option v-for="s in ATK_SPEEDS" :key="s" :label="s" :value="s" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="三维偏移">
|
||||
<div class="stat-row">
|
||||
<el-input-number v-model="form.statBase.hp" placeholder="hp" :controls="false" style="width: 90px" />
|
||||
<el-input-number v-model="form.statBase.ap" placeholder="ap" :controls="false" style="width: 90px" />
|
||||
<el-input-number v-model="form.statBase.def" placeholder="def" :controls="false" style="width: 90px" />
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="资源路径">
|
||||
<el-input v-model="form.path" placeholder="ui/role/xxx(可后补)" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<h3>技能底座</h3>
|
||||
<div class="form-grid">
|
||||
<el-form-item label="普攻底座">
|
||||
<el-select v-model="form.atkUuid" filterable>
|
||||
<el-option v-for="uuid in atkSkillOptions" :key="uuid" :label="`${uuid} ${skillName(uuid)}`" :value="uuid" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="大招底座">
|
||||
<el-select v-model="form.ultUuid" filterable>
|
||||
<el-option v-for="uuid in ultSkillOptions" :key="uuid" :label="`${uuid} ${skillName(uuid)}`" :value="uuid" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</div>
|
||||
<div class="muted" style="font-size: 11px">普攻自动按攻速档生成 cd/cdExpr + lv2 档(ap120);大招自动 cd:8 + lv8/lv16 档</div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<h3>触发组预设 <span class="muted">来自预设库,勾选即注入,默认不勾</span></h3>
|
||||
<div v-if="triggerPresets.length">
|
||||
<div v-for="g in TRIGGER_GROUPS" :key="g.key" class="preset-group" v-show="presetsByGroup(g.key).length">
|
||||
<div class="preset-group-title">{{ g.label }}</div>
|
||||
<el-checkbox
|
||||
v-for="p in presetsByGroup(g.key)"
|
||||
:key="p.id"
|
||||
v-model="p.checked"
|
||||
:label="presetTriggerLabel(p)"
|
||||
class="preset-item"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="muted">暂无触发预设(可在"预设库"页添加)</div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<h3>光环预设 <span class="muted">来自预设库,单选或不选</span></h3>
|
||||
<div v-if="fieldPresets.length">
|
||||
<el-radio-group v-model="checkedFieldId">
|
||||
<el-radio :value="''">无光环</el-radio>
|
||||
<el-radio v-for="p in fieldPresets" :key="p.id" :value="p.id" class="preset-item">
|
||||
{{ p.label || '(未命名)' }} <span class="muted">lv{{ p.lv }} · {{ p.uuids.length }} 个词条</span>
|
||||
</el-radio>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
<div v-else class="muted">暂无光环预设(可在"预设库"页添加)</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<el-button size="small" @click="emit('update:visible', false)">取消</el-button>
|
||||
<el-button size="small" type="primary" @click="confirm" :disabled="!form.name || !form.roleKey || !form.atkUuid || !form.ultUuid">
|
||||
创建英雄
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import {
|
||||
HERO_ROLES, H_TYPES, ATK_SPEEDS, ATK_SPEED_MAP,
|
||||
ROLE_DEFAULTS, TRIGGER_GROUPS,
|
||||
ROLE_ATK_SKILL, ROLE_ULT_SKILL, SUPPORT_ULT,
|
||||
defaultHero,
|
||||
} from '../constants'
|
||||
|
||||
const props = defineProps({
|
||||
visible: { type: Boolean, default: false },
|
||||
heroes: { type: Array, default: () => [] },
|
||||
skillMap: { type: Object, default: () => ({}) },
|
||||
// 预设库(跨职业复用资产)
|
||||
triggerPresets: { type: Array, default: () => [] },
|
||||
fieldPresets: { type: Array, default: () => [] },
|
||||
})
|
||||
const emit = defineEmits(['update:visible', 'confirm'])
|
||||
|
||||
const form = ref(emptyForm())
|
||||
const checkedFieldId = ref('')
|
||||
|
||||
function emptyForm() {
|
||||
return {
|
||||
roleKey: '',
|
||||
name: '',
|
||||
rarity: 4,
|
||||
typeKey: 'Melee',
|
||||
atkSpeed: 'Normal',
|
||||
statBase: { hp: 0, ap: 0, def: 0 },
|
||||
path: '',
|
||||
atkUuid: null,
|
||||
ultUuid: null,
|
||||
}
|
||||
}
|
||||
|
||||
function skillName(uuid) { return props.skillMap[uuid]?.name || String(uuid ?? '') }
|
||||
|
||||
const atkSkillOptions = computed(() => (form.value.roleKey ? ROLE_ATK_SKILL[form.value.roleKey] : []))
|
||||
const ultSkillOptions = computed(() => {
|
||||
if (!form.value.roleKey) return []
|
||||
return [...new Set([...(ROLE_ULT_SKILL[form.value.roleKey] || []), ...SUPPORT_ULT])]
|
||||
})
|
||||
|
||||
function presetsByGroup(group) {
|
||||
return props.triggerPresets.filter((p) => p.group === group)
|
||||
}
|
||||
|
||||
function presetTriggerLabel(p) {
|
||||
return p.label || `${p.uuid} ${skillName(p.uuid)} t${p.entry.t_num}`
|
||||
}
|
||||
|
||||
function onRoleChange(roleKey) {
|
||||
form.value.roleKey = roleKey
|
||||
// 职业默认值:形态/攻速/稀有度/三维偏移(用户可微调)
|
||||
const d = ROLE_DEFAULTS[roleKey]
|
||||
if (d) {
|
||||
form.value.typeKey = d.typeKey
|
||||
form.value.atkSpeed = d.atkSpeed
|
||||
form.value.rarity = d.rarity
|
||||
form.value.statBase = { ...d.statBase }
|
||||
}
|
||||
// 切职业时重置已选技能(职业过滤后原选择可能不可用)
|
||||
form.value.atkUuid = null
|
||||
form.value.ultUuid = null
|
||||
}
|
||||
|
||||
function confirm() {
|
||||
const f = form.value
|
||||
// uuid:现有最小空闲值,从 5001 起步
|
||||
const used = new Set(props.heroes.map((h) => h.uuid))
|
||||
let uuid = 5001
|
||||
while (used.has(uuid)) uuid++
|
||||
|
||||
// 以 defaultHero() 为基础骨架,覆写向导字段
|
||||
const hero = defaultHero()
|
||||
hero.uuid = uuid
|
||||
hero.name = f.name
|
||||
hero.info = f.name
|
||||
hero.roleKey = f.roleKey
|
||||
hero.growKey = f.roleKey
|
||||
hero.typeKey = f.typeKey
|
||||
hero.rarity = f.rarity
|
||||
hero.path = f.path
|
||||
hero.statBase = { ...f.statBase }
|
||||
|
||||
// 普攻:cd/cdExpr 按攻速档生成;lv2 档 ap120
|
||||
const atkSpeedConf = ATK_SPEED_MAP[f.atkSpeed] || ATK_SPEED_MAP.Normal
|
||||
hero.skills[0] = {
|
||||
uuid: f.atkUuid,
|
||||
name: '',
|
||||
cd: atkSpeedConf.cd,
|
||||
cdExpr: atkSpeedConf.cdExpr,
|
||||
overrides: { ap: 100 },
|
||||
desc: '',
|
||||
lv_entries: [{ lv: 2, s_lv: 2, overrides: { ap: 120 }, desc: '' }],
|
||||
}
|
||||
// 大招:cd:8 + lv8/lv16 档(对齐 addUltSkill 惯例)
|
||||
hero.skills[1] = {
|
||||
uuid: f.ultUuid,
|
||||
name: '',
|
||||
cd: 8,
|
||||
cdExpr: null,
|
||||
overrides: {},
|
||||
desc: '',
|
||||
lv_entries: [
|
||||
{ lv: 8, s_lv: 2, overrides: {}, desc: '' },
|
||||
{ lv: 16, s_lv: 3, overrides: {}, desc: '' },
|
||||
],
|
||||
}
|
||||
|
||||
// 触发组:勾选的预设深拷贝注入对应组(组内值为条目数组)
|
||||
for (const p of props.triggerPresets) {
|
||||
if (!p.checked) continue
|
||||
const entry = JSON.parse(JSON.stringify(p.entry))
|
||||
hero[p.group][String(p.uuid)] = [entry]
|
||||
}
|
||||
|
||||
// 光环:单选预设,深拷贝 uuids
|
||||
if (checkedFieldId.value) {
|
||||
const fp = props.fieldPresets.find((x) => x.id === checkedFieldId.value)
|
||||
if (fp) {
|
||||
hero.field = [{
|
||||
lv: fp.lv,
|
||||
uuids: JSON.parse(JSON.stringify(fp.uuids)),
|
||||
desc: '',
|
||||
}]
|
||||
}
|
||||
}
|
||||
|
||||
emit('confirm', hero)
|
||||
emit('update:visible', false)
|
||||
// 重置
|
||||
form.value = emptyForm()
|
||||
checkedFieldId.value = ''
|
||||
for (const p of props.triggerPresets) p.checked = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.role-cards { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.role-card {
|
||||
padding: 8px 18px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
color: var(--text);
|
||||
background: var(--panel);
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.role-card:hover { border-color: var(--brand-light); }
|
||||
.role-card.active { border-color: var(--brand); background: #eef4fc; color: var(--brand); font-weight: 600; }
|
||||
|
||||
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0 16px; }
|
||||
|
||||
.stat-row { display: flex; gap: 8px; }
|
||||
|
||||
.preset-group { margin-bottom: 6px; }
|
||||
.preset-group-title { font-size: 12px; color: var(--text-light); margin-bottom: 2px; font-family: Menlo, Consolas, monospace; }
|
||||
.preset-item { display: flex; margin-right: 0; }
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.form-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
411
put/web/src/components/TemplateManager.vue
Normal file
411
put/web/src/components/TemplateManager.vue
Normal file
@@ -0,0 +1,411 @@
|
||||
<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">·</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>
|
||||
<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>
|
||||
@@ -54,6 +54,34 @@ export const ROLE_ULT_SKILL = {
|
||||
// 辅助类技能(任意职业可用作大招)
|
||||
export const SUPPORT_ULT = [6301, 6302, 6407, 6412, 6413, 6414, 6415, 6416, 6501, 6514, 6515, 6516, 6517, 6518, 6519]
|
||||
|
||||
// 职业默认值(新建英雄向导用):形态/攻速/稀有度/三维偏移
|
||||
// 数值取自现有英雄样本中位数,向导内可手动微调
|
||||
export const ROLE_DEFAULTS = {
|
||||
Tank: { typeKey: 'Melee', atkSpeed: 'Slow', rarity: 4, statBase: { hp: 20, ap: 0, def: 10 } },
|
||||
Warrior: { typeKey: 'Melee', atkSpeed: 'Slow', rarity: 4, statBase: { hp: 5, ap: 20, def: 5 } },
|
||||
Assassin: { typeKey: 'Mid', atkSpeed: 'Fast', rarity: 4, statBase: { hp: 0, ap: 30, def: 0 } },
|
||||
Archer: { typeKey: 'Long', atkSpeed: 'Normal', rarity: 4, statBase: { hp: 0, ap: 30, def: 0 } },
|
||||
Mage: { typeKey: 'Long', atkSpeed: 'Slow', rarity: 4, statBase: { hp: -5, ap: 10, def: 0 } },
|
||||
Support: { typeKey: 'Long', atkSpeed: 'Normal', rarity: 4, statBase: { hp: 10, ap: 20, def: 0 } },
|
||||
}
|
||||
|
||||
// 攻速档 → cd 数值 / cdExpr 表达式 映射(向导生成普攻用)
|
||||
export const ATK_SPEED_MAP = {
|
||||
VeryFast: { cd: 0.75, cdExpr: 'AtkSpeedSet[AtkSpeedLv.VeryFast].cd' },
|
||||
Fast: { cd: 0.9, cdExpr: 'AtkSpeedSet[AtkSpeedLv.Fast].cd' },
|
||||
Normal: { cd: 1.05, cdExpr: 'AtkSpeedSet[AtkSpeedLv.Normal].cd' },
|
||||
Slow: { cd: 1.2, cdExpr: 'AtkSpeedSet[AtkSpeedLv.Slow].cd' },
|
||||
VerySlow: { cd: 1.5, cdExpr: 'AtkSpeedSet[AtkSpeedLv.VerySlow].cd' },
|
||||
}
|
||||
|
||||
// 触发组 key 列表(向导/模板管理共用)
|
||||
export const TRIGGER_GROUPS = [
|
||||
{ key: 'atking', label: 'atking 攻击后' },
|
||||
{ key: 'atked', label: 'atked 受击后' },
|
||||
{ key: 'fstart', label: 'fstart 战斗开始' },
|
||||
{ key: 'dead', label: 'dead 死亡后' },
|
||||
]
|
||||
|
||||
export function defaultHero() {
|
||||
return {
|
||||
uuid: 5001,
|
||||
@@ -97,6 +125,29 @@ export function defaultTriggerEntry(lv = 4) {
|
||||
return { lv, s_lv: 1, name: '', t_num: 3, overrides: {}, desc: '' }
|
||||
}
|
||||
|
||||
// 触发预设工厂:预设是跨职业复用的触发组条目(group + 技能底座 uuid + 完整 entry)
|
||||
export function defaultTriggerPreset(group = 'atking') {
|
||||
return {
|
||||
id: `tp-${group}-${Date.now()}`,
|
||||
label: '',
|
||||
group,
|
||||
uuid: null,
|
||||
tags: [],
|
||||
entry: defaultTriggerEntry(4),
|
||||
}
|
||||
}
|
||||
|
||||
// 光环预设工厂:一个预设 = 解锁等级 + 多个光环词条
|
||||
export function defaultFieldPreset() {
|
||||
return {
|
||||
id: `fp-${Date.now()}`,
|
||||
label: '',
|
||||
tags: [],
|
||||
lv: 4,
|
||||
uuids: [],
|
||||
}
|
||||
}
|
||||
|
||||
// overrides 可用键说明
|
||||
export const OVERRIDE_KEYS = [
|
||||
{ key: 'ap', label: 'ap 攻击百分比' },
|
||||
|
||||
Reference in New Issue
Block a user