feat: 初始提交

This commit is contained in:
anonymous
2025-10-21 09:38:26 +08:00
committed by t59688
parent 2965b8e28f
commit c9fc816fab
175 changed files with 23968 additions and 87 deletions

View File

@@ -0,0 +1,86 @@
<template>
<div class="space-y-6">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h2 class="text-2xl font-bold text-slate-900">章节大纲</h2>
<p class="text-sm text-slate-500">故事结构与章节节奏一目了然</p>
</div>
<div v-if="editable" class="flex items-center gap-2">
<button
type="button"
class="flex items-center gap-1 px-3 py-2 text-sm font-medium text-indigo-600 bg-indigo-50 hover:bg-indigo-100 rounded-lg"
@click="$emit('add')"
>
<svg class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 5a1 1 0 011 1v3h3a1 1 0 110 2h-3v3a1 1 0 11-2 0v-3H6a1 1 0 110-2h3V6a1 1 0 011-1z" clip-rule="evenodd" />
</svg>
新增章节
</button>
<button
type="button"
class="flex items-center gap-1 px-3 py-2 text-sm text-gray-500 hover:text-indigo-600 transition-colors"
@click="emitEdit('chapter_outline', '章节大纲', outline)"
>
<svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path d="M17.414 2.586a2 2 0 00-2.828 0L7 10.172V13h2.828l7.586-7.586a2 2 0 000-2.828z" />
<path fill-rule="evenodd" d="M2 6a2 2 0 012-2h4a1 1 0 010 2H4v10h10v-4a1 1 0 112 0v4a2 2 0 01-2 2H4a2 2 0 01-2-2V6z" clip-rule="evenodd" />
</svg>
编辑大纲
</button>
</div>
</div>
<ol class="relative border-l border-slate-200 ml-3 space-y-8">
<li
v-for="chapter in outline"
:key="chapter.chapter_number"
class="ml-6"
>
<span class="absolute -left-3 mt-1 flex h-6 w-6 items-center justify-center rounded-full bg-indigo-500 text-white text-xs font-semibold">
{{ chapter.chapter_number }}
</span>
<div class="bg-white/95 rounded-2xl border border-slate-200 shadow-sm p-5">
<div class="flex items-center justify-between gap-4">
<h3 class="text-lg font-semibold text-slate-900">{{ chapter.title || `${chapter.chapter_number}` }}</h3>
<span class="text-xs text-slate-400">#{{ chapter.chapter_number }}</span>
</div>
<p class="mt-3 text-sm text-slate-600 leading-6 whitespace-pre-line">{{ chapter.summary || '暂无摘要' }}</p>
</div>
</li>
<li v-if="!outline.length" class="ml-6 text-slate-400 text-sm">暂无章节大纲</li>
</ol>
</div>
</template>
<script setup lang="ts">
import { defineEmits, defineProps } from 'vue'
interface OutlineItem {
chapter_number: number
title: string
summary: string
}
const props = defineProps<{
outline: OutlineItem[]
editable?: boolean
}>()
const emit = defineEmits<{
(e: 'edit', payload: { field: string; title: string; value: any }): void
(e: 'add'): void
}>()
const emitEdit = (field: string, title: string, value: any) => {
if (!props.editable) return
emit('edit', { field, title, value })
}
</script>
<script lang="ts">
import { defineComponent } from 'vue'
export default defineComponent({
name: 'ChapterOutlineSection'
})
</script>

View File

@@ -0,0 +1,711 @@
<template>
<div class="flex flex-col h-full min-h-0 overflow-hidden relative">
<div class="flex flex-row flex-1 h-full lg:min-h-0 overflow-hidden">
<!-- 移动端遮罩层 -->
<div
v-if="showChapterList"
class="fixed inset-0 bg-black/50 z-40 lg:hidden"
@click="showChapterList = false"
></div>
<!-- 章节列表侧边栏 -->
<aside
class="fixed lg:static inset-y-0 left-0 z-50 w-72 lg:w-72 bg-white lg:bg-slate-50/70 border-r border-slate-200 flex flex-col h-full min-h-0 max-h-full overflow-hidden transition-transform duration-300 lg:translate-x-0 shadow-2xl lg:shadow-none"
:class="showChapterList ? 'translate-x-0' : '-translate-x-full'"
>
<div class="px-5 py-4 border-b border-slate-200 flex items-center justify-between">
<h3 class="text-base font-semibold text-slate-900">章节</h3>
<span class="text-xs text-slate-500">{{ chapters.length }} </span>
</div>
<ul class="flex-1 h-full overflow-y-auto divide-y divide-slate-200 overscroll-contain">
<li v-for="(chapter, index) in chapters" :key="chapter.chapter_number">
<button
class="w-full text-left px-5 py-3 transition-colors duration-200"
:class="selectedChapter?.chapter_number === chapter.chapter_number ? 'bg-indigo-50 text-indigo-600 font-semibold' : 'hover:bg-slate-50 lg:hover:bg-white text-slate-700'"
@click="selectChapter(chapter.chapter_number)"
>
<div class="flex items-center justify-between gap-3">
<div class="flex items-center gap-3 min-w-0">
<span class="inline-flex items-center justify-center w-6 h-6 text-xs font-semibold text-slate-500 bg-slate-100 rounded-full">
{{ index + 1 }}
</span>
<span class="truncate">{{ chapter.title || `${chapter.chapter_number}` }}</span>
</div>
<span v-if="chapterCache.has(chapter.chapter_number)" class="text-xs text-slate-400">
{{ calculateWordCount(chapterCache.get(chapter.chapter_number)?.content) }}
</span>
<span v-else class="text-xs text-slate-400">-</span>
</div>
<p v-if="chapter.summary" class="mt-1 text-xs text-slate-500 truncate">
{{ chapter.summary }}
</p>
</button>
</li>
</ul>
</aside>
<section class="flex-1 flex flex-col bg-white h-full min-h-0 max-h-full overflow-hidden relative">
<!-- 移动端浮动按钮 -->
<button
v-if="!showChapterList"
@click="showChapterList = true"
class="lg:hidden fixed bottom-6 left-6 z-30 w-14 h-14 bg-indigo-600 text-white rounded-full shadow-lg flex items-center justify-center hover:bg-indigo-700 transition-colors"
>
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
<!-- Loading State -->
<div v-if="isLoading" class="h-full flex items-center justify-center">
<div class="text-center">
<div class="w-10 h-10 border-4 border-indigo-200 border-t-indigo-600 rounded-full animate-spin mx-auto mb-3"></div>
<p class="text-sm text-slate-500">加载中...</p>
</div>
</div>
<!-- Error State -->
<div v-else-if="error" class="h-full flex items-center justify-center">
<div class="text-center">
<div class="w-12 h-12 bg-red-50 rounded-full flex items-center justify-center mx-auto mb-3">
<svg class="w-6 h-6 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<p class="text-sm text-slate-600">{{ error }}</p>
</div>
</div>
<!-- Content -->
<template v-else-if="selectedChapter">
<!-- Header with Status and Tabs -->
<header class="px-6 py-4 border-b border-slate-200 bg-slate-50/50">
<div class="flex items-start justify-between gap-4 mb-3">
<div class="flex-1">
<h4 class="text-xl font-bold text-slate-900">{{ selectedChapter.title || `${selectedChapter.chapter_number}` }}</h4>
<div class="flex items-center gap-3 mt-1.5">
<span class="text-sm text-slate-500"> {{ selectedChapter.chapter_number }} </span>
<span class="text-sm text-slate-400">·</span>
<span class="text-sm text-slate-500">{{ calculateWordCount(selectedChapter.content) }} </span>
</div>
</div>
<div class="flex items-center gap-2">
<button
class="inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium rounded-lg border transition-colors duration-200"
:class="selectedChapter?.content ? 'border-indigo-200 text-indigo-600 hover:bg-indigo-50' : 'border-slate-200 text-slate-400 cursor-not-allowed'"
:disabled="!selectedChapter?.content"
@click="exportChapterAsTxt"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v16h16V4m-4 4l-4-4-4 4m4-4v12" />
</svg>
导出TXT
</button>
<span v-if="selectedChapter.generation_status"
class="px-3 py-1 text-xs font-medium rounded-full"
:class="getStatusColor(selectedChapter.generation_status)">
{{ getStatusLabel(selectedChapter.generation_status) }}
</span>
</div>
</div>
<!-- Tab Navigation -->
<div class="flex gap-1">
<button
v-for="tab in tabs"
:key="tab.key"
@click="activeTab = tab.key"
class="px-4 py-2 text-sm font-medium rounded-lg transition-all duration-200"
:class="activeTab === tab.key
? 'bg-white text-indigo-600 shadow-sm'
: 'text-slate-600 hover:text-slate-900 hover:bg-white/50'"
>
{{ tab.label }}
<span v-if="tab.badge && getTabBadgeCount(tab.key)"
class="ml-1.5 px-1.5 py-0.5 text-xs rounded-full"
:class="activeTab === tab.key ? 'bg-indigo-100 text-indigo-600' : 'bg-slate-200 text-slate-600'">
{{ getTabBadgeCount(tab.key) }}
</span>
</button>
</div>
</header>
<!-- Tab Content -->
<article class="flex-1 h-full overflow-y-auto min-h-0 overscroll-contain">
<!-- 正文 Tab -->
<div v-show="activeTab === 'content'" class="px-2 py-3">
<div class="max-w-full space-y-4">
<!-- Summary Cards -->
<div v-if="selectedChapter.summary || selectedChapter.real_summary" class="grid gap-4">
<div v-if="selectedChapter.summary" class="bg-blue-50 border border-blue-100 rounded-xl p-4">
<h5 class="text-xs font-semibold text-blue-900 mb-2 flex items-center gap-1.5">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
计划大纲
</h5>
<p class="text-sm text-blue-800 leading-relaxed">{{ selectedChapter.summary }}</p>
</div>
<div v-if="selectedChapter.real_summary" class="bg-green-50 border border-green-100 rounded-xl p-4">
<h5 class="text-xs font-semibold text-green-900 mb-2 flex items-center gap-1.5">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />
</svg>
实际内容概要
</h5>
<div class="prose prose-sm prose-green max-w-none text-green-800" v-html="renderMarkdown(selectedChapter.real_summary)"></div>
</div>
</div>
<!-- Main Content -->
<div class="prose prose-slate max-w-none p-4 sm:p-6 rounded-xl bg-[var(--paper-card)]">
<div class="text-base text-slate-900 leading-8 whitespace-pre-wrap font-serif">
{{ selectedChapter.content || '暂无内容' }}
</div>
</div>
</div>
</div>
<!-- 版本 Tab -->
<div v-show="activeTab === 'versions'" class="px-2 py-3">
<div class="max-w-full">
<div v-if="selectedChapter.versions && selectedChapter.versions.length > 0" class="space-y-4">
<div v-for="(version, index) in selectedChapter.versions" :key="index"
class="border border-slate-200 rounded-xl p-5 hover:border-indigo-300 hover:shadow-md transition-all duration-200 group cursor-pointer"
@click="openVersionModal(version, index)">
<div class="flex items-center justify-between mb-3">
<h5 class="text-sm font-semibold text-slate-900 flex items-center gap-2">
<span class="w-6 h-6 bg-indigo-100 text-indigo-600 rounded-full flex items-center justify-center text-xs font-bold">
{{ index + 1 }}
</span>
版本 {{ index + 1 }}
</h5>
<div class="flex items-center gap-3">
<span class="text-xs text-slate-500">{{ calculateWordCount(version) }} </span>
<span class="text-xs font-medium text-indigo-600 opacity-0 group-hover:opacity-100 transition-opacity">
点击查看全文
</span>
</div>
</div>
<div class="text-sm text-slate-700 leading-7 whitespace-pre-wrap line-clamp-4">
{{ version }}
</div>
</div>
</div>
<div v-else class="text-center py-12 text-slate-400">
暂无版本记录
</div>
</div>
</div>
<!-- 评审 Tab -->
<div v-show="activeTab === 'evaluation'" class="px-2 py-3">
<div class="max-w-full">
<div v-if="evaluationData" class="space-y-4">
<!-- 最佳选择 -->
<div v-if="evaluationData.best_choice" class="bg-gradient-to-br from-indigo-50 to-purple-50 border border-indigo-200 rounded-xl p-4">
<div class="flex items-start gap-4">
<div class="w-12 h-12 bg-indigo-500 rounded-xl flex items-center justify-center flex-shrink-0">
<svg class="w-7 h-7 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4M7.835 4.697a3.42 3.42 0 001.946-.806 3.42 3.42 0 014.438 0 3.42 3.42 0 001.946.806 3.42 3.42 0 013.138 3.138 3.42 3.42 0 00.806 1.946 3.42 3.42 0 010 4.438 3.42 3.42 0 00-.806 1.946 3.42 3.42 0 01-3.138 3.138 3.42 3.42 0 00-1.946.806 3.42 3.42 0 01-4.438 0 3.42 3.42 0 00-1.946-.806 3.42 3.42 0 01-3.138-3.138 3.42 3.42 0 00-.806-1.946 3.42 3.42 0 010-4.438 3.42 3.42 0 00.806-1.946 3.42 3.42 0 013.138-3.138z" />
</svg>
</div>
<div class="flex-1">
<h5 class="text-lg font-bold text-indigo-900 mb-2">最佳版本选择</h5>
<div class="flex items-center gap-2 mb-3">
<span class="px-3 py-1 bg-indigo-500 text-white text-sm font-bold rounded-full">
版本 {{ evaluationData.best_choice }}
</span>
</div>
<p v-if="evaluationData.reason_for_choice" class="text-sm text-indigo-900 leading-relaxed">
{{ evaluationData.reason_for_choice }}
</p>
</div>
</div>
</div>
<!-- 各版本详细评审 -->
<div v-if="evaluationData.evaluation" class="space-y-4">
<div v-for="(versionEval, versionKey) in evaluationData.evaluation" :key="versionKey"
class="border border-slate-200 rounded-xl overflow-hidden"
:class="isSelectedVersion(versionKey, evaluationData.best_choice) ? 'ring-2 ring-indigo-400' : ''">
<!-- 版本标题 -->
<div class="px-5 py-3 bg-slate-50 border-b border-slate-200 flex items-center justify-between">
<h6 class="font-bold text-slate-900 flex items-center gap-2">
<span class="w-6 h-6 bg-slate-700 text-white rounded-full flex items-center justify-center text-xs">
{{ getVersionNumber(versionKey) }}
</span>
{{ getVersionLabel(versionKey) }}
</h6>
<span v-if="isSelectedVersion(versionKey, evaluationData.best_choice)"
class="px-2.5 py-1 bg-indigo-100 text-indigo-700 text-xs font-semibold rounded-full">
最佳
</span>
</div>
<div class="p-4 space-y-3">
<!-- 优点 -->
<div v-if="versionEval.pros && versionEval.pros.length > 0"
class="bg-green-50 border border-green-100 rounded-lg p-3">
<h6 class="text-xs font-bold text-green-900 mb-2 flex items-center gap-1.5">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
优点
</h6>
<ul class="space-y-1.5">
<li v-for="(item, idx) in versionEval.pros" :key="idx"
class="flex items-start gap-2 text-xs text-green-800 leading-relaxed">
<span class="w-1 h-1 bg-green-500 rounded-full mt-1.5 flex-shrink-0"></span>
<span>{{ item }}</span>
</li>
</ul>
</div>
<!-- 缺点 -->
<div v-if="versionEval.cons && versionEval.cons.length > 0"
class="bg-red-50 border border-red-100 rounded-lg p-3">
<h6 class="text-xs font-bold text-red-900 mb-2 flex items-center gap-1.5">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
缺点
</h6>
<ul class="space-y-1.5">
<li v-for="(item, idx) in versionEval.cons" :key="idx"
class="flex items-start gap-2 text-xs text-red-800 leading-relaxed">
<span class="w-1 h-1 bg-red-500 rounded-full mt-1.5 flex-shrink-0"></span>
<span>{{ item }}</span>
</li>
</ul>
</div>
<!-- 总体评价 -->
<div v-if="versionEval.overall_review"
class="bg-blue-50 border border-blue-100 rounded-lg p-3">
<h6 class="text-xs font-bold text-blue-900 mb-2">总体评价</h6>
<p class="text-xs text-blue-800 leading-relaxed">{{ versionEval.overall_review }}</p>
</div>
</div>
</div>
</div>
<!-- 简单格式兼容 -->
<div v-else-if="evaluationData.decision || evaluationData.feedback" class="space-y-4">
<!-- 评审决策 -->
<div v-if="evaluationData.decision" class="bg-gradient-to-br from-indigo-50 to-blue-50 border border-indigo-200 rounded-xl p-4">
<div class="flex items-center gap-3 mb-4">
<div class="w-10 h-10 bg-indigo-500 rounded-lg flex items-center justify-center">
<svg class="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div>
<h5 class="text-sm font-bold text-indigo-900">评审决策</h5>
<p class="text-xs text-indigo-700">{{ evaluationData.decision }}</p>
</div>
</div>
</div>
<!-- 评分卡片 -->
<div v-if="evaluationData.scores" class="grid grid-cols-2 md:grid-cols-3 gap-4">
<div v-for="(score, key) in evaluationData.scores" :key="key"
class="bg-white border border-slate-200 rounded-xl p-4 hover:shadow-md transition-shadow">
<div class="flex items-center justify-between mb-2">
<span class="text-xs font-medium text-slate-600">{{ getScoreLabel(key) }}</span>
<span class="text-lg font-bold" :class="getScoreColor(score)">{{ score }}</span>
</div>
<div class="w-full bg-slate-100 rounded-full h-2">
<div class="h-2 rounded-full transition-all duration-300"
:class="getScoreBarColor(score)"
:style="{ width: `${(score / 10) * 100}%` }"></div>
</div>
</div>
</div>
<!-- 详细反馈 -->
<div v-if="evaluationData.feedback"
class="bg-slate-50 border border-slate-200 rounded-xl p-4">
<h5 class="text-sm font-bold text-slate-900 mb-3">详细反馈</h5>
<p class="text-sm text-slate-700 leading-relaxed whitespace-pre-wrap">{{ evaluationData.feedback }}</p>
</div>
</div>
</div>
<div v-else class="text-center py-12">
<div class="w-16 h-16 bg-slate-100 rounded-full flex items-center justify-center mx-auto mb-3">
<svg class="w-8 h-8 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</div>
<p class="text-slate-400">暂无评审意见</p>
</div>
</div>
</div>
</article>
</template>
<!-- Empty State -->
<div v-else class="h-full flex items-center justify-center text-slate-400">
<div class="text-center">
<svg class="w-16 h-16 mx-auto mb-3 text-slate-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
</svg>
<p class="text-sm">请选择章节查看详细内容</p>
</div>
</div>
</section>
</div>
<!-- 版本全文弹窗 -->
<transition
enter-active-class="transition-all duration-300"
leave-active-class="transition-all duration-300"
enter-from-class="opacity-0"
leave-to-class="opacity-0"
>
<div v-if="versionModal.show" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm"
@click="closeVersionModal">
<div class="bg-white rounded-2xl shadow-2xl max-w-4xl w-full max-h-[85vh] overflow-hidden"
@click.stop>
<!-- Modal Header -->
<div class="flex items-center justify-between px-6 py-4 border-b border-slate-200 bg-slate-50">
<div class="flex items-center gap-3">
<span class="w-8 h-8 bg-indigo-500 text-white rounded-full flex items-center justify-center text-sm font-bold">
{{ versionModal.index + 1 }}
</span>
<div>
<h3 class="text-lg font-bold text-slate-900">版本 {{ versionModal.index + 1 }}</h3>
<p class="text-xs text-slate-500">{{ calculateWordCount(versionModal.content) }} </p>
</div>
</div>
<button @click="closeVersionModal"
class="w-8 h-8 flex items-center justify-center rounded-lg hover:bg-slate-200 transition-colors">
<svg class="w-5 h-5 text-slate-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<!-- Modal Content -->
<div class="overflow-y-auto p-6 max-h-[calc(85vh-5rem)]">
<div class="prose prose-slate max-w-none">
<div class="text-base text-slate-900 leading-8 whitespace-pre-wrap font-serif">
{{ versionModal.content }}
</div>
</div>
</div>
</div>
</div>
</transition>
</div>
</template>
<script setup lang="ts">
import { computed, defineProps, ref, watch } from 'vue'
import { NovelAPI } from '@/api/novel'
import { AdminAPI } from '@/api/admin'
import { useRoute } from 'vue-router'
import { marked } from 'marked'
interface ChapterItem {
chapter_number: number
title?: string | null
summary?: string | null
content?: string | null
word_count?: number
}
interface ChapterDetail extends ChapterItem {
real_summary?: string | null
versions?: string[] | null
evaluation?: string | null
generation_status?: string
}
const props = defineProps<{
chapters: ChapterItem[]
isAdmin?: boolean
}>()
const route = useRoute()
const projectId = route.params.id as string
const selectedChapter = ref<ChapterDetail | null>(null)
const isLoading = ref(false)
const error = ref<string | null>(null)
const activeTab = ref<'content' | 'versions' | 'evaluation'>('content')
// 移动端章节列表显示状态
const showChapterList = ref(false)
// 版本弹窗状态
const versionModal = ref({
show: false,
content: '',
index: 0
})
// 缓存已加载的章节详情
const chapterCache = new Map<number, ChapterDetail>()
const chapters = computed(() => props.chapters || [])
// Tab 配置
const tabs = [
{ key: 'content' as const, label: '正文', badge: false },
{ key: 'versions' as const, label: '版本', badge: true },
{ key: 'evaluation' as const, label: '评审', badge: false }
]
// 计算字数的辅助函数
const calculateWordCount = (content: string | null | undefined): number => {
if (!content) return 0
// 移除所有空白字符后计算字数
return content.replace(/\s/g, '').length
}
// 获取状态标签
const getStatusLabel = (status: string): string => {
const statusMap: Record<string, string> = {
'not_generated': '未生成',
'generating': '生成中',
'evaluating': '评审中',
'selecting': '选择中',
'failed': '生成失败',
'evaluation_failed': '评审失败',
'waiting_for_confirm': '待确认',
'successful': '已完成'
}
return statusMap[status] || status
}
// 获取状态颜色
const getStatusColor = (status: string): string => {
const colorMap: Record<string, string> = {
'not_generated': 'bg-slate-100 text-slate-600',
'generating': 'bg-blue-100 text-blue-700',
'evaluating': 'bg-purple-100 text-purple-700',
'selecting': 'bg-yellow-100 text-yellow-700',
'failed': 'bg-red-100 text-red-700',
'evaluation_failed': 'bg-orange-100 text-orange-700',
'waiting_for_confirm': 'bg-amber-100 text-amber-700',
'successful': 'bg-green-100 text-green-700'
}
return colorMap[status] || 'bg-slate-100 text-slate-600'
}
// 获取 Tab Badge 数量
const getTabBadgeCount = (tabKey: string): number => {
if (!selectedChapter.value) return 0
if (tabKey === 'versions') {
return selectedChapter.value.versions?.length || 0
}
return 0
}
const sanitizeFileName = (name: string): string => {
return name.replace(/[\\/:*?"<>|]/g, '_')
}
const exportChapterAsTxt = () => {
const chapter = selectedChapter.value
if (!chapter) return
const title = chapter.title?.trim() || `${chapter.chapter_number}`
const safeTitle = sanitizeFileName(title) || `chapter-${chapter.chapter_number}`
const content = chapter.content ?? ''
const blob = new Blob([content], { type: 'text/plain;charset=utf-8' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `${safeTitle}.txt`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
}
// 打开版本弹窗
const openVersionModal = (content: string, index: number) => {
versionModal.value = {
show: true,
content,
index
}
}
// 关闭版本弹窗
const closeVersionModal = () => {
versionModal.value.show = false
}
// 解析评审数据
const evaluationData = computed(() => {
if (!selectedChapter.value?.evaluation) return null
try {
// 尝试解析 JSON
const parsed = JSON.parse(selectedChapter.value.evaluation)
return parsed
} catch {
// 如果不是 JSON返回简单的文本格式
return {
feedback: selectedChapter.value.evaluation
}
}
})
// 获取评分标签
const getScoreLabel = (key: string | number): string => {
const normalizedKey = typeof key === 'number' ? key.toString() : key
const labelMap: Record<string, string> = {
'plot': '情节',
'character': '人物',
'writing': '文笔',
'logic': '逻辑',
'emotion': '情感',
'creativity': '创意',
'coherence': '连贯性',
'engagement': '吸引力'
}
return labelMap[normalizedKey] || normalizedKey
}
// 获取评分颜色
const getScoreColor = (score: number): string => {
if (score >= 8) return 'text-green-600'
if (score >= 6) return 'text-blue-600'
if (score >= 4) return 'text-amber-600'
return 'text-red-600'
}
// 获取评分条颜色
const getScoreBarColor = (score: number): string => {
if (score >= 8) return 'bg-green-500'
if (score >= 6) return 'bg-blue-500'
if (score >= 4) return 'bg-amber-500'
return 'bg-red-500'
}
// 从版本 key 中提取版本号 (version1 -> 1)
const getVersionNumber = (versionKey: string | number): number => {
const normalizedKey = typeof versionKey === 'number' ? versionKey.toString() : versionKey
const match = normalizedKey.match(/\d+/)
return match ? parseInt(match[0]) : 0
}
// 获取版本标签
const getVersionLabel = (versionKey: string | number): string => {
const num = getVersionNumber(versionKey)
return `版本 ${num}`
}
// 判断是否为选中的版本
const isSelectedVersion = (versionKey: string | number, bestChoice?: number): boolean => {
if (!bestChoice) return false
return getVersionNumber(versionKey) === bestChoice
}
// 渲染 Markdown
const renderMarkdown = (text: string | null | undefined): string => {
if (!text) return ''
try {
return marked.parse(text, { breaks: true }) as string
} catch (error) {
console.error('Markdown 渲染失败:', error)
return text
}
}
// 加载章节详情
const loadChapterDetail = async (chapterNumber: number) => {
// 检查缓存
if (chapterCache.has(chapterNumber)) {
selectedChapter.value = chapterCache.get(chapterNumber)!
return
}
isLoading.value = true
error.value = null
try {
const detail: ChapterDetail = props.isAdmin
? await AdminAPI.getNovelChapter(projectId, chapterNumber)
: await NovelAPI.getChapter(projectId, chapterNumber)
// 存入缓存
chapterCache.set(chapterNumber, detail)
selectedChapter.value = detail
} catch (err) {
error.value = err instanceof Error ? err.message : '加载失败'
console.error('加载章节详情失败:', err)
} finally {
isLoading.value = false
}
}
watch(
chapters,
async (list) => {
if (list.length === 0) {
selectedChapter.value = null
return
}
// 自动选中第一个章节(但不加载详情,等用户点击)
if (!selectedChapter.value && list.length > 0) {
await loadChapterDetail(list[0].chapter_number)
}
},
{ immediate: true }
)
const selectChapter = async (chapterNumber: number) => {
activeTab.value = 'content' // 切换章节时重置到正文标签
await loadChapterDetail(chapterNumber)
// 移动端选择章节后关闭章节列表
showChapterList.value = false
}
const isAdmin = computed(() => props.isAdmin ?? false)
defineExpose({
focusChapter: async (chapterNumber: number) => {
const target = chapters.value.find(ch => ch.chapter_number === chapterNumber)
if (target) {
await loadChapterDetail(chapterNumber)
}
}
})
</script>
<style scoped>
.line-clamp-3 {
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.line-clamp-4 {
display: -webkit-box;
-webkit-line-clamp: 4;
-webkit-box-orient: vertical;
overflow: hidden;
}
.line-clamp-6 {
display: -webkit-box;
-webkit-line-clamp: 6;
-webkit-box-orient: vertical;
overflow: hidden;
}
</style>
<script lang="ts">
import { defineComponent } from 'vue'
export default defineComponent({
name: 'ChaptersSection'
})
</script>

View File

@@ -0,0 +1,97 @@
<template>
<div class="space-y-6">
<div class="flex items-center justify-between">
<div>
<h2 class="text-2xl font-bold text-slate-900">主要角色</h2>
<p class="text-sm text-slate-500">了解故事中核心人物的目标与个性</p>
</div>
<button
v-if="editable"
type="button"
class="text-gray-400 hover:text-indigo-600 transition-colors"
@click="emitEdit('characters', '主要角色', data?.characters)">
<svg class="h-6 w-6" viewBox="0 0 20 20" fill="currentColor">
<path d="M17.414 2.586a2 2 0 00-2.828 0L7 10.172V13h2.828l7.586-7.586a2 2 0 000-2.828z" />
<path fill-rule="evenodd" d="M2 6a2 2 0 012-2h4a1 1 0 010 2H4v10h10v-4a1 1 0 112 0v4a2 2 0 01-2 2H4a2 2 0 01-2-2V6z" clip-rule="evenodd" />
</svg>
</button>
</div>
<div class="grid grid-cols-1 xl:grid-cols-2 gap-6">
<article
v-for="(character, index) in characters"
:key="index"
class="bg-white/95 rounded-2xl border border-slate-200 shadow-sm hover:shadow-lg transition-all duration-300">
<div class="p-6">
<div class="flex flex-col sm:flex-row sm:items-center gap-4 mb-4">
<div class="w-16 h-16 rounded-full bg-indigo-100 flex items-center justify-center text-indigo-600 text-lg font-semibold">
{{ character.name?.slice(0, 1) || '角' }}
</div>
<div>
<h3 class="text-xl font-bold text-slate-900">{{ character.name || '未命名角色' }}</h3>
<p v-if="character.identity" class="text-sm text-indigo-500 font-medium">{{ character.identity }}</p>
</div>
</div>
<dl class="space-y-3 text-sm text-slate-600">
<div v-if="character.personality">
<dt class="font-semibold text-slate-800 mb-1">性格</dt>
<dd class="leading-6">{{ character.personality }}</dd>
</div>
<div v-if="character.goals">
<dt class="font-semibold text-slate-800 mb-1">目标</dt>
<dd class="leading-6">{{ character.goals }}</dd>
</div>
<div v-if="character.abilities">
<dt class="font-semibold text-slate-800 mb-1">能力</dt>
<dd class="leading-6">{{ character.abilities }}</dd>
</div>
<div v-if="character.relationship_to_protagonist">
<dt class="font-semibold text-slate-800 mb-1">与主角的关系</dt>
<dd class="leading-6">{{ character.relationship_to_protagonist }}</dd>
</div>
</dl>
</div>
</article>
<div v-if="!characters.length" class="bg-white/95 rounded-2xl border border-dashed border-slate-300 p-10 text-center text-slate-400">
暂无角色信息
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, defineEmits, defineProps } from 'vue'
interface CharacterItem {
name?: string
identity?: string
personality?: string
goals?: string
abilities?: string
relationship_to_protagonist?: string
}
const props = defineProps<{
data: { characters?: CharacterItem[] } | null
editable?: boolean
}>()
const emit = defineEmits<{
(e: 'edit', payload: { field: string; title: string; value: any }): void
}>()
const characters = computed(() => props.data?.characters || [])
const emitEdit = (field: string, title: string, value: any) => {
if (!props.editable) return
emit('edit', { field, title, value })
}
</script>
<script lang="ts">
import { defineComponent } from 'vue'
export default defineComponent({
name: 'CharactersSection'
})
</script>

View File

@@ -0,0 +1,96 @@
<template>
<div class="space-y-6">
<div class="bg-white/95 rounded-2xl shadow-sm border border-slate-200 p-6">
<div class="flex items-start justify-between gap-4 mb-3">
<div>
<h3 class="text-sm font-semibold text-indigo-600 uppercase tracking-wide">核心摘要</h3>
<p class="text-gray-500 text-xs">快速了解项目的定位与调性</p>
</div>
<button
v-if="editable"
type="button"
class="text-gray-400 hover:text-indigo-600 transition-colors"
@click="emitEdit('one_sentence_summary', '核心摘要', data?.one_sentence_summary)">
<svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path d="M17.414 2.586a2 2 0 00-2.828 0L7 10.172V13h2.828l7.586-7.586a2 2 0 000-2.828z" />
<path fill-rule="evenodd" d="M2 6a2 2 0 012-2h4a1 1 0 010 2H4v10h10v-4a1 1 0 112 0v4a2 2 0 01-2 2H4a2 2 0 01-2-2V6z" clip-rule="evenodd" />
</svg>
</button>
</div>
<p class="text-slate-800 text-lg leading-relaxed min-h-[2.5rem]">{{ data?.one_sentence_summary || '暂无' }}</p>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-4">
<div class="bg-white/95 rounded-2xl shadow-sm border border-slate-200 p-4">
<h4 class="text-xs font-semibold text-slate-500 uppercase tracking-wide mb-2">目标受众</h4>
<p class="text-base font-medium text-slate-800 min-h-[1.5rem]">{{ data?.target_audience || '暂无' }}</p>
</div>
<div class="bg-white/95 rounded-2xl shadow-sm border border-slate-200 p-4">
<h4 class="text-xs font-semibold text-slate-500 uppercase tracking-wide mb-2">类型</h4>
<p class="text-base font-medium text-slate-800 min-h-[1.5rem]">{{ data?.genre || '暂无' }}</p>
</div>
<div class="bg-white/95 rounded-2xl shadow-sm border border-slate-200 p-4">
<h4 class="text-xs font-semibold text-slate-500 uppercase tracking-wide mb-2">风格</h4>
<p class="text-base font-medium text-slate-800 min-h-[1.5rem]">{{ data?.style || '暂无' }}</p>
</div>
<div class="bg-white/95 rounded-2xl shadow-sm border border-slate-200 p-4">
<h4 class="text-xs font-semibold text-slate-500 uppercase tracking-wide mb-2">基调</h4>
<p class="text-base font-medium text-slate-800 min-h-[1.5rem]">{{ data?.tone || '暂无' }}</p>
</div>
</div>
<div class="bg-white/95 rounded-2xl shadow-sm border border-slate-200 p-6">
<div class="flex items-start justify-between gap-4 mb-4">
<h3 class="text-lg font-semibold text-slate-900">完整剧情梗概</h3>
<button
v-if="editable"
type="button"
class="text-gray-400 hover:text-indigo-600 transition-colors"
@click="emitEdit('full_synopsis', '完整剧情梗概', data?.full_synopsis)">
<svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path d="M17.414 2.586a2 2 0 00-2.828 0L7 10.172V13h2.828l7.586-7.586a2 2 0 000-2.828z" />
<path fill-rule="evenodd" d="M2 6a2 2 0 012-2h4a1 1 0 010 2H4v10h10v-4a1 1 0 112 0v4a2 2 0 01-2 2H4a2 2 0 01-2-2V6z" clip-rule="evenodd" />
</svg>
</button>
</div>
<div class="prose prose-sm max-w-none text-slate-600 leading-7 whitespace-pre-line">
<p>{{ data?.full_synopsis || '暂无' }}</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { defineEmits, defineProps } from 'vue'
interface OverviewData {
one_sentence_summary?: string | null
target_audience?: string | null
genre?: string | null
style?: string | null
tone?: string | null
full_synopsis?: string | null
}
const props = defineProps<{
data: OverviewData | null
editable?: boolean
}>()
const emit = defineEmits<{
(e: 'edit', payload: { field: string; title: string; value: any }): void
}>()
const emitEdit = (field: string, title: string, value: any) => {
if (!props.editable) return
emit('edit', { field, title, value })
}
</script>
<script lang="ts">
import { defineComponent } from 'vue'
export default defineComponent({
name: 'OverviewSection'
})
</script>

View File

@@ -0,0 +1,85 @@
<template>
<div class="space-y-6">
<div class="flex items-center justify-between">
<div>
<h2 class="text-2xl font-bold text-slate-900">人物关系</h2>
<p class="text-sm text-slate-500">角色之间的纽带与冲突</p>
</div>
<button
v-if="editable"
type="button"
class="text-gray-400 hover:text-indigo-600 transition-colors"
@click="emitEdit('relationships', '人物关系', data?.relationships)">
<svg class="h-6 w-6" viewBox="0 0 20 20" fill="currentColor">
<path d="M17.414 2.586a2 2 0 00-2.828 0L7 10.172V13h2.828l7.586-7.586a2 2 0 000-2.828z" />
<path fill-rule="evenodd" d="M2 6a2 2 0 012-2h4a1 1 0 010 2H4v10h10v-4a1 1 0 112 0v4a2 2 0 01-2 2H4a2 2 0 01-2-2V6z" clip-rule="evenodd" />
</svg>
</button>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div
v-for="(relation, index) in relationships"
:key="index"
class="bg-white/95 rounded-2xl border border-slate-200 shadow-sm p-6">
<div class="flex items-center justify-between">
<div class="flex items-center space-x-3">
<div class="w-10 h-10 rounded-full bg-indigo-100 flex items-center justify-center text-indigo-600 font-semibold">
{{ relation.character_from?.slice(0, 1) || '角' }}
</div>
<span class="font-semibold text-slate-900 truncate">{{ relation.character_from || '未知角色' }}</span>
</div>
<svg class="text-slate-400" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14"/><path d="m12 5 7 7-7 7"/></svg>
<div class="flex items-center space-x-3">
<span class="font-semibold text-slate-900 truncate">{{ relation.character_to || '未知角色' }}</span>
<div class="w-10 h-10 rounded-full bg-emerald-100 flex items-center justify-center text-emerald-600 font-semibold">
{{ relation.character_to?.slice(0, 1) || '角' }}
</div>
</div>
</div>
<div class="mt-4 bg-slate-50 border border-slate-100 rounded-xl p-4 text-center">
<p class="text-sm font-semibold text-slate-700">{{ relation.relationship_type || '关系' }}</p>
<p class="text-xs text-slate-500 leading-5 mt-1">{{ relation.description || '暂无描述' }}</p>
</div>
</div>
<div v-if="!relationships.length" class="bg-white/95 rounded-2xl border border-dashed border-slate-300 p-10 text-center text-slate-400">
暂无人际关系信息
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, defineEmits, defineProps } from 'vue'
interface RelationshipItem {
character_from?: string
character_to?: string
relationship_type?: string
description?: string
}
const props = defineProps<{
data: { relationships?: RelationshipItem[] } | null
editable?: boolean
}>()
const emit = defineEmits<{
(e: 'edit', payload: { field: string; title: string; value: any }): void
}>()
const relationships = computed(() => props.data?.relationships || [])
const emitEdit = (field: string, title: string, value: any) => {
if (!props.editable) return
emit('edit', { field, title, value })
}
</script>
<script lang="ts">
import { defineComponent } from 'vue'
export default defineComponent({
name: 'RelationshipsSection'
})
</script>

View File

@@ -0,0 +1,130 @@
<template>
<div class="space-y-6">
<div class="bg-white/95 rounded-2xl shadow-sm border border-slate-200 p-6">
<div class="flex items-start justify-between gap-4 mb-4">
<h3 class="text-lg font-semibold text-slate-900">核心规则</h3>
<button
v-if="editable"
type="button"
class="text-gray-400 hover:text-indigo-600 transition-colors"
@click="emitEdit('world_setting.core_rules', '核心规则', worldSetting.core_rules)">
<svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path d="M17.414 2.586a2 2 0 00-2.828 0L7 10.172V13h2.828l7.586-7.586a2 2 0 000-2.828z" />
<path fill-rule="evenodd" d="M2 6a2 2 0 012-2h4a1 1 0 010 2H4v10h10v-4a1 1 0 112 0v4a2 2 0 01-2 2H4a2 2 0 01-2-2V6z" clip-rule="evenodd" />
</svg>
</button>
</div>
<p class="text-slate-600 leading-7 whitespace-pre-line">{{ worldSetting.core_rules || '暂无' }}</p>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div class="bg-white/95 rounded-2xl shadow-sm border border-slate-200 p-6">
<div class="flex items-center justify-between mb-4">
<div class="flex items-center text-slate-900 font-semibold">
<svg class="mr-2 text-indigo-500" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 22V4a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v18"/><path d="M6 18H4a2 2 0 0 1-2-2v-3a2 2 0 0 1 2-2h2v7Z"/><path d="M18 18h2a2 2 0 0 0 2-2v-3a2 2 0 0 0-2-2h-2v7Z"/></svg>
<span>关键地点</span>
</div>
<button
v-if="editable"
type="button"
class="text-gray-400 hover:text-indigo-600 transition-colors"
@click="emitEdit('world_setting.key_locations', '关键地点', worldSetting.key_locations)">
<svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path d="M17.414 2.586a2 2 0 00-2.828 0L7 10.172V13h2.828l7.586-7.586a2 2 0 000-2.828z" />
<path fill-rule="evenodd" d="M2 6a2 2 0 012-2h4a1 1 0 010 2H4v10h10v-4a1 1 0 112 0v4a2 2 0 01-2 2H4a2 2 0 01-2-2V6z" clip-rule="evenodd" />
</svg>
</button>
</div>
<ul class="space-y-4 text-sm text-slate-600">
<li v-for="(item, index) in locations" :key="index" class="bg-slate-50 border border-slate-100 rounded-xl p-4">
<strong class="block text-slate-800 mb-1">{{ item.title }}</strong>
<span class="text-xs text-slate-500 leading-5">{{ item.description }}</span>
</li>
<li v-if="!locations.length" class="text-slate-400 text-sm">暂无数据</li>
</ul>
</div>
<div class="bg-white/95 rounded-2xl shadow-sm border border-slate-200 p-6">
<div class="flex items-center justify-between mb-4">
<div class="flex items-center text-slate-900 font-semibold">
<svg class="mr-2 text-indigo-500" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
<span>主要阵营</span>
</div>
<button
v-if="editable"
type="button"
class="text-gray-400 hover:text-indigo-600 transition-colors"
@click="emitEdit('world_setting.factions', '主要阵营', worldSetting.factions)">
<svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path d="M17.414 2.586a2 2 0 00-2.828 0L7 10.172V13h2.828l7.586-7.586a2 2 0 000-2.828z" />
<path fill-rule="evenodd" d="M2 6a2 2 0 012-2h4a1 1 0 010 2H4v10h10v-4a1 1 0 112 0v4a2 2 0 01-2 2H4a2 2 0 01-2-2V6z" clip-rule="evenodd" />
</svg>
</button>
</div>
<ul class="space-y-4 text-sm text-slate-600">
<li v-for="(item, index) in factions" :key="index" class="bg-slate-50 border border-slate-100 rounded-xl p-4">
<strong class="block text-slate-800 mb-1">{{ item.title }}</strong>
<span class="text-xs text-slate-500 leading-5">{{ item.description }}</span>
</li>
<li v-if="!factions.length" class="text-slate-400 text-sm">暂无数据</li>
</ul>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, defineEmits, defineProps } from 'vue'
interface ListItem {
title: string
description: string
}
const props = defineProps<{
data: Record<string, any> | null
editable?: boolean
}>()
const emit = defineEmits<{
(e: 'edit', payload: { field: string; title: string; value: any }): void
}>()
const worldSetting = computed(() => props.data?.world_setting || {})
const normalizeList = (source: any): ListItem[] => {
if (!source) return []
if (Array.isArray(source)) {
return source.map((item: any) => {
if (typeof item === 'string') {
const [title, ...rest] = item.split('')
return {
title: title || item,
description: rest.join('') || '暂无描述'
}
}
return {
title: item?.name || '未命名',
description: item?.description || item?.details || '暂无描述'
}
})
}
return []
}
const locations = computed(() => normalizeList(worldSetting.value?.key_locations))
const factions = computed(() => normalizeList(worldSetting.value?.factions))
const emitEdit = (field: string, title: string, value: any) => {
if (!props.editable) return
emit('edit', { field, title, value })
}
</script>
<script lang="ts">
import { defineComponent } from 'vue'
export default defineComponent({
name: 'WorldSettingSection'
})
</script>