Files
Echo-Tavern/static/js/card_editor.js
T
2026-03-08 09:16:44 +08:00

451 lines
18 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.
(function () {
const body = document.body;
const user = (() => {
try {
return JSON.parse(body.dataset.user || 'null');
} catch {
return null;
}
})();
const cardId = String(body.dataset.cardId || '').trim();
const isEditMode = Boolean(cardId);
const statusEl = document.getElementById('status');
const form = document.getElementById('card-form');
const saveBtn = document.getElementById('save-card-btn');
const pageTitle = document.getElementById('editor-page-title');
const pageSubtitle = document.getElementById('editor-page-subtitle');
const cardMetaNote = document.getElementById('card-meta-note');
const assistBtn = document.getElementById('assist-btn');
const assistLoading = document.getElementById('assist-loading');
const assistModifyBtn = document.getElementById('assist-modify-btn');
const assistModifyLoading = document.getElementById('assist-modify-loading');
const assistModifyInput = document.getElementById('assist-modify-instruction');
const assistNotes = document.getElementById('assist-notes');
const fields = {
title: document.getElementById('card-title'),
subtitle: document.getElementById('card-subtitle'),
summary: document.getElementById('card-summary'),
cover: document.getElementById('card-cover'),
roomBg: document.getElementById('card-room-bg'),
opening: document.getElementById('card-opening'),
goals: document.getElementById('card-goals'),
inventoryLabel: document.getElementById('card-inventory-label'),
cluesLabel: document.getElementById('card-clues-label'),
choicesLabel: document.getElementById('card-choices-label'),
sceneLabel: document.getElementById('card-scene-label'),
heroStatusLabel: document.getElementById('card-hero-status-label'),
heroStatusKey: document.getElementById('card-hero-status-key'),
heroStatusColor: document.getElementById('card-hero-status-color'),
dataScopeJson: document.getElementById('card-data-scope-json'),
visibilityJson: document.getElementById('card-visibility-json'),
metricsJson: document.getElementById('card-metrics-json'),
statusTracksJson: document.getElementById('card-status-tracks-json'),
endConditionsJson: document.getElementById('card-end-conditions-json'),
systemsJson: document.getElementById('card-systems-json'),
genreTags: document.getElementById('card-genre-tags'),
featureBadges: document.getElementById('card-feature-badges'),
worldLore: document.getElementById('card-world-lore'),
coreConflict: document.getElementById('card-core-conflict'),
playerRoles: document.getElementById('card-player-roles'),
interactionModes: document.getElementById('card-interaction-modes'),
systemRules: document.getElementById('card-system-rules'),
npcRoster: document.getElementById('card-npc-roster'),
sceneBlueprint: document.getElementById('card-scene-blueprint'),
endingCatalog: document.getElementById('card-ending-catalog'),
hiddenTruths: document.getElementById('card-hidden-truths'),
};
const setStatus = (text) => {
statusEl.textContent = text || '';
};
const setAssistLoading = (loading, mode = 'draft') => {
if (assistBtn) {
assistBtn.disabled = loading;
assistBtn.textContent = loading ? 'AI 生成中...' : 'AI 生成草稿并填充';
}
if (assistModifyBtn) {
assistModifyBtn.disabled = loading;
assistModifyBtn.textContent = loading ? 'AI 修改中...' : 'AI 按指令修改当前内容';
}
if (assistLoading) {
assistLoading.hidden = !(loading && mode === 'draft');
}
if (assistModifyLoading) {
assistModifyLoading.hidden = !(loading && mode === 'modify');
}
};
const getJson = async (url) => {
const res = await fetch(url);
return res.json();
};
const sendJson = async (url, method, payload) => {
const res = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
return res.json();
};
const listToMultiline = (arr) => {
if (!Array.isArray(arr)) return '';
return arr.map((row) => String(row || '').trim()).filter(Boolean).join('\n');
};
const prettyJson = (value) => {
try {
return JSON.stringify(value, null, 2);
} catch {
return '';
}
};
const parseJsonField = (element, fallbackValue, expectedType) => {
const text = String(element?.value || '').trim();
if (!text) return fallbackValue;
let parsed;
try {
parsed = JSON.parse(text);
} catch {
throw new Error(`${element.id} 不是合法 JSON`);
}
if (expectedType === 'array' && !Array.isArray(parsed)) {
throw new Error(`${element.id} 需要是数组 JSON`);
}
if (expectedType === 'object' && (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))) {
throw new Error(`${element.id} 需要是对象 JSON`);
}
return parsed;
};
const ensureDefaults = () => {
if (!fields.dataScopeJson.value.trim()) {
fields.dataScopeJson.value = prettyJson({
metrics: 'per_player',
inventory: 'per_player',
clues: 'shared',
});
}
if (!fields.visibilityJson.value.trim()) {
fields.visibilityJson.value = prettyJson({
show_all_player_metrics: true,
show_all_player_inventory: true,
});
}
if (!fields.metricsJson.value.trim()) {
fields.metricsJson.value = prettyJson([
{ key: 'tension', label: '紧张度', min: 0, max: 100, initial: 20, color: '#ff8c2f' },
{ key: 'progress', label: '推进度', min: 0, max: 100, initial: 0, color: '#3b82f6' },
]);
}
if (!fields.statusTracksJson.value.trim()) {
fields.statusTracksJson.value = prettyJson([
{ key: 'tension', label: '紧张度', min: 0, max: 100, color: '#ff8c2f' },
{ key: 'progress', label: '推进度', min: 0, max: 100, color: '#3b82f6' },
]);
}
if (!fields.endConditionsJson.value.trim()) {
fields.endConditionsJson.value = '[]';
}
if (!fields.systemsJson.value.trim()) {
fields.systemsJson.value = '{}';
}
};
const fillForm = (card) => {
const schema = card?.state_schema || {};
const labels = schema.labels || {};
const dataScope = schema.data_scope || {};
const visibility = schema.visibility || {};
const ui = schema.ui || {};
const hero = ui.hero_status || {};
const pack = card?.content_pack || {};
fields.title.value = card?.title || '';
fields.subtitle.value = card?.subtitle || '';
fields.summary.value = card?.summary || '';
fields.cover.value = card?.cover_url || '';
fields.roomBg.value = ui.room_background_url || card?.cover_url || '';
fields.opening.value = card?.opening_text || '';
fields.goals.value = listToMultiline(card?.starter_goals || []);
fields.inventoryLabel.value = labels.inventory || '';
fields.cluesLabel.value = labels.clues || '';
fields.choicesLabel.value = labels.choices || '';
fields.sceneLabel.value = labels.scene || '';
fields.heroStatusLabel.value = hero.label || '';
fields.heroStatusKey.value = hero.key || '';
fields.heroStatusColor.value = /^#[0-9a-fA-F]{6}$/.test(String(hero.color || '')) ? hero.color : '#ffb500';
fields.dataScopeJson.value = prettyJson(dataScope);
fields.visibilityJson.value = prettyJson(visibility);
fields.metricsJson.value = prettyJson(Array.isArray(schema.metrics) ? schema.metrics : []);
fields.statusTracksJson.value = prettyJson(Array.isArray(ui.status_tracks) ? ui.status_tracks : []);
fields.endConditionsJson.value = prettyJson(Array.isArray(pack.end_conditions) ? pack.end_conditions : []);
fields.systemsJson.value = prettyJson(pack.systems && typeof pack.systems === 'object' ? pack.systems : {});
fields.genreTags.value = (Array.isArray(pack.genre_tags) ? pack.genre_tags : []).join(', ');
fields.featureBadges.value = (Array.isArray(pack.feature_badges) ? pack.feature_badges : []).join(', ');
fields.worldLore.value = pack.world_lore || '';
fields.coreConflict.value = pack.core_conflict || '';
fields.playerRoles.value = listToMultiline(pack.player_roles || []);
fields.interactionModes.value = listToMultiline(pack.interaction_modes || []);
fields.systemRules.value = listToMultiline(pack.system_rules || []);
fields.npcRoster.value = listToMultiline(pack.npc_roster || []);
fields.sceneBlueprint.value = listToMultiline(pack.scene_blueprint || []);
fields.endingCatalog.value = listToMultiline(pack.ending_catalog || []);
fields.hiddenTruths.value = listToMultiline(pack.hidden_truths || []);
ensureDefaults();
};
const fillWithDraft = (draft) => {
fields.title.value = draft.title || fields.title.value;
fields.subtitle.value = draft.subtitle || fields.subtitle.value;
fields.summary.value = draft.summary || fields.summary.value;
fields.cover.value = draft.cover_url || fields.cover.value;
fields.roomBg.value = draft.room_background_url || fields.roomBg.value;
fields.opening.value = draft.opening_text || fields.opening.value;
if (Array.isArray(draft.starter_goals)) fields.goals.value = listToMultiline(draft.starter_goals);
if (draft.data_scope && typeof draft.data_scope === 'object') fields.dataScopeJson.value = prettyJson(draft.data_scope);
if (draft.visibility && typeof draft.visibility === 'object') fields.visibilityJson.value = prettyJson(draft.visibility);
if (Array.isArray(draft.metrics)) fields.metricsJson.value = prettyJson(draft.metrics);
if (Array.isArray(draft.status_tracks)) fields.statusTracksJson.value = prettyJson(draft.status_tracks);
if (Array.isArray(draft.end_conditions)) fields.endConditionsJson.value = prettyJson(draft.end_conditions);
if (draft.systems && typeof draft.systems === 'object') fields.systemsJson.value = prettyJson(draft.systems);
fields.inventoryLabel.value = draft.inventory_label || fields.inventoryLabel.value;
fields.cluesLabel.value = draft.clues_label || fields.cluesLabel.value;
fields.choicesLabel.value = draft.choices_label || fields.choicesLabel.value;
fields.sceneLabel.value = draft.scene_label || fields.sceneLabel.value;
fields.heroStatusLabel.value = draft.hero_status_label || fields.heroStatusLabel.value;
fields.heroStatusColor.value = /^#[0-9a-fA-F]{6}$/.test(String(draft.hero_status_color || ''))
? draft.hero_status_color
: fields.heroStatusColor.value;
if (Array.isArray(draft.genre_tags)) fields.genreTags.value = draft.genre_tags.join(', ');
if (Array.isArray(draft.feature_badges)) fields.featureBadges.value = draft.feature_badges.join(', ');
fields.worldLore.value = draft.world_lore || fields.worldLore.value;
fields.coreConflict.value = draft.core_conflict || fields.coreConflict.value;
if (Array.isArray(draft.player_roles)) fields.playerRoles.value = listToMultiline(draft.player_roles);
if (Array.isArray(draft.interaction_modes)) fields.interactionModes.value = listToMultiline(draft.interaction_modes);
if (Array.isArray(draft.system_rules)) fields.systemRules.value = listToMultiline(draft.system_rules);
if (Array.isArray(draft.npc_roster)) fields.npcRoster.value = listToMultiline(draft.npc_roster);
if (Array.isArray(draft.scene_blueprint)) fields.sceneBlueprint.value = listToMultiline(draft.scene_blueprint);
if (Array.isArray(draft.ending_catalog)) fields.endingCatalog.value = listToMultiline(draft.ending_catalog);
if (Array.isArray(draft.hidden_truths)) fields.hiddenTruths.value = listToMultiline(draft.hidden_truths);
const notes = Array.isArray(draft.design_notes) ? draft.design_notes : [];
assistNotes.textContent = '';
notes.forEach((row) => {
const li = document.createElement('li');
li.textContent = row;
assistNotes.appendChild(li);
});
ensureDefaults();
};
const addAssistNote = (text) => {
if (!assistNotes || !text) return;
const li = document.createElement('li');
li.textContent = text;
assistNotes.appendChild(li);
};
const buildPayload = () => {
const metrics = parseJsonField(fields.metricsJson, [], 'array');
const statusTracks = parseJsonField(fields.statusTracksJson, [], 'array');
const endConditions = parseJsonField(fields.endConditionsJson, [], 'array');
const dataScope = parseJsonField(fields.dataScopeJson, {}, 'object');
const visibility = parseJsonField(fields.visibilityJson, {}, 'object');
const systems = parseJsonField(fields.systemsJson, {}, 'object');
return {
title: fields.title.value.trim(),
subtitle: fields.subtitle.value.trim(),
summary: fields.summary.value.trim(),
cover_url: fields.cover.value.trim(),
room_background_url: fields.roomBg.value.trim(),
opening_text: fields.opening.value.trim(),
starter_goals: fields.goals.value.trim(),
inventory_label: fields.inventoryLabel.value.trim(),
clues_label: fields.cluesLabel.value.trim(),
choices_label: fields.choicesLabel.value.trim(),
scene_label: fields.sceneLabel.value.trim(),
hero_status_label: fields.heroStatusLabel.value.trim(),
hero_status_key: fields.heroStatusKey.value.trim(),
hero_status_color: fields.heroStatusColor.value.trim(),
data_scope_json: dataScope,
visibility_json: visibility,
metrics,
status_tracks: statusTracks,
systems_json: systems,
genre_tags: fields.genreTags.value.trim(),
feature_badges: fields.featureBadges.value.trim(),
world_lore: fields.worldLore.value.trim(),
core_conflict: fields.coreConflict.value.trim(),
player_roles: fields.playerRoles.value.trim(),
interaction_modes: fields.interactionModes.value.trim(),
system_rules: fields.systemRules.value.trim(),
npc_roster: fields.npcRoster.value.trim(),
scene_blueprint: fields.sceneBlueprint.value.trim(),
ending_catalog: fields.endingCatalog.value.trim(),
hidden_truths: fields.hiddenTruths.value.trim(),
end_conditions: endConditions,
};
};
const initMode = () => {
if (isEditMode) {
pageTitle.textContent = '编辑游戏卡';
pageSubtitle.textContent = '你正在修改已发布的游戏卡,保存后会用于新开房间。';
saveBtn.textContent = '保存修改';
cardMetaNote.textContent = `当前卡片 ID:${cardId}`;
} else {
pageTitle.textContent = '新建游戏卡';
pageSubtitle.textContent = '创建一张可复用的多人冒险卡,支持多状态条和复杂系统。';
saveBtn.textContent = '发布游戏卡';
cardMetaNote.textContent = '保存后会自动跳转到该卡的编辑页。';
ensureDefaults();
}
};
const loadCard = async () => {
if (!isEditMode) return;
setStatus('正在加载游戏卡...');
const data = await getJson(`/api/cards/${cardId}`);
if (!data.ok) {
setStatus(data.error || '加载失败');
return;
}
fillForm(data.card || {});
setStatus('已加载,可继续编辑。');
};
if (assistBtn) {
assistBtn.addEventListener('click', async () => {
const concept = String(document.getElementById('assist-concept').value || '').trim();
const tone = String(document.getElementById('assist-tone').value || '').trim();
const difficulty = String(document.getElementById('assist-difficulty').value || '').trim();
if (!concept) {
setStatus('请先输入创意草案。');
return;
}
setAssistLoading(true, 'draft');
setStatus('AI 正在生成游戏卡草稿...');
try {
const data = await sendJson('/api/cards/assist', 'POST', { concept, tone, difficulty });
if (!data.ok) {
setStatus(data.error || 'AI 生成失败');
return;
}
fillWithDraft(data.draft || {});
setStatus('AI 草稿已填充,请检查后保存。');
} catch (error) {
setStatus(`网络错误: ${error}`);
} finally {
setAssistLoading(false, 'draft');
}
});
}
if (assistModifyBtn) {
assistModifyBtn.addEventListener('click', async () => {
const instruction = String(assistModifyInput?.value || '').trim();
if (!instruction) {
setStatus('请先输入修改指令。');
return;
}
let current;
try {
current = buildPayload();
} catch (error) {
setStatus(error instanceof Error ? error.message : `当前配置有误: ${error}`);
return;
}
setAssistLoading(true, 'modify');
setStatus('AI 正在按指令增量修改...');
try {
const data = await sendJson('/api/cards/assist_patch', 'POST', { instruction, current });
if (!data.ok) {
setStatus(data.error || 'AI 修改失败');
return;
}
fillWithDraft(data.updated || {});
const changedKeys = Object.keys(data.patch || {});
if (changedKeys.length) {
addAssistNote(`本次增量修改字段:${changedKeys.join('、')}`);
}
setStatus('AI 增量修改已应用,请检查并保存。');
} catch (error) {
setStatus(`网络错误: ${error}`);
} finally {
setAssistLoading(false, 'modify');
}
});
}
form.addEventListener('submit', async (event) => {
event.preventDefault();
let payload;
try {
payload = buildPayload();
} catch (error) {
setStatus(error instanceof Error ? error.message : `配置有误: ${error}`);
return;
}
const method = isEditMode ? 'PATCH' : 'POST';
const endpoint = isEditMode ? `/api/cards/${cardId}` : '/api/cards';
saveBtn.disabled = true;
setStatus(isEditMode ? '正在保存修改...' : '正在发布...');
try {
const data = await sendJson(endpoint, method, payload);
if (!data.ok) {
setStatus(data.error || '保存失败');
return;
}
const nextCard = data.card || {};
setStatus('保存成功。');
if (!isEditMode && nextCard.card_id) {
window.location.href = `/cards/${nextCard.card_id}/edit`;
}
} catch (error) {
setStatus(`网络错误: ${error}`);
} finally {
saveBtn.disabled = false;
}
});
if (!user) {
setStatus('请先登录再编辑游戏卡。');
saveBtn.disabled = true;
if (assistBtn) assistBtn.disabled = true;
if (assistModifyBtn) assistModifyBtn.disabled = true;
return;
}
initMode();
loadCard().catch((error) => {
setStatus(`加载失败: ${error}`);
});
})();