新增角色管理、世界观设定、章节管理和AI工具调用功能

- 角色管理:CRUD API + 卡片网格UI,每个角色约200字描述
- 世界观设定:单篇长文编辑器,建议500字以上
- 章节管理:CRUD + 排序 API,左右分栏编辑器,单章5000字上限
- AI工具调用:支持工具审批流程和结构化工具调用
- 小说详情页新增章节、角色、世界观入口按钮

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-21 13:58:17 +08:00
parent 580dc8be52
commit 20c759150a
34 changed files with 3495 additions and 127 deletions

View File

@@ -0,0 +1,212 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { fetchNovel } from '@/api/novels'
import { fetchWorldSetting, saveWorldSetting } from '@/api/worldSetting'
import { useToast } from '@/composables/useToast'
import type { Novel } from '@/types/novel'
import BaseButton from '@/components/ui/BaseButton.vue'
import BaseTextarea from '@/components/ui/BaseTextarea.vue'
const route = useRoute()
const router = useRouter()
const toast = useToast()
const novelId = Number(route.params.id)
const novel = ref<Novel | null>(null)
const content = ref('')
const loading = ref(true)
const saving = ref(false)
const hasChanges = ref(false)
const contentLength = computed(() => content.value.length)
const contentHint = computed(() => {
if (contentLength.value === 0) return '建议至少 500 字'
if (contentLength.value < 500) return `${contentLength.value} 字,建议至少 500 字`
return `${contentLength.value}`
})
async function load() {
loading.value = true
try {
const [n, ws] = await Promise.all([
fetchNovel(novelId),
fetchWorldSetting(novelId),
])
novel.value = n
content.value = ws.content
} catch {
toast.error('加载失败')
router.push({ name: 'home' })
} finally {
loading.value = false
}
}
function onInput() {
hasChanges.value = true
}
async function handleSave() {
saving.value = true
try {
await saveWorldSetting(novelId, { content: content.value })
hasChanges.value = false
toast.success('世界观已保存')
} catch {
toast.error('保存失败')
} finally {
saving.value = false
}
}
onMounted(load)
</script>
<template>
<div v-if="!loading && novel" class="world-view">
<div class="world-header">
<BaseButton variant="ghost" @click="router.push({ name: 'novel-detail', params: { id: novelId } })">
&larr; 返回
</BaseButton>
<div class="header-center">
<h1 class="world-title">{{ novel.title }}</h1>
<p class="world-subtitle">世界观设定</p>
</div>
<BaseButton
variant="primary"
:loading="saving"
:disabled="!hasChanges"
@click="handleSave"
>
保存
</BaseButton>
</div>
<div class="editor-container">
<div class="editor-hint">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="1.2" />
<path d="M8 4v5M8 11v1" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" />
</svg>
<span>描述故事发生的世界背景历史规则地理环境等设定</span>
</div>
<BaseTextarea
v-model="content"
placeholder="在此描述你的故事世界..."
:rows="16"
:maxlength="5000"
:max-height="600"
@input="onInput"
/>
<div class="editor-footer">
<span class="word-count" :class="{ 'word-count--low': contentLength < 500 && contentLength > 0 }">
{{ contentHint }}
</span>
<span v-if="hasChanges" class="unsaved-hint">未保存的更改</span>
</div>
</div>
</div>
<div v-else-if="loading" class="world-loading">
<div class="loading-pulse" style="height: 2rem; width: 40%; margin: 0 auto var(--space-lg)" />
<div class="loading-pulse" style="height: 1rem; width: 20%; margin: 0 auto var(--space-2xl)" />
<div class="loading-pulse" style="height: 300px; width: 100%" />
</div>
</template>
<style scoped>
.world-view {
max-width: 800px;
margin: 0 auto;
animation: fadeInUp var(--duration-normal) var(--ease-out-smooth) both;
}
.world-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-md);
margin-bottom: var(--space-2xl);
}
.header-center {
flex: 1;
text-align: center;
}
.world-title {
font-family: var(--font-display);
font-size: 1.8rem;
font-weight: 700;
color: var(--ink-900);
line-height: 1.3;
}
.world-subtitle {
font-size: 0.85rem;
color: var(--ink-400);
margin-top: var(--space-xs);
}
.editor-container {
background: var(--paper-50);
border: 1px solid var(--paper-200);
border-radius: var(--radius-md);
padding: var(--space-xl);
}
.editor-hint {
display: flex;
align-items: center;
gap: var(--space-sm);
font-size: 0.85rem;
color: var(--ink-400);
margin-bottom: var(--space-lg);
padding: var(--space-sm) var(--space-md);
background: var(--paper-100);
border-radius: var(--radius-sm);
}
.editor-footer {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: var(--space-sm);
}
.word-count {
font-size: 0.75rem;
color: var(--ink-300);
}
.word-count--low {
color: var(--vermilion);
}
.unsaved-hint {
font-size: 0.75rem;
color: var(--vermilion);
font-style: italic;
}
/* Loading */
.world-loading {
max-width: 800px;
margin: var(--space-3xl) auto;
}
.loading-pulse {
background: linear-gradient(
90deg,
var(--paper-100) 25%,
var(--paper-200) 50%,
var(--paper-100) 75%
);
background-size: 200% 100%;
border-radius: var(--radius-sm);
animation: shimmer 1.5s ease infinite;
}
</style>