TH1/Tools/Dashboard/js/form_helper.js
2026-06-24 20:24:00 +08:00

1143 lines
43 KiB
JavaScript
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.

/**
* TH1 Dashboard - Form Helper
*/
let fhSkills = [];
let fhSkillLoaded = false;
let fhSkillLoading = false;
let fhSkillSortDirection = 'desc';
let fhActiveCollection = 'skills';
const fhCollections = {
techs: {
title: 'TechDataAssets / TechList',
itemLabel: 'TechType',
nameField: 'TechName',
descField: 'Description',
rows: [],
fields: [],
readonlyFields: [],
loaded: false,
loading: false,
sortDirection: 'desc',
},
'tech-atoms': {
title: 'TechDataAssets / TechAtomList',
itemLabel: 'TechAtom',
nameField: 'TechAtomName',
descField: 'Desc',
rows: [],
fields: [],
readonlyFields: [],
loaded: false,
loading: false,
sortDirection: 'desc',
},
units: {
title: 'UnitTypeDataAssets / UnitTypeList',
itemLabel: 'UnitType',
nameField: 'Name',
descField: 'Desc',
rows: [],
fields: [],
readonlyFields: [],
loaded: false,
loading: false,
sortDirection: 'desc',
},
resources: {
title: 'GridAndResourceDataAssets / ResourceInfoList',
itemLabel: 'Resource',
nameField: 'ResourceName',
descField: 'ResourceDesc',
rows: [],
fields: [],
readonlyFields: [],
loaded: false,
loading: false,
sortDirection: 'desc',
},
actions: {
title: 'ActionDataAssets / ActionList',
itemLabel: 'ActionIndex',
nameField: 'ActionName',
descField: 'Desc',
rows: [],
fields: [],
readonlyFields: [],
loaded: false,
loading: false,
sortDirection: 'desc',
},
texts: {
title: 'TextDataAssets / Text Fields',
itemLabel: 'TextIndex',
nameField: 'FieldName',
descField: 'Text',
rows: [],
fields: [],
readonlyFields: [],
loaded: false,
loading: false,
sortDirection: 'desc',
nameReadonly: true,
},
'wiki-list': {
title: 'WikiDataAssets / Items',
itemLabel: 'Id',
nameField: 'Name',
descField: 'Desc',
rows: [],
fields: [],
readonlyFields: [],
loaded: false,
loading: false,
sortDirection: 'desc',
canAdd: true,
},
};
const FH_VIEW_TYPES = [
{ value: 0, label: 'Normal' },
{ value: 1, label: 'Special' },
{ value: 2, label: 'Unique' },
{ value: 3, label: 'Negative' },
{ value: 4, label: 'Positive' },
];
const FH_PRIORITIES = [
{ value: 0, label: 'Normal' },
{ value: 1, label: 'Origin' },
];
function fhEsc(value) {
const div = document.createElement('div');
div.textContent = value == null ? '' : String(value);
return div.innerHTML;
}
function fhAttr(value) {
return fhEsc(value).replace(/"/g, '"');
}
function fhBindLineTextareas(root) {
if (!root) return;
root.querySelectorAll('textarea.fh-line-textarea').forEach(textarea => {
const resize = () => {
textarea.style.height = 'auto';
textarea.style.height = `${textarea.scrollHeight + 2}px`;
};
textarea.addEventListener('input', resize);
resize();
});
}
function fhLoad() {
fhInit();
if (fhActiveCollection !== 'skills') {
fhLoadCollection(fhActiveCollection);
} else if (!fhSkillLoaded && !fhSkillLoading) {
fhLoadSkills();
}
}
function fhInit() {
const tabs = document.querySelectorAll('#form-helper-sub-tabs .sub-tab');
tabs.forEach(tab => {
if (tab.dataset.bound) return;
tab.dataset.bound = '1';
tab.addEventListener('click', () => {
tabs.forEach(item => item.classList.remove('active'));
tab.classList.add('active');
document.querySelectorAll('#panel-form-helper .sub-panel').forEach(panel => panel.classList.remove('active'));
const panel = document.getElementById('sub-' + tab.dataset.sub);
if (panel) panel.classList.add('active');
fhActiveCollection = tab.dataset.sub === 'fh-skills' ? 'skills' : tab.dataset.sub.replace(/^fh-/, '');
if (fhActiveCollection === 'skills') {
fhLoadSkills();
} else {
fhLoadCollection(fhActiveCollection);
}
});
});
const search = document.getElementById('fh-skill-search');
if (search && !search.dataset.bound) {
search.dataset.bound = '1';
search.addEventListener('input', fhRenderSkills);
}
const refresh = document.getElementById('fh-skill-refresh');
if (refresh && !refresh.dataset.bound) {
refresh.dataset.bound = '1';
refresh.addEventListener('click', () => fhLoadSkills(true));
}
const sort = document.getElementById('fh-skill-sort');
if (sort && !sort.dataset.bound) {
sort.dataset.bound = '1';
sort.addEventListener('click', () => {
fhSkillSortDirection = fhSkillSortDirection === 'asc' ? 'desc' : 'asc';
fhRenderSkills();
});
}
fhUpdateSkillSortButton();
Object.keys(fhCollections).forEach(key => fhInitCollectionControls(key));
}
async function fhLoadSkills(force = false) {
if (fhSkillLoading) return;
if (fhSkillLoaded && !force) return;
fhSkillLoading = true;
const badge = document.getElementById('fh-skill-badge');
const content = document.getElementById('fh-skill-content');
if (badge) badge.textContent = '加载中';
if (content) content.innerHTML = '<div class="loading-inline">正在读取 SkillDataAssets...</div>';
try {
const resp = await fetch('/api/form-helper/skills?t=' + Date.now());
const data = await resp.json();
if (!resp.ok || data.error) {
throw new Error(data.error || `HTTP ${resp.status}`);
}
fhSkills = (data.skills || []).map(fhNormalizeSkill);
fhSkillLoaded = true;
const source = document.getElementById('fh-skill-source');
if (source) {
source.textContent = `${data.source || 'SkillDataAssets.asset'} · ${data.generatedAt || ''}`;
}
fhRenderSkills();
} catch (err) {
if (badge) badge.textContent = '读取失败';
if (content) {
content.innerHTML = `<div class="loading-inline">读取失败:${fhEsc(err.message || err)}</div>`;
}
} finally {
fhSkillLoading = false;
}
}
function fhNormalizeSkill(skill) {
const row = { ...skill };
row._original = fhCloneSkill(skill);
return row;
}
function fhCloneSkill(skill) {
return JSON.parse(JSON.stringify({
assetIndex: skill.assetIndex,
skillType: skill.skillType,
skillViewType: Number(skill.skillViewType || 0),
name: skill.name || '',
desc: skill.desc || '',
notShow: !!skill.notShow,
showOnUnitMono: !!skill.showOnUnitMono,
hasShowList: !!skill.hasShowList,
skillPriority: Number(skill.skillPriority || 0),
reserveOnCarry: !!skill.reserveOnCarry,
reserveLeaveCarry: !!skill.reserveLeaveCarry,
reserveGiantUpgrade: !!skill.reserveGiantUpgrade,
reserveCommonTransform: !!skill.reserveCommonTransform,
showList: (skill.showList || []).map(item => ({
index: item.index,
unitType: Number(item.unitType || 0),
giantType: Number(item.giantType || 0),
unitLevel: Number(item.unitLevel || 0),
ignoreUnitGiantType: !!item.ignoreUnitGiantType,
ignoreUnitLevel: !!item.ignoreUnitLevel,
name: item.name || '',
desc: item.desc || '',
})),
}));
}
function fhRenderSkills() {
const content = document.getElementById('fh-skill-content');
if (!content) return;
if (!fhSkillLoaded) {
content.innerHTML = '<div class="loading-inline">正在读取 SkillDataAssets...</div>';
return;
}
const query = (document.getElementById('fh-skill-search')?.value || '').trim().toLowerCase();
let rows = fhSkills.slice();
if (query) {
rows = rows.filter(skill => {
const haystack = [
skill.skillType,
skill.assetIndex,
skill.skillViewTypeLabel,
skill.name,
skill.desc,
...(skill.showList || []).flatMap(item => [item.name, item.desc]),
].join(' ').toLowerCase();
return haystack.includes(query);
});
}
rows.sort((a, b) => {
const left = Number(a.skillType || 0);
const right = Number(b.skillType || 0);
return fhSkillSortDirection === 'asc' ? left - right : right - left;
});
fhUpdateSkillSortButton();
const badge = document.getElementById('fh-skill-badge');
if (badge) badge.textContent = `${rows.length}/${fhSkills.length}`;
const body = rows.map(skill => `
<tr data-asset-index="${skill.assetIndex}">
<td class="num">${skill.skillType}</td>
<td class="num">${skill.assetIndex}</td>
<td>
<input class="fh-line-input" data-field="name" value="${fhAttr(skill.name)}">
</td>
<td>
<textarea class="fh-line-textarea fh-line-desc" data-field="desc">${fhEsc(skill.desc)}</textarea>
</td>
<td><span class="badge ${fhBadgeFor(skill.skillViewTypeLabel)}">${fhEsc(skill.skillViewTypeLabel)}</span></td>
<td>${skill.notShow ? '<span class="badge badge-red">隐藏</span>' : '<span class="badge badge-green">可见</span>'}</td>
<td class="fh-action-cell">
<button class="gb-btn-sm fh-restore-row" onclick="fhRestoreRow(${skill.assetIndex})">还原</button>
<button class="gb-btn-sm gb-btn-primary fh-save-row" onclick="fhSaveRow(${skill.assetIndex})">修改</button>
<button class="gb-btn-sm fh-detail-row" onclick="fhOpenDetail(${skill.assetIndex})">详细修改</button>
</td>
</tr>
`).join('');
content.innerHTML = `
<div class="table-count">${rows.length} 个 Skill</div>
<div class="table-wrap fh-table-wrap">
<table class="dtable fh-skill-table">
<thead>
<tr>
<th>SkillType</th>
<th>Index</th>
<th>Name</th>
<th>Desc</th>
<th>类型</th>
<th>显示</th>
<th>操作</th>
</tr>
</thead>
<tbody>${body || '<tr><td colspan="7" class="fh-empty">没有匹配的 Skill</td></tr>'}</tbody>
</table>
</div>
`;
fhBindLineTextareas(content);
}
function fhUpdateSkillSortButton() {
const sort = document.getElementById('fh-skill-sort');
if (!sort) return;
sort.textContent = fhSkillSortDirection === 'asc' ? 'ID 正序' : 'ID 倒序';
}
function fhBadgeFor(type) {
const map = {
Normal: 'badge-blue',
Special: 'badge-purple',
Unique: 'badge-orange',
Negative: 'badge-red',
Positive: 'badge-green',
};
return map[type] || 'badge-gray';
}
function fhFindSkill(assetIndex) {
return fhSkills.find(skill => Number(skill.assetIndex) === Number(assetIndex));
}
function fhApplyLineInputs(assetIndex) {
const skill = fhFindSkill(assetIndex);
const row = document.querySelector(`#fh-skill-content tr[data-asset-index="${assetIndex}"]`);
if (!skill || !row) return skill;
skill.name = row.querySelector('[data-field="name"]')?.value ?? skill.name;
skill.desc = row.querySelector('[data-field="desc"]')?.value ?? skill.desc;
return skill;
}
function fhRestoreRow(assetIndex) {
const skill = fhFindSkill(assetIndex);
if (!skill || !skill._original) return;
skill.name = skill._original.name;
skill.desc = skill._original.desc;
fhRenderSkills();
}
async function fhSaveRow(assetIndex) {
const skill = fhApplyLineInputs(assetIndex);
if (!skill) return;
await fhSaveSkill(skill, {
SkillName: skill.name,
SkillDesc: skill.desc,
}, undefined, { render: false });
}
function fhCaptureSkillScroll() {
const wrap = document.querySelector('#fh-skill-content .fh-table-wrap');
return {
windowX: window.scrollX,
windowY: window.scrollY,
wrapLeft: wrap ? wrap.scrollLeft : 0,
wrapTop: wrap ? wrap.scrollTop : 0,
};
}
function fhRestoreSkillScroll(scrollState) {
if (!scrollState) return;
requestAnimationFrame(() => {
const wrap = document.querySelector('#fh-skill-content .fh-table-wrap');
if (wrap) {
wrap.scrollLeft = scrollState.wrapLeft;
wrap.scrollTop = scrollState.wrapTop;
}
window.scrollTo(scrollState.windowX, scrollState.windowY);
});
}
async function fhSaveSkill(skill, values, showList = undefined, options = {}) {
const payload = {
assetIndex: skill.assetIndex,
skillType: skill.skillType,
values,
};
if (showList !== undefined) payload.showList = showList;
try {
const resp = await fetch('/api/form-helper/skills/save', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const data = await resp.json();
if (!resp.ok || !data.success) {
throw new Error(data.error || `HTTP ${resp.status}`);
}
const index = fhSkills.findIndex(item => item.assetIndex === skill.assetIndex);
if (index >= 0) fhSkills[index] = fhNormalizeSkill(data.skill);
if (options.render !== false) {
const scrollState = fhCaptureSkillScroll();
fhRenderSkills();
fhRestoreSkillScroll(scrollState);
}
fhToast(data.changed ? '已保存 SkillDataAssets' : '没有变化');
return data.skill;
} catch (err) {
fhToast(`保存失败:${err.message || err}`, true);
throw err;
}
}
function fhToast(message, isError = false) {
const toast = document.getElementById('toast');
if (!toast) return;
toast.textContent = message;
toast.style.display = 'block';
toast.style.background = isError ? 'var(--accent-red)' : 'var(--accent-green)';
clearTimeout(fhToast._timer);
fhToast._timer = setTimeout(() => {
toast.style.display = 'none';
toast.style.background = '';
}, 2200);
}
function fhOpenDetail(assetIndex) {
const rowSkill = fhApplyLineInputs(assetIndex);
const skill = rowSkill ? fhCloneSkill(rowSkill) : null;
if (!skill) return;
document.getElementById('fh-skill-detail-modal')?.remove();
const overlay = document.createElement('div');
overlay.id = 'fh-skill-detail-modal';
overlay.className = 'gb-modal-overlay';
overlay.innerHTML = `
<div class="gb-modal fh-modal">
<div class="gb-modal-header">
<span class="gb-modal-title">Skill ${skill.skillType} · ${fhEsc(skill.name || '未命名')}</span>
<button class="gb-modal-close" onclick="fhCloseDetail()">&times;</button>
</div>
<div class="gb-modal-body">
${fhRenderDetailForm(skill)}
</div>
<div class="fh-modal-footer">
<button class="gb-btn-sm" onclick="fhRestoreDetail(${skill.assetIndex})">还原</button>
<button class="gb-btn-sm gb-btn-primary" onclick="fhSaveDetail(${skill.assetIndex})">保存</button>
<button class="gb-btn-sm" onclick="fhCloseDetail()">关闭</button>
</div>
</div>
`;
overlay.addEventListener('click', event => {
if (event.target === overlay) fhCloseDetail();
});
document.body.appendChild(overlay);
}
function fhRenderDetailForm(skill) {
return `
<div class="fh-detail-grid">
${fhNumberField('SkillType', 'SkillType', skill.skillType)}
${fhSelectField('SkillViewType', 'SkillViewType', skill.skillViewType, FH_VIEW_TYPES)}
${fhSelectField('skillPriority', 'skillPriority', skill.skillPriority, FH_PRIORITIES)}
${fhCheckboxField('NotShow', 'NotShow', skill.notShow)}
${fhCheckboxField('ShowOnUnitMono', 'ShowOnUnitMono', skill.showOnUnitMono)}
${fhCheckboxField('HasShowList', 'HasShowList', skill.hasShowList)}
${fhCheckboxField('ReserveOnCarry', 'ReserveOnCarry', skill.reserveOnCarry)}
${fhCheckboxField('ReserveLeaveCarry', 'ReserveLeaveCarry', skill.reserveLeaveCarry)}
${fhCheckboxField('ReserveGiantUpgrade', 'ReserveGiantUpgrade', skill.reserveGiantUpgrade)}
${fhCheckboxField('ReserveCommonTransform', 'ReserveCommonTransform', skill.reserveCommonTransform)}
</div>
<label class="fh-field fh-wide">
<span>SkillName</span>
<input data-fh-field="SkillName" value="${fhAttr(skill.name)}">
</label>
<label class="fh-field fh-wide">
<span>SkillDesc</span>
<textarea data-fh-field="SkillDesc">${fhEsc(skill.desc)}</textarea>
</label>
<div class="fh-readonly-line"><span>SkillIcon</span><code>${fhEsc(fhFindSkill(skill.assetIndex)?.skillIcon || '')}</code></div>
<div class="fh-section-title">SkillShowList</div>
${fhRenderShowList(skill.showList || [])}
`;
}
function fhNumberField(label, key, value) {
return `<label class="fh-field"><span>${label}</span><input type="number" data-fh-field="${key}" value="${fhAttr(value)}"></label>`;
}
function fhSelectField(label, key, value, options) {
const opts = options.map(option => `
<option value="${option.value}" ${Number(value) === option.value ? 'selected' : ''}>${fhEsc(option.label)}</option>
`).join('');
return `<label class="fh-field"><span>${label}</span><select data-fh-field="${key}">${opts}</select></label>`;
}
function fhCheckboxField(label, key, value) {
return `<label class="fh-check"><input type="checkbox" data-fh-field="${key}" ${value ? 'checked' : ''}><span>${label}</span></label>`;
}
function fhRenderShowList(showList) {
if (!showList.length) {
return '<div class="fh-empty">无 SkillShowList 条目</div>';
}
return showList.map(item => `
<div class="fh-show-card" data-show-index="${item.index}">
<div class="fh-show-head">#${item.index}</div>
<div class="fh-detail-grid">
${fhNumberShowField(item.index, 'UnitType', item.unitType)}
${fhNumberShowField(item.index, 'GiantType', item.giantType)}
${fhNumberShowField(item.index, 'UnitLevel', item.unitLevel)}
${fhCheckboxShowField(item.index, 'IgnoreUnitGiantType', item.ignoreUnitGiantType)}
${fhCheckboxShowField(item.index, 'IgnoreUnitLevel', item.ignoreUnitLevel)}
</div>
<label class="fh-field fh-wide">
<span>SkillName</span>
<input data-fh-show="${item.index}" data-fh-show-field="SkillName" value="${fhAttr(item.name)}">
</label>
<label class="fh-field fh-wide">
<span>SkillDesc</span>
<textarea data-fh-show="${item.index}" data-fh-show-field="SkillDesc">${fhEsc(item.desc)}</textarea>
</label>
<div class="fh-readonly-line"><span>Icon</span><code>${fhEsc(item.icon || '')}</code></div>
</div>
`).join('');
}
function fhNumberShowField(index, key, value) {
return `<label class="fh-field"><span>${key}</span><input type="number" data-fh-show="${index}" data-fh-show-field="${key}" value="${fhAttr(value)}"></label>`;
}
function fhCheckboxShowField(index, key, value) {
return `<label class="fh-check"><input type="checkbox" data-fh-show="${index}" data-fh-show-field="${key}" ${value ? 'checked' : ''}><span>${key}</span></label>`;
}
function fhCloseDetail() {
document.getElementById('fh-skill-detail-modal')?.remove();
}
function fhRestoreDetail(assetIndex) {
const skill = fhFindSkill(assetIndex);
if (!skill || !skill._original) return;
const modalBody = document.querySelector('#fh-skill-detail-modal .gb-modal-body');
if (modalBody) modalBody.innerHTML = fhRenderDetailForm(skill._original);
}
async function fhSaveDetail(assetIndex) {
const skill = fhFindSkill(assetIndex);
const modal = document.getElementById('fh-skill-detail-modal');
if (!skill || !modal) return;
const values = {
SkillType: fhReadField(modal, 'SkillType', 'int'),
SkillViewType: fhReadField(modal, 'SkillViewType', 'int'),
SkillName: fhReadField(modal, 'SkillName', 'string'),
SkillDesc: fhReadField(modal, 'SkillDesc', 'string'),
NotShow: fhReadField(modal, 'NotShow', 'bool'),
ShowOnUnitMono: fhReadField(modal, 'ShowOnUnitMono', 'bool'),
HasShowList: fhReadField(modal, 'HasShowList', 'bool'),
skillPriority: fhReadField(modal, 'skillPriority', 'int'),
ReserveOnCarry: fhReadField(modal, 'ReserveOnCarry', 'bool'),
ReserveLeaveCarry: fhReadField(modal, 'ReserveLeaveCarry', 'bool'),
ReserveGiantUpgrade: fhReadField(modal, 'ReserveGiantUpgrade', 'bool'),
ReserveCommonTransform: fhReadField(modal, 'ReserveCommonTransform', 'bool'),
};
const showList = (skill.showList || []).map(item => ({
UnitType: fhReadShowField(modal, item.index, 'UnitType', 'int'),
GiantType: fhReadShowField(modal, item.index, 'GiantType', 'int'),
UnitLevel: fhReadShowField(modal, item.index, 'UnitLevel', 'int'),
IgnoreUnitGiantType: fhReadShowField(modal, item.index, 'IgnoreUnitGiantType', 'bool'),
IgnoreUnitLevel: fhReadShowField(modal, item.index, 'IgnoreUnitLevel', 'bool'),
SkillName: fhReadShowField(modal, item.index, 'SkillName', 'string'),
SkillDesc: fhReadShowField(modal, item.index, 'SkillDesc', 'string'),
}));
const saved = await fhSaveSkill(skill, values, showList, { render: true });
fhCloseDetail();
if (saved) fhOpenDetail(saved.assetIndex);
}
function fhReadField(root, key, kind) {
const el = root.querySelector(`[data-fh-field="${key}"]`);
return fhReadInput(el, kind);
}
function fhReadShowField(root, index, key, kind) {
const el = root.querySelector(`[data-fh-show="${index}"][data-fh-show-field="${key}"]`);
return fhReadInput(el, kind);
}
function fhReadInput(el, kind) {
if (!el) return kind === 'bool' ? false : kind === 'int' ? 0 : '';
if (kind === 'bool') return !!el.checked;
if (kind === 'int') return Number.parseInt(el.value || '0', 10) || 0;
return el.value || '';
}
function fhInitCollectionControls(key) {
const search = document.getElementById(`fh-${key}-search`);
if (search && !search.dataset.bound) {
search.dataset.bound = '1';
search.addEventListener('input', () => fhRenderCollection(key));
}
const refresh = document.getElementById(`fh-${key}-refresh`);
if (refresh && !refresh.dataset.bound) {
refresh.dataset.bound = '1';
refresh.addEventListener('click', () => fhLoadCollection(key, true));
}
const sort = document.getElementById(`fh-${key}-sort`);
if (sort && !sort.dataset.bound) {
sort.dataset.bound = '1';
sort.addEventListener('click', () => {
const config = fhCollections[key];
config.sortDirection = config.sortDirection === 'asc' ? 'desc' : 'asc';
fhRenderCollection(key);
});
}
fhUpdateCollectionSortButton(key);
const add = document.getElementById(`fh-${key}-add`);
if (add && !add.dataset.bound) {
add.dataset.bound = '1';
add.addEventListener('click', () => fhOpenCollectionAdd(key));
}
}
async function fhLoadCollection(key, force = false) {
const config = fhCollections[key];
if (!config || config.loading) return;
if (config.loaded && !force) return;
config.loading = true;
const badge = document.getElementById(`fh-${key}-badge`);
const content = document.getElementById(`fh-${key}-content`);
if (badge) badge.textContent = '加载中';
if (content) content.innerHTML = `<div class="loading-inline">正在读取 ${fhEsc(config.title)}...</div>`;
try {
const resp = await fetch(`/api/form-helper/collection/${key}?t=${Date.now()}`);
const data = await resp.json();
if (!resp.ok || data.error || data.success === false) {
throw new Error(data.error || `HTTP ${resp.status}`);
}
config.title = data.title || config.title;
config.fields = data.fields || [];
config.readonlyFields = data.readonlyFields || [];
config.canAdd = !!data.canAdd;
config.rows = (data.rows || []).map(row => fhNormalizeCollectionRow(row));
config.loaded = true;
const source = document.getElementById(`fh-${key}-source`);
if (source) {
source.textContent = `${data.source || config.title} · ${data.generatedAt || ''}`;
}
fhRenderCollection(key);
} catch (err) {
if (badge) badge.textContent = '读取失败';
if (content) content.innerHTML = `<div class="loading-inline">读取失败:${fhEsc(err.message || err)}</div>`;
} finally {
config.loading = false;
}
}
function fhNormalizeCollectionRow(row) {
const normalized = { ...row };
normalized._original = fhCloneCollectionRow(row);
return normalized;
}
function fhCloneCollectionRow(row) {
return JSON.parse(JSON.stringify({
assetIndex: row.assetIndex,
itemId: row.itemId,
name: row.name || '',
desc: row.desc || '',
values: row.values || {},
fields: row.fields || [],
readonly: row.readonly || {},
}));
}
function fhRenderCollection(key) {
const config = fhCollections[key];
const content = document.getElementById(`fh-${key}-content`);
if (!config || !content) return;
if (!config.loaded) {
content.innerHTML = `<div class="loading-inline">正在读取 ${fhEsc(config.title)}...</div>`;
return;
}
const query = (document.getElementById(`fh-${key}-search`)?.value || '').trim().toLowerCase();
let rows = config.rows.slice();
if (query) {
rows = rows.filter(row => {
const haystack = [
row.assetIndex,
row.itemId,
row.name,
row.desc,
row.searchText,
JSON.stringify(row.values || {}),
].join(' ').toLowerCase();
return haystack.includes(query);
});
}
rows.sort((a, b) => {
const left = Number(a.itemId || 0);
const right = Number(b.itemId || 0);
if (left !== right) {
return config.sortDirection === 'asc' ? left - right : right - left;
}
return Number(a.assetIndex || 0) - Number(b.assetIndex || 0);
});
fhUpdateCollectionSortButton(key);
const badge = document.getElementById(`fh-${key}-badge`);
if (badge) badge.textContent = `${rows.length}/${config.rows.length}`;
const body = rows.map(row => `
<tr data-collection="${key}" data-asset-index="${row.assetIndex}">
<td class="num">${fhEsc(row.itemId)}</td>
<td class="num">${fhEsc(row.assetIndex)}</td>
<td>
${config.nameReadonly ? `<span class="fh-readonly-name">${fhEsc(row.name)}</span>` : `<input class="fh-line-input" data-field="name" value="${fhAttr(row.name)}">`}
</td>
<td>
<textarea class="fh-line-textarea fh-line-desc" data-field="desc">${fhEsc(row.desc)}</textarea>
</td>
<td class="fh-summary-cell">${fhEsc(fhCollectionSummary(key, row))}</td>
<td class="fh-action-cell">
<button class="gb-btn-sm fh-restore-row" onclick="fhRestoreCollectionRow('${key}', ${row.assetIndex})">还原</button>
<button class="gb-btn-sm gb-btn-primary fh-save-row" onclick="fhSaveCollectionRow('${key}', ${row.assetIndex})">修改</button>
<button class="gb-btn-sm fh-detail-row" onclick="fhOpenCollectionDetail('${key}', ${row.assetIndex})">详细修改</button>
</td>
</tr>
`).join('');
content.innerHTML = `
<div class="table-count">${rows.length} 条记录</div>
<div class="table-wrap fh-table-wrap">
<table class="dtable fh-collection-table">
<thead>
<tr>
<th>${fhEsc(config.itemLabel || 'ID')}</th>
<th>Index</th>
<th>Name</th>
<th>Desc</th>
<th>字段摘要</th>
<th>操作</th>
</tr>
</thead>
<tbody>${body || '<tr><td colspan="6" class="fh-empty">没有匹配记录</td></tr>'}</tbody>
</table>
</div>
`;
fhBindLineTextareas(content);
}
function fhCollectionSummary(key, row) {
const config = fhCollections[key];
if (!config) return '';
const skip = new Set([config.nameField, config.descField]);
return (config.fields || [])
.filter(field => !skip.has(field.key))
.slice(0, 6)
.map(field => `${field.label || field.key}: ${fhCompactValue(row.values?.[field.key])}`)
.join(' · ');
}
function fhCompactValue(value) {
if (Array.isArray(value)) return value.join(',');
if (typeof value === 'boolean') return value ? '1' : '0';
return value == null ? '' : String(value);
}
function fhUpdateCollectionSortButton(key) {
const config = fhCollections[key];
const sort = document.getElementById(`fh-${key}-sort`);
if (!config || !sort) return;
sort.textContent = config.sortDirection === 'asc' ? 'ID 正序' : 'ID 倒序';
}
function fhFindCollectionRow(key, assetIndex) {
return fhCollections[key]?.rows.find(row => Number(row.assetIndex) === Number(assetIndex));
}
function fhApplyCollectionLineInputs(key, assetIndex) {
const config = fhCollections[key];
const row = fhFindCollectionRow(key, assetIndex);
const tr = document.querySelector(`#fh-${key}-content tr[data-asset-index="${assetIndex}"]`);
if (!config || !row || !tr) return row;
if (!config.nameReadonly) {
row.name = tr.querySelector('[data-field="name"]')?.value ?? row.name;
}
row.desc = tr.querySelector('[data-field="desc"]')?.value ?? row.desc;
row.values = { ...(row.values || {}) };
if (!config.nameReadonly) {
row.values[config.nameField] = row.name;
}
row.values[config.descField] = row.desc;
return row;
}
function fhRestoreCollectionRow(key, assetIndex) {
const row = fhFindCollectionRow(key, assetIndex);
if (!row || !row._original) return;
const restored = fhNormalizeCollectionRow(row._original);
const rows = fhCollections[key].rows;
const index = rows.findIndex(item => item.assetIndex === assetIndex);
if (index >= 0) rows[index] = restored;
fhRenderCollection(key);
}
async function fhSaveCollectionRow(key, assetIndex) {
const config = fhCollections[key];
const row = fhApplyCollectionLineInputs(key, assetIndex);
if (!config || !row) return;
const values = config.nameReadonly
? { [config.descField]: row.desc }
: { [config.nameField]: row.name, [config.descField]: row.desc };
await fhSaveCollection(key, row, values, { render: false });
}
function fhCaptureCollectionScroll(key) {
const wrap = document.querySelector(`#fh-${key}-content .fh-table-wrap`);
return {
windowX: window.scrollX,
windowY: window.scrollY,
wrapLeft: wrap ? wrap.scrollLeft : 0,
wrapTop: wrap ? wrap.scrollTop : 0,
};
}
function fhRestoreCollectionScroll(key, scrollState) {
if (!scrollState) return;
requestAnimationFrame(() => {
const wrap = document.querySelector(`#fh-${key}-content .fh-table-wrap`);
if (wrap) {
wrap.scrollLeft = scrollState.wrapLeft;
wrap.scrollTop = scrollState.wrapTop;
}
window.scrollTo(scrollState.windowX, scrollState.windowY);
});
}
async function fhSaveCollection(key, row, values, options = {}) {
const payload = {
assetIndex: row.assetIndex,
itemId: row.itemId,
values,
};
try {
const resp = await fetch(`/api/form-helper/collection/${key}/save`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const data = await resp.json();
if (!resp.ok || !data.success) {
throw new Error(data.error || `HTTP ${resp.status}`);
}
const rows = fhCollections[key].rows;
const index = rows.findIndex(item => item.assetIndex === row.assetIndex);
if (index >= 0) rows[index] = fhNormalizeCollectionRow(data.row);
if (options.render !== false) {
const scrollState = fhCaptureCollectionScroll(key);
fhRenderCollection(key);
fhRestoreCollectionScroll(key, scrollState);
}
fhToast(data.changed ? `已保存 ${fhCollections[key].title}` : '没有变化');
return data.row;
} catch (err) {
fhToast(`保存失败:${err.message || err}`, true);
throw err;
}
}
async function fhOpenCollectionAdd(key) {
const config = fhCollections[key];
if (!config || !config.canAdd) return;
if (!config.loaded) {
await fhLoadCollection(key, true);
}
if (!config.fields?.length) {
fhToast('字段配置尚未加载', true);
return;
}
const maxId = Math.max(0, ...(config.rows || []).map(row => Number(row.itemId || 0)));
const row = {
assetIndex: -1,
itemId: maxId + 1,
name: '',
desc: '',
values: {},
fields: (config.fields || []).map(field => ({ ...field })),
readonly: {},
};
(config.fields || []).forEach(field => {
if (field.key === config.itemLabel || field.key === 'Id') {
row.values[field.key] = maxId + 1;
} else if (field.key === config.nameField) {
row.values[field.key] = '';
} else if (field.key === config.descField) {
row.values[field.key] = '';
} else if (field.kind === 'bool') {
row.values[field.key] = false;
} else if (field.kind === 'intList') {
row.values[field.key] = [];
} else if (field.kind === 'int' || field.kind === 'float') {
row.values[field.key] = 0;
} else {
row.values[field.key] = '';
}
});
document.getElementById('fh-collection-add-modal')?.remove();
const overlay = document.createElement('div');
overlay.id = 'fh-collection-add-modal';
overlay.className = 'gb-modal-overlay';
overlay.innerHTML = `
<div class="gb-modal fh-modal">
<div class="gb-modal-header">
<span class="gb-modal-title">新增 ${fhEsc(config.title)}</span>
<button class="gb-modal-close" onclick="fhCloseCollectionAdd()">&times;</button>
</div>
<div class="gb-modal-body">
${fhRenderCollectionDetailForm(key, row)}
</div>
<div class="fh-modal-footer">
<button class="gb-btn-sm gb-btn-primary" onclick="fhSaveCollectionAdd('${key}')">新增</button>
<button class="gb-btn-sm" onclick="fhCloseCollectionAdd()">关闭</button>
</div>
</div>
`;
overlay.addEventListener('click', event => {
if (event.target === overlay) fhCloseCollectionAdd();
});
document.body.appendChild(overlay);
}
function fhCloseCollectionAdd() {
document.getElementById('fh-collection-add-modal')?.remove();
}
async function fhSaveCollectionAdd(key) {
const config = fhCollections[key];
const modal = document.getElementById('fh-collection-add-modal');
if (!config || !modal) return;
const values = {};
(config.fields || []).forEach(field => {
values[field.key] = fhReadCollectionField(modal, field.key, field.kind || 'string');
});
try {
const resp = await fetch(`/api/form-helper/collection/${key}/add`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ values }),
});
const data = await resp.json();
if (!resp.ok || !data.success) {
throw new Error(data.error || `HTTP ${resp.status}`);
}
if (data.row) {
config.rows.push(fhNormalizeCollectionRow(data.row));
} else {
config.loaded = false;
await fhLoadCollection(key, true);
}
fhCloseCollectionAdd();
fhRenderCollection(key);
fhToast(`已新增 ${config.title}`);
if (data.row) fhOpenCollectionDetail(key, data.row.assetIndex);
} catch (err) {
fhToast(`新增失败:${err.message || err}`, true);
throw err;
}
}
function fhOpenCollectionDetail(key, assetIndex) {
const liveRow = fhApplyCollectionLineInputs(key, assetIndex);
const row = liveRow ? fhCloneCollectionRow(liveRow) : null;
const config = fhCollections[key];
if (!row || !config) return;
document.getElementById('fh-collection-detail-modal')?.remove();
const overlay = document.createElement('div');
overlay.id = 'fh-collection-detail-modal';
overlay.className = 'gb-modal-overlay';
overlay.innerHTML = `
<div class="gb-modal fh-modal">
<div class="gb-modal-header">
<span class="gb-modal-title">${fhEsc(config.title)} · ${fhEsc(row.itemId)} · ${fhEsc(row.name || '未命名')}</span>
<button class="gb-modal-close" onclick="fhCloseCollectionDetail()">&times;</button>
</div>
<div class="gb-modal-body">
${fhRenderCollectionDetailForm(key, row)}
</div>
<div class="fh-modal-footer">
<button class="gb-btn-sm" onclick="fhRestoreCollectionDetail('${key}', ${row.assetIndex})">还原</button>
<button class="gb-btn-sm gb-btn-primary" onclick="fhSaveCollectionDetail('${key}', ${row.assetIndex})">保存</button>
<button class="gb-btn-sm" onclick="fhCloseCollectionDetail()">关闭</button>
</div>
</div>
`;
overlay.addEventListener('click', event => {
if (event.target === overlay) fhCloseCollectionDetail();
});
document.body.appendChild(overlay);
}
function fhRenderCollectionDetailForm(key, row) {
const config = fhCollections[key];
const fields = row.fields?.length ? row.fields : config.fields;
const controls = fields.map(field => fhRenderCollectionField(field, row.values?.[field.key])).join('');
const readonly = row.readonly || {};
const readonlyHtml = Object.keys(readonly).length
? `<div class="fh-section-title">只读字段</div>${Object.entries(readonly).map(([label, value]) => fhReadonlyValue(label, value)).join('')}`
: '';
return `
<div class="fh-detail-grid">
${controls}
</div>
${readonlyHtml}
`;
}
function fhRenderCollectionField(field, value) {
const key = field.key;
const kind = field.kind || 'string';
const label = field.label || key;
if (kind === 'bool') {
return fhCheckboxField(label, key, !!value).replaceAll('data-fh-field', 'data-fh-collection-field');
}
if (kind === 'int') {
return `<label class="fh-field"><span>${fhEsc(label)}</span><input type="number" data-fh-collection-field="${fhAttr(key)}" data-kind="${kind}" value="${fhAttr(value)}"></label>`;
}
if (kind === 'float') {
return `<label class="fh-field"><span>${fhEsc(label)}</span><input type="number" step="0.01" data-fh-collection-field="${fhAttr(key)}" data-kind="${kind}" value="${fhAttr(value)}"></label>`;
}
if (kind === 'intList') {
return `<label class="fh-field fh-wide"><span>${fhEsc(label)}</span><textarea data-fh-collection-field="${fhAttr(key)}" data-kind="${kind}" placeholder="用逗号或空格分隔 ID">${fhEsc(Array.isArray(value) ? value.join(', ') : '')}</textarea></label>`;
}
return `<label class="fh-field fh-wide"><span>${fhEsc(label)}</span><textarea data-fh-collection-field="${fhAttr(key)}" data-kind="${kind}">${fhEsc(value || '')}</textarea></label>`;
}
function fhReadonlyValue(label, value) {
const text = typeof value === 'string' ? value : JSON.stringify(value, null, 2);
return `<div class="fh-readonly-line"><span>${fhEsc(label)}</span><code>${fhEsc(text || '')}</code></div>`;
}
function fhCloseCollectionDetail() {
document.getElementById('fh-collection-detail-modal')?.remove();
}
function fhRestoreCollectionDetail(key, assetIndex) {
const row = fhFindCollectionRow(key, assetIndex);
if (!row || !row._original) return;
const modalBody = document.querySelector('#fh-collection-detail-modal .gb-modal-body');
if (modalBody) modalBody.innerHTML = fhRenderCollectionDetailForm(key, row._original);
}
async function fhSaveCollectionDetail(key, assetIndex) {
const row = fhFindCollectionRow(key, assetIndex);
const modal = document.getElementById('fh-collection-detail-modal');
if (!row || !modal) return;
const values = {};
(fhCollections[key].fields || []).forEach(field => {
values[field.key] = fhReadCollectionField(modal, field.key, field.kind || 'string');
});
const saved = await fhSaveCollection(key, row, values, { render: true });
fhCloseCollectionDetail();
if (saved) fhOpenCollectionDetail(key, saved.assetIndex);
}
function fhReadCollectionField(root, key, kind) {
const el = root.querySelector(`[data-fh-collection-field="${key}"]`);
if (!el) {
if (kind === 'bool') return false;
if (kind === 'int' || kind === 'float') return 0;
if (kind === 'intList') return [];
return '';
}
if (kind === 'bool') return !!el.checked;
if (kind === 'int') return Number.parseInt(el.value || '0', 10) || 0;
if (kind === 'float') return Number.parseFloat(el.value || '0') || 0;
if (kind === 'intList') {
return (el.value || '')
.split(/[,\s]+/)
.map(item => item.trim())
.filter(Boolean)
.map(item => Number.parseInt(item, 10))
.filter(item => Number.isFinite(item));
}
return el.value || '';
}
window.fhLoad = fhLoad;
window.fhRestoreRow = fhRestoreRow;
window.fhSaveRow = fhSaveRow;
window.fhOpenDetail = fhOpenDetail;
window.fhRestoreDetail = fhRestoreDetail;
window.fhSaveDetail = fhSaveDetail;
window.fhCloseDetail = fhCloseDetail;
window.fhRestoreCollectionRow = fhRestoreCollectionRow;
window.fhSaveCollectionRow = fhSaveCollectionRow;
window.fhOpenCollectionDetail = fhOpenCollectionDetail;
window.fhRestoreCollectionDetail = fhRestoreCollectionDetail;
window.fhSaveCollectionDetail = fhSaveCollectionDetail;
window.fhCloseCollectionDetail = fhCloseCollectionDetail;
window.fhOpenCollectionAdd = fhOpenCollectionAdd;
window.fhSaveCollectionAdd = fhSaveCollectionAdd;
window.fhCloseCollectionAdd = fhCloseCollectionAdd;