451 lines
18 KiB
JavaScript
451 lines
18 KiB
JavaScript
(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}`);
|
||
});
|
||
})();
|