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

6
frontend/.dockerignore Normal file
View File

@@ -0,0 +1,6 @@
node_modules
dist
.git
.gitignore
*.md
.env*

1
frontend/.gitattributes vendored Normal file
View File

@@ -0,0 +1 @@
* text=auto eol=lf

30
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,30 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.DS_Store
dist
dist-ssr
coverage
*.local
/cypress/videos/
/cypress/screenshots/
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
*.tsbuildinfo

View File

@@ -0,0 +1,6 @@
{
"$schema": "https://json.schemastore.org/prettierrc",
"semi": false,
"singleQuote": true,
"printWidth": 100
}

1
frontend/env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

14
frontend/index.html Normal file
View File

@@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>拯救小说家</title>
<link rel="stylesheet" href="/src/assets/main.css">
</head>
<body class="bg-[#F8F7F2] text-[#333] transition-colors duration-500">
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

4424
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

45
frontend/package.json Normal file
View File

@@ -0,0 +1,45 @@
{
"name": "mynovel",
"version": "0.0.0",
"private": true,
"type": "module",
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"scripts": {
"dev": "vite",
"build": "run-p type-check \"build-only {@}\" --",
"preview": "vite preview",
"build-only": "vite build",
"type-check": "vue-tsc --build",
"format": "prettier --write src/"
},
"dependencies": {
"@fontsource/noto-sans-sc": "^5.2.8",
"@headlessui/vue": "^1.7.23",
"@types/marked": "^5.0.2",
"naive-ui": "^2.39.0",
"marked": "^16.3.0",
"pinia": "^3.0.3",
"vue": "^3.5.18",
"vue-router": "^4.5.1"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.1.13",
"@tailwindcss/typography": "^0.5.18",
"@tsconfig/node22": "^22.0.2",
"@types/node": "^22.16.5",
"@vitejs/plugin-vue": "^6.0.1",
"@vitejs/plugin-vue-jsx": "^5.0.1",
"@vue/tsconfig": "^0.7.0",
"autoprefixer": "^10.4.21",
"npm-run-all2": "^8.0.4",
"postcss": "^8.5.6",
"prettier": "3.6.2",
"tailwindcss": "^4.1.13",
"typescript": "~5.8.0",
"vite": "^7.0.6",
"vite-plugin-vue-devtools": "^8.0.0",
"vue-tsc": "^3.0.4"
}
}

View File

@@ -0,0 +1,7 @@
export default {
plugins: {
'@tailwindcss/postcss': {},
'@tailwindcss/typography': {},
autoprefixer: {},
},
}

BIN
frontend/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

30
frontend/src/App.vue Normal file
View File

@@ -0,0 +1,30 @@
<script setup lang="ts">
import { RouterView } from 'vue-router'
import CustomAlert from '@/components/CustomAlert.vue'
import { globalAlert } from '@/composables/useAlert'
</script>
<template>
<div>
<RouterView />
<!-- 全局提示框 -->
<CustomAlert
v-for="alert in globalAlert.alerts.value"
:key="alert.id"
:visible="alert.visible"
:type="alert.type"
:title="alert.title"
:message="alert.message"
:show-cancel="alert.showCancel"
:confirm-text="alert.confirmText"
:cancel-text="alert.cancelText"
@confirm="globalAlert.closeAlert(alert.id, true)"
@cancel="globalAlert.closeAlert(alert.id, false)"
@close="globalAlert.closeAlert(alert.id, false)"
/>
</div>
</template>
<style scoped>
</style>

271
frontend/src/api/admin.ts Normal file
View File

@@ -0,0 +1,271 @@
import { useAuthStore } from '@/stores/auth'
import router from '@/router'
import type { NovelSectionResponse, NovelSectionType } from '@/api/novel'
// API 配置
export const API_BASE_URL = import.meta.env.MODE === 'production' ? '' : 'http://127.0.0.1:8000'
export const ADMIN_API_PREFIX = '/api/admin'
// 统一请求封装
const request = async (url: string, options: RequestInit = {}) => {
const authStore = useAuthStore()
const headers = new Headers({
'Content-Type': 'application/json',
...options.headers
})
if (authStore.isAuthenticated && authStore.token) {
headers.set('Authorization', `Bearer ${authStore.token}`)
}
const response = await fetch(url, { ...options, headers })
if (response.status === 401) {
authStore.logout()
router.push('/login')
throw new Error('会话已过期,请重新登录')
}
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
throw new Error(errorData.detail || `请求失败,状态码: ${response.status}`)
}
if (response.status === 204) {
return
}
return response.json()
}
const adminRequest = (path: string, options: RequestInit = {}) =>
request(`${API_BASE_URL}${ADMIN_API_PREFIX}${path}`, options)
// 类型定义
export interface Statistics {
novel_count: number
user_count: number
api_request_count: number
}
export interface AdminUser {
id: number
username: string
email?: string | null
is_admin: boolean
}
export interface NovelProjectSummary {
id: string
title: string
genre: string
last_edited: string
completed_chapters: number
total_chapters: number
}
export interface AdminNovelSummary extends NovelProjectSummary {
owner_id: number
owner_username: string
}
export interface Chapter {
chapter_number: number
title: string
summary: string
content?: string | null
status?: string
version_id?: string | number | null
versions?: any[]
word_count?: number
}
export interface NovelProject {
id: string
user_id: number
title: string
initial_prompt: string
conversation_history: any[]
blueprint?: any
chapters: Chapter[]
}
export interface PromptItem {
id: number
name: string
title?: string | null
content: string
tags?: string[] | null
}
export interface PromptCreatePayload {
name: string
content: string
title?: string
tags?: string[]
}
export type PromptUpdatePayload = Partial<Omit<PromptCreatePayload, 'name'>>
export interface UpdateLog {
id: number
content: string
created_at: string
created_by?: string | null
is_pinned: boolean
}
export interface UpdateLogPayload {
content?: string
is_pinned?: boolean
}
export interface DailyRequestLimit {
limit: number
}
export interface SystemConfig {
key: string
value: string
description?: string | null
}
export interface SystemConfigUpsertPayload {
value: string
description?: string | null
}
export type SystemConfigUpdatePayload = Partial<SystemConfigUpsertPayload>
export class AdminAPI {
private static request(path: string, options: RequestInit = {}) {
return adminRequest(path, options)
}
// Overview
static getStatistics(): Promise<Statistics> {
return this.request('/stats')
}
// Users
static listUsers(): Promise<AdminUser[]> {
return this.request('/users')
}
// Novels
static listNovels(): Promise<AdminNovelSummary[]> {
return this.request('/novel-projects')
}
static getNovelDetails(projectId: string): Promise<NovelProject> {
return this.request(`/novel-projects/${projectId}`)
}
static getNovelSection(projectId: string, section: NovelSectionType): Promise<NovelSectionResponse> {
return this.request(`/novel-projects/${projectId}/sections/${section}`)
}
static getNovelChapter(projectId: string, chapterNumber: number): Promise<Chapter> {
return this.request(`/novel-projects/${projectId}/chapters/${chapterNumber}`)
}
// Prompts
static listPrompts(): Promise<PromptItem[]> {
return this.request('/prompts')
}
static createPrompt(payload: PromptCreatePayload): Promise<PromptItem> {
return this.request('/prompts', {
method: 'POST',
body: JSON.stringify(payload)
})
}
static getPrompt(id: number): Promise<PromptItem> {
return this.request(`/prompts/${id}`)
}
static updatePrompt(id: number, payload: PromptUpdatePayload): Promise<PromptItem> {
return this.request(`/prompts/${id}`, {
method: 'PATCH',
body: JSON.stringify(payload)
})
}
static deletePrompt(id: number): Promise<void> {
return this.request(`/prompts/${id}`, {
method: 'DELETE'
})
}
// Update logs
static listUpdateLogs(): Promise<UpdateLog[]> {
return this.request('/update-logs')
}
static createUpdateLog(payload: UpdateLogPayload & { content: string }): Promise<UpdateLog> {
return this.request('/update-logs', {
method: 'POST',
body: JSON.stringify(payload)
})
}
static updateUpdateLog(id: number, payload: UpdateLogPayload): Promise<UpdateLog> {
return this.request(`/update-logs/${id}`, {
method: 'PATCH',
body: JSON.stringify(payload)
})
}
static deleteUpdateLog(id: number): Promise<void> {
return this.request(`/update-logs/${id}`, {
method: 'DELETE'
})
}
// Settings
static getDailyRequestLimit(): Promise<DailyRequestLimit> {
return this.request('/settings/daily-request-limit')
}
static setDailyRequestLimit(limit: number): Promise<DailyRequestLimit> {
return this.request('/settings/daily-request-limit', {
method: 'PUT',
body: JSON.stringify({ limit })
})
}
static listSystemConfigs(): Promise<SystemConfig[]> {
return this.request('/system-configs')
}
static upsertSystemConfig(key: string, payload: SystemConfigUpsertPayload): Promise<SystemConfig> {
return this.request(`/system-configs/${key}`, {
method: 'PUT',
body: JSON.stringify({ key, ...payload })
})
}
static patchSystemConfig(key: string, payload: SystemConfigUpdatePayload): Promise<SystemConfig> {
return this.request(`/system-configs/${key}`, {
method: 'PATCH',
body: JSON.stringify(payload)
})
}
static deleteSystemConfig(key: string): Promise<void> {
return this.request(`/system-configs/${key}`, {
method: 'DELETE'
})
}
static changePassword(oldPassword: string, newPassword: string): Promise<void> {
return this.request('/password', {
method: 'POST',
body: JSON.stringify({
old_password: oldPassword,
new_password: newPassword
})
})
}
}

61
frontend/src/api/llm.ts Normal file
View File

@@ -0,0 +1,61 @@
import { useAuthStore } from '@/stores/auth';
const API_PREFIX = '/api';
const LLM_BASE = `${API_PREFIX}/llm-config`;
export interface LLMConfig {
user_id: number;
llm_provider_url: string | null;
llm_provider_api_key: string | null;
llm_provider_model: string | null;
}
export interface LLMConfigCreate {
llm_provider_url?: string;
llm_provider_api_key?: string;
llm_provider_model?: string;
}
const getHeaders = () => {
const authStore = useAuthStore();
return {
'Content-Type': 'application/json',
'Authorization': `Bearer ${authStore.token}`,
};
};
export const getLLMConfig = async (): Promise<LLMConfig | null> => {
const response = await fetch(LLM_BASE, {
method: 'GET',
headers: getHeaders(),
});
if (response.status === 404) {
return null;
}
if (!response.ok) {
throw new Error('Failed to fetch LLM config');
}
return response.json();
};
export const createOrUpdateLLMConfig = async (config: LLMConfigCreate): Promise<LLMConfig> => {
const response = await fetch(LLM_BASE, {
method: 'PUT',
headers: getHeaders(),
body: JSON.stringify(config),
});
if (!response.ok) {
throw new Error('Failed to save LLM config');
}
return response.json();
};
export const deleteLLMConfig = async (): Promise<void> => {
const response = await fetch(LLM_BASE, {
method: 'DELETE',
headers: getHeaders(),
});
if (!response.ok) {
throw new Error('Failed to delete LLM config');
}
};

292
frontend/src/api/novel.ts Normal file
View File

@@ -0,0 +1,292 @@
import { useAuthStore } from '@/stores/auth'
import router from '@/router'
// API 配置
// 在生产环境中使用相对路径,在开发环境中使用绝对路径
export const API_BASE_URL = import.meta.env.MODE === 'production' ? '' : 'http://127.0.0.1:8000'
export const API_PREFIX = '/api'
// 统一的请求处理函数
const request = async (url: string, options: RequestInit = {}) => {
const authStore = useAuthStore()
const headers = new Headers({
'Content-Type': 'application/json',
...options.headers
})
if (authStore.isAuthenticated && authStore.token) {
headers.set('Authorization', `Bearer ${authStore.token}`)
}
const response = await fetch(url, { ...options, headers })
if (response.status === 401) {
// Token 失效或未授权
authStore.logout()
router.push('/login')
throw new Error('会话已过期,请重新登录')
}
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
throw new Error(errorData.detail || `请求失败,状态码: ${response.status}`)
}
return response.json()
}
// 类型定义
export interface NovelProject {
id: string
title: string
initial_prompt: string
blueprint?: Blueprint
chapters: Chapter[]
conversation_history: ConversationMessage[]
}
export interface NovelProjectSummary {
id: string
title: string
genre: string
last_edited: string
completed_chapters: number
total_chapters: number
}
export interface Blueprint {
title?: string
target_audience?: string
genre?: string
style?: string
tone?: string
one_sentence_summary?: string
full_synopsis?: string
world_setting?: any
characters?: Character[]
relationships?: any[]
chapter_outline?: ChapterOutline[]
}
export interface Character {
name: string
description: string
identity?: string
personality?: string
goals?: string
abilities?: string
relationship_to_protagonist?: string
}
export interface ChapterOutline {
chapter_number: number
title: string
summary: string
}
export interface ChapterVersion {
content: string
style?: string
}
export interface Chapter {
chapter_number: number
title: string
summary: string
content: string | null
versions: string[] | null // versions是字符串数组不是对象数组
evaluation: string | null
generation_status: 'not_generated' | 'generating' | 'evaluating' | 'selecting' | 'failed' | 'evaluation_failed' | 'waiting_for_confirm' | 'successful'
word_count?: number // 字数统计
}
export interface ConversationMessage {
role: 'user' | 'assistant'
content: string
}
export interface ConverseResponse {
ai_message: string
ui_control: UIControl
conversation_state: any
is_complete: boolean
ready_for_blueprint?: boolean // 新增:表示准备生成蓝图
}
export interface BlueprintGenerationResponse {
blueprint: Blueprint
ai_message: string
}
export interface UIControl {
type: 'single_choice' | 'text_input'
options?: Array<{ id: string; label: string }>
placeholder?: string
}
export interface ChapterGenerationResponse {
versions: ChapterVersion[] // Renamed from chapter_versions for consistency
evaluation: string | null
ai_message: string
chapter_number: number
}
export interface DeleteNovelsResponse {
status: string
message: string
}
export type NovelSectionType = 'overview' | 'world_setting' | 'characters' | 'relationships' | 'chapter_outline' | 'chapters'
export interface NovelSectionResponse {
section: NovelSectionType
data: Record<string, any>
}
// API 函数
const NOVELS_BASE = `${API_BASE_URL}${API_PREFIX}/novels`
const WRITER_PREFIX = '/api/writer'
const WRITER_BASE = `${API_BASE_URL}${WRITER_PREFIX}/novels`
export class NovelAPI {
static async createNovel(title: string, initialPrompt: string): Promise<NovelProject> {
return request(NOVELS_BASE, {
method: 'POST',
body: JSON.stringify({ title, initial_prompt: initialPrompt })
})
}
static async getNovel(projectId: string): Promise<NovelProject> {
return request(`${NOVELS_BASE}/${projectId}`)
}
static async getChapter(projectId: string, chapterNumber: number): Promise<Chapter> {
return request(`${NOVELS_BASE}/${projectId}/chapters/${chapterNumber}`)
}
static async getSection(projectId: string, section: NovelSectionType): Promise<NovelSectionResponse> {
return request(`${NOVELS_BASE}/${projectId}/sections/${section}`)
}
static async converseConcept(
projectId: string,
userInput: any,
conversationState: any = {}
): Promise<ConverseResponse> {
const formattedUserInput = userInput || { id: null, value: null }
return request(`${NOVELS_BASE}/${projectId}/concept/converse`, {
method: 'POST',
body: JSON.stringify({
user_input: formattedUserInput,
conversation_state: conversationState
})
})
}
static async generateBlueprint(projectId: string): Promise<BlueprintGenerationResponse> {
return request(`${NOVELS_BASE}/${projectId}/blueprint/generate`, {
method: 'POST'
})
}
static async saveBlueprint(projectId: string, blueprint: Blueprint): Promise<NovelProject> {
return request(`${NOVELS_BASE}/${projectId}/blueprint/save`, {
method: 'POST',
body: JSON.stringify(blueprint)
})
}
static async generateChapter(projectId: string, chapterNumber: number): Promise<NovelProject> {
return request(`${WRITER_BASE}/${projectId}/chapters/generate`, {
method: 'POST',
body: JSON.stringify({ chapter_number: chapterNumber })
})
}
static async evaluateChapter(projectId: string, chapterNumber: number): Promise<NovelProject> {
return request(`${WRITER_BASE}/${projectId}/chapters/evaluate`, {
method: 'POST',
body: JSON.stringify({ chapter_number: chapterNumber })
})
}
static async selectChapterVersion(
projectId: string,
chapterNumber: number,
versionIndex: number
): Promise<NovelProject> {
return request(`${WRITER_BASE}/${projectId}/chapters/select`, {
method: 'POST',
body: JSON.stringify({
chapter_number: chapterNumber,
version_index: versionIndex
})
})
}
static async getAllNovels(): Promise<NovelProjectSummary[]> {
return request(NOVELS_BASE)
}
static async deleteNovels(projectIds: string[]): Promise<DeleteNovelsResponse> {
return request(NOVELS_BASE, {
method: 'DELETE',
body: JSON.stringify(projectIds)
})
}
static async updateChapterOutline(
projectId: string,
chapterOutline: ChapterOutline
): Promise<NovelProject> {
return request(`${WRITER_BASE}/${projectId}/chapters/update-outline`, {
method: 'POST',
body: JSON.stringify(chapterOutline)
})
}
static async deleteChapter(
projectId: string,
chapterNumbers: number[]
): Promise<NovelProject> {
return request(`${WRITER_BASE}/${projectId}/chapters/delete`, {
method: 'POST',
body: JSON.stringify({ chapter_numbers: chapterNumbers })
})
}
static async generateChapterOutline(
projectId: string,
startChapter: number,
numChapters: number
): Promise<NovelProject> {
return request(`${WRITER_BASE}/${projectId}/chapters/outline`, {
method: 'POST',
body: JSON.stringify({
start_chapter: startChapter,
num_chapters: numChapters
})
})
}
static async updateBlueprint(projectId: string, data: Record<string, any>): Promise<NovelProject> {
return request(`${NOVELS_BASE}/${projectId}/blueprint`, {
method: 'PATCH',
body: JSON.stringify(data)
})
}
static async editChapterContent(
projectId: string,
chapterNumber: number,
content: string
): Promise<NovelProject> {
return request(`${WRITER_BASE}/${projectId}/chapters/edit`, {
method: 'POST',
body: JSON.stringify({
chapter_number: chapterNumber,
content: content
})
})
}
}

View File

@@ -0,0 +1,29 @@
// Using a relative path to avoid potential alias issues
import { API_BASE_URL } from './admin';
// A simplified request function for public endpoints that don't require authentication.
const publicRequest = async (url: string, options: RequestInit = {}) => {
const response = await fetch(url, { ...options });
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.detail || `Request failed, status code: ${response.status}`);
}
// For DELETE requests which might not have a body
if (response.status === 204) {
return;
}
return response.json();
};
export interface UpdateLog {
id: number;
content: string;
created_at: string;
}
export const getLatestUpdates = (): Promise<UpdateLog[]> => {
return publicRequest(`${API_BASE_URL}/api/updates/latest`);
};

View File

@@ -0,0 +1,86 @@
/* color palette from <https://github.com/vuejs/theme> */
:root {
--vt-c-white: #ffffff;
--vt-c-white-soft: #f8f8f8;
--vt-c-white-mute: #f2f2f2;
--vt-c-black: #181818;
--vt-c-black-soft: #222222;
--vt-c-black-mute: #282828;
--vt-c-indigo: #2c3e50;
--vt-c-divider-light-1: rgba(60, 60, 60, 0.29);
--vt-c-divider-light-2: rgba(60, 60, 60, 0.12);
--vt-c-divider-dark-1: rgba(84, 84, 84, 0.65);
--vt-c-divider-dark-2: rgba(84, 84, 84, 0.48);
--vt-c-text-light-1: var(--vt-c-indigo);
--vt-c-text-light-2: rgba(60, 60, 60, 0.66);
--vt-c-text-dark-1: var(--vt-c-white);
--vt-c-text-dark-2: rgba(235, 235, 235, 0.64);
}
/* semantic color variables for this project */
:root {
--color-background: var(--vt-c-white);
--color-background-soft: var(--vt-c-white-soft);
--color-background-mute: var(--vt-c-white-mute);
--color-border: var(--vt-c-divider-light-2);
--color-border-hover: var(--vt-c-divider-light-1);
--color-heading: var(--vt-c-text-light-1);
--color-text: var(--vt-c-text-light-1);
--section-gap: 160px;
}
@media (prefers-color-scheme: dark) {
:root {
--color-background: var(--vt-c-black);
--color-background-soft: var(--vt-c-black-soft);
--color-background-mute: var(--vt-c-black-mute);
--color-border: var(--vt-c-divider-dark-2);
--color-border-hover: var(--vt-c-divider-dark-1);
--color-heading: var(--vt-c-text-dark-1);
--color-text: var(--vt-c-text-dark-2);
}
}
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
font-weight: normal;
}
body {
min-height: 100vh;
color: var(--color-text);
background: var(--color-background);
transition:
color 0.5s,
background-color 0.5s;
line-height: 1.6;
font-family:
Inter,
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
Oxygen,
Ubuntu,
Cantarell,
'Fira Sans',
'Droid Sans',
'Helvetica Neue',
sans-serif;
font-size: 15px;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 261.76 226.69"><path d="M161.096.001l-30.225 52.351L100.647.001H-.005l130.877 226.688L261.749.001z" fill="#41b883"/><path d="M161.096.001l-30.225 52.351L100.647.001H52.346l78.526 136.01L209.398.001z" fill="#34495e"/></svg>

After

Width:  |  Height:  |  Size: 276 B

View File

@@ -0,0 +1,88 @@
@import "tailwindcss";
@plugin "@tailwindcss/typography";
/* 原始frontend-demo的样式直接复制 */
body {
font-family: 'Noto Sans SC', 'Inter', sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background-color: #F8F7F2; /* A light, neutral background */
}
/* 自定义滚动条 */
::-webkit-scrollbar { width: 8px; }
::-webkit-scrollbar-track { background: #f1f1f1; }
::-webkit-scrollbar-thumb { background: #d1d5db; border-radius: 10px; }
::-webkit-scrollbar-thumb:hover { background: #a3a8b0; }
/* 加载动画 */
.loader {
border: 4px solid #f3f3f3;
border-top: 4px solid #6366f1;
border-radius: 50%;
width: 40px;
height: 40px;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* 元素渐入动画 */
.fade-in {
animation: fadeIn 0.8s ease-in-out;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
/* 聊天气泡样式 */
.chat-bubble-ai {
background-color: #f3f4f6;
color: #1f2937;
border-radius: 20px 20px 20px 5px;
}
.chat-bubble-user {
background-color: #6366f1;
color: #ffffff;
border-radius: 20px 20px 5px 20px;
}
@layer base {
button,
a[href] {
cursor: pointer;
}
}
/* Markdown 排版优化(配合 Typography 插件的 prose 类) */
.prose {
max-width: none;
}
/* 代码块与内联代码 */
.prose pre {
overflow: auto;
}
.prose code {
white-space: pre-wrap;
word-break: break-word;
}
/* 表格滚动 */
.prose table {
display: block;
width: max-content;
max-width: 100%;
overflow-x: auto;
}
/* 图片自适应 */
.prose img {
max-width: 100%;
height: auto;
border-radius: 0.375rem; /* rounded-md */
}

View File

@@ -0,0 +1,81 @@
<template>
<div class="bg-white rounded-lg shadow-sm p-6">
<h3 class="text-lg font-semibold text-gray-800 mb-4">小说蓝图</h3>
<div v-if="!blueprint" class="text-gray-500 text-center py-8">
暂无蓝图信息
</div>
<div v-else class="space-y-4">
<!-- 基本信息 -->
<div class="grid grid-cols-2 gap-4 text-sm">
<div>
<span class="font-medium text-gray-600">类型</span>
<span class="text-gray-800">{{ blueprint.genre || '未指定' }}</span>
</div>
<div>
<span class="font-medium text-gray-600">风格</span>
<span class="text-gray-800">{{ blueprint.style || '未指定' }}</span>
</div>
<div>
<span class="font-medium text-gray-600">基调</span>
<span class="text-gray-800">{{ blueprint.tone || '未指定' }}</span>
</div>
<div>
<span class="font-medium text-gray-600">目标读者</span>
<span class="text-gray-800">{{ blueprint.target_audience || '未指定' }}</span>
</div>
</div>
<!-- 一句话总结 -->
<div v-if="blueprint.one_sentence_summary">
<h4 class="font-medium text-gray-600 mb-2">一句话总结</h4>
<p class="text-gray-800 text-sm">{{ blueprint.one_sentence_summary }}</p>
</div>
<!-- 主要角色 -->
<div v-if="blueprint.characters && blueprint.characters.length > 0">
<h4 class="font-medium text-gray-600 mb-2">主要角色</h4>
<div class="space-y-2">
<div
v-for="character in blueprint.characters"
:key="character.name"
class="text-sm"
>
<span class="font-medium text-gray-800">{{ character.name }}:</span>
<span class="text-gray-600 ml-1">{{ character.description }}</span>
</div>
</div>
</div>
<!-- 展开按钮 -->
<button
@click="showDetails = !showDetails"
class="text-indigo-600 hover:text-indigo-800 text-sm font-medium"
>
{{ showDetails ? '收起详情' : '查看详情' }}
</button>
<!-- 详细信息 -->
<div v-if="showDetails" class="space-y-4 pt-4 border-t">
<div v-if="blueprint.full_synopsis">
<h4 class="font-medium text-gray-600 mb-2">完整简介</h4>
<p class="text-gray-800 text-sm leading-relaxed">{{ blueprint.full_synopsis }}</p>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import type { Blueprint } from '@/api/novel'
interface Props {
blueprint: Blueprint | undefined
}
defineProps<Props>()
const showDetails = ref(false)
</script>

View File

@@ -0,0 +1,293 @@
<template>
<div class="p-8 bg-white rounded-2xl shadow-2xl fade-in">
<h2 class="text-3xl font-bold text-center text-gray-800 mb-6">信息收集完成</h2>
<div class="text-center mb-8">
<p class="text-lg text-gray-600 mb-4">{{ aiMessage }}</p>
<p class="text-sm text-gray-500">
我们已经收集了足够的信息来为您创建详细的小说蓝图点击下方按钮开始生成您的专属故事大纲
</p>
</div>
<!-- 高级加载状态 -->
<div v-if="isGenerating" class="text-center py-12">
<!-- 主加载动画 -->
<div class="relative mx-auto mb-8 w-24 h-24">
<!-- 外圆环 -->
<div
class="absolute inset-0 border-4 rounded-full transition-colors duration-500"
:class="progress >= 100 ? 'border-green-100' : 'border-indigo-100'"
></div>
<!-- 旋转的渐变圆环 -->
<div
class="absolute inset-0 border-4 border-transparent rounded-full transition-colors duration-500"
:class="[
progress >= 100
? 'border-t-green-500 border-r-green-400'
: 'border-t-indigo-500 border-r-indigo-400',
progress < 100 ? 'animate-spin' : ''
]"
></div>
<!-- 内部脉冲圆 -->
<div
class="absolute inset-3 rounded-full animate-pulse opacity-20 transition-colors duration-500"
:class="progress >= 100 ? 'bg-green-500' : 'bg-indigo-500'"
></div>
<!-- 中心图标 -->
<div
class="absolute inset-6 rounded-full flex items-center justify-center transition-colors duration-500"
:class="progress >= 100 ? 'bg-green-500' : 'bg-indigo-500'"
>
<svg
v-if="progress >= 100"
class="w-6 h-6 text-white"
fill="currentColor"
viewBox="0 0 20 20"
>
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"></path>
</svg>
<svg
v-else
class="w-6 h-6 text-white animate-pulse"
fill="currentColor"
viewBox="0 0 20 20"
>
<path d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
</div>
</div>
<!-- 加载文本和进度 -->
<div class="space-y-4">
<h3 class="text-xl font-semibold text-gray-800 animate-pulse">{{ loadingText }}</h3>
<p class="text-gray-600">AI正在为您精心打造独特的故事蓝图...</p>
<!-- 进度条 -->
<div class="w-full max-w-md mx-auto">
<!-- <div class="flex justify-between text-xs text-gray-500 mb-2">
<span>生成进度</span>
<span>{{ Math.round(progress) }}%</span>
</div> -->
<div class="w-full bg-gray-200 rounded-full h-2 overflow-hidden">
<div
class="h-2 rounded-full transition-all duration-1000 ease-out relative"
:class="progress >= 100 ? 'bg-gradient-to-r from-green-500 to-emerald-600' : 'bg-gradient-to-r from-indigo-500 to-purple-600'"
:style="{ width: `${progress}%` }"
>
<!-- 闪光效果 -->
<div class="absolute inset-0 bg-gradient-to-r from-transparent via-white to-transparent opacity-30 animate-shimmer"></div>
</div>
</div>
</div>
<!-- 倒计时 -->
<!-- <div class="text-sm text-gray-500">
<span>预计完成时间: {{ timeRemaining }}</span>
</div> -->
<!-- 温馨提示 -->
<div class="mt-6 p-4 bg-blue-50 rounded-lg border border-blue-200">
<p class="text-sm text-blue-800">
<svg class="inline w-4 h-4 mr-2" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd"></path>
</svg>
AI正在分析您的创意偏好生成过程需要一些时间请耐心等待...
</p>
</div>
</div>
</div>
<!-- 操作按钮 -->
<div v-else class="text-center space-x-4">
<!-- <button
@click="$emit('back')"
class="bg-gray-200 text-gray-700 font-bold py-3 px-8 rounded-full hover:bg-gray-300 transition-all duration-300 transform hover:scale-105"
>
返回对话
</button> -->
<button
@click="generateBlueprint"
:disabled="isGenerating"
class="bg-gradient-to-r from-indigo-500 to-purple-600 text-white font-bold py-3 px-8 rounded-full hover:from-indigo-600 hover:to-purple-700 transition-all duration-300 transform hover:scale-105 shadow-lg hover:shadow-xl disabled:opacity-50 disabled:cursor-not-allowed disabled:transform-none"
>
<span class="flex items-center justify-center">
<svg class="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 20 20">
<path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z"></path>
</svg>
开始创建蓝图
</span>
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onUnmounted, inject } from 'vue'
import { useNovelStore } from '@/stores/novel'
import { globalAlert } from '@/composables/useAlert'
interface Props {
aiMessage: string
}
defineProps<Props>()
const emit = defineEmits<{
blueprintGenerated: [response: any]
back: []
}>()
const novelStore = useNovelStore()
const isGenerating = ref(false)
const progress = ref(0)
const timeElapsed = ref(0)
const maxTime = 180 // 180秒超时
let progressTimer: NodeJS.Timeout | null = null
let timeoutTimer: NodeJS.Timeout | null = null
// 动态加载文本
const loadingText = computed(() => {
if (progress.value >= 100) {
return '生成完成!正在准备展示...'
}
const messages = [
'正在分析故事结构...',
'构建角色关系网络...',
'生成情节发展脉络...',
'完善世界观设定...',
'优化章节安排...',
'最后润色细节...'
]
const index = Math.floor((progress.value / 100) * messages.length)
return messages[Math.min(index, messages.length - 1)]
})
// 剩余时间计算
const timeRemaining = computed(() => {
return Math.max(0, maxTime - timeElapsed.value)
})
const generateBlueprint = async () => {
isGenerating.value = true
progress.value = 0
timeElapsed.value = 0
// 启动进度条动画
progressTimer = setInterval(() => {
timeElapsed.value += 0.1
// 非线性进度增长,前面快后面慢
const normalizedTime = timeElapsed.value / maxTime
if (normalizedTime < 0.7) {
// 前70%时间内进度到80%
progress.value = Math.min(80, (normalizedTime / 0.7) * 80)
} else {
// 后30%时间内从80%到95%
const remainingProgress = (normalizedTime - 0.7) / 0.3
progress.value = Math.min(95, 80 + remainingProgress * 15)
}
}, 100)
// 60秒超时
timeoutTimer = setTimeout(() => {
clearTimers()
isGenerating.value = false
globalAlert.showError('生成超时,请稍后重试。如果问题持续,请检查网络连接。', '生成超时')
}, maxTime * 1000)
try {
// 直接调用store中的API
console.log('开始调用generateBlueprint API...')
const response = await novelStore.generateBlueprint()
console.log('API调用成功收到响应:', response)
// API成功后快速完成进度条到100%
if (progressTimer) {
clearInterval(progressTimer)
progressTimer = null
}
// 动画到100%并显示完成状态
progress.value = 100
// 等待一下让用户看到100%完成状态,然后再切换界面
await new Promise(resolve => setTimeout(resolve, 800))
// 清理并重置状态
clearTimers()
isGenerating.value = false
// 通知父组件生成完成
emit('blueprintGenerated', response)
} catch (error) {
console.error('生成蓝图失败:', error)
clearTimers()
isGenerating.value = false
globalAlert.showError(`生成蓝图失败: ${error instanceof Error ? error.message : '未知错误'}`, '生成失败')
}
}
const clearTimers = () => {
if (progressTimer) {
clearInterval(progressTimer)
progressTimer = null
}
if (timeoutTimer) {
clearTimeout(timeoutTimer)
timeoutTimer = null
}
}
onUnmounted(() => {
clearTimers()
})
</script>
<style scoped>
@keyframes shimmer {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(100%);
}
}
.animate-shimmer {
animation: shimmer 2s infinite;
}
/* 自定义动画增强 */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.fade-in {
animation: fadeIn 0.6s ease-out;
}
/* 按钮悬停效果增强 */
.transform {
transition: transform 0.2s ease-in-out;
}
.hover\:scale-105:hover {
transform: scale(1.05);
}
/* 禁用状态样式 */
.disabled\:transform-none:disabled {
transform: none !important;
}
</style>

View File

@@ -0,0 +1,443 @@
<template>
<div class="p-8 bg-white rounded-2xl shadow-2xl fade-in">
<h2 class="text-3xl font-bold text-center text-gray-800 mb-6">你的故事蓝图已生成</h2>
<!-- AI消息 -->
<div v-if="aiMessage" class="mb-6 p-4 bg-blue-50 rounded-lg border border-blue-200">
<p class="text-blue-800">{{ aiMessage }}</p>
</div>
<div class="prose max-w-none p-6 bg-gray-50 rounded-lg border border-gray-200" v-html="formattedBlueprint"></div>
<!-- 加载状态 -->
<div v-if="isSaving" class="text-center py-8">
<!-- 保存动画 -->
<div class="relative mx-auto mb-6 w-16 h-16">
<!-- 旋转圆环 -->
<div class="absolute inset-0 border-4 border-green-100 rounded-full"></div>
<div class="absolute inset-0 border-4 border-transparent border-t-green-500 rounded-full animate-spin"></div>
<!-- 中心保存图标 -->
<div class="absolute inset-2 bg-green-500 rounded-full flex items-center justify-center">
<svg class="w-6 h-6 text-white animate-pulse" fill="currentColor" viewBox="0 0 20 20">
<path d="M7.707 10.293a1 1 0 10-1.414 1.414l3 3a1 1 0 001.414 0l3-3a1 1 0 00-1.414-1.414L11 11.586V6a1 1 0 10-2 0v5.586l-1.293-1.293z"></path>
<path d="M5 4a2 2 0 012-2h6a2 2 0 012 2v1a1 1 0 11-2 0V4H7v1a1 1 0 11-2 0V4z"></path>
</svg>
</div>
</div>
<h3 class="text-lg font-semibold text-gray-800 mb-2 animate-pulse">正在保存蓝图...</h3>
<p class="text-gray-600">即将跳转到写作工作台开始您的创作之旅</p>
<!-- 保存进度指示 -->
<div class="mt-4 w-32 mx-auto">
<div class="w-full bg-gray-200 rounded-full h-1">
<div class="h-1 bg-gradient-to-r from-green-400 to-green-600 rounded-full animate-pulse" style="width: 100%"></div>
</div>
</div>
</div>
<div v-else class="text-center mt-8 space-x-4">
<button
@click="confirmRegenerate"
class="bg-gray-200 text-gray-700 font-bold py-3 px-8 rounded-full hover:bg-gray-300 transition-all duration-300 transform hover:scale-105"
>
<span class="flex items-center justify-center">
<svg class="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z" clip-rule="evenodd"></path>
</svg>
重新生成
</span>
</button>
<button
@click="confirmBlueprint"
:disabled="isSaving"
class="bg-gradient-to-r from-green-500 to-emerald-600 text-white font-bold py-3 px-8 rounded-full hover:from-green-600 hover:to-emerald-700 transition-all duration-300 transform hover:scale-105 shadow-lg hover:shadow-xl disabled:opacity-50 disabled:cursor-not-allowed disabled:transform-none"
>
<span class="flex items-center justify-center">
<svg class="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"></path>
</svg>
确认并开始创作
</span>
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { globalAlert } from '@/composables/useAlert'
import type { Blueprint } from '@/api/novel'
interface DisplayField {
label: string;
value: any;
priority: number;
}
type ExtractedFields = Record<string, DisplayField>;
interface Props {
blueprint: Blueprint | null
aiMessage?: string
}
const props = defineProps<Props>()
const emit = defineEmits<{
confirm: []
regenerate: []
}>()
const isSaving = ref(false)
const confirmRegenerate = async () => {
const confirmed = await globalAlert.showConfirm('重新生成会覆盖当前蓝图,确定继续吗?', '重新生成确认')
if (confirmed) {
emit('regenerate')
}
}
const confirmBlueprint = async () => {
isSaving.value = true
try {
await emit('confirm')
} finally {
isSaving.value = false
}
}
const formattedBlueprint = computed(() => {
if (!props.blueprint) {
return '<p class="text-center text-red-500">抱歉,生成大纲失败,未能获取到最终数据。</p>'
}
const blueprint = props.blueprint
// Helper function to safely access nested properties
const safe = (value: any, fallback = '待补充') => value || fallback
// Create section with icon and styling
const createSection = (title: string, content: string, icon: string) => `
<div class="mb-8 bg-white rounded-xl border border-gray-200 p-6 shadow-sm hover:shadow-md transition-shadow duration-300">
<div class="flex items-center mb-4">
<div class="w-8 h-8 bg-indigo-100 rounded-lg flex items-center justify-center mr-3">
${icon}
</div>
<h3 class="text-xl font-bold text-gray-800">${title}</h3>
</div>
<div class="prose max-w-none text-gray-700">
${content}
</div>
</div>
`
// Icons
const icons = {
summary: '<svg class="w-5 h-5 text-indigo-600" fill="currentColor" viewBox="0 0 20 20"><path d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>',
story: '<svg class="w-5 h-5 text-indigo-600" fill="currentColor" viewBox="0 0 20 20"><path 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.246 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"></path></svg>',
world: '<svg class="w-5 h-5 text-indigo-600" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM4.332 8.027a6.012 6.012 0 011.912-2.706C6.512 5.73 6.974 6 7.5 6A1.5 1.5 0 019 7.5V8a2 2 0 004 0 2 2 0 011.523-1.943A5.977 5.977 0 0116 10c0 .34-.028.675-.083 1H15a2 2 0 00-2 2v2.197A5.973 5.973 0 0110 16v-2a2 2 0 00-2-2 2 2 0 01-2-2 2 2 0 00-1.668-1.973z" clip-rule="evenodd"></path></svg>',
characters: '<svg class="w-5 h-5 text-indigo-600" fill="currentColor" viewBox="0 0 20 20"><path d="M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z"></path></svg>',
relationships: '<svg class="w-5 h-5 text-indigo-600" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M3.172 5.172a4 4 0 015.656 0L10 6.343l1.172-1.171a4 4 0 115.656 5.656L10 17.657l-6.828-6.829a4 4 0 010-5.656z" clip-rule="evenodd"></path></svg>',
chapters: '<svg class="w-5 h-5 text-indigo-600" fill="currentColor" viewBox="0 0 20 20"><path d="M4 4a2 2 0 00-2 2v1h16V6a2 2 0 00-2-2H4zM18 9H2v5a2 2 0 002 2h12a2 2 0 002-2V9zM4 13a1 1 0 011-1h1a1 1 0 110 2H5a1 1 0 01-1-1zm5-1a1 1 0 100 2h1a1 1 0 100-2H9z"></path></svg>'
}
// Format characters with enhanced styling - 动态兼容所有字段
const formatCharacters = (characters: any[]) => {
if (!characters || characters.length === 0) return '<p class="text-gray-500 italic">暂无角色信息</p>'
return characters.map(char => {
if (typeof char === 'object' && char.name) {
const name = char.name
// 定义字段映射和图标支持多种可能的key名称
const fieldMappings = {
identity: {
keys: ['identity_background', 'identity', 'background', '身份背景', '身份'],
label: '🎭 身份背景',
priority: 1
},
personality: {
keys: ['personality_traits', 'personality', 'traits', 'character', '性格特质', '性格'],
label: '🎨 性格特质',
priority: 2
},
goal: {
keys: ['core_goal', 'goal', 'objectives', 'aims', '核心目标', '目标'],
label: '🎯 核心目标',
priority: 3
},
abilities: {
keys: ['abilities_skills', 'abilities', 'skills', 'powers', '能力技能', '能力', '技能'],
label: '⚡ 能力技能',
priority: 4
},
relationship: {
keys: ['relationship_with_protagonist', 'relationship_to_protagonist', 'relationship', 'relation', '与主角关系', '关系'],
label: '🤝 与主角关系',
priority: 5
},
role: {
keys: ['role', 'character_role', 'story_role', '角色定位', '角色'],
label: '👤 角色定位',
priority: 0
}
}
// 提取所有字段
const extractedFields: ExtractedFields = {}
const usedKeys = new Set(['name']) // 已使用的key
// 按优先级提取已知字段
Object.entries(fieldMappings).forEach(([fieldType, mapping]) => {
for (const key of mapping.keys) {
if (char[key] && !usedKeys.has(key)) {
extractedFields[fieldType] = {
value: char[key],
label: mapping.label,
priority: mapping.priority
}
usedKeys.add(key)
break
}
}
})
// 提取剩余的未知字段
Object.entries(char).forEach(([key, value]) => {
if (!usedKeys.has(key) && value && typeof value === 'string' && value.trim()) {
// 为未知字段生成友好的标签
const friendlyLabel = key
.replace(/_/g, ' ')
.replace(/([A-Z])/g, ' $1')
.replace(/^./, str => str.toUpperCase())
extractedFields[`unknown_${key}`] = {
value: value,
label: `📝 ${friendlyLabel}`,
priority: 99
}
usedKeys.add(key)
}
})
// 按优先级排序字段
const sortedFields = Object.entries(extractedFields).sort(([,a], [,b]) => a.priority - b.priority)
// 生成HTML
let fieldsHTML = ''
sortedFields.forEach(([fieldType, field]) => {
if (fieldType === 'role') {
// role字段显示为标签不在详细信息中
return
}
fieldsHTML += `
<div class="bg-white/70 rounded-lg p-3">
<span class="font-medium text-gray-700 block mb-1">${field.label}</span>
<span class="text-gray-800">${field.value}</span>
</div>
`
})
const roleField = extractedFields.role
return `
<div class="bg-gradient-to-r from-blue-50 to-indigo-50 border-l-4 border-indigo-400 rounded-lg p-5 mb-4">
<div class="flex items-center justify-between mb-3">
<h4 class="text-lg font-bold text-indigo-800 flex items-center">
<span class="w-2 h-2 bg-indigo-500 rounded-full mr-2"></span>
${name}
</h4>
${roleField ? `<span class="bg-indigo-100 text-indigo-700 px-2 py-1 rounded-full text-xs font-medium">${roleField.value}</span>` : ''}
</div>
<div class="space-y-3 text-sm">
${fieldsHTML}
</div>
</div>
`
}
// 处理简单的角色结构 (向后兼容)
else if (typeof char === 'object' && char.description) {
const desc = char.description
const identity = desc.identity || ''
const personality = desc.personality || ''
const relationship = desc.relationship_to_protagonist || ''
return `
<div class="bg-gradient-to-r from-blue-50 to-indigo-50 border-l-4 border-indigo-400 rounded-lg p-5 mb-4">
<h4 class="text-lg font-bold text-indigo-800 mb-3 flex items-center">
<span class="w-2 h-2 bg-indigo-500 rounded-full mr-2"></span>
${char.name}
</h4>
<div class="space-y-2 text-sm">
${identity ? `<div class="flex items-start"><span class="font-medium text-gray-600 min-w-16">身份:</span><span class="text-gray-800">${identity}</span></div>` : ''}
${personality ? `<div class="flex items-start"><span class="font-medium text-gray-600 min-w-16">性格:</span><span class="text-gray-800">${personality}</span></div>` : ''}
${relationship ? `<div class="flex items-start"><span class="font-medium text-gray-600 min-w-16">关系:</span><span class="text-gray-800">${relationship}</span></div>` : ''}
</div>
</div>
`
}
// 处理最简单的结构
else {
return `
<div class="bg-gray-50 border-l-4 border-gray-300 rounded-lg p-4 mb-3">
<h4 class="font-semibold text-gray-800">${char.name || '未知角色'}</h4>
<p class="text-gray-600 text-sm mt-1">${char.description || '无描述'}</p>
</div>
`
}
}).join('')
}
// Format world setting with enhanced styling
const formatWorldSetting = (worldSetting: any) => {
if (!worldSetting || typeof worldSetting !== 'object') return '<p class="text-gray-500 italic">暂无世界设定信息</p>'
let html = ''
if (worldSetting.core_rules) {
html += `
<div class="bg-amber-50 border border-amber-200 rounded-lg p-4 mb-4">
<h4 class="font-semibold text-amber-800 mb-2 flex items-center">
<svg class="w-4 h-4 mr-2" fill="currentColor" viewBox="0 0 20 20"><path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"></path></svg>
核心设定
</h4>
<p class="text-amber-700">${worldSetting.core_rules}</p>
</div>
`
}
if (worldSetting.key_locations && worldSetting.key_locations.length > 0) {
html += `
<div class="mb-4">
<h4 class="font-semibold text-gray-800 mb-3 flex items-center">
<svg class="w-4 h-4 mr-2 text-teal-600" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" clip-rule="evenodd"></path></svg>
关键地点
</h4>
<div class="grid gap-3">
${worldSetting.key_locations.map((loc: any) => `
<div class="bg-teal-50 border-l-3 border-teal-400 p-3 rounded-r-lg">
<h5 class="font-medium text-teal-800">${loc.name}</h5>
<p class="text-teal-700 text-sm mt-1">${loc.description}</p>
</div>
`).join('')}
</div>
</div>
`
}
if (worldSetting.factions && worldSetting.factions.length > 0) {
html += `
<div>
<h4 class="font-semibold text-gray-800 mb-3 flex items-center">
<svg class="w-4 h-4 mr-2 text-purple-600" fill="currentColor" viewBox="0 0 20 20"><path d="M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z"></path></svg>
主要势力
</h4>
<div class="grid gap-3">
${worldSetting.factions.map((fac: any) => `
<div class="bg-purple-50 border-l-3 border-purple-400 p-3 rounded-r-lg">
<h5 class="font-medium text-purple-800">${fac.name}</h5>
<p class="text-purple-700 text-sm mt-1">${fac.description}</p>
</div>
`).join('')}
</div>
</div>
`
}
return html || '<p class="text-gray-500 italic">暂无世界设定详细信息</p>'
}
// Format relationships with enhanced styling - 支持新的数据结构
const formatRelationships = (relationships: any[]) => {
if (!relationships || relationships.length === 0) return '<p class="text-gray-500 italic">暂无关系设定</p>'
return `
<div class="space-y-3">
${relationships.map(rel => {
// 支持新的字段名character_from, character_to 以及旧的 source, target
const fromChar = rel.character_from || rel.source || '角色A'
const toChar = rel.character_to || rel.target || '角色B'
const description = rel.description || '暂无描述'
return `
<div class="bg-rose-50 border border-rose-200 rounded-lg p-4">
<div class="flex items-center justify-between mb-2">
<div class="flex items-center">
<span class="font-medium text-rose-800 bg-white px-3 py-1 rounded-full text-sm shadow-sm">${fromChar}</span>
<svg class="w-5 h-5 mx-3 text-rose-400" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M12.293 5.293a1 1 0 011.414 0l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-2.293-2.293a1 1 0 010-1.414z" clip-rule="evenodd"></path>
</svg>
<span class="font-medium text-rose-800 bg-white px-3 py-1 rounded-full text-sm shadow-sm">${toChar}</span>
</div>
</div>
<div class="text-sm text-rose-700 bg-white/50 rounded-lg p-3">
<span class="font-medium">关系描述:</span>${description}
</div>
</div>
`
}).join('')}
</div>
`
}
// Header with title and badges
const headerHTML = `
<div class="text-center mb-8 p-6 bg-gradient-to-r from-indigo-500 to-purple-600 rounded-xl text-white">
<h1 class="text-4xl font-bold mb-4">${safe(blueprint.title, '未知标题')}</h1>
<div class="flex flex-wrap justify-center gap-3 mb-4">
<span class="bg-white/20 backdrop-blur-sm px-4 py-2 rounded-full text-sm font-medium">${safe(blueprint.genre, '未指定')}</span>
<span class="bg-white/20 backdrop-blur-sm px-4 py-2 rounded-full text-sm font-medium">${safe(blueprint.style, '未指定')}</span>
<span class="bg-white/20 backdrop-blur-sm px-4 py-2 rounded-full text-sm font-medium">${safe(blueprint.tone, '未指定')}</span>
<span class="bg-white/20 backdrop-blur-sm px-4 py-2 rounded-full text-sm font-medium">${safe(blueprint.target_audience, '未指定')}</span>
</div>
</div>
`
// Summary section
const summaryHTML = createSection(
'故事梗概',
`
<div class="bg-gradient-to-r from-blue-50 to-indigo-50 border border-blue-200 rounded-lg p-5 mb-4">
<h4 class="font-semibold text-blue-800 mb-2">一句话总结</h4>
<p class="text-lg italic text-blue-700">"${safe(blueprint.one_sentence_summary)}"</p>
</div>
<div class="prose max-w-none">
<h4 class="font-semibold text-gray-800 mb-3">完整简介</h4>
<p class="text-gray-700 leading-relaxed">${safe(blueprint.full_synopsis)}</p>
</div>
`,
icons.summary
)
// Chapters section with enhanced styling
const chaptersHTML = `
<div class="space-y-4">
${(blueprint.chapter_outline || []).map((ch, index) => `
<div class="group relative overflow-hidden bg-gradient-to-r from-gray-50 to-white border border-gray-200 rounded-lg p-5 hover:shadow-md transition-all duration-300">
<div class="absolute left-0 top-0 bottom-0 w-1 bg-gradient-to-b from-indigo-500 to-purple-600 transform origin-top group-hover:scale-y-110 transition-transform duration-300"></div>
<div class="flex items-start">
<div class="flex-shrink-0 w-10 h-10 bg-indigo-100 rounded-lg flex items-center justify-center mr-4">
<span class="text-indigo-600 font-bold text-sm">${ch.chapter_number}</span>
</div>
<div class="flex-1">
<h4 class="text-lg font-bold text-gray-800 mb-2 group-hover:text-indigo-600 transition-colors duration-300">第 ${ch.chapter_number} 章: ${ch.title}</h4>
<p class="text-gray-600 leading-relaxed">${ch.summary}</p>
</div>
</div>
</div>
`).join('')}
</div>
`
return `
${headerHTML}
${summaryHTML}
${createSection('世界设定', formatWorldSetting(blueprint.world_setting), icons.world)}
${createSection('主要角色', formatCharacters(blueprint.characters || []), icons.characters)}
${createSection('角色关系', formatRelationships(blueprint.relationships || []), icons.relationships)}
${createSection('章节大纲', chaptersHTML, icons.chapters)}
`
})
</script>

View File

@@ -0,0 +1,73 @@
<template>
<div v-if="show" class="fixed inset-0 bg-black/40 backdrop-blur-sm z-50 flex justify-center items-center" @click.self="$emit('close')">
<div class="bg-white rounded-lg shadow-xl w-full max-w-2xl mx-4">
<div class="p-6 border-b border-gray-200">
<h3 class="text-xl font-semibold text-gray-800">编辑 {{ title }}</h3>
</div>
<div class="p-6">
<ChapterOutlineEditor v-if="props.field === 'chapter_outline'" v-model="editableContent" />
<KeyLocationsEditor v-else-if="props.field === 'world_setting.key_locations'" v-model="editableContent" />
<CharactersEditor v-else-if="props.field === 'characters'" v-model="editableContent" />
<RelationshipsEditor v-else-if="props.field === 'relationships'" v-model="editableContent" />
<FactionsEditor v-else-if="props.field === 'world_setting.factions'" v-model="editableContent" />
<textarea
v-else
v-model="editableContent"
class="w-full h-64 p-3 border border-gray-300 rounded-md focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition duration-150 ease-in-out"
placeholder="请输入内容..."
></textarea>
</div>
<div class="px-6 py-4 bg-gray-50 rounded-b-lg flex justify-end space-x-3">
<button @click="$emit('close')" class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
取消
</button>
<button @click="saveChanges" class="px-4 py-2 text-sm font-medium text-white bg-indigo-600 border border-transparent rounded-md shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
保存
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch, defineProps, defineEmits } from 'vue';
import ChapterOutlineEditor from './ChapterOutlineEditor.vue';
import KeyLocationsEditor from './KeyLocationsEditor.vue';
import CharactersEditor from './CharactersEditor.vue';
import RelationshipsEditor from './RelationshipsEditor.vue';
import FactionsEditor from './FactionsEditor.vue';
import type { ChapterOutline } from '@/api/novel';
const props = defineProps({
show: Boolean,
title: String,
content: {
type: [String, Object, Array],
default: ''
},
field: String
});
const emit = defineEmits(['close', 'save']);
const editableContent = ref<any>('');
watch(() => props.show, (isVisible) => {
if (isVisible) {
// 当模态框显示时,进行一次性的深克隆,创建一个独立的、可编辑的副本。
// 这会切断与外部的响应式链接,避免编辑时的大规模更新。
// 我们在这里恢复使用 JSON.parse(JSON.stringify(...)),因为它的性能开销
// 在“一次性”操作中是可接受的,并且是实现深克隆和响应式隔离的最简单方法。
try {
editableContent.value = JSON.parse(JSON.stringify(props.content || ''));
} catch (e) {
// 对于无法序列化的简单类型,直接赋值
editableContent.value = props.content || '';
}
}
}, { immediate: true });
const saveChanges = () => {
emit('save', { field: props.field, content: editableContent.value });
};
</script>

View File

@@ -0,0 +1,96 @@
<template>
<div class="bg-white rounded-lg shadow-sm p-6">
<h3 class="text-lg font-semibold text-gray-800 mb-4">章节列表</h3>
<div v-if="chapterOutline.length === 0" class="text-gray-500 text-center py-8">
暂无章节大纲
</div>
<div v-else class="space-y-3">
<div
v-for="outline in chapterOutline"
:key="outline.chapter_number"
class="border rounded-lg p-3 hover:bg-gray-50 transition-colors"
>
<div class="flex justify-between items-start">
<div class="flex-1">
<h4 class="font-medium text-gray-800">
{{ outline.chapter_number }}: {{ outline.title }}
</h4>
<p class="text-sm text-gray-600 mt-1">{{ outline.summary }}</p>
<!-- 章节状态 -->
<div class="mt-2">
<span
:class="getChapterStatusClass(outline.chapter_number)"
class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium"
>
{{ getChapterStatus(outline.chapter_number) }}
</span>
</div>
</div>
<div class="flex flex-col gap-2 ml-4">
<!-- 查看按钮 -->
<button
v-if="isChapterCompleted(outline.chapter_number)"
@click="$emit('selectChapter', outline.chapter_number)"
class="px-3 py-1 bg-blue-100 text-blue-700 rounded hover:bg-blue-200 transition-colors text-sm"
>
查看
</button>
<!-- 生成按钮 -->
<button
v-if="!isChapterCompleted(outline.chapter_number)"
@click="$emit('generateChapter', outline.chapter_number)"
class="px-3 py-1 bg-green-100 text-green-700 rounded hover:bg-green-200 transition-colors text-sm"
>
生成
</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { Chapter, ChapterOutline } from '@/api/novel'
interface Props {
chapters: Chapter[]
chapterOutline: ChapterOutline[]
}
const props = defineProps<Props>()
defineEmits<{
selectChapter: [chapterNumber: number]
generateChapter: [chapterNumber: number]
}>()
const isChapterCompleted = (chapterNumber: number) => {
return props.chapters.some(ch => ch.chapter_number === chapterNumber && ch.content)
}
const getChapterStatus = (chapterNumber: number) => {
const chapter = props.chapters.find(ch => ch.chapter_number === chapterNumber)
if (!chapter) return '未开始'
if (chapter.content) return '已完成'
if (chapter.versions && chapter.versions.length > 0) return '待选择'
return '未开始'
}
const getChapterStatusClass = (chapterNumber: number) => {
const status = getChapterStatus(chapterNumber)
switch (status) {
case '已完成':
return 'bg-green-100 text-green-800'
case '待选择':
return 'bg-yellow-100 text-yellow-800'
default:
return 'bg-gray-100 text-gray-800'
}
}
</script>

View File

@@ -0,0 +1,65 @@
<template>
<div class="space-y-4 max-h-96 overflow-y-auto p-1">
<div v-for="(chapter, index) in localOutline" :key="index" class="p-4 border border-gray-200 rounded-lg bg-gray-50">
<div class="flex items-center mb-2">
<span class="font-bold text-indigo-600 mr-2"> {{ chapter.chapter_number }} </span>
<input
type="text"
v-model="chapter.title"
class="flex-grow p-1 border-b-2 border-gray-300 focus:border-indigo-500 outline-none transition"
placeholder="章节标题"
/>
<button @click="removeChapter(index)" class="ml-2 text-red-400 hover:text-red-600 transition-colors p-1">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm4 0a1 1 0 012 0v6a1 1 0 11-2 0V8z" clip-rule="evenodd" />
</svg>
</button>
</div>
<textarea
v-model="chapter.summary"
class="w-full h-24 p-2 mt-2 border border-gray-300 rounded-md focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 transition text-sm"
placeholder="章节摘要"
></textarea>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch, defineProps, defineEmits, nextTick } from 'vue';
import type { ChapterOutline } from '@/api/novel';
const props = defineProps({
modelValue: {
type: Array as () => ChapterOutline[],
default: () => []
}
});
const emit = defineEmits(['update:modelValue']);
const localOutline = ref<ChapterOutline[]>([]);
let syncing = false;
watch(() => props.modelValue, (newVal) => {
syncing = true;
// Deep copy to prevent modifying the original prop
localOutline.value = JSON.parse(JSON.stringify(newVal || []));
nextTick(() => {
syncing = false;
});
}, { immediate: true });
// Watch for local changes and emit them upwards
watch(localOutline, (newVal) => {
if (syncing) return;
emit('update:modelValue', JSON.parse(JSON.stringify(newVal)));
}, { deep: true });
const removeChapter = (index: number) => {
localOutline.value.splice(index, 1);
// Re-number all subsequent chapters to ensure they are sequential
localOutline.value.forEach((chapter, i) => {
chapter.chapter_number = i + 1;
});
};
</script>

View File

@@ -0,0 +1,111 @@
<template>
<div class="bg-white rounded-lg shadow-sm p-6">
<!-- 章节头部 -->
<div v-if="chapterOutline" class="mb-6">
<h2 class="text-xl font-bold text-gray-800">
{{ chapterOutline.chapter_number }}: {{ chapterOutline.title }}
</h2>
<p class="text-gray-600 mt-2">{{ chapterOutline.summary }}</p>
</div>
<!-- 无选择状态 -->
<div v-if="!chapterOutline" class="text-center py-12 text-gray-500">
请从左侧选择一个章节开始工作
</div>
<!-- 已完成的章节 -->
<div v-else-if="chapter && chapter.content" class="space-y-6">
<div class="flex justify-between items-center">
<h3 class="text-lg font-semibold text-gray-800">已发布内容</h3>
<button
@click="confirmRegenerate"
class="px-4 py-2 bg-indigo-100 text-indigo-700 rounded hover:bg-indigo-200 transition-colors"
>
重新生成版本
</button>
</div>
<div class="prose max-w-none p-4 bg-gray-50 rounded-lg border">
<div class="whitespace-pre-wrap">{{ chapter.content }}</div>
</div>
</div>
<!-- 生成结果选择 -->
<div v-else-if="generationResult" class="space-y-6">
<div class="flex justify-between items-center">
<h3 class="text-lg font-semibold text-gray-800">选择版本</h3>
<button
@click="confirmRegenerate"
class="px-4 py-2 bg-gray-100 text-gray-700 rounded hover:bg-gray-200 transition-colors"
>
重新生成
</button>
</div>
<!-- AI评估 -->
<div class="bg-blue-50 p-4 rounded-lg border border-blue-200">
<h4 class="font-medium text-blue-800 mb-2">AI评估建议</h4>
<p class="text-blue-700 text-sm">{{ generationResult.evaluation }}</p>
</div>
<!-- 版本选择 -->
<div class="space-y-4">
<div
v-for="(version, index) in generationResult.versions"
:key="index"
class="border rounded-lg p-4 hover:bg-gray-50 transition-colors cursor-pointer"
@click="$emit('selectVersion', index)"
>
<div class="flex justify-between items-start mb-3">
<h4 class="font-medium text-gray-800">版本 {{ index + 1 }}</h4>
<button
@click.stop="$emit('selectVersion', index)"
class="px-3 py-1 bg-green-100 text-green-700 rounded hover:bg-green-200 transition-colors text-sm"
>
选择此版本
</button>
</div>
<div class="prose max-w-none text-sm text-gray-700 whitespace-pre-wrap">
{{ version }}
</div>
</div>
</div>
</div>
<!-- 等待生成状态 -->
<div v-else class="text-center py-12">
<div class="text-gray-500 mb-4">点击左侧的"生成"按钮开始创作这一章</div>
<button
@click="confirmRegenerate"
class="px-6 py-3 bg-indigo-500 text-white rounded-lg hover:bg-indigo-600 transition-colors"
>
开始生成
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { globalAlert } from '@/composables/useAlert'
import type { Chapter, ChapterOutline, ChapterGenerationResponse } from '@/api/novel'
interface Props {
chapter: Chapter | null
chapterOutline: ChapterOutline | null
generationResult: ChapterGenerationResponse | null
}
defineProps<Props>()
const emit = defineEmits<{
selectVersion: [versionIndex: number]
regenerate: []
}>()
const confirmRegenerate = async () => {
const confirmed = await globalAlert.showConfirm('重新生成会覆盖当前章节的现有结果,确定继续吗?', '重新生成确认')
if (confirmed) {
emit('regenerate')
}
}
</script>

View File

@@ -0,0 +1,94 @@
<template>
<div class="space-y-4 max-h-96 overflow-y-auto p-1">
<div v-for="(character, index) in localCharacters" :key="index" class="p-4 border border-gray-200 rounded-lg bg-gray-50 relative">
<button @click="removeCharacter(index)" class="absolute top-2 right-2 text-red-400 hover:text-red-600 transition-colors p-1">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm4 0a1 1 0 012 0v6a1 1 0 11-2 0V8z" clip-rule="evenodd" />
</svg>
</button>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-600 mb-1">姓名</label>
<input type="text" v-model="character.name" class="w-full p-1 border-b-2 border-gray-300 focus:border-indigo-500 outline-none transition bg-transparent" />
</div>
<div>
<label class="block text-sm font-medium text-gray-600 mb-1">身份</label>
<input type="text" v-model="character.identity" class="w-full p-1 border-b-2 border-gray-300 focus:border-indigo-500 outline-none transition bg-transparent" />
</div>
<div>
<label class="block text-sm font-medium text-gray-600 mb-1">性格</label>
<input type="text" v-model="character.personality" class="w-full p-1 border-b-2 border-gray-300 focus:border-indigo-500 outline-none transition bg-transparent" />
</div>
<div>
<label class="block text-sm font-medium text-gray-600 mb-1">目标</label>
<input type="text" v-model="character.goals" class="w-full p-1 border-b-2 border-gray-300 focus:border-indigo-500 outline-none transition bg-transparent" />
</div>
<div>
<label class="block text-sm font-medium text-gray-600 mb-1">能力</label>
<input type="text" v-model="character.abilities" class="w-full p-1 border-b-2 border-gray-300 focus:border-indigo-500 outline-none transition bg-transparent" />
</div>
<div>
<label class="block text-sm font-medium text-gray-600 mb-1">与主角关系</label>
<input type="text" v-model="character.relationship_to_protagonist" class="w-full p-1 border-b-2 border-gray-300 focus:border-indigo-500 outline-none transition bg-transparent" />
</div>
</div>
</div>
<button @click="addCharacter" class="w-full mt-4 px-4 py-2 text-sm font-medium text-indigo-600 bg-indigo-50 border border-indigo-200 rounded-md hover:bg-indigo-100 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
+ 添加新角色
</button>
</div>
</template>
<script setup lang="ts">
import { ref, watch, defineProps, defineEmits, nextTick } from 'vue';
interface Character {
name: string;
identity: string;
personality: string;
goals: string;
abilities: string;
relationship_to_protagonist: string;
}
const props = defineProps({
modelValue: {
type: Array as () => Character[],
default: () => []
}
});
const emit = defineEmits(['update:modelValue']);
const localCharacters = ref<Character[]>([]);
let syncing = false;
watch(() => props.modelValue, (newVal) => {
syncing = true;
localCharacters.value = JSON.parse(JSON.stringify(newVal || []));
nextTick(() => {
syncing = false;
});
}, { immediate: true });
watch(localCharacters, (newVal) => {
if (syncing) return;
emit('update:modelValue', JSON.parse(JSON.stringify(newVal)));
}, { deep: true });
const addCharacter = () => {
localCharacters.value.push({
name: '',
identity: '',
personality: '',
goals: '',
abilities: '',
relationship_to_protagonist: ''
});
};
const removeCharacter = (index: number) => {
localCharacters.value.splice(index, 1);
};
</script>

View File

@@ -0,0 +1,76 @@
<template>
<div :class="wrapperClass">
<div :class="bubbleClass">
<!-- AI 消息支持 markdown 渲染 -->
<div
v-if="type === 'ai'"
class="prose prose-sm max-w-none prose-headings:mt-2 prose-headings:mb-1 prose-p:my-1 prose-ul:my-1 prose-ol:my-1 prose-li:my-0"
v-html="renderedMessage"
></div>
<!-- 用户消息保持原样 -->
<div v-else>{{ message }}</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
interface Props {
message: string
type: 'user' | 'ai'
}
const props = defineProps<Props>()
// 简单的 markdown 解析函数
const parseMarkdown = (text: string): string => {
if (!text) return ''
// 处理转义字符
let parsed = text
.replace(/\\n/g, '\n')
.replace(/\\\"/g, '"')
.replace(/\\'/g, "'")
.replace(/\\\\/g, '\\')
// 处理加粗文本 **text**
parsed = parsed.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
// 处理斜体文本 *text*
parsed = parsed.replace(/(?<!\*)\*([^*]+)\*(?!\*)/g, '<em>$1</em>')
// 处理选项列表 A) text
parsed = parsed.replace(/^([A-Z])\)\s*\*\*(.*?)\*\*(.*)/gm, '<div class="mb-2"><span class="inline-flex items-center justify-center w-6 h-6 bg-indigo-100 text-indigo-600 text-sm font-bold rounded-full mr-2">$1</span><strong>$2</strong>$3</div>')
// 处理普通换行
parsed = parsed.replace(/\n/g, '<br>')
// 处理多个连续的 <br> 标签为段落
parsed = parsed.replace(/(<br\s*\/?>\s*){2,}/g, '</p><p class="mt-2">')
// 包装在段落标签中
if (!parsed.includes('<p>')) {
parsed = `<p>${parsed}</p>`
}
return parsed
}
const renderedMessage = computed(() => {
if (props.type === 'ai') {
return parseMarkdown(props.message)
}
return props.message
})
const wrapperClass = computed(() => {
return `w-full flex ${props.type === 'ai' ? 'justify-start' : 'justify-end'}`
})
const bubbleClass = computed(() => {
const baseClass = 'max-w-md lg:max-w-lg p-4 rounded-lg shadow-md fade-in'
const typeClass = props.type === 'ai' ? 'chat-bubble-ai' : 'chat-bubble-user'
return `${baseClass} ${typeClass}`
})
</script>

View File

@@ -0,0 +1,177 @@
<template>
<div class="fade-in">
<!-- 加载状态 -->
<div v-if="loading || !uiControl" class="flex justify-center items-center p-4">
<div class="loader"></div>
</div>
<!-- 单选题 -->
<div v-else-if="uiControl.type === 'single_choice'">
<div class="grid grid-cols-2 md:grid-cols-4 gap-3 mb-3">
<button
v-for="option in uiControl.options"
:key="option.id"
@click="handleOptionSelect(option.id, option.label)"
class="p-3 bg-indigo-100 text-indigo-700 rounded-lg hover:bg-indigo-200 transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-indigo-400"
>
{{ option.label }}
</button>
<button
@click="isManualInput = true"
class="p-3 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-gray-400"
>
我要输入
</button>
</div>
<form @submit.prevent="handleTextSubmit" class="flex items-center gap-3">
<textarea
v-model="textInput"
:placeholder="isManualInput ? '请输入您的想法...' : '选择上方选项或点击“我要输入”'"
class="w-full px-4 py-3 border border-gray-300 rounded-2xl focus:ring-2 focus:ring-indigo-400 focus:border-indigo-400 outline-none transition-all disabled:bg-gray-100 resize-none overflow-y-auto leading-relaxed"
:disabled="!isManualInput"
rows="5"
ref="textInputRef"
@input="handleTextareaInput"
></textarea>
<button
type="submit"
class="flex-shrink-0 w-12 h-12 bg-indigo-500 rounded-full flex items-center justify-center hover:bg-indigo-600 transition-all shadow-md disabled:bg-gray-300"
:disabled="!isManualInput"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="text-white"
>
<line x1="22" y1="2" x2="11" y2="13"></line>
<polygon points="22 2 15 22 11 13 2 9 22 2"></polygon>
</svg>
</button>
</form>
</div>
<!-- 文本输入 -->
<form v-else-if="uiControl.type === 'text_input'" @submit.prevent="handleTextSubmit" class="flex items-center gap-3">
<textarea
v-model="textInput"
:placeholder="uiControl.placeholder || '请输入...'"
class="w-full px-4 py-3 border border-gray-300 rounded-2xl focus:ring-2 focus:ring-indigo-400 focus:border-indigo-400 outline-none transition-all resize-none overflow-y-auto leading-relaxed"
required
ref="textInputRef"
rows="5"
@input="handleTextareaInput"
></textarea>
<button
type="submit"
class="flex-shrink-0 w-12 h-12 bg-indigo-500 rounded-full flex items-center justify-center hover:bg-indigo-600 transition-all shadow-md"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="text-white"
>
<line x1="22" y1="2" x2="11" y2="13"></line>
<polygon points="22 2 15 22 11 13 2 9 22 2"></polygon>
</svg>
</button>
</form>
</div>
</template>
<script setup lang="ts">
import { ref, nextTick, watch } from 'vue'
import type { UIControl } from '@/api/novel'
interface Props {
uiControl: UIControl | null
loading: boolean
}
const props = defineProps<Props>()
const emit = defineEmits<{
submit: [userInput: { id: string; value: string } | null]
}>()
const textInput = ref('')
const textInputRef = ref<HTMLTextAreaElement>()
const isManualInput = ref(false)
const MIN_ROWS = 5
const MAX_ROWS = 5
const adjustTextareaHeight = () => {
const textarea = textInputRef.value
if (!textarea) {
return
}
if (typeof window === 'undefined') {
return
}
const lineHeight = parseFloat(window.getComputedStyle(textarea).lineHeight || '0') || 20
const minHeight = lineHeight * MIN_ROWS
const maxHeight = lineHeight * MAX_ROWS
textarea.style.height = 'auto'
const targetHeight = Math.min(maxHeight, Math.max(minHeight, textarea.scrollHeight))
textarea.style.height = `${targetHeight}px`
}
const handleTextareaInput = () => {
adjustTextareaHeight()
}
const handleOptionSelect = (id: string, label: string) => {
emit('submit', { id, value: label })
}
const handleTextSubmit = () => {
if (textInput.value.trim()) {
emit('submit', { id: 'text_input', value: textInput.value.trim() })
textInput.value = ''
nextTick(() => adjustTextareaHeight())
}
}
// 当输入控件变为文本输入时,自动聚焦
watch(
() => props.uiControl,
async (newControl) => {
// 每次控件更新时,都重置手动输入状态和文本内容
isManualInput.value = false
textInput.value = ''
await nextTick()
adjustTextareaHeight()
if (newControl?.type === 'text_input') {
textInputRef.value?.focus()
}
},
{ deep: true } // 使用 deep watch 确保即使是相同类型的控件也能触发
)
// 监听手动输入状态的变化,以聚焦输入框
watch(isManualInput, async (newValue) => {
if (newValue) {
await nextTick()
adjustTextareaHeight()
textInputRef.value?.focus()
}
})
</script>

View File

@@ -0,0 +1,198 @@
<template>
<Teleport to="body">
<div
v-if="visible"
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm"
@click.self="handleClose"
>
<div
class="bg-white rounded-2xl shadow-2xl max-w-md w-full transform transition-all duration-300 ease-out"
:class="visible ? 'scale-100 opacity-100' : 'scale-95 opacity-0'"
>
<!-- 头部 -->
<div
class="flex items-center p-6 pb-4"
:class="headerColorClass"
>
<div
class="w-12 h-12 rounded-full flex items-center justify-center mr-4"
:class="iconBgClass"
>
<!-- 错误图标 -->
<svg
v-if="type === 'error'"
class="w-6 h-6 text-white"
fill="currentColor"
viewBox="0 0 20 20"
>
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"></path>
</svg>
<!-- 成功图标 -->
<svg
v-else-if="type === 'success'"
class="w-6 h-6 text-white"
fill="currentColor"
viewBox="0 0 20 20"
>
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"></path>
</svg>
<!-- 警告图标 -->
<svg
v-else-if="type === 'warning'"
class="w-6 h-6 text-white"
fill="currentColor"
viewBox="0 0 20 20"
>
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"></path>
</svg>
<!-- 确认图标 -->
<svg
v-else-if="type === 'confirmation'"
class="w-6 h-6 text-white"
fill="currentColor"
viewBox="0 0 20 20"
>
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd"></path>
</svg>
<!-- 信息图标 -->
<svg
v-else
class="w-6 h-6 text-white"
fill="currentColor"
viewBox="0 0 20 20"
>
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd"></path>
</svg>
</div>
<div>
<h3 class="text-lg font-semibold text-gray-800">{{ titleText }}</h3>
</div>
</div>
<!-- 内容 -->
<div class="px-6 pb-4">
<p class="text-gray-600 leading-relaxed">{{ message }}</p>
</div>
<!-- 底部按钮 -->
<div class="flex justify-end gap-3 p-6 pt-4 bg-gray-50 rounded-b-2xl">
<button
v-if="showCancel"
@click="handleCancel"
class="px-4 py-2 text-gray-600 hover:text-gray-800 font-medium transition-colors duration-200"
>
{{ cancelText }}
</button>
<button
@click="handleConfirm"
class="px-6 py-2 rounded-lg font-medium transition-all duration-200 transform hover:scale-105"
:class="confirmButtonClass"
>
{{ confirmText }}
</button>
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { computed } from 'vue'
interface Props {
visible: boolean
type?: 'success' | 'error' | 'warning' | 'info' | 'confirmation'
title?: string
message: string
showCancel?: boolean
confirmText?: string
cancelText?: string
}
const props = withDefaults(defineProps<Props>(), {
type: 'info',
title: '',
showCancel: false,
confirmText: '确定',
cancelText: '取消'
})
const emit = defineEmits<{
confirm: []
cancel: []
close: []
}>()
const titleText = computed(() => {
if (props.title) return props.title
switch (props.type) {
case 'success': return '操作成功'
case 'error': return '出现错误'
case 'warning': return '警告提示'
case 'confirmation': return '请确认'
default: return '提示信息'
}
})
const headerColorClass = computed(() => {
switch (props.type) {
case 'success': return ''
case 'error': return ''
case 'warning': return ''
default: return ''
}
})
const iconBgClass = computed(() => {
switch (props.type) {
case 'success': return 'bg-green-500'
case 'error': return 'bg-red-500'
case 'warning': return 'bg-amber-500'
case 'confirmation': return 'bg-gray-500'
default: return 'bg-blue-500'
}
})
const confirmButtonClass = computed(() => {
switch (props.type) {
case 'success': return 'bg-green-500 hover:bg-green-600 text-white shadow-lg hover:shadow-green-200'
case 'error': return 'bg-red-500 hover:bg-red-600 text-white shadow-lg hover:shadow-red-200'
case 'warning': return 'bg-amber-500 hover:bg-amber-600 text-white shadow-lg hover:shadow-amber-200'
case 'confirmation': return 'bg-indigo-600 hover:bg-indigo-700 text-white shadow-lg hover:shadow-indigo-200'
default: return 'bg-blue-500 hover:bg-blue-600 text-white shadow-lg hover:shadow-blue-200'
}
})
const handleConfirm = () => {
emit('confirm')
emit('close')
}
const handleCancel = () => {
emit('cancel')
emit('close')
}
const handleClose = () => {
emit('close')
}
</script>
<style scoped>
/* 自定义动画 */
@keyframes slideIn {
from {
opacity: 0;
transform: scale(0.95) translateY(-10px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
.transform {
animation: slideIn 0.3s ease-out;
}
</style>

View File

@@ -0,0 +1,73 @@
<template>
<div class="space-y-4 max-h-96 overflow-y-auto p-1">
<div v-for="(faction, index) in localFactions" :key="index" class="p-4 border border-gray-200 rounded-lg bg-gray-50 relative">
<button @click="removeFaction(index)" class="absolute top-2 right-2 text-red-400 hover:text-red-600 transition-colors p-1">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm4 0a1 1 0 012 0v6a1 1 0 11-2 0V8z" clip-rule="evenodd" />
</svg>
</button>
<div class="mb-2">
<label class="block text-sm font-medium text-gray-600 mb-1">阵营名称</label>
<input
type="text"
v-model="faction.name"
class="w-full p-1 border-b-2 border-gray-300 focus:border-indigo-500 outline-none transition bg-transparent"
placeholder="例如:幽灵侦探林远"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-600 mb-1">描述</label>
<textarea
v-model="faction.description"
class="w-full h-20 p-2 mt-1 border border-gray-300 rounded-md focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 transition text-sm"
placeholder="关于这个阵营的详细描述..."
></textarea>
</div>
</div>
<button @click="addFaction" class="w-full mt-4 px-4 py-2 text-sm font-medium text-indigo-600 bg-indigo-50 border border-indigo-200 rounded-md hover:bg-indigo-100 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
+ 添加新阵营
</button>
</div>
</template>
<script setup lang="ts">
import { ref, watch, defineProps, defineEmits, nextTick } from 'vue';
interface Faction {
name: string;
description: string;
}
const props = defineProps({
modelValue: {
type: Array as () => Faction[],
default: () => []
}
});
const emit = defineEmits(['update:modelValue']);
const localFactions = ref<Faction[]>([]);
let syncing = false;
watch(() => props.modelValue, (newVal) => {
syncing = true;
localFactions.value = JSON.parse(JSON.stringify(newVal || []));
nextTick(() => {
syncing = false;
});
}, { immediate: true });
watch(localFactions, (newVal) => {
if (syncing) return;
emit('update:modelValue', JSON.parse(JSON.stringify(newVal)));
}, { deep: true });
const addFaction = () => {
localFactions.value.push({ name: '', description: '' });
};
const removeFaction = (index: number) => {
localFactions.value.splice(index, 1);
};
</script>

View File

@@ -0,0 +1,41 @@
<script setup lang="ts">
defineProps<{
msg: string
}>()
</script>
<template>
<div class="greetings">
<h1 class="green">{{ msg }}</h1>
<h3>
Youve successfully created a project with
<a href="https://vite.dev/" target="_blank" rel="noopener">Vite</a> +
<a href="https://vuejs.org/" target="_blank" rel="noopener">Vue 3</a>. What's next?
</h3>
</div>
</template>
<style scoped>
h1 {
font-weight: 500;
font-size: 2.6rem;
position: relative;
top: -10px;
}
h3 {
font-size: 1.2rem;
}
.greetings h1,
.greetings h3 {
text-align: center;
}
@media (min-width: 1024px) {
.greetings h1,
.greetings h3 {
text-align: left;
}
}
</style>

View File

@@ -0,0 +1,22 @@
<template>
<div class="absolute inset-0 bg-white flex flex-col items-center justify-center text-center p-8 transition-opacity duration-500">
<div class="relative mb-8">
<div class="w-24 h-24 bg-gradient-to-r from-indigo-400 to-purple-500 rounded-full mx-auto flex items-center justify-center animate-pulse shadow-lg">
<svg class="w-12 h-12 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707m12.728 0l-.707-.707"></path>
</svg>
</div>
<div class="absolute inset-0 w-24 h-24 bg-gradient-to-r from-indigo-400 to-purple-500 rounded-full mx-auto animate-ping opacity-30"></div>
</div>
<h2 class="text-2xl font-bold text-gray-800 mb-3">正在为你准备灵感空间...</h2>
<div class="space-y-2 text-gray-600">
<p class="animate-pulse" style="animation-delay: 0.2s"> 连接文思泉涌的AI...</p>
<p class="animate-pulse" style="animation-delay: 0.6s">🎨 铺开创意的画卷...</p>
<p class="animate-pulse" style="animation-delay: 1s;">🚀 准备开启灵感之旅</p>
</div>
</div>
</template>
<script setup lang="ts">
// This is a purely presentational component.
</script>

View File

@@ -0,0 +1,73 @@
<template>
<div class="space-y-4 max-h-96 overflow-y-auto p-1">
<div v-for="(location, index) in localLocations" :key="index" class="p-4 border border-gray-200 rounded-lg bg-gray-50 relative">
<button @click="removeLocation(index)" class="absolute top-2 right-2 text-red-400 hover:text-red-600 transition-colors p-1">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm4 0a1 1 0 012 0v6a1 1 0 11-2 0V8z" clip-rule="evenodd" />
</svg>
</button>
<div class="mb-2">
<label class="block text-sm font-medium text-gray-600 mb-1">地点名称</label>
<input
type="text"
v-model="location.name"
class="w-full p-1 border-b-2 border-gray-300 focus:border-indigo-500 outline-none transition bg-transparent"
placeholder="例如:林远生前的公寓"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-600 mb-1">描述</label>
<textarea
v-model="location.description"
class="w-full h-20 p-2 mt-1 border border-gray-300 rounded-md focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 transition text-sm"
placeholder="关于这个地点的详细描述..."
></textarea>
</div>
</div>
<button @click="addLocation" class="w-full mt-4 px-4 py-2 text-sm font-medium text-indigo-600 bg-indigo-50 border border-indigo-200 rounded-md hover:bg-indigo-100 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
+ 添加新地点
</button>
</div>
</template>
<script setup lang="ts">
import { ref, watch, defineProps, defineEmits, nextTick } from 'vue';
interface KeyLocation {
name: string;
description: string;
}
const props = defineProps({
modelValue: {
type: Array as () => KeyLocation[],
default: () => []
}
});
const emit = defineEmits(['update:modelValue']);
const localLocations = ref<KeyLocation[]>([]);
let syncing = false;
watch(() => props.modelValue, (newVal) => {
syncing = true;
localLocations.value = JSON.parse(JSON.stringify(newVal || []));
nextTick(() => {
syncing = false;
});
}, { immediate: true });
watch(localLocations, (newVal) => {
if (syncing) return;
emit('update:modelValue', JSON.parse(JSON.stringify(newVal)));
}, { deep: true });
const addLocation = () => {
localLocations.value.push({ name: '', description: '' });
};
const removeLocation = (index: number) => {
localLocations.value.splice(index, 1);
};
</script>

View File

@@ -0,0 +1,63 @@
<template>
<div class="bg-white/70 backdrop-blur-xl rounded-2xl shadow-lg p-8">
<h2 class="text-2xl font-bold text-gray-800 mb-6">LLM 配置</h2>
<h5 class="text-1xl font-bold text-gray-800 mb-6">建议使用自己的中转API和KEY</h5>
<form @submit.prevent="handleSave" class="space-y-6">
<div>
<label for="url" class="block text-sm font-medium text-gray-700">API URL</label>
<input type="text" id="url" v-model="config.llm_provider_url" class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm" placeholder="https://api.example.com/v1">
</div>
<div>
<label for="key" class="block text-sm font-medium text-gray-700">API Key</label>
<input type="password" id="key" v-model="config.llm_provider_api_key" class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm" placeholder="留空则使用默认Key">
</div>
<div>
<label for="model" class="block text-sm font-medium text-gray-700">Model</label>
<input type="text" id="model" v-model="config.llm_provider_model" class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm" placeholder="留空则使用默认模型">
</div>
<div class="flex justify-end space-x-4 pt-4">
<button type="button" @click="handleDelete" class="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors">删除配置</button>
<button type="submit" class="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors">保存</button>
</div>
</form>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { getLLMConfig, createOrUpdateLLMConfig, deleteLLMConfig, type LLMConfigCreate } from '@/api/llm';
const config = ref<LLMConfigCreate>({
llm_provider_url: '',
llm_provider_api_key: '',
llm_provider_model: '',
});
onMounted(async () => {
const existingConfig = await getLLMConfig();
if (existingConfig) {
config.value = {
llm_provider_url: existingConfig.llm_provider_url || '',
llm_provider_api_key: existingConfig.llm_provider_api_key || '',
llm_provider_model: existingConfig.llm_provider_model || '',
};
}
});
const handleSave = async () => {
await createOrUpdateLLMConfig(config.value);
alert('设置已保存!');
};
const handleDelete = async () => {
if (confirm('确定要删除您的自定义LLM配置吗删除后将恢复为默认配置。')) {
await deleteLLMConfig();
config.value = {
llm_provider_url: '',
llm_provider_api_key: '',
llm_provider_model: '',
};
alert('配置已删除!');
}
};
</script>

View File

@@ -0,0 +1,170 @@
<template>
<div
class="group bg-white rounded-xl border border-gray-200 p-5 hover:shadow-xl hover:-translate-y-1.5 transition-all duration-300 flex flex-col justify-between"
>
<div>
<div class="flex items-center gap-4 mb-4">
<div :class="themeClasses.bg" class="w-12 h-12 rounded-lg flex items-center justify-center">
<svg
xmlns="http://www.w3.org/2000/svg"
:class="themeClasses.text"
class="h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
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.246 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"
/>
</svg>
</div>
<div class="flex-1 cursor-pointer" @click="$emit('detail', project.id)">
<h3 class="font-bold text-lg text-gray-900 hover:text-indigo-600 transition-colors">{{ project.title }}</h3>
<p class="text-sm text-gray-500">
{{ project.genre || '未知类型' }} | {{ getStatusText }}
</p>
<p class="text-xs text-gray-400 mt-1">
最后编辑: {{ project.last_edited }}
</p>
</div>
</div>
<div class="mb-4">
<div class="flex justify-between text-sm text-gray-600 mb-1">
<span>完成进度</span>
<span>{{ progress }}%</span>
</div>
<div class="w-full bg-gray-200 rounded-full h-2">
<div :class="themeClasses.progress" class="h-2 rounded-full transition-all duration-300" :style="{ width: `${progress}%` }"></div>
</div>
</div>
<!-- 项目信息标签 -->
<div class="flex flex-wrap gap-2 mb-4">
<span v-if="project.genre"
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
{{ project.genre }}
</span>
<span v-if="chapterCount > 0"
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
{{ chapterCount }} 章节
</span>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-600">
{{ project.last_edited }}
</span>
</div>
</div>
<!-- 操作按钮区域 -->
<div class="flex gap-2 opacity-0 group-hover:opacity-100 transition-all duration-300 transform translate-y-2 group-hover:translate-y-0">
<button
@click.stop="$emit('detail', project.id)"
class="flex-1 bg-gray-100 hover:bg-gray-200 text-gray-700 text-sm font-medium py-2 px-4 rounded-lg transition-colors duration-200 flex items-center justify-center gap-1"
>
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path d="M10 12a2 2 0 100-4 2 2 0 000 4z"></path>
<path fill-rule="evenodd" d="M.458 10C1.732 5.943 5.522 3 10 3s8.268 2.943 9.542 7c-1.274 4.057-5.064 7-9.542 7S1.732 14.057.458 10zM14 10a4 4 0 11-8 0 4 4 0 018 0z" clip-rule="evenodd"></path>
</svg>
查看详情
</button>
<button
@click.stop="handleDelete"
class="px-3 py-2 bg-red-100 hover:bg-red-200 text-red-600 rounded-lg transition-colors duration-200 flex items-center justify-center"
title="删除项目"
>
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M9 2a1 1 0 000 2h2a1 1 0 100-2H9z" clip-rule="evenodd"></path>
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8 7a1 1 0 012 0v4a1 1 0 11-2 0V7zM12 7a1 1 0 012 0v4a1 1 0 11-2 0V7z" clip-rule="evenodd"></path>
</svg>
</button>
<button
@click.stop="$emit('continue', project)"
:class="[themeClasses.bg, themeClasses.text, 'flex-1 text-sm font-semibold py-2 px-4 rounded-lg transition-colors duration-200 flex items-center justify-center gap-1 hover:opacity-80']"
>
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z"></path>
</svg>
继续创作
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { NovelProjectSummary } from '@/api/novel'
interface Props {
project: NovelProjectSummary
}
const props = defineProps<Props>()
const emit = defineEmits<{
(e: 'click', id: string): void
(e: 'detail', id: string): void
(e: 'continue', project: NovelProjectSummary): void
(e: 'delete', id: string): void
}>()
const themeClasses = computed(() => {
const colors = {
'科幻': { bg: 'bg-indigo-100', text: 'text-indigo-600', progress: 'bg-indigo-500' },
'悬疑': { bg: 'bg-teal-100', text: 'text-teal-600', progress: 'bg-teal-500' },
'奇幻': { bg: 'bg-green-100', text: 'text-green-600', progress: 'bg-green-500' },
'东方奇幻': { bg: 'bg-purple-100', text: 'text-purple-600', progress: 'bg-purple-500' },
'穿越': { bg: 'bg-pink-100', text: 'text-pink-600', progress: 'bg-pink-500' },
default: { bg: 'bg-gray-100', text: 'text-gray-600', progress: 'bg-gray-500' }
}
// 使用后端返回的 genre 字段进行匹配
const genre = props.project.genre || ''
const genreKey = Object.keys(colors).find(key => key !== 'default' && genre.includes(key)) || 'default'
return colors[genreKey as keyof typeof colors]
})
// 使用后端预计算的进度数据
const progress = computed(() => {
const { completed_chapters, total_chapters } = props.project
return total_chapters > 0 ? Math.round((completed_chapters / total_chapters) * 100) : 0
})
const getStatusText = computed(() => {
const { completed_chapters, total_chapters } = props.project
if (completed_chapters > 0) {
return `已完成 ${completed_chapters}/${total_chapters}`
} else if (total_chapters > 0) {
return '准备创作'
} else {
return '蓝图完成'
}
})
// 使用后端返回的预计算数据
const chapterCount = computed(() => {
return props.project.total_chapters
})
// 由于 NovelProjectSummary 没有 characters 信息,我们暂时返回 0 或者隐藏这个标签
const characterCount = computed(() => {
return 0 // 后端 Summary 没有提供角色数量
})
const handleDelete = () => {
emit('delete', props.project.id)
}
</script>
<style scoped>
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
</style>

View File

@@ -0,0 +1,80 @@
<template>
<div class="space-y-4 max-h-96 overflow-y-auto p-1">
<div v-for="(relationship, index) in localRelationships" :key="index" class="p-4 border border-gray-200 rounded-lg bg-gray-50 relative">
<button @click="removeRelationship(index)" class="absolute top-2 right-2 text-red-400 hover:text-red-600 transition-colors p-1">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm4 0a1 1 0 012 0v6a1 1 0 11-2 0V8z" clip-rule="evenodd" />
</svg>
</button>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-2">
<div>
<label class="block text-sm font-medium text-gray-600 mb-1"></label>
<input type="text" v-model="relationship.character_from" class="w-full p-1 border-b-2 border-gray-300 focus:border-indigo-500 outline-none transition bg-transparent" placeholder="例如:林远" />
</div>
<div>
<label class="block text-sm font-medium text-gray-600 mb-1"></label>
<input type="text" v-model="relationship.character_to" class="w-full p-1 border-b-2 border-gray-300 focus:border-indigo-500 outline-none transition bg-transparent" placeholder="例如:苏晴" />
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-600 mb-1">关系描述</label>
<textarea
v-model="relationship.description"
class="w-full h-20 p-2 mt-1 border border-gray-300 rounded-md focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 transition text-sm"
placeholder="关于这段关系的详细描述..."
></textarea>
</div>
</div>
<button @click="addRelationship" class="w-full mt-4 px-4 py-2 text-sm font-medium text-indigo-600 bg-indigo-50 border border-indigo-200 rounded-md hover:bg-indigo-100 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
+ 添加新关系
</button>
</div>
</template>
<script setup lang="ts">
import { ref, watch, defineProps, defineEmits, nextTick } from 'vue';
interface Relationship {
character_from: string;
character_to: string;
description: string;
}
const props = defineProps({
modelValue: {
type: Array as () => Relationship[],
default: () => []
}
});
const emit = defineEmits(['update:modelValue']);
const localRelationships = ref<Relationship[]>([]);
let syncing = false;
watch(() => props.modelValue, (newVal) => {
syncing = true;
localRelationships.value = JSON.parse(JSON.stringify(newVal || []));
nextTick(() => {
syncing = false;
});
}, { immediate: true });
watch(localRelationships, (newVal) => {
if (syncing) return;
emit('update:modelValue', JSON.parse(JSON.stringify(newVal)));
}, { deep: true });
const addRelationship = () => {
localRelationships.value.push({
character_from: '',
character_to: '',
description: ''
});
};
const removeRelationship = (index: number) => {
localRelationships.value.splice(index, 1);
};
</script>

View File

@@ -0,0 +1,94 @@
<script setup lang="ts">
import WelcomeItem from './WelcomeItem.vue'
import DocumentationIcon from './icons/IconDocumentation.vue'
import ToolingIcon from './icons/IconTooling.vue'
import EcosystemIcon from './icons/IconEcosystem.vue'
import CommunityIcon from './icons/IconCommunity.vue'
import SupportIcon from './icons/IconSupport.vue'
const openReadmeInEditor = () => fetch('/__open-in-editor?file=README.md')
</script>
<template>
<WelcomeItem>
<template #icon>
<DocumentationIcon />
</template>
<template #heading>Documentation</template>
Vues
<a href="https://vuejs.org/" target="_blank" rel="noopener">official documentation</a>
provides you with all information you need to get started.
</WelcomeItem>
<WelcomeItem>
<template #icon>
<ToolingIcon />
</template>
<template #heading>Tooling</template>
This project is served and bundled with
<a href="https://vite.dev/guide/features.html" target="_blank" rel="noopener">Vite</a>. The
recommended IDE setup is
<a href="https://code.visualstudio.com/" target="_blank" rel="noopener">VSCode</a>
+
<a href="https://github.com/vuejs/language-tools" target="_blank" rel="noopener">Vue - Official</a>. If
you need to test your components and web pages, check out
<a href="https://vitest.dev/" target="_blank" rel="noopener">Vitest</a>
and
<a href="https://www.cypress.io/" target="_blank" rel="noopener">Cypress</a>
/
<a href="https://playwright.dev/" target="_blank" rel="noopener">Playwright</a>.
<br />
More instructions are available in
<a href="javascript:void(0)" @click="openReadmeInEditor"><code>README.md</code></a
>.
</WelcomeItem>
<WelcomeItem>
<template #icon>
<EcosystemIcon />
</template>
<template #heading>Ecosystem</template>
Get official tools and libraries for your project:
<a href="https://pinia.vuejs.org/" target="_blank" rel="noopener">Pinia</a>,
<a href="https://router.vuejs.org/" target="_blank" rel="noopener">Vue Router</a>,
<a href="https://test-utils.vuejs.org/" target="_blank" rel="noopener">Vue Test Utils</a>, and
<a href="https://github.com/vuejs/devtools" target="_blank" rel="noopener">Vue Dev Tools</a>. If
you need more resources, we suggest paying
<a href="https://github.com/vuejs/awesome-vue" target="_blank" rel="noopener">Awesome Vue</a>
a visit.
</WelcomeItem>
<WelcomeItem>
<template #icon>
<CommunityIcon />
</template>
<template #heading>Community</template>
Got stuck? Ask your question on
<a href="https://chat.vuejs.org" target="_blank" rel="noopener">Vue Land</a>
(our official Discord server), or
<a href="https://stackoverflow.com/questions/tagged/vue.js" target="_blank" rel="noopener"
>StackOverflow</a
>. You should also follow the official
<a href="https://bsky.app/profile/vuejs.org" target="_blank" rel="noopener">@vuejs.org</a>
Bluesky account or the
<a href="https://x.com/vuejs" target="_blank" rel="noopener">@vuejs</a>
X account for latest news in the Vue world.
</WelcomeItem>
<WelcomeItem>
<template #icon>
<SupportIcon />
</template>
<template #heading>Support Vue</template>
As an independent project, Vue relies on community backing for its sustainability. You can help
us by
<a href="https://vuejs.org/sponsor/" target="_blank" rel="noopener">becoming a sponsor</a>.
</WelcomeItem>
</template>

View File

@@ -0,0 +1,100 @@
<template>
<div ref="triggerRef" class="inline-block" @mouseenter="onMouseEnter" @mouseleave="onMouseLeave">
<slot></slot>
<Teleport to="body">
<transition
enter-active-class="transition ease-out duration-200"
enter-from-class="transform opacity-0 scale-95"
enter-to-class="transform opacity-100 scale-100"
leave-active-class="transition ease-in duration-150"
leave-from-class="transform opacity-100 scale-100"
leave-to-class="transform opacity-0 scale-95"
>
<div
v-if="showTooltip && text"
ref="tooltipRef"
:style="tooltipStyle"
class="fixed z-50 p-3 text-sm leading-tight text-white bg-gray-800 rounded-lg shadow-lg max-w-xs"
@mouseenter="onTooltipEnter"
@mouseleave="onTooltipLeave"
>
{{ text }}
</div>
</transition>
</Teleport>
</div>
</template>
<script setup lang="ts">
import { ref, nextTick, computed } from 'vue'
interface Props {
text?: string
}
const props = defineProps<Props>()
const showTooltip = ref(false)
const triggerRef = ref<HTMLElement | null>(null)
const tooltipRef = ref<HTMLElement | null>(null)
const tooltipPosition = ref({ top: 0, left: 0 })
const tooltipStyle = computed(() => ({
top: `${tooltipPosition.value.top}px`,
left: `${tooltipPosition.value.left}px`,
}))
let leaveTimeout: NodeJS.Timeout
let enterTimeout: NodeJS.Timeout
const onMouseEnter = () => {
clearTimeout(leaveTimeout)
enterTimeout = setTimeout(async () => {
showTooltip.value = true
await nextTick()
updatePosition()
}, 1000) // 延迟1秒显示
}
const onMouseLeave = () => {
clearTimeout(enterTimeout) // 清除进入的计时器
leaveTimeout = setTimeout(() => {
showTooltip.value = false
}, 200) // 增加延时以便鼠标可以移动到 tooltip 上
}
const onTooltipEnter = () => {
clearTimeout(leaveTimeout)
}
const onTooltipLeave = () => {
showTooltip.value = false
}
const updatePosition = () => {
if (!triggerRef.value || !tooltipRef.value) return
const triggerRect = triggerRef.value.getBoundingClientRect()
const tooltipRect = tooltipRef.value.getBoundingClientRect()
let top = triggerRect.top - tooltipRect.height - 8 // 默认在上方,留 8px 间距
let left = triggerRect.left + (triggerRect.width / 2) - (tooltipRect.width / 2)
// 如果上方空间不足,则显示在下方
if (top < 0) {
top = triggerRect.bottom + 8
}
// 如果左侧超出屏幕,则向右对齐
if (left < 0) {
left = 8
}
// 如果右侧超出屏幕,则向左对齐
if (left + tooltipRect.width > window.innerWidth) {
left = window.innerWidth - tooltipRect.width - 8
}
tooltipPosition.value = { top, left }
}
</script>

View File

@@ -0,0 +1,63 @@
<template>
<h1 class="typewriter text-4xl md:text-5xl font-extrabold text-center text-gray-800 tracking-wider" :style="{ '--char-count': fullText.length }">
{{ displayedText }}
</h1>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
const props = defineProps({
text: {
type: String,
required: true,
},
});
const fullText = props.text;
const displayedText = ref('');
let index = 0;
onMounted(() => {
const interval = setInterval(() => {
if (index < fullText.length) {
displayedText.value += fullText.charAt(index);
index++;
} else {
clearInterval(interval);
}
}, 150); // Adjust typing speed here
});
</script>
<style scoped>
.typewriter {
display: inline-block;
overflow: hidden;
white-space: nowrap;
border-right: 0.1em solid #333; /* Blinking cursor */
animation: typing 2s steps(var(--char-count, 10), end), blink-caret 0.75s step-end infinite;
width: 100%;
}
/* Typing effect */
@keyframes typing {
from {
width: 0;
}
to {
width: 100%;
}
}
/* Cursor blinking effect */
@keyframes blink-caret {
from,
to {
border-color: transparent;
}
50% {
border-color: #333;
}
}
</style>

View File

@@ -0,0 +1,87 @@
<template>
<div class="item">
<i>
<slot name="icon"></slot>
</i>
<div class="details">
<h3>
<slot name="heading"></slot>
</h3>
<slot></slot>
</div>
</div>
</template>
<style scoped>
.item {
margin-top: 2rem;
display: flex;
position: relative;
}
.details {
flex: 1;
margin-left: 1rem;
}
i {
display: flex;
place-items: center;
place-content: center;
width: 32px;
height: 32px;
color: var(--color-text);
}
h3 {
font-size: 1.2rem;
font-weight: 500;
margin-bottom: 0.4rem;
color: var(--color-heading);
}
@media (min-width: 1024px) {
.item {
margin-top: 0;
padding: 0.4rem 0 1rem calc(var(--section-gap) / 2);
}
i {
top: calc(50% - 25px);
left: -26px;
position: absolute;
border: 1px solid var(--color-border);
background: var(--color-background);
border-radius: 8px;
width: 50px;
height: 50px;
}
.item:before {
content: ' ';
border-left: 1px solid var(--color-border);
position: absolute;
left: 0;
bottom: calc(50% + 25px);
height: calc(50% - 25px);
}
.item:after {
content: ' ';
border-left: 1px solid var(--color-border);
position: absolute;
left: 0;
top: calc(50% + 25px);
height: calc(50% - 25px);
}
.item:first-of-type:before {
display: none;
}
.item:last-of-type:after {
display: none;
}
}
</style>

View File

@@ -0,0 +1,309 @@
<template>
<n-card class="novel-management-card" size="large" :bordered="false">
<template #header>
<div class="card-header">
<span class="card-title">小说管理</span>
<n-tag size="small" type="primary" round> {{ novels.length }} </n-tag>
</div>
</template>
<n-space vertical size="large">
<n-alert v-if="error" type="error" closable @close="error = null">
{{ error }}
</n-alert>
<n-spin :show="loading">
<template #default>
<n-empty
v-if="!novels.length && !loading"
description="暂无小说项目"
class="empty-state"
/>
<div v-else>
<n-space v-if="isMobile" vertical size="large">
<n-card
v-for="novel in novels"
:key="novel.id"
size="small"
embedded
class="novel-card"
>
<template #header>
<div class="mobile-card-header">
<span class="mobile-card-title">{{ novel.title }}</span>
<n-tag size="small" type="info" round>{{ novel.genre || '未分类' }}</n-tag>
</div>
</template>
<div class="mobile-meta">
<span class="mobile-label">编号</span>
<span class="mobile-value">{{ novel.id }}</span>
</div>
<div class="mobile-meta">
<span class="mobile-label">创作者</span>
<span class="mobile-value">{{ novel.owner_username }}</span>
</div>
<div class="mobile-meta">
<span class="mobile-label">进度</span>
<span class="mobile-value">{{ formatProgress(novel) }}</span>
</div>
<div class="mobile-meta">
<span class="mobile-label">最近更新</span>
<span class="mobile-value">{{ formatDate(novel.last_edited) }}</span>
</div>
<template #footer>
<n-button type="primary" size="small" block @click="viewDetails(novel.id)">
查看详情
</n-button>
</template>
</n-card>
</n-space>
<n-data-table
v-else
:columns="columns"
:data="novels"
:pagination="pagination"
:bordered="false"
size="small"
class="novel-table"
/>
</div>
</template>
</n-spin>
</n-space>
</n-card>
</template>
<script setup lang="ts">
import { h, onBeforeUnmount, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import {
NAlert,
NButton,
NCard,
NDataTable,
NEmpty,
NSpin,
NTag,
NSpace,
type DataTableColumns
} from 'naive-ui'
import { AdminAPI } from '@/api/admin'
import type { AdminNovelSummary } from '@/api/admin'
const novels = ref<AdminNovelSummary[]>([])
const loading = ref(true)
const error = ref<string | null>(null)
const isMobile = ref(false)
const router = useRouter()
const pagination = {
pageSize: 8,
showSizePicker: false
}
const updateLayout = () => {
isMobile.value = window.innerWidth < 768
}
const formatDate = (value: string | null | undefined) => {
if (!value) return '未记录'
const date = new Date(value)
return Number.isNaN(date.getTime()) ? '未记录' : date.toLocaleString()
}
const formatProgress = (novel: Pick<AdminNovelSummary, 'completed_chapters' | 'total_chapters'>) => {
const total = novel.total_chapters || 0
const completed = novel.completed_chapters || 0
return `${completed} / ${total}`
}
const viewDetails = (novelId: string) => {
router.push(`/admin/novel/${novelId}`)
}
const columns: DataTableColumns<AdminNovelSummary> = [
{
title: '项目',
key: 'title',
ellipsis: { tooltip: true },
render(row) {
return h('div', { class: 'table-title-cell' }, [
h('div', { class: 'table-title' }, row.title),
h('div', { class: 'table-subtitle' }, row.id)
])
}
},
{
title: '类型',
key: 'genre',
render(row) {
return h(
NTag,
{ type: 'info', size: 'small', round: true, bordered: false },
{ default: () => row.genre || '未分类' }
)
}
},
{
title: '创作者',
key: 'owner_username',
render(row) {
return h('span', { class: 'table-owner' }, row.owner_username)
}
},
{
title: '进度',
key: 'progress',
render(row) {
return h('span', { class: 'table-progress' }, formatProgress(row))
}
},
{
title: '最近更新',
key: 'last_edited',
render(row) {
return h('span', { class: 'table-date' }, formatDate(row.last_edited))
}
},
{
title: '操作',
key: 'actions',
align: 'center',
render(row) {
return h(
NButton,
{
size: 'small',
type: 'primary',
tertiary: true,
onClick: () => viewDetails(row.id)
},
{ default: () => '详情' }
)
}
}
]
const fetchNovels = async () => {
loading.value = true
error.value = null
try {
novels.value = await AdminAPI.listNovels()
} catch (e) {
error.value = e instanceof Error ? e.message : '获取小说数据失败'
} finally {
loading.value = false
}
}
onMounted(() => {
updateLayout()
window.addEventListener('resize', updateLayout)
fetchNovels()
})
onBeforeUnmount(() => {
window.removeEventListener('resize', updateLayout)
})
</script>
<style scoped>
.novel-management-card {
width: 100%;
box-sizing: border-box;
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.card-title {
font-size: 1.25rem;
font-weight: 600;
color: #1f2937;
}
.novel-table {
width: 100%;
}
.table-title-cell {
display: flex;
flex-direction: column;
gap: 4px;
}
.table-title {
font-weight: 600;
color: #111827;
}
.table-subtitle {
font-size: 0.75rem;
color: #6b7280;
word-break: break-all;
}
.table-owner,
.table-progress,
.table-date {
color: #374151;
}
.novel-card {
border-radius: 16px;
}
.mobile-card-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.mobile-card-title {
font-size: 1rem;
font-weight: 600;
color: #111827;
}
.mobile-meta {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 10px;
font-size: 0.875rem;
color: #4b5563;
word-break: break-word;
}
.mobile-label {
color: #6b7280;
}
.mobile-value {
color: #111827;
font-weight: 500;
text-align: right;
margin-left: 12px;
}
.empty-state {
padding: 48px 0;
}
@media (max-width: 767px) {
.card-header {
align-items: flex-start;
flex-direction: column;
}
.card-title {
font-size: 1.125rem;
}
}
</style>

View File

@@ -0,0 +1,155 @@
<template>
<n-space vertical size="large" class="password-container">
<n-card :bordered="false" class="password-card">
<template #header>
<div class="card-header">
<span class="card-title">管理员密码修改</span>
</div>
</template>
<n-alert v-if="mustReset" type="warning" class="mb-4">
为保障安全请先更新默认密码后再继续使用管理后台
</n-alert>
<n-alert v-if="error" type="error" closable @close="error = null" class="mb-4">
{{ error }}
</n-alert>
<n-spin :show="submitting">
<n-form class="password-form" label-placement="top" @submit.prevent="handleSubmit">
<n-form-item label="当前密码">
<n-input
v-model:value="form.oldPassword"
type="password"
show-password-on="click"
placeholder="请输入当前管理员密码"
autocomplete="current-password"
/>
</n-form-item>
<n-form-item label="新密码">
<n-input
v-model:value="form.newPassword"
type="password"
show-password-on="click"
placeholder="请输入至少 8 位新密码"
autocomplete="new-password"
/>
</n-form-item>
<n-form-item label="确认新密码">
<n-input
v-model:value="form.confirmPassword"
type="password"
show-password-on="click"
placeholder="请再次输入新密码"
autocomplete="new-password"
/>
</n-form-item>
<n-space justify="end">
<n-button type="primary" :loading="submitting" @click="handleSubmit">
保存新密码
</n-button>
</n-space>
</n-form>
</n-spin>
</n-card>
</n-space>
</template>
<script setup lang="ts">
import { computed, reactive, ref } from 'vue'
import { NAlert, NButton, NCard, NForm, NFormItem, NInput, NSpace, NSpin } from 'naive-ui'
import { AdminAPI } from '@/api/admin'
import { useAlert } from '@/composables/useAlert'
import { useAuthStore } from '@/stores/auth'
const authStore = useAuthStore()
const { showAlert } = useAlert()
const form = reactive({
oldPassword: '',
newPassword: '',
confirmPassword: ''
})
const submitting = ref(false)
const error = ref<string | null>(null)
const mustReset = computed(() => authStore.mustChangePassword && authStore.user?.is_admin)
const resetForm = () => {
form.oldPassword = ''
form.newPassword = ''
form.confirmPassword = ''
}
const handleSubmit = async () => {
error.value = null
if (!form.oldPassword.trim() || !form.newPassword.trim()) {
error.value = '请填写完整的密码信息'
return
}
if (form.newPassword.length < 8) {
error.value = '新密码长度需至少 8 位'
return
}
if (form.newPassword === form.oldPassword) {
error.value = '新密码不能与当前密码相同'
return
}
if (form.newPassword !== form.confirmPassword) {
error.value = '两次输入的新密码不一致'
return
}
submitting.value = true
try {
await AdminAPI.changePassword(form.oldPassword, form.newPassword)
await authStore.fetchUser()
resetForm()
await showAlert('密码已更新,请使用新密码继续操作', 'success')
} catch (err) {
error.value = err instanceof Error ? err.message : '密码更新失败'
} finally {
submitting.value = false
}
}
</script>
<style scoped>
.password-container {
max-width: 520px;
margin: 0 auto;
}
.password-card {
border-radius: 16px;
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.card-title {
font-size: 1.2rem;
font-weight: 600;
color: #1f2937;
}
.password-form {
max-width: 420px;
}
.mb-4 {
margin-bottom: 16px;
}
</style>

View File

@@ -0,0 +1,421 @@
<template>
<n-card :bordered="false" class="admin-card">
<template #header>
<div class="card-header">
<span class="card-title">提示词管理</span>
<n-space :size="12">
<n-button quaternary size="small" @click="fetchPrompts" :loading="loading">
刷新
</n-button>
<n-button type="primary" size="small" @click="openCreateModal">
新建 Prompt
</n-button>
</n-space>
</div>
</template>
<n-space vertical size="large">
<n-alert v-if="error" type="error" closable @close="error = null">
{{ error }}
</n-alert>
<n-spin :show="loading">
<div :class="['prompt-layout', { mobile: isMobile }]">
<div class="prompt-sidebar">
<n-scrollbar class="prompt-scroll">
<n-empty v-if="!prompts.length && !loading" description="暂无提示词" />
<n-space v-else vertical size="small">
<n-button
v-for="prompt in prompts"
:key="prompt.id"
type="primary"
:ghost="selectedPrompt?.id !== prompt.id"
quaternary
block
@click="selectPrompt(prompt)"
>
<div class="prompt-item">
<span class="prompt-name">{{ prompt.title || prompt.name }}</span>
<n-tag v-if="prompt.tags?.length" size="tiny" type="info">
{{ prompt.tags.length }}
</n-tag>
</div>
</n-button>
</n-space>
</n-scrollbar>
</div>
<div class="prompt-editor">
<div v-if="!selectedPrompt" class="empty-editor">
<n-empty description="请选择一个提示词以编辑" />
</div>
<div v-else class="editor-content">
<n-form label-placement="top" :model="editForm">
<n-form-item label="唯一标识">
<n-input v-model:value="editForm.name" disabled />
</n-form-item>
<n-form-item label="标题">
<n-input
v-model:value="editForm.title"
placeholder="用于后台识别的标题,可为空"
/>
</n-form-item>
<n-form-item label="标签">
<n-dynamic-tags
v-model:value="editForm.tags"
size="small"
placeholder="输入标签后回车"
/>
</n-form-item>
<n-form-item label="提示词内容">
<n-input
v-model:value="editForm.content"
type="textarea"
:autosize="{ minRows: isMobile ? 8 : 16, maxRows: 40 }"
placeholder="请输入完整的提示词内容..."
class="prompt-textarea"
/>
</n-form-item>
</n-form>
<n-space justify="end">
<n-popconfirm
v-if="selectedPrompt"
placement="bottom"
positive-text="删除"
negative-text="取消"
type="error"
@positive-click="deletePrompt"
>
<template #trigger>
<n-button type="error" quaternary :loading="deleting">
删除
</n-button>
</template>
确认删除该 Prompt
</n-popconfirm>
<n-button type="primary" :loading="saving" @click="savePrompt">
保存修改
</n-button>
</n-space>
</div>
</div>
</div>
</n-spin>
</n-space>
</n-card>
<n-modal v-model:show="createModalVisible" preset="card" title="新建 Prompt" class="prompt-modal">
<n-form label-placement="top" :model="createForm">
<n-form-item label="唯一标识(必填)">
<n-input v-model:value="createForm.name" placeholder="例如 concept / outline" />
</n-form-item>
<n-form-item label="标题">
<n-input v-model:value="createForm.title" placeholder="可选,用于后台展示" />
</n-form-item>
<n-form-item label="标签">
<n-dynamic-tags
v-model:value="createForm.tags"
size="small"
placeholder="输入标签后回车"
/>
</n-form-item>
<n-form-item label="内容">
<n-input
v-model:value="createForm.content"
type="textarea"
:autosize="{ minRows: 10, maxRows: 30 }"
placeholder="输入提示词内容..."
/>
</n-form-item>
</n-form>
<template #footer>
<n-space justify="end">
<n-button quaternary @click="closeCreateModal">取消</n-button>
<n-button type="primary" :loading="creating" @click="createPrompt">创建</n-button>
</n-space>
</template>
</n-modal>
</template>
<script setup lang="ts">
import { onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import {
NAlert,
NButton,
NCard,
NDynamicTags,
NEmpty,
NForm,
NFormItem,
NInput,
NModal,
NPopconfirm,
NScrollbar,
NSpace,
NSpin,
NTag
} from 'naive-ui'
import { AdminAPI, type PromptCreatePayload, type PromptItem } from '@/api/admin'
import { useAlert } from '@/composables/useAlert'
const { showAlert } = useAlert()
const prompts = ref<PromptItem[]>([])
const selectedPrompt = ref<PromptItem | null>(null)
const loading = ref(false)
const saving = ref(false)
const deleting = ref(false)
const creating = ref(false)
const error = ref<string | null>(null)
const editForm = reactive({
name: '',
title: '',
content: '',
tags: [] as string[]
})
const createModalVisible = ref(false)
const createForm = reactive<PromptCreatePayload>({
name: '',
title: '',
content: '',
tags: []
})
const isMobile = ref(false)
const updateLayout = () => {
isMobile.value = window.innerWidth < 920
}
const fetchPrompts = async () => {
loading.value = true
error.value = null
try {
prompts.value = await AdminAPI.listPrompts()
if (selectedPrompt.value) {
const refreshed = prompts.value.find((item) => item.id === selectedPrompt.value?.id)
if (refreshed) {
selectPrompt(refreshed)
} else {
resetSelection()
}
} else if (prompts.value.length) {
selectPrompt(prompts.value[0])
}
} catch (err) {
error.value = err instanceof Error ? err.message : '获取提示词列表失败'
} finally {
loading.value = false
}
}
const resetSelection = () => {
selectedPrompt.value = null
editForm.name = ''
editForm.title = ''
editForm.content = ''
editForm.tags = []
}
const selectPrompt = (prompt: PromptItem) => {
selectedPrompt.value = prompt
editForm.name = prompt.name
editForm.title = prompt.title || ''
editForm.content = prompt.content
editForm.tags = prompt.tags ? [...prompt.tags] : []
}
const savePrompt = async () => {
if (!selectedPrompt.value) return
if (!editForm.content.trim()) {
showAlert('提示词内容不能为空', 'error')
return
}
saving.value = true
try {
const updated = await AdminAPI.updatePrompt(selectedPrompt.value.id, {
title: editForm.title || undefined,
content: editForm.content,
tags: editForm.tags
})
selectPrompt(updated)
const index = prompts.value.findIndex((item) => item.id === updated.id)
if (index !== -1) {
prompts.value.splice(index, 1, updated)
}
showAlert('保存成功', 'success')
} catch (err) {
showAlert(err instanceof Error ? err.message : '保存失败', 'error')
} finally {
saving.value = false
}
}
const deletePrompt = async () => {
if (!selectedPrompt.value) return
deleting.value = true
try {
await AdminAPI.deletePrompt(selectedPrompt.value.id)
showAlert('删除成功', 'success')
prompts.value = prompts.value.filter((item) => item.id !== selectedPrompt.value?.id)
resetSelection()
} catch (err) {
showAlert(err instanceof Error ? err.message : '删除失败', 'error')
} finally {
deleting.value = false
}
}
const openCreateModal = () => {
createModalVisible.value = true
}
const closeCreateModal = () => {
createModalVisible.value = false
createForm.name = ''
createForm.title = ''
createForm.content = ''
createForm.tags = []
}
const createPrompt = async () => {
if (!createForm.name.trim() || !createForm.content.trim()) {
showAlert('名称与内容均为必填项', 'error')
return
}
creating.value = true
try {
const created = await AdminAPI.createPrompt({
name: createForm.name.trim(),
title: createForm.title?.trim() || undefined,
content: createForm.content,
tags: createForm.tags?.length ? [...createForm.tags] : undefined
})
prompts.value.unshift(created)
selectPrompt(created)
showAlert('创建成功', 'success')
closeCreateModal()
} catch (err) {
showAlert(err instanceof Error ? err.message : '创建失败', 'error')
} finally {
creating.value = false
}
}
onMounted(() => {
updateLayout()
window.addEventListener('resize', updateLayout)
fetchPrompts()
})
onBeforeUnmount(() => {
window.removeEventListener('resize', updateLayout)
})
</script>
<style scoped>
.admin-card {
width: 100%;
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 16px;
}
.card-title {
font-size: 1.25rem;
font-weight: 600;
color: #1f2937;
}
.prompt-layout {
display: flex;
align-items: stretch;
gap: 20px;
min-height: 420px;
}
.prompt-layout.mobile {
flex-direction: column;
}
.prompt-sidebar {
width: 260px;
flex-shrink: 0;
}
.prompt-layout.mobile .prompt-sidebar {
width: 100%;
max-height: 220px;
}
.prompt-scroll {
max-height: 520px;
}
.prompt-layout.mobile .prompt-scroll {
max-height: 200px;
}
.prompt-item {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
font-weight: 500;
}
.prompt-name {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
margin-right: 8px;
}
.prompt-editor {
flex: 1;
min-width: 0;
}
.empty-editor {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
padding: 48px 0;
}
.editor-content {
display: flex;
flex-direction: column;
gap: 16px;
}
.prompt-textarea :deep(textarea) {
font-family: 'Fira Code', 'JetBrains Mono', 'SFMono-Regular', Menlo, Monaco, Consolas, monospace;
font-size: 14px;
line-height: 1.5;
}
.prompt-modal {
max-width: min(720px, 90vw);
}
@media (max-width: 1023px) {
.prompt-sidebar {
width: 220px;
}
}
@media (max-width: 767px) {
.card-title {
font-size: 1.125rem;
}
}
</style>

View File

@@ -0,0 +1,355 @@
<template>
<n-space vertical size="large" class="admin-settings">
<n-card :bordered="false">
<template #header>
<div class="card-header">
<span class="card-title">每日请求额度</span>
<n-button quaternary size="small" @click="fetchDailyLimit" :loading="dailyLimitLoading">
刷新
</n-button>
</div>
</template>
<n-spin :show="dailyLimitLoading">
<n-alert v-if="dailyLimitError" type="error" closable @close="dailyLimitError = null">
{{ dailyLimitError }}
</n-alert>
<n-form label-placement="top" class="limit-form">
<n-form-item label="未配置 API Key 的用户每日可用请求次数">
<n-input-number
v-model:value="dailyLimit"
:min="0"
:step="10"
placeholder="请输入每日请求上限"
/>
</n-form-item>
<n-space justify="end">
<n-button type="primary" :loading="dailyLimitSaving" @click="saveDailyLimit">
保存设置
</n-button>
</n-space>
</n-form>
</n-spin>
</n-card>
<n-card :bordered="false">
<template #header>
<div class="card-header">
<span class="card-title">系统配置</span>
<n-button type="primary" size="small" @click="openCreateModal">
新增配置
</n-button>
</div>
</template>
<n-spin :show="configLoading">
<n-alert v-if="configError" type="error" closable @close="configError = null">
{{ configError }}
</n-alert>
<n-data-table
:columns="columns"
:data="configs"
:loading="configLoading"
:bordered="false"
:row-key="rowKey"
class="config-table"
/>
</n-spin>
</n-card>
</n-space>
<n-modal
v-model:show="configModalVisible"
preset="card"
:title="modalTitle"
class="config-modal"
:style="{ width: '520px', maxWidth: '92vw' }"
>
<n-form label-placement="top" :model="configForm">
<n-form-item label="Key">
<n-input
v-model:value="configForm.key"
:disabled="!isCreateMode"
placeholder="请输入唯一 Key"
/>
</n-form-item>
<n-form-item label="值">
<n-input v-model:value="configForm.value" placeholder="配置的具体值" />
</n-form-item>
<n-form-item label="描述">
<n-input v-model:value="configForm.description" placeholder="配置项的用途说明,可选" />
</n-form-item>
</n-form>
<template #footer>
<n-space justify="end">
<n-button quaternary @click="closeConfigModal">取消</n-button>
<n-button type="primary" :loading="configSaving" @click="submitConfig">
保存
</n-button>
</n-space>
</template>
</n-modal>
</template>
<script setup lang="ts">
import { computed, h, onMounted, reactive, ref } from 'vue'
import {
NAlert,
NButton,
NCard,
NDataTable,
NForm,
NFormItem,
NInput,
NInputNumber,
NModal,
NPopconfirm,
NSpace,
NSpin,
type DataTableColumns
} from 'naive-ui'
import {
AdminAPI,
type DailyRequestLimit,
type SystemConfig,
type SystemConfigUpdatePayload,
type SystemConfigUpsertPayload
} from '@/api/admin'
import { useAlert } from '@/composables/useAlert'
const { showAlert } = useAlert()
const dailyLimit = ref<number | null>(null)
const dailyLimitLoading = ref(false)
const dailyLimitSaving = ref(false)
const dailyLimitError = ref<string | null>(null)
const configs = ref<SystemConfig[]>([])
const configLoading = ref(false)
const configSaving = ref(false)
const configError = ref<string | null>(null)
const configModalVisible = ref(false)
const isCreateMode = ref(true)
const configForm = reactive<SystemConfig>({
key: '',
value: '',
description: ''
})
const rowKey = (row: SystemConfig) => row.key
const modalTitle = computed(() => (isCreateMode.value ? '新增配置项' : '编辑配置项'))
const fetchDailyLimit = async () => {
dailyLimitLoading.value = true
dailyLimitError.value = null
try {
const result = await AdminAPI.getDailyRequestLimit()
dailyLimit.value = result.limit
} catch (err) {
dailyLimitError.value = err instanceof Error ? err.message : '加载每日限制失败'
} finally {
dailyLimitLoading.value = false
}
}
const saveDailyLimit = async () => {
if (dailyLimit.value === null || dailyLimit.value < 0) {
showAlert('请设置有效的每日额度', 'error')
return
}
dailyLimitSaving.value = true
try {
await AdminAPI.setDailyRequestLimit(dailyLimit.value)
showAlert('每日额度已更新', 'success')
} catch (err) {
showAlert(err instanceof Error ? err.message : '保存失败', 'error')
} finally {
dailyLimitSaving.value = false
}
}
const fetchConfigs = async () => {
configLoading.value = true
configError.value = null
try {
configs.value = await AdminAPI.listSystemConfigs()
} catch (err) {
configError.value = err instanceof Error ? err.message : '加载配置失败'
} finally {
configLoading.value = false
}
}
const openCreateModal = () => {
isCreateMode.value = true
configForm.key = ''
configForm.value = ''
configForm.description = ''
configModalVisible.value = true
}
const openEditModal = (config: SystemConfig) => {
isCreateMode.value = false
configForm.key = config.key
configForm.value = config.value
configForm.description = config.description || ''
configModalVisible.value = true
}
const closeConfigModal = () => {
configModalVisible.value = false
configSaving.value = false
}
const submitConfig = async () => {
if (!configForm.key.trim() || !configForm.value.trim()) {
showAlert('Key 与 Value 均为必填项', 'error')
return
}
configSaving.value = true
try {
let updated: SystemConfig
if (isCreateMode.value) {
updated = await AdminAPI.upsertSystemConfig(configForm.key.trim(), {
value: configForm.value,
description: configForm.description || undefined
})
configs.value.unshift(updated)
} else {
updated = await AdminAPI.patchSystemConfig(configForm.key, {
value: configForm.value,
description: configForm.description || undefined
} as SystemConfigUpdatePayload)
const index = configs.value.findIndex((item) => item.key === updated.key)
if (index !== -1) {
configs.value.splice(index, 1, updated)
}
}
showAlert('配置已保存', 'success')
closeConfigModal()
} catch (err) {
showAlert(err instanceof Error ? err.message : '保存失败', 'error')
} finally {
configSaving.value = false
}
}
const deleteConfig = async (key: string) => {
try {
await AdminAPI.deleteSystemConfig(key)
configs.value = configs.value.filter((item) => item.key !== key)
showAlert('配置已删除', 'success')
} catch (err) {
showAlert(err instanceof Error ? err.message : '删除失败', 'error')
}
}
const columns: DataTableColumns<SystemConfig> = [
{
title: 'Key',
key: 'key',
width: 220,
ellipsis: { tooltip: true }
},
{
title: '值',
key: 'value',
ellipsis: { tooltip: true }
},
{
title: '描述',
key: 'description',
ellipsis: { tooltip: true },
render(row) {
return row.description || '—'
}
},
{
title: '操作',
key: 'actions',
align: 'center',
width: 160,
render(row) {
return h(
NSpace,
{ justify: 'center', size: 'small' },
{
default: () => [
h(
NButton,
{
size: 'small',
type: 'primary',
tertiary: true,
onClick: () => openEditModal(row)
},
{ default: () => '编辑' }
),
h(
NPopconfirm,
{
'positive-text': '删除',
'negative-text': '取消',
type: 'error',
placement: 'left',
onPositiveClick: () => deleteConfig(row.key)
},
{
default: () => '确认删除该配置项?',
trigger: () =>
h(
NButton,
{ size: 'small', type: 'error', quaternary: true },
{ default: () => '删除' }
)
}
)
]
}
)
}
}
]
onMounted(() => {
fetchDailyLimit()
fetchConfigs()
})
</script>
<style scoped>
.admin-settings {
width: 100%;
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
}
.card-title {
font-size: 1.25rem;
font-weight: 600;
color: #1f2937;
}
.limit-form {
max-width: 360px;
}
.config-modal {
max-width: min(640px, 92vw);
}
@media (max-width: 767px) {
.card-title {
font-size: 1.125rem;
}
}
</style>

View File

@@ -0,0 +1,146 @@
<template>
<n-card :bordered="false" class="admin-card">
<template #header>
<div class="card-header">
<span class="card-title">数据总览</span>
<n-button quaternary size="small" @click="fetchStats" :loading="loading">
刷新
</n-button>
</div>
</template>
<n-space vertical size="large">
<n-alert v-if="error" type="error" closable @close="error = null">
{{ error }}
</n-alert>
<n-spin :show="loading">
<n-grid :cols="gridCols" :x-gap="16" :y-gap="16">
<n-gi>
<n-card class="stat-card" :bordered="false">
<div class="stat-icon">📚</div>
<n-statistic label="小说总数" :value="stats?.novel_count ?? 0" show-separator>
<template #suffix></template>
</n-statistic>
</n-card>
</n-gi>
<n-gi>
<n-card class="stat-card" :bordered="false">
<div class="stat-icon">👥</div>
<n-statistic label="用户总数" :value="stats?.user_count ?? 0" show-separator>
<template #suffix></template>
</n-statistic>
</n-card>
</n-gi>
<n-gi>
<n-card class="stat-card" :bordered="false">
<div class="stat-icon"></div>
<n-statistic label="API 请求总数" :value="stats?.api_request_count ?? 0" show-separator>
<template #suffix></template>
</n-statistic>
</n-card>
</n-gi>
</n-grid>
</n-spin>
</n-space>
</n-card>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import {
NAlert,
NButton,
NCard,
NGi,
NGrid,
NSpin,
NStatistic,
NSpace
} from 'naive-ui'
import { AdminAPI, type Statistics } from '@/api/admin'
const stats = ref<Statistics | null>(null)
const loading = ref(false)
const error = ref<string | null>(null)
const isMobile = ref(false)
const updateLayout = () => {
isMobile.value = window.innerWidth < 768
}
const gridCols = computed(() => (isMobile.value ? 1 : 3))
const fetchStats = async () => {
loading.value = true
error.value = null
try {
stats.value = await AdminAPI.getStatistics()
} catch (err) {
error.value = err instanceof Error ? err.message : '获取统计数据失败'
} finally {
loading.value = false
}
}
onMounted(() => {
updateLayout()
window.addEventListener('resize', updateLayout)
fetchStats()
})
onBeforeUnmount(() => {
window.removeEventListener('resize', updateLayout)
})
</script>
<style scoped>
.admin-card {
width: 100%;
box-sizing: border-box;
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.card-title {
font-size: 1.25rem;
font-weight: 600;
color: #1f2937;
}
.stat-card {
display: flex;
align-items: center;
gap: 16px;
padding: 20px;
border-radius: 18px;
background: linear-gradient(135deg, rgba(79, 70, 229, 0.08), rgba(79, 70, 229, 0));
}
.stat-icon {
font-size: 28px;
line-height: 1;
}
@media (max-width: 767px) {
.card-header {
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
.card-title {
font-size: 1.125rem;
}
.stat-card {
padding: 16px;
}
}
</style>

View File

@@ -0,0 +1,262 @@
<template>
<n-card :bordered="false" class="admin-card">
<template #header>
<div class="card-header">
<span class="card-title">更新日志管理</span>
<n-button quaternary size="small" @click="fetchLogs" :loading="loading">
刷新
</n-button>
</div>
</template>
<n-space vertical size="large">
<n-alert v-if="error" type="error" closable @close="error = null">
{{ error }}
</n-alert>
<n-card size="small" class="form-card">
<n-form :model="form" label-placement="top">
<n-form-item label="更新内容">
<n-input
v-model:value="form.content"
type="textarea"
:autosize="{ minRows: 3, maxRows: 10 }"
placeholder="输入新的更新日志..."
/>
</n-form-item>
<n-form-item label="置顶">
<n-switch v-model:value="form.isPinned" />
</n-form-item>
<n-space justify="end">
<n-button type="primary" :loading="submitting" @click="addLog" :disabled="!form.content.trim()">
发布日志
</n-button>
</n-space>
</n-form>
</n-card>
<n-spin :show="loading">
<n-empty v-if="!logs.length && !loading" description="目前还没有更新记录" />
<n-space v-else vertical size="large">
<n-card
v-for="log in orderedLogs"
:key="log.id"
:bordered="false"
size="small"
class="log-card"
>
<div class="log-header">
<n-space align="center" size="small">
<n-tag v-if="log.is_pinned" type="warning" :bordered="false">置顶</n-tag>
<span class="log-date">{{ formatDate(log.created_at) }}</span>
<span v-if="log.created_by" class="log-author">by {{ log.created_by }}</span>
</n-space>
<n-space size="small">
<n-switch
:value="log.is_pinned"
size="small"
:loading="togglingId === log.id"
@update:value="(value) => togglePin(log, value)"
>
<template #checked>置顶</template>
<template #unchecked>置顶</template>
</n-switch>
<n-popconfirm
placement="left"
positive-text="删除"
negative-text="取消"
type="error"
@positive-click="() => deleteLog(log.id)"
>
<template #trigger>
<n-button quaternary type="error" size="small" :loading="deletingId === log.id">
删除
</n-button>
</template>
确认删除该更新日志?
</n-popconfirm>
</n-space>
</div>
<div class="log-content">
{{ log.content }}
</div>
</n-card>
</n-space>
</n-spin>
</n-space>
</n-card>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import {
NAlert,
NButton,
NCard,
NEmpty,
NForm,
NFormItem,
NInput,
NPopconfirm,
NSpace,
NSpin,
NSwitch,
NTag
} from 'naive-ui'
import { AdminAPI, type UpdateLog } from '@/api/admin'
import { useAlert } from '@/composables/useAlert'
const { showAlert } = useAlert()
const logs = ref<UpdateLog[]>([])
const loading = ref(false)
const submitting = ref(false)
const deletingId = ref<number | null>(null)
const togglingId = ref<number | null>(null)
const error = ref<string | null>(null)
const form = ref({
content: '',
isPinned: false
})
const orderedLogs = computed(() => {
return [...logs.value].sort((a, b) => {
if (a.is_pinned && !b.is_pinned) return -1
if (!a.is_pinned && b.is_pinned) return 1
return new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
})
})
const fetchLogs = async () => {
loading.value = true
error.value = null
try {
logs.value = await AdminAPI.listUpdateLogs()
} catch (err) {
error.value = err instanceof Error ? err.message : '获取更新日志失败'
} finally {
loading.value = false
}
}
const resetForm = () => {
form.value.content = ''
form.value.isPinned = false
}
const addLog = async () => {
if (!form.value.content.trim()) return
submitting.value = true
try {
const created = await AdminAPI.createUpdateLog({
content: form.value.content.trim(),
is_pinned: form.value.isPinned
})
logs.value.unshift(created)
resetForm()
showAlert('更新日志发布成功', 'success')
} catch (err) {
showAlert(err instanceof Error ? err.message : '发布失败', 'error')
} finally {
submitting.value = false
}
}
const deleteLog = async (id: number) => {
deletingId.value = id
try {
await AdminAPI.deleteUpdateLog(id)
logs.value = logs.value.filter((item) => item.id !== id)
showAlert('删除成功', 'success')
} catch (err) {
showAlert(err instanceof Error ? err.message : '删除失败', 'error')
} finally {
deletingId.value = null
}
}
const togglePin = async (log: UpdateLog, value: boolean) => {
togglingId.value = log.id
try {
const updated = await AdminAPI.updateUpdateLog(log.id, { is_pinned: value })
const index = logs.value.findIndex((item) => item.id === log.id)
if (index !== -1) {
logs.value.splice(index, 1, updated)
}
} catch (err) {
showAlert(err instanceof Error ? err.message : '更新失败', 'error')
} finally {
togglingId.value = null
}
}
const formatDate = (date: string) => {
const d = new Date(date)
return Number.isNaN(d.getTime()) ? date : d.toLocaleString()
}
onMounted(fetchLogs)
</script>
<style scoped>
.admin-card {
width: 100%;
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
}
.card-title {
font-size: 1.25rem;
font-weight: 600;
color: #1f2937;
}
.form-card {
border-radius: 16px;
}
.log-card {
border-radius: 16px;
background: linear-gradient(135deg, rgba(15, 118, 110, 0.06), rgba(15, 118, 110, 0));
}
.log-header {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 10px;
}
.log-date {
font-size: 0.85rem;
color: #4b5563;
}
.log-author {
font-size: 0.85rem;
color: #6b7280;
}
.log-content {
font-size: 0.95rem;
color: #1f2937;
line-height: 1.6;
white-space: pre-wrap;
}
@media (max-width: 767px) {
.card-title {
font-size: 1.125rem;
}
}
</style>

View File

@@ -0,0 +1,176 @@
<template>
<n-card :bordered="false" class="admin-card">
<template #header>
<div class="card-header">
<span class="card-title">用户管理</span>
<n-space :size="12">
<n-input
v-model:value="keyword"
clearable
round
placeholder="搜索用户名或邮箱"
@update:value="handleSearch"
class="search-input"
/>
<n-button quaternary size="small" @click="fetchUsers" :loading="loading">
刷新
</n-button>
</n-space>
</div>
</template>
<n-space vertical size="large">
<n-alert v-if="error" type="error" closable @close="error = null">
{{ error }}
</n-alert>
<n-spin :show="loading">
<n-data-table
:columns="columns"
:data="filteredUsers"
:bordered="false"
:pagination="pagination"
:row-key="rowKey"
class="user-table"
/>
</n-spin>
</n-space>
</n-card>
</template>
<script setup lang="ts">
import { computed, h, onMounted, reactive, ref } from 'vue'
import {
NAlert,
NButton,
NCard,
NDataTable,
NInput,
NSpin,
NTag,
NSpace,
type DataTableColumns
} from 'naive-ui'
import { AdminAPI, type AdminUser } from '@/api/admin'
const users = ref<AdminUser[]>([])
const loading = ref(false)
const error = ref<string | null>(null)
const keyword = ref('')
const pagination = reactive({
page: 1,
pageSize: 10,
showSizePicker: false
})
const columns: DataTableColumns<AdminUser> = [
{
title: 'ID',
key: 'id',
sorter: (a, b) => a.id - b.id,
width: 80
},
{
title: '用户名',
key: 'username',
ellipsis: { tooltip: true }
},
{
title: '邮箱',
key: 'email',
ellipsis: { tooltip: true },
render(row) {
return row.email || '—'
}
},
{
title: '权限',
key: 'is_admin',
align: 'center',
render(row) {
return h(
NTag,
{
type: row.is_admin ? 'success' : 'default',
bordered: false,
size: 'small'
},
{ default: () => (row.is_admin ? '管理员' : '普通用户') }
)
}
}
]
const filteredUsers = computed(() => {
if (!keyword.value.trim()) {
return users.value
}
const q = keyword.value.trim().toLowerCase()
return users.value.filter(
(user) =>
user.username.toLowerCase().includes(q) ||
(user.email && user.email.toLowerCase().includes(q))
)
})
const rowKey = (row: AdminUser) => row.id
const fetchUsers = async () => {
loading.value = true
error.value = null
try {
users.value = await AdminAPI.listUsers()
} catch (err) {
error.value = err instanceof Error ? err.message : '获取用户数据失败'
} finally {
loading.value = false
}
}
const handleSearch = () => {
pagination.page = 1
}
onMounted(fetchUsers)
</script>
<style scoped>
.admin-card {
width: 100%;
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
}
.card-title {
font-size: 1.25rem;
font-weight: 600;
color: #1f2937;
}
.search-input {
width: min(230px, 60vw);
}
@media (max-width: 767px) {
.card-header {
flex-direction: column;
align-items: stretch;
}
.card-title {
font-size: 1.125rem;
}
.search-input {
width: 100%;
}
}
</style>

View File

@@ -0,0 +1,7 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor">
<path
d="M15 4a1 1 0 1 0 0 2V4zm0 11v-1a1 1 0 0 0-1 1h1zm0 4l-.707.707A1 1 0 0 0 16 19h-1zm-4-4l.707-.707A1 1 0 0 0 11 14v1zm-4.707-1.293a1 1 0 0 0-1.414 1.414l1.414-1.414zm-.707.707l-.707-.707.707.707zM9 11v-1a1 1 0 0 0-.707.293L9 11zm-4 0h1a1 1 0 0 0-1-1v1zm0 4H4a1 1 0 0 0 1.707.707L5 15zm10-9h2V4h-2v2zm2 0a1 1 0 0 1 1 1h2a3 3 0 0 0-3-3v2zm1 1v6h2V7h-2zm0 6a1 1 0 0 1-1 1v2a3 3 0 0 0 3-3h-2zm-1 1h-2v2h2v-2zm-3 1v4h2v-4h-2zm1.707 3.293l-4-4-1.414 1.414 4 4 1.414-1.414zM11 14H7v2h4v-2zm-4 0c-.276 0-.525-.111-.707-.293l-1.414 1.414C5.42 15.663 6.172 16 7 16v-2zm-.707 1.121l3.414-3.414-1.414-1.414-3.414 3.414 1.414 1.414zM9 12h4v-2H9v2zm4 0a3 3 0 0 0 3-3h-2a1 1 0 0 1-1 1v2zm3-3V3h-2v6h2zm0-6a3 3 0 0 0-3-3v2a1 1 0 0 1 1 1h2zm-3-3H3v2h10V0zM3 0a3 3 0 0 0-3 3h2a1 1 0 0 1 1-1V0zM0 3v6h2V3H0zm0 6a3 3 0 0 0 3 3v-2a1 1 0 0 1-1-1H0zm3 3h2v-2H3v2zm1-1v4h2v-4H4zm1.707 4.707l.586-.586-1.414-1.414-.586.586 1.414 1.414z"
/>
</svg>
</template>

View File

@@ -0,0 +1,7 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="17" fill="currentColor">
<path
d="M11 2.253a1 1 0 1 0-2 0h2zm-2 13a1 1 0 1 0 2 0H9zm.447-12.167a1 1 0 1 0 1.107-1.666L9.447 3.086zM1 2.253L.447 1.42A1 1 0 0 0 0 2.253h1zm0 13H0a1 1 0 0 0 1.553.833L1 15.253zm8.447.833a1 1 0 1 0 1.107-1.666l-1.107 1.666zm0-14.666a1 1 0 1 0 1.107 1.666L9.447 1.42zM19 2.253h1a1 1 0 0 0-.447-.833L19 2.253zm0 13l-.553.833A1 1 0 0 0 20 15.253h-1zm-9.553-.833a1 1 0 1 0 1.107 1.666L9.447 14.42zM9 2.253v13h2v-13H9zm1.553-.833C9.203.523 7.42 0 5.5 0v2c1.572 0 2.961.431 3.947 1.086l1.107-1.666zM5.5 0C3.58 0 1.797.523.447 1.42l1.107 1.666C2.539 2.431 3.928 2 5.5 2V0zM0 2.253v13h2v-13H0zm1.553 13.833C2.539 15.431 3.928 15 5.5 15v-2c-1.92 0-3.703.523-5.053 1.42l1.107 1.666zM5.5 15c1.572 0 2.961.431 3.947 1.086l1.107-1.666C9.203 13.523 7.42 13 5.5 13v2zm5.053-11.914C11.539 2.431 12.928 2 14.5 2V0c-1.92 0-3.703.523-5.053 1.42l1.107 1.666zM14.5 2c1.573 0 2.961.431 3.947 1.086l1.107-1.666C18.203.523 16.421 0 14.5 0v2zm3.5.253v13h2v-13h-2zm1.553 12.167C18.203 13.523 16.421 13 14.5 13v2c1.573 0 2.961.431 3.947 1.086l1.107-1.666zM14.5 13c-1.92 0-3.703.523-5.053 1.42l1.107 1.666C11.539 15.431 12.928 15 14.5 15v-2z"
/>
</svg>
</template>

View File

@@ -0,0 +1,7 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="20" fill="currentColor">
<path
d="M11.447 8.894a1 1 0 1 0-.894-1.789l.894 1.789zm-2.894-.789a1 1 0 1 0 .894 1.789l-.894-1.789zm0 1.789a1 1 0 1 0 .894-1.789l-.894 1.789zM7.447 7.106a1 1 0 1 0-.894 1.789l.894-1.789zM10 9a1 1 0 1 0-2 0h2zm-2 2.5a1 1 0 1 0 2 0H8zm9.447-5.606a1 1 0 1 0-.894-1.789l.894 1.789zm-2.894-.789a1 1 0 1 0 .894 1.789l-.894-1.789zm2 .789a1 1 0 1 0 .894-1.789l-.894 1.789zm-1.106-2.789a1 1 0 1 0-.894 1.789l.894-1.789zM18 5a1 1 0 1 0-2 0h2zm-2 2.5a1 1 0 1 0 2 0h-2zm-5.447-4.606a1 1 0 1 0 .894-1.789l-.894 1.789zM9 1l.447-.894a1 1 0 0 0-.894 0L9 1zm-2.447.106a1 1 0 1 0 .894 1.789l-.894-1.789zm-6 3a1 1 0 1 0 .894 1.789L.553 4.106zm2.894.789a1 1 0 1 0-.894-1.789l.894 1.789zm-2-.789a1 1 0 1 0-.894 1.789l.894-1.789zm1.106 2.789a1 1 0 1 0 .894-1.789l-.894 1.789zM2 5a1 1 0 1 0-2 0h2zM0 7.5a1 1 0 1 0 2 0H0zm8.553 12.394a1 1 0 1 0 .894-1.789l-.894 1.789zm-1.106-2.789a1 1 0 1 0-.894 1.789l.894-1.789zm1.106 1a1 1 0 1 0 .894 1.789l-.894-1.789zm2.894.789a1 1 0 1 0-.894-1.789l.894 1.789zM8 19a1 1 0 1 0 2 0H8zm2-2.5a1 1 0 1 0-2 0h2zm-7.447.394a1 1 0 1 0 .894-1.789l-.894 1.789zM1 15H0a1 1 0 0 0 .553.894L1 15zm1-2.5a1 1 0 1 0-2 0h2zm12.553 2.606a1 1 0 1 0 .894 1.789l-.894-1.789zM17 15l.447.894A1 1 0 0 0 18 15h-1zm1-2.5a1 1 0 1 0-2 0h2zm-7.447-5.394l-2 1 .894 1.789 2-1-.894-1.789zm-1.106 1l-2-1-.894 1.789 2 1 .894-1.789zM8 9v2.5h2V9H8zm8.553-4.894l-2 1 .894 1.789 2-1-.894-1.789zm.894 0l-2-1-.894 1.789 2 1 .894-1.789zM16 5v2.5h2V5h-2zm-4.553-3.894l-2-1-.894 1.789 2 1 .894-1.789zm-2.894-1l-2 1 .894 1.789 2-1L8.553.106zM1.447 5.894l2-1-.894-1.789-2 1 .894 1.789zm-.894 0l2 1 .894-1.789-2-1-.894 1.789zM0 5v2.5h2V5H0zm9.447 13.106l-2-1-.894 1.789 2 1 .894-1.789zm0 1.789l2-1-.894-1.789-2 1 .894 1.789zM10 19v-2.5H8V19h2zm-6.553-3.894l-2-1-.894 1.789 2 1 .894-1.789zM2 15v-2.5H0V15h2zm13.447 1.894l2-1-.894-1.789-2 1 .894 1.789zM18 15v-2.5h-2V15h2z"
/>
</svg>
</template>

View File

@@ -0,0 +1,7 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor">
<path
d="M10 3.22l-.61-.6a5.5 5.5 0 0 0-7.666.105 5.5 5.5 0 0 0-.114 7.665L10 18.78l8.39-8.4a5.5 5.5 0 0 0-.114-7.665 5.5 5.5 0 0 0-7.666-.105l-.61.61z"
/>
</svg>
</template>

View File

@@ -0,0 +1,19 @@
<!-- This icon is from <https://github.com/Templarian/MaterialDesign>, distributed under Apache 2.0 (https://www.apache.org/licenses/LICENSE-2.0) license-->
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
aria-hidden="true"
role="img"
class="iconify iconify--mdi"
width="24"
height="24"
preserveAspectRatio="xMidYMid meet"
viewBox="0 0 24 24"
>
<path
d="M20 18v-4h-3v1h-2v-1H9v1H7v-1H4v4h16M6.33 8l-1.74 4H7v-1h2v1h6v-1h2v1h2.41l-1.74-4H6.33M9 5v1h6V5H9m12.84 7.61c.1.22.16.48.16.8V18c0 .53-.21 1-.6 1.41c-.4.4-.85.59-1.4.59H4c-.55 0-1-.19-1.4-.59C2.21 19 2 18.53 2 18v-4.59c0-.32.06-.58.16-.8L4.5 7.22C4.84 6.41 5.45 6 6.33 6H7V5c0-.55.18-1 .57-1.41C7.96 3.2 8.44 3 9 3h6c.56 0 1.04.2 1.43.59c.39.41.57.86.57 1.41v1h.67c.88 0 1.49.41 1.83 1.22l2.34 5.39z"
fill="currentColor"
></path>
</svg>
</template>

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>

View File

@@ -0,0 +1,596 @@
<template>
<div class="h-screen flex flex-col overflow-hidden bg-gradient-to-br from-slate-50 via-blue-50/30 to-indigo-50/40">
<!-- Header -->
<header class="sticky top-0 z-40 bg-white/90 backdrop-blur-lg border-b border-slate-200/60 shadow-sm">
<div class="max-w-[1800px] mx-auto px-4 sm:px-6 lg:px-8 py-4">
<div class="flex items-center justify-between">
<!-- Left: Title & Info -->
<div class="flex items-center gap-3 flex-1 min-w-0">
<button
class="lg:hidden flex-shrink-0 p-2 text-slate-600 hover:text-indigo-600 hover:bg-indigo-50 rounded-lg transition-all duration-200"
@click="toggleSidebar"
aria-label="Toggle sidebar"
>
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
<div class="flex-1 min-w-0">
<h1 class="text-xl sm:text-2xl lg:text-3xl font-bold text-slate-900 truncate">
{{ formattedTitle }}
</h1>
<p v-if="overviewMeta.updated_at" class="text-xs sm:text-sm text-slate-500 mt-0.5">
最近更新{{ overviewMeta.updated_at }}
</p>
</div>
</div>
<!-- Right: Actions -->
<div class="flex items-center gap-2 flex-shrink-0">
<button
class="px-3 py-2 sm:px-4 text-sm font-medium text-slate-700 bg-white hover:bg-slate-50 border border-slate-200 rounded-lg transition-all duration-200 hover:shadow-md"
@click="goBack"
>
<span class="hidden sm:inline">返回列表</span>
<span class="sm:hidden">返回</span>
</button>
<button
v-if="!isAdmin"
class="px-3 py-2 sm:px-4 text-sm font-medium text-white bg-gradient-to-r from-indigo-600 to-indigo-700 hover:from-indigo-700 hover:to-indigo-800 rounded-lg transition-all duration-200 shadow-md hover:shadow-lg"
@click="goToWritingDesk"
>
<span class="hidden sm:inline">开始创作</span>
<span class="sm:hidden">创作</span>
</button>
</div>
</div>
</div>
</header>
<!-- Main Content -->
<div class="flex max-w-[1800px] mx-auto w-full flex-1 min-h-0 overflow-hidden">
<!-- Sidebar -->
<aside
class="fixed left-0 top-[73px] bottom-0 z-30 w-72 bg-white/95 backdrop-blur-lg border-r border-slate-200/60 shadow-2xl transform transition-transform duration-300 ease-out lg:translate-x-0"
:class="isSidebarOpen ? 'translate-x-0' : '-translate-x-full'"
>
<!-- Sidebar Header -->
<div class="hidden lg:flex items-center justify-between px-6 py-5 border-b border-slate-200/60">
<div class="flex items-center gap-2">
<div class="w-2 h-2 rounded-full bg-gradient-to-r from-indigo-500 to-purple-500"></div>
<span class="text-sm font-semibold text-slate-700 uppercase tracking-wide">
{{ isAdmin ? '内容视图' : '蓝图导航' }}
</span>
</div>
</div>
<!-- Navigation -->
<nav class="px-4 py-6 space-y-1.5 overflow-y-auto h-[calc(100%-5rem)] lg:h-[calc(100%-5rem)]">
<button
v-for="section in sections"
:key="section.key"
type="button"
@click="switchSection(section.key)"
:class="[
'w-full group flex items-center gap-3 rounded-xl px-4 py-3.5 text-sm font-medium transition-all duration-200',
activeSection === section.key
? 'bg-gradient-to-r from-indigo-50 to-indigo-100/80 text-indigo-700 shadow-sm ring-1 ring-indigo-200/50'
: 'text-slate-600 hover:bg-slate-50 hover:text-slate-900'
]"
>
<span
class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg transition-all duration-200"
:class="activeSection === section.key
? 'bg-gradient-to-br from-indigo-500 to-indigo-600 text-white shadow-md'
: 'bg-slate-100 text-slate-500 group-hover:bg-slate-200'"
>
<component :is="getSectionIcon(section.key)" class="w-5 h-5" />
</span>
<span class="text-left flex-1">
<span class="block font-semibold">{{ section.label }}</span>
<span class="text-xs font-normal opacity-70">{{ section.description }}</span>
</span>
</button>
</nav>
</aside>
<!-- Sidebar Overlay (Mobile) -->
<transition
enter-active-class="transition-opacity duration-300"
leave-active-class="transition-opacity duration-300"
enter-from-class="opacity-0"
leave-to-class="opacity-0"
>
<div
v-if="isSidebarOpen"
class="fixed inset-0 bg-slate-900/40 backdrop-blur-sm z-20 lg:hidden"
@click="toggleSidebar"
></div>
</transition>
<!-- Main Content Area -->
<div class="flex-1 lg:ml-72 min-h-0 flex flex-col h-full">
<div class="flex-1 min-h-0 h-full px-4 sm:px-6 lg:px-8 xl:px-12 py-6 sm:py-8 flex flex-col overflow-hidden box-border">
<div class="flex-1 flex flex-col min-h-0 h-full">
<!-- Content Card -->
<div class="flex-1 h-full bg-white/95 backdrop-blur-sm rounded-2xl border border-slate-200/60 shadow-xl p-6 sm:p-8 lg:p-10 min-h-[20rem] transition-shadow duration-300 hover:shadow-2xl flex flex-col box-border" :class="contentCardClass">
<!-- Loading State -->
<div v-if="isSectionLoading" class="flex flex-col items-center justify-center py-20 sm:py-28">
<div class="relative">
<div class="w-12 h-12 border-4 border-indigo-100 rounded-full"></div>
<div class="absolute top-0 left-0 w-12 h-12 border-4 border-indigo-600 border-t-transparent rounded-full animate-spin"></div>
</div>
<p class="mt-4 text-sm text-slate-500">加载中...</p>
</div>
<!-- Error State -->
<div v-else-if="currentError" class="flex flex-col items-center justify-center py-20 sm:py-28 space-y-4">
<div class="w-16 h-16 bg-red-50 rounded-full flex items-center justify-center">
<svg class="w-8 h-8 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-slate-600 text-center">{{ currentError }}</p>
<button
class="px-6 py-2.5 text-sm font-medium text-white bg-gradient-to-r from-indigo-600 to-indigo-700 hover:from-indigo-700 hover:to-indigo-800 rounded-lg shadow-md hover:shadow-lg transition-all duration-200"
@click="reloadSection(activeSection, true)"
>
重试
</button>
</div>
<!-- Content -->
<component
v-else
:is="currentComponent"
v-bind="componentProps"
:class="componentContainerClass"
@edit="handleSectionEdit"
@add="startAddChapter"
/>
</div>
</div>
</div>
</div>
</div>
<!-- Blueprint Edit Modal -->
<BlueprintEditModal
v-if="!isAdmin"
:show="isModalOpen"
:title="modalTitle"
:content="modalContent"
:field="modalField"
@close="isModalOpen = false"
@save="handleSave"
/>
<!-- Add Chapter Modal -->
<transition
enter-active-class="transition-all duration-300"
leave-active-class="transition-all duration-300"
enter-from-class="opacity-0 scale-95"
leave-to-class="opacity-0 scale-95"
>
<div v-if="isAddChapterModalOpen && !isAdmin" class="fixed inset-0 z-50 flex items-center justify-center px-4">
<div class="absolute inset-0 bg-slate-900/40 backdrop-blur-sm" @click="cancelNewChapter"></div>
<div class="relative bg-white rounded-2xl shadow-2xl border border-slate-200 p-6 sm:p-8 w-full max-w-lg transform transition-all" @click.stop>
<h3 class="text-xl font-bold text-slate-900 mb-6">新增章节大纲</h3>
<div class="space-y-5">
<div>
<label for="new-chapter-title" class="block text-sm font-semibold text-slate-700 mb-2">
章节标题
</label>
<input
id="new-chapter-title"
v-model="newChapterTitle"
type="text"
class="block w-full rounded-lg border border-slate-300 px-4 py-2.5 text-sm focus:border-indigo-500 focus:ring-2 focus:ring-indigo-200 transition-all duration-200"
placeholder="例如:意外的相遇"
>
</div>
<div>
<label for="new-chapter-summary" class="block text-sm font-semibold text-slate-700 mb-2">
章节摘要
</label>
<textarea
id="new-chapter-summary"
v-model="newChapterSummary"
rows="4"
class="block w-full rounded-lg border border-slate-300 px-4 py-2.5 text-sm focus:border-indigo-500 focus:ring-2 focus:ring-indigo-200 transition-all duration-200 resize-none"
placeholder="简要描述本章发生的主要事件"
></textarea>
</div>
</div>
<div class="mt-8 flex justify-end gap-3">
<button
type="button"
class="px-5 py-2.5 text-sm font-medium text-slate-600 bg-white hover:bg-slate-50 border border-slate-200 rounded-lg transition-all duration-200"
@click="cancelNewChapter"
>
取消
</button>
<button
type="button"
class="px-5 py-2.5 text-sm font-medium text-white bg-gradient-to-r from-indigo-600 to-indigo-700 hover:from-indigo-700 hover:to-indigo-800 rounded-lg shadow-md hover:shadow-lg transition-all duration-200"
@click="saveNewChapter"
>
保存
</button>
</div>
</div>
</div>
</transition>
</div>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, reactive, ref, h } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useNovelStore } from '@/stores/novel'
import { NovelAPI } from '@/api/novel'
import { AdminAPI } from '@/api/admin'
import type { NovelProject, NovelSectionResponse, NovelSectionType } from '@/api/novel'
import BlueprintEditModal from '@/components/BlueprintEditModal.vue'
import OverviewSection from '@/components/novel-detail/OverviewSection.vue'
import WorldSettingSection from '@/components/novel-detail/WorldSettingSection.vue'
import CharactersSection from '@/components/novel-detail/CharactersSection.vue'
import RelationshipsSection from '@/components/novel-detail/RelationshipsSection.vue'
import ChapterOutlineSection from '@/components/novel-detail/ChapterOutlineSection.vue'
import ChaptersSection from '@/components/novel-detail/ChaptersSection.vue'
interface Props {
isAdmin?: boolean
}
type SectionKey = NovelSectionType
const props = withDefaults(defineProps<Props>(), {
isAdmin: false
})
const route = useRoute()
const router = useRouter()
const novelStore = useNovelStore()
const projectId = route.params.id as string
const isSidebarOpen = ref(typeof window !== 'undefined' ? window.innerWidth >= 1024 : true)
const sections: Array<{ key: SectionKey; label: string; description: string }> = [
{ key: 'overview', label: '项目概览', description: '定位与整体梗概' },
{ key: 'world_setting', label: '世界设定', description: '规则、地点与阵营' },
{ key: 'characters', label: '主要角色', description: '人物性格与目标' },
{ key: 'relationships', label: '人物关系', description: '角色之间的联系' },
{ key: 'chapter_outline', label: '章节大纲', description: props.isAdmin ? '故事章节规划' : '故事结构规划' },
{ key: 'chapters', label: '章节内容', description: props.isAdmin ? '生成章节与正文' : '生成状态与摘要' }
]
const sectionComponents: Record<SectionKey, any> = {
overview: OverviewSection,
world_setting: WorldSettingSection,
characters: CharactersSection,
relationships: RelationshipsSection,
chapter_outline: ChapterOutlineSection,
chapters: ChaptersSection
}
// Section icons as functional components
const getSectionIcon = (key: SectionKey) => {
const icons: Record<SectionKey, any> = {
overview: () => h('svg', { viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 2 }, [
h('rect', { x: 3, y: 3, width: 18, height: 18, rx: 2 }),
h('line', { x1: 3, y1: 9, x2: 21, y2: 9 }),
h('line', { x1: 9, y1: 21, x2: 9, y2: 9 })
]),
world_setting: () => h('svg', { viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 2 }, [
h('circle', { cx: 12, cy: 12, r: 10 }),
h('path', { d: 'M2 12h20M12 2a15.3 15.3 0 014 10 15.3 15.3 0 01-4 10 15.3 15.3 0 01-4-10 15.3 15.3 0 014-10z' })
]),
characters: () => h('svg', { viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 2 }, [
h('path', { d: 'M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2' }),
h('circle', { cx: 9, cy: 7, r: 4 }),
h('path', { d: 'M23 21v-2a4 4 0 00-3-3.87M16 3.13a4 4 0 010 7.75' })
]),
relationships: () => h('svg', { viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 2 }, [
h('path', { d: 'M16 21v-2a4 4 0 00-4-4H6a4 4 0 00-4 4v2' }),
h('circle', { cx: 9, cy: 7, r: 4 }),
h('path', { d: 'M22 21v-2a4 4 0 00-3-3.87M16 3.13a4 4 0 010 7.75' })
]),
chapter_outline: () => h('svg', { viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 2 }, [
h('line', { x1: 8, y1: 6, x2: 21, y2: 6 }),
h('line', { x1: 8, y1: 12, x2: 21, y2: 12 }),
h('line', { x1: 8, y1: 18, x2: 21, y2: 18 }),
h('line', { x1: 3, y1: 6, x2: 3.01, y2: 6 }),
h('line', { x1: 3, y1: 12, x2: 3.01, y2: 12 }),
h('line', { x1: 3, y1: 18, x2: 3.01, y2: 18 })
]),
chapters: () => h('svg', { viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 2 }, [
h('path', { d: 'M4 19.5A2.5 2.5 0 016.5 17H20' }),
h('path', { d: 'M6.5 2H20v20H6.5A2.5 2.5 0 014 19.5v-15A2.5 2.5 0 016.5 2z' })
])
}
return icons[key]
}
const sectionData = reactive<Partial<Record<SectionKey, any>>>({})
const sectionLoading = reactive<Record<SectionKey, boolean>>({
overview: false,
world_setting: false,
characters: false,
relationships: false,
chapter_outline: false,
chapters: false
})
const sectionError = reactive<Record<SectionKey, string | null>>({
overview: null,
world_setting: null,
characters: null,
relationships: null,
chapter_outline: null,
chapters: null
})
const overviewMeta = reactive<{ title: string; updated_at: string | null }>({
title: '加载中...',
updated_at: null
})
const activeSection = ref<SectionKey>('overview')
// Modal state (user mode only)
const isModalOpen = ref(false)
const modalTitle = ref('')
const modalContent = ref<any>('')
const modalField = ref('')
// Add chapter modal state (user mode only)
const isAddChapterModalOpen = ref(false)
const newChapterTitle = ref('')
const newChapterSummary = ref('')
const originalBodyOverflow = ref('')
const novel = computed(() => !props.isAdmin ? novelStore.currentProject as NovelProject | null : null)
const formattedTitle = computed(() => {
const title = overviewMeta.title || '加载中...'
return title.startsWith('《') && title.endsWith('》') ? title : `${title}`
})
const componentContainerClass = computed(() => {
const fillSections: SectionKey[] = ['chapters']
return fillSections.includes(activeSection.value)
? 'flex-1 min-h-0 h-full flex flex-col overflow-hidden'
: 'overflow-y-auto'
})
const contentCardClass = computed(() => {
const fillSections: SectionKey[] = ['chapters']
return fillSections.includes(activeSection.value)
? 'overflow-hidden'
: 'overflow-visible'
})
// 懒加载完整项目(仅在需要编辑时)
const ensureProjectLoaded = async () => {
if (props.isAdmin || !projectId) return
if (novel.value) return // 已加载
await novelStore.loadProject(projectId)
}
const toggleSidebar = () => {
isSidebarOpen.value = !isSidebarOpen.value
}
const handleResize = () => {
if (typeof window === 'undefined') return
isSidebarOpen.value = window.innerWidth >= 1024
}
const loadSection = async (section: SectionKey, force = false) => {
if (!projectId) return
if (!force && sectionData[section]) {
return
}
sectionLoading[section] = true
sectionError[section] = null
try {
const response: NovelSectionResponse = props.isAdmin
? await AdminAPI.getNovelSection(projectId, section)
: await NovelAPI.getSection(projectId, section)
sectionData[section] = response.data
if (section === 'overview') {
overviewMeta.title = response.data?.title || overviewMeta.title
overviewMeta.updated_at = response.data?.updated_at || null
}
} catch (error) {
console.error('加载模块失败:', error)
sectionError[section] = error instanceof Error ? error.message : '加载失败'
} finally {
sectionLoading[section] = false
}
}
const reloadSection = (section: SectionKey, force = false) => {
loadSection(section, force)
}
const switchSection = (section: SectionKey) => {
activeSection.value = section
if (typeof window !== 'undefined' && window.innerWidth < 1024) {
isSidebarOpen.value = false
}
loadSection(section)
}
const goBack = () => router.push(props.isAdmin ? '/admin' : '/workspace')
const goToWritingDesk = async () => {
await ensureProjectLoaded()
const project = novel.value
if (!project) return
const path = project.title === '未命名灵感' ? `/inspiration?project_id=${project.id}` : `/novel/${project.id}`
router.push(path)
}
const currentComponent = computed(() => sectionComponents[activeSection.value])
const isSectionLoading = computed(() => sectionLoading[activeSection.value])
const currentError = computed(() => sectionError[activeSection.value])
const componentProps = computed(() => {
const data = sectionData[activeSection.value]
const editable = !props.isAdmin
switch (activeSection.value) {
case 'overview':
return { data: data || null, editable }
case 'world_setting':
return { data: data || null, editable }
case 'characters':
return { data: data || null, editable }
case 'relationships':
return { data: data || null, editable }
case 'chapter_outline':
return { outline: data?.chapter_outline || [], editable }
case 'chapters':
return { chapters: data?.chapters || [], isAdmin: props.isAdmin }
default:
return {}
}
})
const handleSectionEdit = (payload: { field: string; title: string; value: any }) => {
if (props.isAdmin) return
modalField.value = payload.field
modalTitle.value = payload.title
modalContent.value = payload.value
isModalOpen.value = true
}
const resolveSectionKey = (field: string): SectionKey => {
if (field.startsWith('world_setting')) return 'world_setting'
if (field.startsWith('characters')) return 'characters'
if (field.startsWith('relationships')) return 'relationships'
if (field.startsWith('chapter_outline')) return 'chapter_outline'
return 'overview'
}
const handleSave = async (data: { field: string; content: any }) => {
if (props.isAdmin) return
await ensureProjectLoaded()
const project = novel.value
if (!project) return
const { field, content } = data
const payload: Record<string, any> = {}
if (field.includes('.')) {
const [parentField, childField] = field.split('.')
payload[parentField] = {
...(project.blueprint?.[parentField as keyof typeof project.blueprint] as Record<string, any> | undefined),
[childField]: content
}
} else {
payload[field] = content
}
try {
const updatedProject = await NovelAPI.updateBlueprint(project.id, payload)
novelStore.setCurrentProject(updatedProject)
const sectionToReload = resolveSectionKey(field)
await loadSection(sectionToReload, true)
if (sectionToReload !== 'overview') {
await loadSection('overview', true)
}
isModalOpen.value = false
} catch (error) {
console.error('保存变更失败:', error)
}
}
const startAddChapter = async () => {
if (props.isAdmin) return
await ensureProjectLoaded()
const outline = sectionData.chapter_outline?.chapter_outline || novel.value?.blueprint?.chapter_outline || []
const nextNumber = outline.length > 0 ? Math.max(...outline.map((item: any) => item.chapter_number)) + 1 : 1
newChapterTitle.value = `新章节 ${nextNumber}`
newChapterSummary.value = ''
isAddChapterModalOpen.value = true
}
const cancelNewChapter = () => {
isAddChapterModalOpen.value = false
}
const saveNewChapter = async () => {
if (props.isAdmin) return
await ensureProjectLoaded()
const project = novel.value
if (!project) return
if (!newChapterTitle.value.trim()) {
alert('章节标题不能为空')
return
}
const existingOutline = project.blueprint?.chapter_outline || []
const nextNumber = existingOutline.length > 0 ? Math.max(...existingOutline.map(ch => ch.chapter_number)) + 1 : 1
const newOutline = [...existingOutline, {
chapter_number: nextNumber,
title: newChapterTitle.value,
summary: newChapterSummary.value
}]
try {
const updatedProject = await NovelAPI.updateBlueprint(project.id, { chapter_outline: newOutline })
novelStore.setCurrentProject(updatedProject)
await loadSection('chapter_outline', true)
isAddChapterModalOpen.value = false
} catch (error) {
console.error('新增章节失败:', error)
}
}
onMounted(async () => {
if (typeof window !== 'undefined') {
window.addEventListener('resize', handleResize)
}
if (typeof document !== 'undefined') {
originalBodyOverflow.value = document.body.style.overflow
document.body.style.overflow = 'hidden'
}
// 只加载必要的 section 数据,不预加载完整项目
await loadSection('overview', true)
loadSection('world_setting')
})
onBeforeUnmount(() => {
if (typeof window !== 'undefined') {
window.removeEventListener('resize', handleResize)
}
if (typeof document !== 'undefined') {
document.body.style.overflow = originalBodyOverflow.value || ''
}
})
</script>
<style scoped>
/* Smooth scrollbar */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: #94a3b8;
}
</style>

View File

@@ -0,0 +1,82 @@
<template>
<div v-if="show" class="fixed inset-0 bg-black/30 z-50 flex justify-center items-center" @click.self="$emit('close')">
<div class="bg-white rounded-2xl shadow-xl w-full max-w-lg mx-4 p-8 transform transition-all duration-300 ease-out" :class="show ? 'scale-100 opacity-100' : 'scale-95 opacity-0'">
<div class="flex justify-between items-center mb-6">
<h2 class="text-2xl font-bold text-gray-800">编辑章节大纲</h2>
<button @click="$emit('close')" class="p-2 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-full transition-colors">
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"></path>
</svg>
</button>
</div>
<div v-if="editableChapter" class="space-y-6">
<div>
<label for="chapter-title" class="block text-sm font-medium text-gray-700 mb-2">章节标题</label>
<input
type="text"
id="chapter-title"
v-model="editableChapter.title"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
placeholder="请输入章节标题"
/>
</div>
<div>
<label for="chapter-summary" class="block text-sm font-medium text-gray-700 mb-2">章节摘要</label>
<textarea
id="chapter-summary"
v-model="editableChapter.summary"
rows="5"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
placeholder="请输入章节摘要"
></textarea>
</div>
</div>
<div class="mt-8 flex justify-end gap-4">
<button @click="$emit('close')" class="px-6 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors">
取消
</button>
<button @click="saveChanges" class="px-6 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors disabled:opacity-50" :disabled="!isChanged">
保存更改
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch, computed } from 'vue'
import type { ChapterOutline } from '@/api/novel'
interface Props {
show: boolean
chapter: ChapterOutline | null
}
const props = defineProps<Props>()
const emit = defineEmits(['close', 'save'])
const editableChapter = ref<ChapterOutline | null>(null)
watch(() => props.chapter, (newChapter) => {
if (newChapter) {
editableChapter.value = { ...newChapter }
} else {
editableChapter.value = null
}
}, { deep: true, immediate: true })
const isChanged = computed(() => {
if (!props.chapter || !editableChapter.value) {
return false
}
return props.chapter.title !== editableChapter.value.title || props.chapter.summary !== editableChapter.value.summary
})
const saveChanges = () => {
if (editableChapter.value && isChanged.value) {
emit('save', editableChapter.value)
}
}
</script>

View File

@@ -0,0 +1,120 @@
<template>
<div v-if="show" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
<div class="bg-white rounded-2xl shadow-2xl max-w-4xl w-full max-h-[80vh] overflow-hidden flex flex-col">
<!-- 弹窗头部 -->
<div class="flex items-center justify-between p-6 border-b border-gray-200">
<div class="flex items-center gap-3">
<div class="w-10 h-10 bg-purple-500 rounded-full flex items-center justify-center flex-shrink-0">
<svg class="w-6 h-6 text-white" fill="currentColor" viewBox="0 0 20 20">
<path d="M10 2a6 6 0 00-6 6v3.586l-1.707 1.707A1 1 0 003 15v1a1 1 0 001 1h12a1 1 0 001-1v-1a1 1 0 00-.293-.707L16 11.586V8a6 6 0 00-6-6zM8.05 17a2 2 0 103.9 0H8.05z"></path>
</svg>
</div>
<h3 class="text-xl font-bold text-gray-900">AI 评审详情</h3>
</div>
<button
@click="$emit('close')"
class="p-2 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
>
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"></path>
</svg>
</button>
</div>
<!-- 弹窗内容 -->
<div class="p-6 overflow-y-auto max-h-[calc(80vh-130px)]">
<div v-if="parsedEvaluation" class="space-y-6 text-sm">
<div class="bg-purple-50 border border-purple-200 rounded-xl p-4">
<p class="font-semibold text-purple-800 text-base">🏆 最佳选择版本 {{ parsedEvaluation.best_choice }}</p>
<p class="text-purple-700 mt-2">{{ parsedEvaluation.reason_for_choice }}</p>
</div>
<div class="space-y-4">
<div v-for="(evalResult, versionName) in parsedEvaluation.evaluation" :key="versionName" class="bg-gray-50 p-4 rounded-lg border border-gray-200">
<h5 class="font-bold text-gray-800 text-lg mb-2">版本 {{ String(versionName).replace('version', '') }} 评估</h5>
<div class="prose prose-sm max-w-none text-gray-700 space-y-3">
<div>
<p class="font-semibold text-gray-800">综合评价:</p>
<p>{{ evalResult.overall_review }}</p>
</div>
<div>
<p class="font-semibold text-gray-800">优点:</p>
<ul class="list-disc pl-5 space-y-1">
<li v-for="(pro, i) in evalResult.pros" :key="`pro-${i}`">{{ pro }}</li>
</ul>
</div>
<div>
<p class="font-semibold text-gray-800">缺点:</p>
<ul class="list-disc pl-5 space-y-1">
<li v-for="(con, i) in evalResult.cons" :key="`con-${i}`">{{ con }}</li>
</ul>
</div>
</div>
</div>
</div>
</div>
<div
v-else
class="prose prose-sm max-w-none prose-headings:mt-2 prose-headings:mb-1 prose-p:my-1 prose-ul:my-1 prose-ol:my-1 prose-li:my-0 text-gray-800"
v-html="parseMarkdown(evaluation)"
></div>
</div>
<!-- 弹窗底部操作按钮 -->
<div class="flex items-center justify-end p-6 border-t border-gray-200 bg-gray-50">
<button
@click="$emit('close')"
class="px-6 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors"
>
关闭
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
interface Props {
show: boolean
evaluation: string | null
}
const props = defineProps<Props>()
defineEmits(['close'])
const parsedEvaluation = computed(() => {
if (!props.evaluation) return null
try {
// First, try to parse the whole string as JSON
let data = JSON.parse(props.evaluation);
// If successful and it's a string, parse it again (for double-encoded JSON)
if (typeof data === 'string') {
data = JSON.parse(data);
}
return data;
} catch (error) {
console.error('Failed to parse evaluation JSON:', error)
return null
}
})
const parseMarkdown = (text: string | null): string => {
if (!text) return ''
let parsed = text
.replace(/\\n/g, '\n')
.replace(/\\"/g, '"')
.replace(/\\'/g, "'")
.replace(/\\\\/g, '\\')
parsed = parsed.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
parsed = parsed.replace(/(?<!\*)\*([^*]+)\*(?!\*)/g, '<em>$1</em>')
parsed = parsed.replace(/^([A-Z])\)\s*\*\*(.*?)\*\*(.*)/gm, '<div class="mb-2"><span class="inline-flex items-center justify-center w-6 h-6 bg-indigo-100 text-indigo-600 text-sm font-bold rounded-full mr-2">$1</span><strong>$2</strong>$3</div>')
parsed = parsed.replace(/\n/g, '<br>')
parsed = parsed.replace(/(<br\s*\/?>\s*){2,}/g, '</p><p class="mt-2">')
if (!parsed.includes('<p>')) {
parsed = `<p>${parsed}</p>`
}
return parsed
}
</script>

View File

@@ -0,0 +1,72 @@
<template>
<TransitionRoot as="template" :show="show">
<Dialog as="div" class="relative z-50" @close="$emit('close')">
<TransitionChild as="template" enter="ease-out duration-300" enter-from="opacity-0" enter-to="opacity-100" leave="ease-in duration-200" leave-from="opacity-100" leave-to="opacity-0">
<div class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" />
</TransitionChild>
<div class="fixed inset-0 z-10 overflow-y-auto">
<div class="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
<TransitionChild as="template" enter="ease-out duration-300" enter-from="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95" enter-to="opacity-100 translate-y-0 sm:scale-100" leave="ease-in duration-200" leave-from="opacity-100 translate-y-0 sm:scale-100" leave-to="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95">
<DialogPanel class="relative transform overflow-hidden rounded-lg bg-white text-left shadow-xl transition-all sm:my-6 sm:w-full sm:max-w-lg">
<div class="bg-white px-5 pt-6 pb-5 sm:px-6 sm:pt-6 sm:pb-5">
<div class="flex flex-col gap-4 sm:flex-row sm:items-start">
<div class="mx-auto flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-indigo-100 sm:mx-0 sm:h-12 sm:w-12">
<svg class="h-6 w-6 text-indigo-600" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6v12m6-6H6" />
</svg>
</div>
<div class="text-center sm:flex-1 sm:text-left">
<DialogTitle as="h3" class="text-xl font-semibold leading-7 text-gray-900">生成后续大纲</DialogTitle>
<div class="mt-2">
<p class="text-base text-gray-500">请输入或选择要生成的后续章节数量</p>
</div>
</div>
</div>
<div class="mt-6">
<label for="numChapters" class="block text-base font-medium text-gray-700">生成数量</label>
<input type="number" name="numChapters" id="numChapters" v-model.number="numChapters" class="mt-2 block w-full rounded-xl border border-gray-200 bg-gray-50 px-4 py-3 text-lg shadow-sm focus:border-indigo-500 focus:bg-white focus:outline-none focus:ring-2 focus:ring-indigo-500" min="1" max="20">
<div class="mt-5 flex flex-wrap justify-center gap-3">
<button v-for="count in [1, 2, 5, 10]" :key="count" @click="setNumChapters(count)"
:class="['px-5 py-2 text-base rounded-full transition-colors duration-150', numChapters === count ? 'bg-indigo-600 text-white shadow-md' : 'bg-gray-200 text-gray-700 hover:bg-gray-300']">
{{ count }}
</button>
</div>
</div>
</div>
<div class="bg-gray-50 px-6 py-4 sm:flex sm:flex-row-reverse sm:px-8">
<button type="button" class="inline-flex w-full justify-center rounded-lg border border-transparent bg-indigo-600 px-5 py-3 text-base font-semibold text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:ml-3 sm:w-auto" @click="handleGenerate">生成</button>
<button type="button" class="mt-3 inline-flex w-full justify-center rounded-lg border border-gray-300 bg-white px-5 py-3 text-base font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:mt-0 sm:ml-3 sm:w-auto" @click="$emit('close')">取消</button>
</div>
</DialogPanel>
</TransitionChild>
</div>
</div>
</Dialog>
</TransitionRoot>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { Dialog, DialogPanel, DialogTitle, TransitionChild, TransitionRoot } from '@headlessui/vue'
interface Props {
show: boolean
}
const props = defineProps<Props>()
const emit = defineEmits(['close', 'generate'])
const numChapters = ref(5)
const setNumChapters = (count: number) => {
numChapters.value = count
}
const handleGenerate = () => {
if (numChapters.value > 0) {
emit('generate', numChapters.value)
emit('close')
}
}
</script>

View File

@@ -0,0 +1,86 @@
<template>
<div class="flex-shrink-0 z-30 bg-white/80 backdrop-blur-lg border-b border-gray-200 shadow-sm">
<div class="px-4 sm:px-6 lg:px-8">
<div class="flex items-center justify-between h-16">
<!-- 左侧项目信息 -->
<div class="flex items-center gap-2 sm:gap-4 min-w-0">
<button
@click="$emit('goBack')"
class="p-2 text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded-lg transition-colors flex-shrink-0"
>
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M9.707 16.707a1 1 0 01-1.414 0l-6-6a1 1 0 010-1.414l6-6a1 1 0 011.414 1.414L4.414 9H17a1 1 0 110 2H4.414l5.293 5.293a1 1 0 010 1.414z" clip-rule="evenodd"></path>
</svg>
</button>
<div class="min-w-0">
<h1 class="text-base sm:text-lg font-bold text-gray-900 truncate">{{ project?.title || '加载中...' }}</h1>
<div class="hidden sm:flex items-center gap-2 md:gap-4 text-xs md:text-sm text-gray-600">
<span>{{ project?.blueprint?.genre || '--' }}</span>
<span class="hidden md:inline"></span>
<span class="hidden md:inline">{{ progress }}% 完成</span>
<span class="hidden lg:inline"></span>
<span class="hidden lg:inline">{{ completedChapters }}/{{ totalChapters }} </span>
</div>
</div>
</div>
<!-- 右侧操作按钮 -->
<div class="flex items-center gap-1 sm:gap-2">
<button
@click="$emit('viewProjectDetail')"
class="p-2 sm:px-3 sm:py-2 text-gray-600 hover:text-gray-800 hover:bg-gray-100 rounded-lg transition-colors flex items-center gap-2"
>
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path d="M10 12a2 2 0 100-4 2 2 0 000 4z"></path>
<path fill-rule="evenodd" d="M.458 10C1.732 5.943 5.522 3 10 3s8.268 2.943 9.542 7c-1.274 4.057-5.064 7-9.542 7S1.732 14.057.458 10zM14 10a4 4 0 11-8 0 4 4 0 018 0z" clip-rule="evenodd"></path>
</svg>
<span class="hidden md:inline text-sm">项目详情</span>
</button>
<div class="w-px h-6 bg-gray-300 hidden sm:block"></div>
<button
@click="handleLogout"
class="p-2 sm:px-3 sm:py-2 text-gray-600 hover:text-gray-800 hover:bg-gray-100 rounded-lg transition-colors flex items-center gap-2"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg>
<span class="hidden md:inline text-sm">退出登录</span>
</button>
<button
@click="$emit('toggleSidebar')"
class="p-2 text-gray-600 hover:text-gray-800 hover:bg-gray-100 rounded-lg transition-colors lg:hidden"
>
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M3 5a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM3 10a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM3 15a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z" clip-rule="evenodd"></path>
</svg>
</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import type { NovelProject } from '@/api/novel'
const router = useRouter()
const authStore = useAuthStore()
const handleLogout = () => {
authStore.logout()
router.push('/login')
}
interface Props {
project: NovelProject | null
progress: number
completedChapters: number
totalChapters: number
}
defineProps<Props>()
defineEmits(['goBack', 'viewProjectDetail', 'toggleSidebar'])
</script>

View File

@@ -0,0 +1,408 @@
<template>
<div>
<!-- 侧边栏遮罩 (移动端) -->
<div
v-if="sidebarOpen"
@click="$emit('closeSidebar')"
class="fixed inset-0 bg-black/20 backdrop-blur-sm z-40 lg:hidden"
></div>
<!-- 左侧蓝图和章节列表 -->
<div
:class="[
'bg-white rounded-2xl shadow-lg border border-gray-100 transition-all duration-300 h-full',
'lg:relative lg:translate-x-0 lg:w-80 lg:flex-shrink-0',
sidebarOpen
? 'fixed left-4 top-20 bottom-4 w-80 z-50 translate-x-0'
: 'lg:w-80 lg:flex-shrink-0 -translate-x-full absolute lg:relative'
]"
>
<div class="h-full flex flex-col">
<!-- 蓝图预览卡片 -->
<div class="p-6 border-b border-gray-100 flex-shrink-0">
<div class="flex items-center gap-3 mb-4">
<div class="w-10 h-10 bg-gradient-to-r from-indigo-500 to-purple-600 rounded-lg flex items-center justify-center">
<svg class="w-6 h-6 text-white" fill="currentColor" viewBox="0 0 20 20">
<path d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
</div>
<div>
<h2 class="text-lg font-bold text-gray-900">故事蓝图</h2>
<p class="text-sm text-gray-600">{{ project.blueprint?.style || '未设定风格' }}</p>
</div>
</div>
<div class="space-y-3">
<div class="bg-gradient-to-r from-blue-50 to-indigo-50 rounded-lg p-3">
<h3 class="text-sm font-semibold text-blue-900 mb-1">故事概要</h3>
<Tooltip :text="project.blueprint?.one_sentence_summary">
<p class="text-xs text-blue-700 line-clamp-3">{{ project.blueprint?.one_sentence_summary || '暂无概要' }}</p>
</Tooltip>
</div>
<div class="grid grid-cols-2 gap-2 text-xs">
<div class="bg-purple-50 rounded-lg p-2 text-center">
<div class="font-semibold text-purple-800">{{ characterCount }}</div>
<div class="text-purple-600">角色</div>
</div>
<div class="bg-green-50 rounded-lg p-2 text-center">
<div class="font-semibold text-green-800">{{ relationshipCount }}</div>
<div class="text-green-600">关系</div>
</div>
</div>
</div>
</div>
<!-- 章节列表 -->
<div ref="listContainer" class="flex-1 overflow-y-auto">
<div class="p-6 pb-4">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-bold text-gray-900">章节大纲</h3>
<div class="flex items-center gap-2">
<button
v-if="hasIncompleteChapters"
@click.stop="scrollToFirstIncompleteChapter"
class="px-3 py-1 text-xs font-medium text-indigo-600 bg-indigo-50 rounded-full hover:bg-indigo-100 transition-colors"
>
定位到未完成
</button>
<span class="bg-indigo-100 text-indigo-700 px-2 py-1 rounded-full text-xs font-medium">
{{ totalChapters }}
</span>
</div>
</div>
</div>
<div class="px-6 pb-6">
<div v-if="project.blueprint?.chapter_outline?.length" class="space-y-2">
<div
v-for="chapter in project.blueprint.chapter_outline"
:key="chapter.chapter_number"
:ref="el => setChapterRef(chapter.chapter_number, el)"
@click="$emit('selectChapter', chapter.chapter_number)"
:class="[
'group cursor-pointer rounded-lg border-2 p-4 transition-all duration-200',
selectedForDeletion.includes(chapter.chapter_number)
? 'border-red-300 bg-red-50'
: selectedChapterNumber === chapter.chapter_number
? 'border-indigo-300 bg-indigo-50 shadow-md'
: 'border-gray-200 hover:border-indigo-200 hover:bg-gray-50'
]"
>
<div class="flex items-start gap-3">
<div class="flex-shrink-0 pt-1">
<input
type="checkbox"
:disabled="isChapterCompleted(chapter.chapter_number)"
:checked="selectedForDeletion.includes(chapter.chapter_number)"
@click.stop="toggleSelection(chapter.chapter_number)"
class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500 disabled:opacity-50"
/>
</div>
<div
:class="[
'w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold flex-shrink-0',
isChapterCompleted(chapter.chapter_number)
? 'bg-green-500 text-white'
: isChapterGenerating(chapter.chapter_number) || isChapterEvaluating(chapter.chapter_number) || isChapterSelecting(chapter.chapter_number)
? 'bg-blue-500 text-white animate-pulse'
: isChapterFailed(chapter.chapter_number)
? 'bg-red-500 text-white'
: selectedChapterNumber === chapter.chapter_number
? 'bg-indigo-500 text-white'
: 'bg-gray-200 text-gray-600'
]"
>
<svg v-if="isChapterCompleted(chapter.chapter_number)" class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"></path>
</svg>
<svg v-else-if="isChapterGenerating(chapter.chapter_number) || isChapterSelecting(chapter.chapter_number)" class="w-4 h-4 animate-spin" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z" clip-rule="evenodd"></path>
</svg>
<svg v-else-if="isChapterEvaluating(chapter.chapter_number)" class="w-4 h-4 animate-spin" fill="currentColor" viewBox="0 0 20 20">
<path d="M10 2a6 6 0 00-6 6v3.586l-1.707 1.707A1 1 0 003 15v1a1 1 0 001 1h12a1 1 0 001-1v-1a1 1 0 00-.293-.707L16 11.586V8a6 6 0 00-6-6zM8.05 17a2 2 0 103.9 0H8.05z"></path>
</svg>
<svg v-else-if="isChapterFailed(chapter.chapter_number)" class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clip-rule="evenodd"></path>
</svg>
<span v-else>{{ chapter.chapter_number }}</span>
</div>
<div class="flex-1 min-w-0">
<Tooltip :text="chapter.title">
<h4 class="font-semibold text-gray-900 text-sm mb-1 line-clamp-1">{{ chapter.title }}</h4>
</Tooltip>
<Tooltip :text="chapter.summary">
<p class="text-xs text-gray-600 line-clamp-2 leading-relaxed">{{ chapter.summary }}</p>
</Tooltip>
<!-- 章节状态 -->
<div class="mt-2 flex items-center gap-2">
<span v-if="isChapterCompleted(chapter.chapter_number)" class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
已完成
</span>
<span v-else-if="isChapterGenerating(chapter.chapter_number)" class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 animate-pulse">
生成中...
</span>
<span v-else-if="isChapterSelecting(chapter.chapter_number)" class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 animate-pulse">
选择中...
</span>
<span v-else-if="isChapterEvaluating(chapter.chapter_number)" class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800 animate-pulse">
评审中...
</span>
<span v-else-if="isChapterFailed(chapter.chapter_number)" class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">
生成失败
</span>
<span v-else-if="hasChapterInProgress(chapter.chapter_number)" class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-800">
待选择版本
</span>
<span v-else class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-600">
未开始
</span>
</div>
</div>
<!-- 章节操作按钮 -->
<div class="flex items-center opacity-0 group-hover:opacity-100 transition-opacity duration-200">
<button
v-if="!isChapterCompleted(chapter.chapter_number)"
@click.stop="$emit('editChapter', chapter)"
class="p-1.5 text-gray-500 hover:bg-gray-100 rounded-md transition-colors"
title="编辑大纲"
>
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path d="M17.414 2.586a2 2 0 00-2.828 0L7 10.172V13h2.828l7.586-7.586a2 2 0 000-2.828z"></path>
<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"></path>
</svg>
</button>
<button
v-if="canGenerateChapter(chapter.chapter_number) || isChapterFailed(chapter.chapter_number) || hasChapterInProgress(chapter.chapter_number)"
@click.stop="confirmGenerateChapter(chapter.chapter_number)"
:disabled="generatingChapter === chapter.chapter_number || isChapterGenerating(chapter.chapter_number)"
class="p-1.5 text-indigo-600 hover:bg-indigo-100 rounded-md transition-colors disabled:opacity-50"
:title="isChapterCompleted(chapter.chapter_number) ? '重新生成' : isChapterFailed(chapter.chapter_number) ? '重试' : hasChapterInProgress(chapter.chapter_number) ? '重新生成版本' : '开始创作'"
>
<svg v-if="generatingChapter === chapter.chapter_number || isChapterGenerating(chapter.chapter_number)" class="w-4 h-4 animate-spin" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z" clip-rule="evenodd"></path>
</svg>
<svg v-else class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z"></path>
</svg>
</button>
<!-- Batch delete replaces the single delete button -->
</div>
</div>
</div>
</div>
<div v-else class="text-center py-8 text-gray-500">
<svg class="w-12 h-12 mx-auto mb-3 opacity-50" fill="currentColor" viewBox="0 0 20 20">
<path d="M4 4a2 2 0 00-2 2v1h16V6a2 2 0 00-2-2H4zM18 9H2v5a2 2 0 002 2h12a2 2 0 002-2V9z"></path>
</svg>
<p>暂无章节大纲</p>
</div>
<div v-if="selectedForDeletion.length > 0" class="mt-4">
<button
@click="handleDeleteSelected"
class="w-full flex items-center justify-center gap-2 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors"
>
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm4 0a1 1 0 012 0v6a1 1 0 11-2 0V8z" clip-rule="evenodd"></path>
</svg>
<span>删除选中的 {{ selectedForDeletion.length }} </span>
</button>
</div>
<div class="mt-4">
<button
@click="$emit('generateOutline')"
:disabled="props.isGeneratingOutline"
class="w-full flex items-center justify-center gap-2 px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<svg v-if="props.isGeneratingOutline" class="w-5 h-5 animate-spin" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z" clip-rule="evenodd"></path>
</svg>
<svg v-else class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z"></path>
</svg>
<span>{{ props.isGeneratingOutline ? '生成中...' : '生成后续大纲' }}</span>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref, nextTick } from 'vue'
import type { ComponentPublicInstance } from 'vue'
import { globalAlert } from '@/composables/useAlert'
import type { NovelProject } from '@/api/novel'
import Tooltip from '@/components/Tooltip.vue'
interface Props {
project: NovelProject
sidebarOpen: boolean
selectedChapterNumber: number | null
generatingChapter: number | null
evaluatingChapter: number | null
isGeneratingOutline: boolean
}
const props = defineProps<Props>()
const emit = defineEmits(['closeSidebar', 'selectChapter', 'generateChapter', 'editChapter', 'deleteChapter', 'generateOutline'])
const selectedForDeletion = ref<number[]>([])
const listContainer = ref<HTMLElement | null>(null)
const chapterRefs = ref<Record<number, HTMLElement | null>>({})
const characterCount = computed(() => {
return props.project?.blueprint?.characters?.length || 0
})
const relationshipCount = computed(() => {
return props.project?.blueprint?.relationships?.length || 0
})
const lastChapterNumber = computed(() => {
if (!props.project?.blueprint?.chapter_outline || props.project.blueprint.chapter_outline.length === 0) {
return null
}
return Math.max(...props.project.blueprint.chapter_outline.map(ch => ch.chapter_number))
})
const totalChapters = computed(() => {
return props.project?.blueprint?.chapter_outline?.length || 0
})
const hasIncompleteChapters = computed(() => {
if (!props.project?.blueprint?.chapter_outline) return false
return props.project.blueprint.chapter_outline.some(ch => !isChapterCompleted(ch.chapter_number))
})
function toggleSelection(chapterNumber: number) {
if (isChapterCompleted(chapterNumber)) return
const index = selectedForDeletion.value.indexOf(chapterNumber)
if (index > -1) {
selectedForDeletion.value.splice(index, 1)
} else {
selectedForDeletion.value.push(chapterNumber)
}
}
function handleDeleteSelected() {
if (selectedForDeletion.value.length === 0) return
const sortedSelection = [...selectedForDeletion.value].sort((a, b) => a - b)
if (!lastChapterNumber.value || !sortedSelection.includes(lastChapterNumber.value)) {
alert('批量删除必须包含最后一章。')
return
}
const isContinuous = sortedSelection.every((num, i) => {
return i === 0 || num === sortedSelection[i - 1] + 1
})
if (!isContinuous) {
alert('只能删除连续的章节块。')
return
}
emit('deleteChapter', sortedSelection)
selectedForDeletion.value = []
}
async function confirmGenerateChapter(chapterNumber: number) {
const confirmed = await globalAlert.showConfirm('重新生成会覆盖当前章节的生成结果,确定继续吗?', '重新生成确认')
if (confirmed) {
emit('generateChapter', chapterNumber)
}
}
function setChapterRef(chapterNumber: number, el: Element | ComponentPublicInstance | null) {
if (!el) {
delete chapterRefs.value[chapterNumber]
return
}
const element = el instanceof Element ? el : (el.$el instanceof Element ? el.$el : null)
if (element) {
chapterRefs.value[chapterNumber] = element as HTMLElement
}
}
const scrollToFirstIncompleteChapter = async () => {
if (!props.project?.blueprint?.chapter_outline) return
const sorted = [...props.project.blueprint.chapter_outline].sort((a, b) => a.chapter_number - b.chapter_number)
const target = sorted.find(chapter => !isChapterCompleted(chapter.chapter_number))
if (!target) return
await nextTick()
const element = chapterRefs.value[target.chapter_number]
if (!element) return
const container = listContainer.value
if (container) {
element.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'nearest' })
} else {
element.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
}
// 章节状态检查
const isChapterCompleted = (chapterNumber: number) => {
if (!props.project?.chapters) return false
const chapter = props.project.chapters.find(ch => ch.chapter_number === chapterNumber)
return chapter && chapter.generation_status === 'successful'
}
const hasChapterInProgress = (chapterNumber: number) => {
if (!props.project?.chapters) return false
const chapter = props.project.chapters.find(ch => ch.chapter_number === chapterNumber)
return chapter && chapter.generation_status === 'waiting_for_confirm'
}
const isChapterGenerating = (chapterNumber: number) => {
if (!props.project?.chapters) return false
const chapter = props.project.chapters.find(ch => ch.chapter_number === chapterNumber)
return chapter && chapter.generation_status === 'generating'
}
const isChapterEvaluating = (chapterNumber: number) => {
if (!props.project?.chapters) return false
const chapter = props.project.chapters.find(ch => ch.chapter_number === chapterNumber)
return chapter && chapter.generation_status === 'evaluating'
}
const isChapterFailed = (chapterNumber: number) => {
if (!props.project?.chapters) return false
const chapter = props.project.chapters.find(ch => ch.chapter_number === chapterNumber)
return chapter && chapter.generation_status === 'failed'
}
const isChapterSelecting = (chapterNumber: number) => {
if (!props.project?.chapters) return false
const chapter = props.project.chapters.find(ch => ch.chapter_number === chapterNumber)
return chapter && chapter.generation_status === 'selecting'
}
const canGenerateChapter = (chapterNumber: number) => {
if (!props.project?.blueprint?.chapter_outline) return false
const outlines = props.project.blueprint.chapter_outline.sort((a, b) => a.chapter_number - b.chapter_number)
for (const outline of outlines) {
if (outline.chapter_number >= chapterNumber) break
const chapter = props.project?.chapters.find(ch => ch.chapter_number === outline.chapter_number)
if (!chapter || chapter.generation_status !== 'successful') {
return false
}
}
const currentChapter = props.project?.chapters.find(ch => ch.chapter_number === chapterNumber)
if (currentChapter && currentChapter.generation_status === 'successful') {
return true
}
return true
}
</script>

View File

@@ -0,0 +1,99 @@
<template>
<div v-if="show" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
<div class="bg-white rounded-2xl shadow-2xl max-w-4xl w-full max-h-[80vh] overflow-hidden">
<!-- 弹窗头部 -->
<div class="flex items-center justify-between p-6 border-b border-gray-200">
<div>
<h3 class="text-xl font-bold text-gray-900">版本详情</h3>
<p class="text-sm text-gray-600 mt-1">
版本 {{ detailVersionIndex + 1 }}
<span class="text-gray-400"></span>
{{ version?.style || '标准' }}风格
<span class="text-gray-400"></span>
{{ Math.round(cleanVersionContent(version?.content || '').length / 100) * 100 }}
</p>
</div>
<button
@click="$emit('close')"
class="p-2 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
>
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"></path>
</svg>
</button>
</div>
<!-- 弹窗内容 -->
<div class="p-6 overflow-y-auto max-h-[60vh]">
<div class="prose max-w-none">
<div class="whitespace-pre-wrap text-gray-700 leading-relaxed">
{{ cleanVersionContent(version?.content || '') }}
</div>
</div>
</div>
<!-- 弹窗底部操作按钮 -->
<div class="flex items-center justify-between p-6 border-t border-gray-200 bg-gray-50">
<div class="text-sm text-gray-500">
<span v-if="isCurrent" class="inline-flex items-center px-2 py-1 rounded-full bg-green-100 text-green-800 font-medium">
<svg class="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"></path>
</svg>
当前选中版本
</span>
<span v-else class="text-gray-400">未选中版本</span>
</div>
<div class="flex gap-3">
<button
@click="$emit('close')"
class="px-4 py-2 text-gray-600 hover:text-gray-800 hover:bg-gray-200 rounded-lg transition-colors"
>
关闭
</button>
<button
v-if="!isCurrent"
@click="$emit('selectVersion')"
class="px-6 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors"
>
选择此版本
</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { ChapterVersion } from '@/api/novel'
import { computed } from 'vue'
interface Props {
show: boolean
detailVersionIndex: number
version: ChapterVersion | null
isCurrent: boolean
}
const props = defineProps<Props>()
defineEmits(['close', 'selectVersion'])
const cleanVersionContent = (content: string): string => {
if (!content) return ''
try {
const parsed = JSON.parse(content)
if (parsed && typeof parsed === 'object' && parsed.content) {
content = parsed.content
}
} catch (error) {
// not a json
}
let cleaned = content.replace(/^"|"$/g, '')
cleaned = cleaned.replace(/\\n/g, '\n')
cleaned = cleaned.replace(/\\"/g, '"')
cleaned = cleaned.replace(/\\t/g, '\t')
cleaned = cleaned.replace(/\\\\/g, '\\')
return cleaned
}
</script>

View File

@@ -0,0 +1,391 @@
<template>
<div class="flex-1 min-w-0 h-full">
<div class="bg-white rounded-2xl shadow-lg border border-gray-100 h-full flex flex-col">
<!-- 章节工作区头部 -->
<div v-if="selectedChapterNumber" class="border-b border-gray-100 p-6 flex-shrink-0">
<div class="flex items-center justify-between">
<div>
<div class="flex items-center gap-3 mb-2">
<h2 class="text-xl font-bold text-gray-900">{{ selectedChapterNumber }}</h2>
<span
:class="[
'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium',
isChapterCompleted(selectedChapterNumber)
? 'bg-green-100 text-green-800'
: 'bg-gray-100 text-gray-600'
]"
>
{{ isChapterCompleted(selectedChapterNumber) ? '已完成' : '未完成' }}
</span>
</div>
<h3 class="text-lg text-gray-700 mb-1">{{ selectedChapterOutline?.title || '未知标题' }}</h3>
<p class="text-sm text-gray-600">{{ selectedChapterOutline?.summary || '暂无章节描述' }}</p>
</div>
<div class="flex items-center gap-2">
<button
v-if="isChapterCompleted(selectedChapterNumber)"
@click="openEditModal"
class="px-4 py-2 bg-green-600 text-white hover:bg-green-700 rounded-lg transition-colors flex items-center gap-2 whitespace-nowrap"
>
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z"></path>
</svg>
手动编辑
</button>
<button
@click="confirmRegenerateChapter"
:disabled="generatingChapter === selectedChapterNumber"
class="px-4 py-2 bg-indigo-600 text-white hover:bg-indigo-700 rounded-lg transition-colors disabled:opacity-50 flex items-center gap-2 whitespace-nowrap"
>
<svg v-if="generatingChapter === selectedChapterNumber" class="w-4 h-4 animate-spin" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z" clip-rule="evenodd"></path>
</svg>
<svg v-else class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z" clip-rule="evenodd"></path>
</svg>
{{ generatingChapter === selectedChapterNumber ? '生成中...' : '重新生成' }}
</button>
</div>
</div>
</div>
<!-- 章节内容展示区 -->
<div class="flex-1 p-6 overflow-y-auto">
<component
:is="currentComponent"
v-bind="currentComponentProps"
@hideVersionSelector="$emit('hideVersionSelector')"
@update:selectedVersionIndex="$emit('update:selectedVersionIndex', $event)"
@showVersionDetail="$emit('showVersionDetail', $event)"
@confirmVersionSelection="$emit('confirmVersionSelection')"
@generateChapter="$emit('generateChapter', $event)"
@showVersionSelector="$emit('showVersionSelector')"
@regenerateChapter="$emit('regenerateChapter')"
@evaluateChapter="$emit('evaluateChapter')"
@showEvaluationDetail="$emit('showEvaluationDetail')"
/>
</div>
</div>
<!-- 编辑章节内容模态框 -->
<div v-if="showEditModal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div class="bg-white rounded-2xl shadow-xl w-full h-full flex flex-col">
<!-- 模态框头部 -->
<div class="flex items-center justify-between p-6 border-b border-gray-200">
<h3 class="text-lg font-semibold text-gray-900">
编辑第{{ selectedChapterNumber }}章内容
</h3>
<button
@click="closeEditModal"
class="text-gray-400 hover:text-gray-600 transition-colors"
>
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"></path>
</svg>
</button>
</div>
<!-- 模态框内容 -->
<div class="flex-1 p-6 overflow-hidden">
<div class="flex flex-col h-full">
<label class="block text-sm font-medium text-gray-700 mb-2">
章节内容
</label>
<textarea
v-model="editingContent"
class="flex-1 w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 resize-none"
placeholder="请输入章节内容..."
:disabled="isSaving"
></textarea>
<div class="text-sm text-gray-500 mt-2">
字数统计: {{ editingContent.length }}
</div>
</div>
</div>
<!-- 模态框底部 -->
<div class="flex items-center justify-end gap-3 p-6 border-t border-gray-200">
<button
@click="closeEditModal"
:disabled="isSaving"
class="px-4 py-2 text-gray-700 bg-white border border-gray-300 hover:bg-gray-50 rounded-lg transition-colors disabled:opacity-50"
>
取消
</button>
<button
@click="saveEditedContent"
:disabled="isSaving || !editingContent.trim()"
class="px-4 py-2 bg-indigo-600 text-white hover:bg-indigo-700 rounded-lg transition-colors disabled:opacity-50 flex items-center gap-2"
>
<svg v-if="isSaving" class="w-4 h-4 animate-spin" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z" clip-rule="evenodd"></path>
</svg>
{{ isSaving ? '保存中...' : '保存' }}
</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch, onUnmounted } from 'vue'
import { globalAlert } from '@/composables/useAlert'
import type { Chapter, ChapterOutline, ChapterGenerationResponse, ChapterVersion, NovelProject } from '@/api/novel'
import WorkspaceInitial from './workspace/WorkspaceInitial.vue'
import ChapterGenerating from './workspace/ChapterGenerating.vue'
import VersionSelector from './workspace/VersionSelector.vue'
import ChapterContent from './workspace/ChapterContent.vue'
import ChapterFailed from './workspace/ChapterFailed.vue'
import ChapterEmpty from './workspace/ChapterEmpty.vue'
interface Props {
project: NovelProject | null
selectedChapterNumber: number | null
generatingChapter: number | null
evaluatingChapter: number | null
showVersionSelector: boolean
chapterGenerationResult: ChapterGenerationResponse | null
selectedVersionIndex: number
availableVersions: ChapterVersion[]
isSelectingVersion?: boolean
}
const props = defineProps<Props>()
const emit = defineEmits([
'regenerateChapter',
'evaluateChapter',
'hideVersionSelector',
'update:selectedVersionIndex',
'showVersionDetail',
'confirmVersionSelection',
'generateChapter',
'showVersionSelector',
'showEvaluationDetail',
'fetchChapterStatus',
'editChapter'
])
const confirmRegenerateChapter = async () => {
const confirmed = await globalAlert.showConfirm('重新生成会覆盖当前章节的现有内容,确定继续吗?', '重新生成确认')
if (confirmed) {
emit('regenerateChapter')
}
}
// 编辑模态框状态
const showEditModal = ref(false)
const editingContent = ref('')
const isSaving = ref(false)
// 清理版本内容的辅助函数
const cleanVersionContent = (content: string): string => {
if (!content) return ''
try {
const parsed = JSON.parse(content)
if (parsed && typeof parsed === 'object' && parsed.content) {
content = parsed.content
}
} catch (error) {
// not a json
}
let cleaned = content.replace(/^"|"$/g, '')
cleaned = cleaned.replace(/\\n/g, '\n')
cleaned = cleaned.replace(/\\"/g, '"')
cleaned = cleaned.replace(/\\t/g, '\t')
cleaned = cleaned.replace(/\\\\/g, '\\')
return cleaned
}
const openEditModal = () => {
if (selectedChapter.value?.content) {
editingContent.value = cleanVersionContent(selectedChapter.value.content)
showEditModal.value = true
}
}
const closeEditModal = () => {
showEditModal.value = false
editingContent.value = ''
isSaving.value = false
}
const saveEditedContent = async () => {
if (!props.selectedChapterNumber || !editingContent.value.trim()) return
isSaving.value = true
try {
emit('editChapter', {
chapterNumber: props.selectedChapterNumber,
content: editingContent.value
})
closeEditModal()
} catch (error) {
console.error('保存章节内容失败:', error)
} finally {
isSaving.value = false
}
}
const selectedChapter = computed(() => {
if (!props.project || props.selectedChapterNumber === null) return null
return props.project.chapters.find(ch => ch.chapter_number === props.selectedChapterNumber) || null
})
const selectedChapterOutline = computed(() => {
if (!props.project?.blueprint?.chapter_outline || props.selectedChapterNumber === null) return null
return props.project.blueprint.chapter_outline.find(ch => ch.chapter_number === props.selectedChapterNumber) || null
})
const isChapterCompleted = (chapterNumber: number) => {
if (!props.project?.chapters) return false
const chapter = props.project.chapters.find(ch => ch.chapter_number === chapterNumber)
return chapter && chapter.generation_status === 'successful'
}
const isChapterGenerating = (chapterNumber: number) => {
if (!props.project?.chapters) return false
const chapter = props.project.chapters.find(ch => ch.chapter_number === chapterNumber)
return chapter && chapter.generation_status === 'generating'
}
const isChapterFailed = (chapterNumber: number) => {
if (!props.project?.chapters) return false
const chapter = props.project.chapters.find(ch => ch.chapter_number === chapterNumber)
return chapter && chapter.generation_status === 'failed'
}
const isChapterEvaluationFailed = (chapterNumber: number) => {
if (!props.project?.chapters) return false
const chapter = props.project.chapters.find(ch => ch.chapter_number === chapterNumber)
return chapter && chapter.generation_status === 'evaluation_failed'
}
const canGenerateChapter = (chapterNumber: number | null) => {
if (chapterNumber === null || !props.project?.blueprint?.chapter_outline) return false
const outlines = props.project.blueprint.chapter_outline.sort((a, b) => a.chapter_number - b.chapter_number)
for (const outline of outlines) {
if (outline.chapter_number >= chapterNumber) break
const chapter = props.project?.chapters.find(ch => ch.chapter_number === outline.chapter_number)
if (!chapter || chapter.generation_status !== 'successful') {
return false
}
}
const currentChapter = props.project?.chapters.find(ch => ch.chapter_number === chapterNumber)
if (currentChapter && currentChapter.generation_status === 'successful') {
return true
}
return true
}
const currentComponent = computed(() => {
if (!props.selectedChapterNumber) {
return WorkspaceInitial
}
const status = selectedChapter.value?.generation_status
if (status === 'generating' || status === 'evaluating' || status === 'selecting') {
return ChapterGenerating // Use a generic "in-progress" component
}
if (status === 'waiting_for_confirm' || status === 'evaluation_failed') {
return VersionSelector
}
if (selectedChapter.value?.content) {
return ChapterContent
}
if (isChapterFailed(props.selectedChapterNumber)) {
return ChapterFailed
}
return ChapterEmpty
})
// Polling for chapter status updates
const pollingTimer = ref<number | null>(null)
const startPolling = () => {
stopPolling()
pollingTimer.value = window.setInterval(() => {
emit('fetchChapterStatus')
}, 10000)
}
const stopPolling = () => {
if (pollingTimer.value) {
clearInterval(pollingTimer.value)
pollingTimer.value = null
}
}
watch(
() => [selectedChapter.value?.generation_status, props.evaluatingChapter, props.isSelectingVersion, props.selectedChapterNumber],
([status, evaluating, selecting, chapterNumber]) => {
if (chapterNumber === null) {
stopPolling()
return
}
const isEvaluating = evaluating === chapterNumber
// Poll when generating, evaluating, or selecting a version
const needsPolling = status === 'generating' || status === 'evaluating' || status === 'selecting'
if (needsPolling) {
startPolling()
} else {
stopPolling()
}
},
{ immediate: true }
)
onUnmounted(() => {
stopPolling()
})
const currentComponentProps = computed(() => {
if (!props.selectedChapterNumber) {
return {}
}
const status = selectedChapter.value?.generation_status
if (status === 'generating' || status === 'evaluating' || status === 'selecting') {
return {
chapterNumber: props.selectedChapterNumber,
status: status
}
}
if (status === 'waiting_for_confirm' || status === 'evaluation_failed') {
return {
selectedChapter: selectedChapter.value,
chapterGenerationResult: props.chapterGenerationResult,
availableVersions: props.availableVersions,
selectedVersionIndex: props.selectedVersionIndex,
isSelectingVersion: props.isSelectingVersion,
evaluatingChapter: props.evaluatingChapter,
isEvaluationFailed: isChapterEvaluationFailed(props.selectedChapterNumber)
}
}
if (selectedChapter.value?.content) {
return { selectedChapter: selectedChapter.value }
}
if (isChapterFailed(props.selectedChapterNumber)) {
return {
chapterNumber: props.selectedChapterNumber,
generatingChapter: props.generatingChapter
}
}
return {
chapterNumber: props.selectedChapterNumber,
generatingChapter: props.generatingChapter,
canGenerate: canGenerateChapter(props.selectedChapterNumber)
}
})
</script>

View File

@@ -0,0 +1,102 @@
<template>
<div class="space-y-6">
<div class="bg-green-50 border border-green-200 rounded-xl p-4 mb-6">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2 text-green-800">
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"></path>
</svg>
<span class="font-medium">这个章节已经完成</span>
</div>
<button
v-if="selectedChapter.versions && selectedChapter.versions.length > 0"
@click="$emit('showVersionSelector', true)"
class="text-green-700 hover:text-green-800 text-sm font-medium flex items-center gap-1"
>
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path d="M10 12a2 2 0 100-4 2 2 0 000 4z"></path>
<path fill-rule="evenodd" d="M.458 10C1.732 5.943 5.522 3 10 3s8.268 2.943 9.542 7c-1.274 4.057-5.064 7-9.542 7S1.732 14.057.458 10zM14 10a4 4 0 11-8 0 4 4 0 018 0z" clip-rule="evenodd"></path>
</svg>
查看所有版本
</button>
</div>
</div>
<div class="bg-gray-50 rounded-xl p-6">
<div class="flex items-center justify-between mb-4 gap-3">
<h4 class="font-semibold text-gray-800">章节内容</h4>
<div class="flex items-center gap-3">
<div class="text-sm text-gray-500">
{{ Math.round(cleanVersionContent(selectedChapter.content || '').length / 100) * 100 }}
</div>
<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-gray-200 text-gray-400 cursor-not-allowed'"
:disabled="!selectedChapter.content"
@click="exportChapterAsTxt(selectedChapter)"
>
<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>
</div>
</div>
<div class="prose max-w-none">
<div class="whitespace-pre-wrap text-gray-700 leading-relaxed">{{ cleanVersionContent(selectedChapter.content || '') }}</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { Chapter } from '@/api/novel'
interface Props {
selectedChapter: Chapter
}
defineProps<Props>()
defineEmits(['showVersionSelector'])
const cleanVersionContent = (content: string): string => {
if (!content) return ''
try {
const parsed = JSON.parse(content)
if (parsed && typeof parsed === 'object' && parsed.content) {
content = parsed.content
}
} catch (error) {
// not a json
}
let cleaned = content.replace(/^"|"$/g, '')
cleaned = cleaned.replace(/\\n/g, '\n')
cleaned = cleaned.replace(/\\"/g, '"')
cleaned = cleaned.replace(/\\t/g, '\t')
cleaned = cleaned.replace(/\\\\/g, '\\')
return cleaned
}
const sanitizeFileName = (name: string): string => {
return name.replace(/[\\/:*?"<>|]/g, '_')
}
const exportChapterAsTxt = (chapter?: Chapter | null) => {
if (!chapter) return
const title = chapter.title?.trim() || `${chapter.chapter_number}`
const safeTitle = sanitizeFileName(title) || `chapter-${chapter.chapter_number}`
const content = cleanVersionContent(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)
}
</script>

View File

@@ -0,0 +1,49 @@
<template>
<div class="h-full flex items-center justify-center">
<div class="text-center">
<svg class="w-16 h-16 text-gray-300 mx-auto mb-4" fill="currentColor" viewBox="0 0 20 20">
<path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z"></path>
</svg>
<h3 class="text-lg font-semibold text-gray-700 mb-2">开始创作</h3>
<div v-if="canGenerate">
<p class="text-gray-500 mb-4">点击"开始创作"按钮生成这个章节</p>
<button
@click="$emit('generateChapter', chapterNumber)"
:disabled="generatingChapter === chapterNumber"
class="px-6 py-3 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors disabled:opacity-50 flex items-center gap-2 mx-auto"
>
<svg v-if="generatingChapter === chapterNumber" class="w-4 h-4 animate-spin" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z" clip-rule="evenodd"></path>
</svg>
<svg v-else class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z"></path>
</svg>
{{ generatingChapter === chapterNumber ? '生成中...' : '开始创作' }}
</button>
</div>
<div v-else>
<p class="text-gray-500 mb-4">请先完成前面的章节才能生成此章节</p>
<div class="inline-flex items-center px-4 py-2 bg-gray-100 text-gray-600 rounded-lg">
<svg class="w-4 h-4 mr-2" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z" clip-rule="evenodd"></path>
</svg>
按顺序生成
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
interface Props {
chapterNumber: number
generatingChapter: number | null
canGenerate: boolean
}
defineProps<Props>()
defineEmits(['generateChapter'])
</script>

View File

@@ -0,0 +1,37 @@
<template>
<div class="h-full flex items-center justify-center">
<div class="text-center max-w-md">
<div class="w-24 h-24 bg-red-100 rounded-full mx-auto flex items-center justify-center mb-6 shadow-lg">
<svg class="w-12 h-12 text-red-500" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clip-rule="evenodd"></path>
</svg>
</div>
<h3 class="text-2xl font-bold text-gray-800 mb-3">{{ chapterNumber }}章生成失败</h3>
<p class="text-gray-600 mb-6">很抱歉AI在生成这个章节时遇到了问题请点击重试按钮重新生成</p>
<button
@click="$emit('generateChapter', chapterNumber)"
:disabled="generatingChapter === chapterNumber"
class="px-6 py-3 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors disabled:opacity-50 flex items-center gap-2 mx-auto"
>
<svg v-if="generatingChapter === chapterNumber" class="w-4 h-4 animate-spin" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z" clip-rule="evenodd"></path>
</svg>
<svg v-else class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z" clip-rule="evenodd"></path>
</svg>
{{ generatingChapter === chapterNumber ? '重试中...' : '重试生成' }}
</button>
</div>
</div>
</template>
<script setup lang="ts">
interface Props {
chapterNumber: number
generatingChapter: number | null
}
defineProps<Props>()
defineEmits(['generateChapter'])
</script>

View File

@@ -0,0 +1,69 @@
<template>
<div class="h-full flex items-center justify-center">
<div class="text-center max-w-md">
<div class="relative mb-8">
<div class="w-24 h-24 bg-gradient-to-r from-blue-400 to-indigo-500 rounded-full mx-auto flex items-center justify-center animate-pulse shadow-lg">
<svg class="w-12 h-12 text-white animate-spin" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z" clip-rule="evenodd"></path>
</svg>
</div>
<div class="absolute inset-0 w-24 h-24 bg-gradient-to-r from-blue-400 to-indigo-500 rounded-full mx-auto animate-ping opacity-20"></div>
</div>
<h3 class="text-2xl font-bold text-gray-800 mb-3">{{ statusText.title }}</h3>
<div class="space-y-2 text-gray-600 mb-6">
<p class="animate-pulse">{{ statusText.line1 }}</p>
<p class="animate-pulse" style="animation-delay: 0.5s">{{ statusText.line2 }}</p>
<p class="animate-pulse" style="animation-delay: 1s">🎨 描绘生动场景...</p>
</div>
<div class="bg-blue-50 border border-blue-200 rounded-xl p-4">
<p class="text-blue-800 text-sm">
<svg class="w-4 h-4 inline mr-2" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd"></path>
</svg>
生成过程通常需要2分钟以上请耐心等待您可以随时离开此页面生成完成后再回来查看
</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { Chapter } from '@/api/novel'
interface Props {
chapterNumber: number | null
status: Chapter['generation_status'] | null
}
const props = defineProps<Props>()
const statusText = computed(() => {
switch (props.status) {
case 'generating':
return {
title: `AI 正在为您创作第${props.chapterNumber}`,
line1: '✨ 构思情节发展...',
line2: '📝 编织精彩对话...'
}
case 'evaluating':
return {
title: `AI 正在评审第${props.chapterNumber}章的多个版本`,
line1: '🧐 分析故事结构...',
line2: '⚖️ 比较版本优劣...'
}
case 'selecting':
return {
title: `正在确认第${props.chapterNumber}章的最终版本`,
line1: '💾 保存您的选择...',
line2: '✍️ 生成最终摘要...'
}
default:
return {
title: '请稍候...',
line1: '正在处理您的请求...',
line2: '...'
}
}
})
</script>

View File

@@ -0,0 +1,251 @@
<template>
<div class="space-y-6">
<!-- AI 评审提示 -->
<div v-if="isEvaluationFailed" class="bg-red-50 border border-red-200 rounded-xl p-4">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="w-8 h-8 bg-red-500 rounded-full flex items-center justify-center flex-shrink-0">
<svg class="w-5 h-5 text-white" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clip-rule="evenodd" />
</svg>
</div>
<div>
<h4 class="font-bold text-red-900">AI 评审失败</h4>
<p class="text-sm text-red-700">AI 评审时遇到问题请重试</p>
</div>
</div>
<button
@click="$emit('evaluateChapter')"
:disabled="evaluatingChapter === selectedChapter?.chapter_number"
class="px-4 py-2 bg-red-600 text-white hover:bg-red-700 rounded-lg transition-colors disabled:opacity-50 flex items-center gap-2 whitespace-nowrap"
>
<svg v-if="evaluatingChapter === selectedChapter?.chapter_number" class="w-4 h-4 animate-spin" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z" clip-rule="evenodd"></path>
</svg>
{{ evaluatingChapter === selectedChapter?.chapter_number ? '重试中...' : '重新评审' }}
</button>
</div>
</div>
<div v-else-if="selectedChapter?.evaluation" class="bg-purple-50 border border-purple-200 rounded-xl p-4">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="w-8 h-8 bg-purple-500 rounded-full flex items-center justify-center flex-shrink-0">
<svg class="w-5 h-5 text-white" fill="currentColor" viewBox="0 0 20 20">
<path d="M10 2a6 6 0 00-6 6v3.586l-1.707 1.707A1 1 0 003 15v1a1 1 0 001 1h12a1 1 0 001-1v-1a1 1 0 00-.293-.707L16 11.586V8a6 6 0 00-6-6zM8.05 17a2 2 0 103.9 0H8.05z"></path>
</svg>
</div>
<div>
<h4 class="font-bold text-purple-900">AI 评审已完成</h4>
<p class="text-sm text-purple-700">AI 已对所有版本进行评估点击查看详细结果</p>
</div>
</div>
<button @click="$emit('showEvaluationDetail')" class="px-4 py-2 bg-purple-600 text-white hover:bg-purple-700 rounded-lg transition-colors flex items-center gap-2 whitespace-nowrap">
查看 AI 评审
</button>
</div>
</div>
<!-- AI消息 (仅对新生成的内容显示) -->
<div v-if="chapterGenerationResult?.ai_message" class="bg-blue-50 border border-blue-200 rounded-xl p-4">
<div class="flex items-start gap-3">
<div class="w-8 h-8 bg-blue-500 rounded-full flex items-center justify-center flex-shrink-0">
<svg class="w-4 h-4 text-white" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd"></path>
</svg>
</div>
<div class="flex-1">
<div
class="prose prose-sm max-w-none prose-headings:mt-2 prose-headings:mb-1 prose-p:my-1 prose-ul:my-1 prose-ol:my-1 prose-li:my-0 text-blue-800"
v-html="parseMarkdown(chapterGenerationResult.ai_message)"
></div>
</div>
</div>
</div>
<!-- 状态提示 -->
<div v-if="selectedChapter?.content" class="bg-amber-50 border border-amber-200 rounded-xl p-4">
<div class="flex items-center gap-2 text-amber-800">
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"></path>
</svg>
<span class="font-medium">您可以查看所有版本并选择不同的版本</span>
</div>
</div>
<div v-else class="bg-blue-50 border border-blue-200 rounded-xl p-4">
<div class="flex items-center gap-2 text-blue-800">
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd"></path>
</svg>
<span class="font-medium">请选择一个版本来完成这个章节</span>
</div>
</div>
<!-- 版本选择器 -->
<div class="bg-gray-50 rounded-xl p-4">
<div class="flex items-center justify-between mb-4">
<h4 class="font-semibold text-gray-800">
{{ availableVersions.length > 1 ? '选择版本' : '生成内容' }}
<span class="text-sm font-normal text-gray-600 ml-2">({{ availableVersions.length }} 个版本)</span>
</h4>
<!-- <button
@click="$emit('confirmVersionSelection')"
:disabled="!availableVersions?.[selectedVersionIndex]?.content || isCurrentVersion(selectedVersionIndex) || isSelectingVersion"
class="px-6 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center"
>
<svg v-if="isSelectingVersion" class="w-5 h-5 animate-spin" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z" clip-rule="evenodd"></path>
</svg>
<span v-else>
{{ isCurrentVersion(selectedVersionIndex) ? '当前版本' : '确认选择此版本' }}
</span>
</button> -->
</div>
<div class="grid gap-3">
<div
v-for="(version, index) in availableVersions"
:key="index"
@click="$emit('update:selectedVersionIndex', index)"
:class="[
'cursor-pointer border-2 rounded-lg p-4 transition-all duration-200',
selectedVersionIndex === index
? 'border-indigo-300 bg-indigo-50 shadow-md'
: isCurrentVersion(index)
? 'border-green-300 bg-green-50'
: 'border-gray-200 hover:border-indigo-200 hover:bg-white'
]"
>
<div class="flex items-start gap-3">
<div
:class="[
'w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold flex-shrink-0',
selectedVersionIndex === index
? 'bg-indigo-500 text-white'
: isCurrentVersion(index)
? 'bg-green-500 text-white'
: 'bg-gray-300 text-gray-600'
]"
>
<svg v-if="isCurrentVersion(index)" class="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"></path>
</svg>
<span v-else>{{ index + 1 }}</span>
</div>
<div class="flex-1">
<p class="text-sm text-gray-700 line-clamp-3">
{{ cleanVersionContent(version.content).substring(0, 150) }}...
</p>
<div class="mt-2 flex items-center gap-2 text-xs text-gray-500">
<span> {{ Math.round(cleanVersionContent(version.content).length / 100) * 100 }} </span>
<span></span>
<span>{{ version.style || '标准' }}风格</span>
<span v-if="isCurrentVersion(index)" class="text-green-600 font-medium"> 当前选中</span>
</div>
<div class="mt-2">
<button
@click.stop="$emit('showVersionDetail', index)"
class="text-xs text-indigo-600 hover:text-indigo-800 font-medium flex items-center gap-1"
>
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
<path d="M10 12a2 2 0 100-4 2 2 0 000 4z"></path>
<path fill-rule="evenodd" d="M.458 10C1.732 5.943 5.522 3 10 3s8.268 2.943 9.542 7c-1.274 4.057-5.064 7-9.542 7S1.732 14.057.458 10zM14 10a4 4 0 11-8 0 4 4 0 018 0z" clip-rule="evenodd"></path>
</svg>
查看详情
</button>
</div>
</div>
</div>
</div>
</div>
<div class="mt-4 flex justify-end items-center gap-4">
<button
@click="$emit('evaluateChapter')"
:disabled="evaluatingChapter === selectedChapter?.chapter_number || availableVersions.length < 2"
class="px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
<svg v-if="evaluatingChapter === selectedChapter?.chapter_number" class="w-4 h-4 animate-spin" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z" clip-rule="evenodd"></path>
</svg>
{{ evaluatingChapter === selectedChapter?.chapter_number ? '评审中...' : 'AI 评审' }}
</button>
<button
@click="$emit('confirmVersionSelection')"
:disabled="!availableVersions?.[selectedVersionIndex]?.content || isCurrentVersion(selectedVersionIndex) || isSelectingVersion"
class="px-6 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center"
>
<svg v-if="isSelectingVersion" class="w-5 h-5 animate-spin" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z" clip-rule="evenodd"></path>
</svg>
<span v-else>
{{ isCurrentVersion(selectedVersionIndex) ? '当前版本' : '确认选择此版本' }}
</span>
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { Chapter, ChapterGenerationResponse, ChapterVersion } from '@/api/novel'
interface Props {
selectedChapter: Chapter | null
chapterGenerationResult: ChapterGenerationResponse | null
availableVersions: ChapterVersion[]
selectedVersionIndex: number
evaluatingChapter: number | null
isSelectingVersion?: boolean
isEvaluationFailed?: boolean
}
const props = defineProps<Props>()
defineEmits(['hideVersionSelector', 'update:selectedVersionIndex', 'showVersionDetail', 'confirmVersionSelection', 'evaluateChapter', 'showEvaluationDetail'])
const isCurrentVersion = (versionIndex: number) => {
if (!props.selectedChapter?.content || !props.availableVersions?.[versionIndex]?.content) return false
const cleanCurrentContent = cleanVersionContent(props.selectedChapter.content)
const cleanVersionContentStr = cleanVersionContent(props.availableVersions[versionIndex].content)
return cleanCurrentContent === cleanVersionContentStr
}
const cleanVersionContent = (content: string): string => {
if (!content) return ''
try {
const parsed = JSON.parse(content)
if (parsed && typeof parsed === 'object' && parsed.content) {
content = parsed.content
}
} catch (error) {
// not a json
}
let cleaned = content.replace(/^"|"$/g, '')
cleaned = cleaned.replace(/\\n/g, '\n')
cleaned = cleaned.replace(/\\"/g, '"')
cleaned = cleaned.replace(/\\t/g, '\t')
cleaned = cleaned.replace(/\\\\/g, '\\')
return cleaned
}
const parseMarkdown = (text: string): string => {
if (!text) return ''
let parsed = text
.replace(/\\n/g, '\n')
.replace(/\\"/g, '"')
.replace(/\\'/g, "'")
.replace(/\\\\/g, '\\')
parsed = parsed.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
parsed = parsed.replace(/(?<!\*)\*([^*]+)\*(?!\*)/g, '<em>$1</em>')
parsed = parsed.replace(/^([A-Z])\)\s*\*\*(.*?)\*\*(.*)/gm, '<div class="mb-2"><span class="inline-flex items-center justify-center w-6 h-6 bg-indigo-100 text-indigo-600 text-sm font-bold rounded-full mr-2">$1</span><strong>$2</strong>$3</div>')
parsed = parsed.replace(/\n/g, '<br>')
parsed = parsed.replace(/(<br\s*\/?>\s*){2,}/g, '</p><p class="mt-2">')
if (!parsed.includes('<p>')) {
parsed = `<p>${parsed}</p>`
}
return parsed
}
</script>

View File

@@ -0,0 +1,15 @@
<template>
<div class="h-full flex items-center justify-center">
<div class="text-center">
<svg class="w-16 h-16 text-gray-300 mx-auto mb-4" fill="currentColor" viewBox="0 0 20 20">
<path d="M4 4a2 2 0 00-2 2v1h16V6a2 2 0 00-2-2H4zM18 9H2v5a2 2 0 002 2h12a2 2 0 002-2V9zM4 13a1 1 0 011-1h1a1 1 0 110 2H5a1 1 0 01-1-1zm5-1a1 1 0 100 2h1a1 1 0 100-2H9z"></path>
</svg>
<h3 class="text-lg font-semibold text-gray-700 mb-2">选择章节开始创作</h3>
<p class="text-gray-500">从左侧章节列表中选择一个章节开始您的创作之旅</p>
</div>
</div>
</template>
<script setup lang="ts">
// No script needed for this simple component
</script>

View File

@@ -0,0 +1,88 @@
import { ref } from 'vue'
type AlertType = 'success' | 'error' | 'info' | 'confirmation'
interface Alert {
id: number
visible: boolean
type: AlertType
title: string
message: string
showCancel: boolean
confirmText: string
cancelText: string
onConfirm: (result: boolean) => void
}
const alerts = ref<Alert[]>([])
let alertId = 0
const closeAlert = (id: number, result: boolean) => {
const index = alerts.value.findIndex((a) => a.id === id)
if (index !== -1) {
// First, call the onConfirm callback to resolve the promise.
alerts.value[index].onConfirm(result)
// Then, remove the alert from the array to hide it.
alerts.value.splice(index, 1)
}
}
const showAlert = (
message: string,
type: AlertType = 'info',
title: string = '',
options: Partial<Omit<Alert, 'id' | 'visible' | 'message' | 'type' | 'title'>> = {}
) => {
return new Promise<boolean>((resolve) => {
const id = alertId++
const newAlert: Alert = {
id,
visible: true,
type,
title: title || (type === 'success' ? '成功' : type === 'error' ? '错误' : '提示'),
message,
showCancel: options.showCancel || false,
confirmText: options.confirmText || '确定',
cancelText: options.cancelText || '取消',
// The onConfirm callback is simply the resolve function of the promise.
// This breaks the recursive loop.
onConfirm: resolve,
}
alerts.value.push(newAlert)
// For simple notifications (not confirmation dialogs), auto-close after 3 seconds.
if ((type === 'success' || type === 'info') && !newAlert.showCancel) {
setTimeout(() => {
closeAlert(id, false) // Auto-close and resolve promise with false
}, 3000)
}
})
}
const showSuccess = (message: string, title: string = '成功') => {
return showAlert(message, 'success', title);
};
const showError = (message: string, title: string = '错误') => {
return showAlert(message, 'error', title);
};
const showConfirm = (message: string, title: string = '请确认') => {
return showAlert(message, 'confirmation', title, { showCancel: true });
};
export const globalAlert = {
alerts,
showAlert,
closeAlert,
showSuccess,
showError,
showConfirm,
}
export function useAlert() {
return {
showAlert: globalAlert.showAlert,
}
}

34
frontend/src/main.ts Normal file
View File

@@ -0,0 +1,34 @@
import '@fontsource/noto-sans-sc/300.css';
import '@fontsource/noto-sans-sc/400.css';
import '@fontsource/noto-sans-sc/500.css';
import '@fontsource/noto-sans-sc/700.css';
import './assets/main.css'
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
import { useAuthStore } from './stores/auth'
const app = createApp(App)
const pinia = createPinia()
app.use(pinia)
app.use(router)
// Handle token from URL
const urlParams = new URLSearchParams(window.location.search)
const token = urlParams.get('token')
if (token) {
const authStore = useAuthStore()
authStore.token = token
localStorage.setItem('token', token)
// Clean the URL
window.history.replaceState({}, document.title, "/")
authStore.fetchUser()
}
app.mount('#app')

View File

@@ -0,0 +1,109 @@
import { createRouter, createWebHistory } from 'vue-router'
import WorkspaceEntry from '../views/WorkspaceEntry.vue'
import NovelWorkspace from '../views/NovelWorkspace.vue'
import InspirationMode from '../views/InspirationMode.vue'
import WritingDesk from '../views/WritingDesk.vue'
import NovelDetail from '../views/NovelDetail.vue'
import Login from '../views/Login.vue'
import Register from '../views/Register.vue'
import { useAuthStore } from '@/stores/auth'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/',
name: 'workspace-entry',
component: WorkspaceEntry,
meta: { requiresAuth: true },
},
{
path: '/workspace',
name: 'novel-workspace',
component: NovelWorkspace,
meta: { requiresAuth: true },
},
{
path: '/inspiration',
name: 'inspiration-mode',
component: InspirationMode,
meta: { requiresAuth: true },
},
{
path: '/detail/:id',
name: 'novel-detail',
component: NovelDetail,
props: true,
meta: { requiresAuth: true },
},
{
path: '/novel/:id',
name: 'writing-desk',
component: WritingDesk,
props: true,
meta: { requiresAuth: true },
},
{
path: '/login',
name: 'login',
component: Login,
},
{
path: '/register',
name: 'register',
component: Register,
},
{
path: '/admin',
name: 'admin',
component: () => import('../views/AdminView.vue'),
meta: { requiresAuth: true, requiresAdmin: true },
},
{
path: '/admin/novel/:id',
name: 'admin-novel-detail',
component: () => import('../views/AdminNovelDetail.vue'),
props: true,
meta: { requiresAuth: true, requiresAdmin: true },
},
{
path: '/settings',
name: 'settings',
component: () => import('../views/SettingsView.vue'),
meta: { requiresAuth: true },
},
],
})
router.beforeEach(async (to, from, next) => {
const authStore = useAuthStore()
// Attempt to fetch user info if token exists but user info is not loaded
if (authStore.token && !authStore.user) {
await authStore.fetchUser()
}
const requiresAuth = to.matched.some(record => record.meta.requiresAuth)
const requiresAdmin = to.matched.some(record => record.meta.requiresAdmin)
const isAuthenticated = authStore.isAuthenticated
const isAdmin = authStore.user?.is_admin
const mustChangePassword = authStore.user?.is_admin && authStore.mustChangePassword
if (requiresAuth && !isAuthenticated) {
next('/login')
} else if (requiresAdmin && !isAdmin) {
next('/') // Redirect to a non-admin page if not an admin
} else if (isAuthenticated && mustChangePassword) {
if (to.name !== 'admin' || to.query.tab !== 'password') {
next({ name: 'admin', query: { tab: 'password' } })
} else {
next()
}
}
else {
next()
}
})
export default router

143
frontend/src/stores/auth.ts Normal file
View File

@@ -0,0 +1,143 @@
import { defineStore } from 'pinia';
import { API_BASE_URL } from '@/api/novel';
const API_URL = `${API_BASE_URL}/api/auth`;
interface AuthOptions {
// 是否允许用户自助注册
allow_registration: boolean;
// 是否启用 Linux.do 登录
enable_linuxdo_login: boolean;
}
// Helper function to handle fetch requests and token refreshing
async function fetchWithAuth(url: string, options: RequestInit = {}) {
const authStore = useAuthStore();
const headers = new Headers(options.headers || {});
if (authStore.token) {
headers.set('Authorization', `Bearer ${authStore.token}`);
}
options.headers = headers;
const response = await fetch(url, options);
const refreshedToken = response.headers.get('X-Token-Refresh');
if (refreshedToken) {
authStore.token = refreshedToken;
localStorage.setItem('token', refreshedToken);
}
return response;
}
interface User {
id: number;
username: string;
is_admin: boolean;
must_change_password: boolean;
}
export const useAuthStore = defineStore('auth', {
state: () => ({
token: localStorage.getItem('token') || null as string | null,
user: null as User | null,
authOptions: null as AuthOptions | null,
authOptionsLoaded: false,
}),
getters: {
isAuthenticated: (state) => !!state.token,
allowRegistration: (state) => state.authOptions?.allow_registration ?? true,
enableLinuxdoLogin: (state) => state.authOptions?.enable_linuxdo_login ?? false,
mustChangePassword: (state) => state.user?.must_change_password ?? false,
},
actions: {
async fetchAuthOptions(force = false) {
// 拉取后端认证相关开关,供前端动态渲染
if (this.authOptionsLoaded && !force) {
return;
}
try {
const response = await fetch(`${API_URL}/options`);
if (!response.ok) {
throw new Error('读取认证开关失败');
}
const data = await response.json() as AuthOptions;
this.authOptions = data;
} catch (error) {
console.error('获取认证配置失败,将使用默认值', error);
this.authOptions = {
allow_registration: true,
enable_linuxdo_login: false,
};
} finally {
this.authOptionsLoaded = true;
}
},
async login(username: string, password: string): Promise<boolean> {
const params = new URLSearchParams();
params.append('username', username);
params.append('password', password);
const response = await fetchWithAuth(`${API_URL}/token`, {
method: 'POST',
body: params,
});
if (!response.ok) {
throw new Error('Failed to login');
}
const data = await response.json();
this.token = data.access_token;
if (this.token) {
localStorage.setItem('token', this.token);
}
const mustChangePassword = Boolean(data.must_change_password);
await this.fetchUser();
if (this.user) {
this.user.must_change_password = mustChangePassword || this.user.must_change_password;
}
return mustChangePassword;
},
// 当前注册流程在 Register.vue 中实现,此处预留方法以兼容旧逻辑
async register(payload: { username: string; email: string; password: string; verification_code: string }) {
const response = await fetch(`${API_URL}/users`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
const detail = errorData.detail || 'Failed to register';
throw new Error(detail);
}
},
logout() {
this.token = null;
this.user = null;
localStorage.removeItem('token');
},
async fetchUser() {
if (this.token) {
try {
const response = await fetchWithAuth(`${API_URL}/users/me`);
if (!response.ok) {
throw new Error('Failed to fetch user');
}
const userData = await response.json();
this.user = {
id: userData.id,
username: userData.username,
is_admin: userData.is_admin || false,
must_change_password: userData.must_change_password || false,
};
} catch (error) {
this.logout();
}
}
},
},
});

View File

@@ -0,0 +1,322 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { NovelProject, NovelProjectSummary, ConverseResponse, BlueprintGenerationResponse, Blueprint, DeleteNovelsResponse, ChapterOutline } from '@/api/novel'
import { NovelAPI } from '@/api/novel'
export const useNovelStore = defineStore('novel', () => {
// State
const projects = ref<NovelProjectSummary[]>([])
const currentProject = ref<NovelProject | null>(null)
const currentConversationState = ref<any>({})
const isLoading = ref(false)
const error = ref<string | null>(null)
// Getters
const projectsCount = computed(() => projects.value.length)
const hasCurrentProject = computed(() => currentProject.value !== null)
// Actions
async function loadProjects() {
isLoading.value = true
error.value = null
try {
projects.value = await NovelAPI.getAllNovels()
} catch (err) {
error.value = err instanceof Error ? err.message : '加载项目失败'
} finally {
isLoading.value = false
}
}
async function createProject(title: string, initialPrompt: string) {
isLoading.value = true
error.value = null
try {
const project = await NovelAPI.createNovel(title, initialPrompt)
currentProject.value = project
currentConversationState.value = {}
return project
} catch (err) {
error.value = err instanceof Error ? err.message : '创建项目失败'
throw err
} finally {
isLoading.value = false
}
}
async function loadProject(projectId: string, silent: boolean = false) {
if (!silent) {
isLoading.value = true
}
error.value = null
try {
currentProject.value = await NovelAPI.getNovel(projectId)
} catch (err) {
error.value = err instanceof Error ? err.message : '加载项目失败'
} finally {
if (!silent) {
isLoading.value = false
}
}
}
async function loadChapter(chapterNumber: number) {
error.value = null
try {
if (!currentProject.value) {
throw new Error('没有当前项目')
}
const chapter = await NovelAPI.getChapter(currentProject.value.id, chapterNumber)
const project = currentProject.value
if (!Array.isArray(project.chapters)) {
project.chapters = []
}
const index = project.chapters.findIndex(ch => ch.chapter_number === chapterNumber)
if (index >= 0) {
project.chapters.splice(index, 1, chapter)
} else {
project.chapters.push(chapter)
}
project.chapters.sort((a, b) => a.chapter_number - b.chapter_number)
return chapter
} catch (err) {
error.value = err instanceof Error ? err.message : '加载章节失败'
throw err
}
}
async function sendConversation(userInput: any): Promise<ConverseResponse> {
isLoading.value = true
error.value = null
try {
if (!currentProject.value) {
throw new Error('没有当前项目')
}
const response = await NovelAPI.converseConcept(
currentProject.value.id,
userInput,
currentConversationState.value
)
currentConversationState.value = response.conversation_state
return response
} catch (err) {
error.value = err instanceof Error ? err.message : '对话失败'
throw err
} finally {
isLoading.value = false
}
}
async function generateBlueprint(): Promise<BlueprintGenerationResponse> {
// Generate blueprint from conversation history
isLoading.value = true
error.value = null
try {
if (!currentProject.value) {
throw new Error('没有当前项目')
}
return await NovelAPI.generateBlueprint(currentProject.value.id)
} catch (err) {
error.value = err instanceof Error ? err.message : '生成蓝图失败'
throw err
} finally {
isLoading.value = false
}
}
async function saveBlueprint(blueprint: Blueprint) {
isLoading.value = true
error.value = null
try {
if (!currentProject.value) {
throw new Error('没有当前项目')
}
if (!blueprint) {
throw new Error('缺少蓝图数据')
}
currentProject.value = await NovelAPI.saveBlueprint(currentProject.value.id, blueprint)
} catch (err) {
error.value = err instanceof Error ? err.message : '保存蓝图失败'
throw err
} finally {
isLoading.value = false
}
}
async function generateChapter(chapterNumber: number): Promise<NovelProject> {
// 注意:这里不设置全局 isLoading因为 WritingDesk.vue 有自己的局部加载状态
error.value = null
try {
if (!currentProject.value) {
throw new Error('没有当前项目')
}
const updatedProject = await NovelAPI.generateChapter(currentProject.value.id, chapterNumber)
currentProject.value = updatedProject // 更新 store 中的当前项目
return updatedProject
} catch (err) {
error.value = err instanceof Error ? err.message : '生成章节失败'
throw err
}
}
async function evaluateChapter(chapterNumber: number): Promise<NovelProject> {
error.value = null
try {
if (!currentProject.value) {
throw new Error('没有当前项目')
}
const updatedProject = await NovelAPI.evaluateChapter(currentProject.value.id, chapterNumber)
currentProject.value = updatedProject
return updatedProject
} catch (err) {
error.value = err instanceof Error ? err.message : '评估章节失败'
throw err
}
}
async function selectChapterVersion(chapterNumber: number, versionIndex: number) {
// 不设置全局 isLoading让调用方处理局部加载状态
error.value = null
try {
if (!currentProject.value) {
throw new Error('没有当前项目')
}
const updatedProject = await NovelAPI.selectChapterVersion(
currentProject.value.id,
chapterNumber,
versionIndex
)
currentProject.value = updatedProject // 更新 store
} catch (err) {
error.value = err instanceof Error ? err.message : '选择章节版本失败'
throw err
}
}
async function deleteProjects(projectIds: string[]): Promise<DeleteNovelsResponse> {
isLoading.value = true
error.value = null
try {
const response = await NovelAPI.deleteNovels(projectIds)
// 从本地项目列表中移除已删除的项目
projects.value = projects.value.filter(project => !projectIds.includes(project.id))
// 如果当前项目被删除,清空当前项目
if (currentProject.value && projectIds.includes(currentProject.value.id)) {
currentProject.value = null
currentConversationState.value = {}
}
return response
} catch (err) {
error.value = err instanceof Error ? err.message : '删除项目失败'
throw err
} finally {
isLoading.value = false
}
}
async function updateChapterOutline(chapterOutline: ChapterOutline) {
// 不设置全局 isLoading让调用方处理局部加载状态
error.value = null
try {
if (!currentProject.value) {
throw new Error('没有当前项目')
}
const updatedProject = await NovelAPI.updateChapterOutline(
currentProject.value.id,
chapterOutline
)
currentProject.value = updatedProject // 更新 store
} catch (err) {
error.value = err instanceof Error ? err.message : '更新章节大纲失败'
throw err
}
}
async function deleteChapter(chapterNumbers: number | number[]) {
error.value = null
try {
if (!currentProject.value) {
throw new Error('没有当前项目')
}
const numbersToDelete = Array.isArray(chapterNumbers) ? chapterNumbers : [chapterNumbers]
const updatedProject = await NovelAPI.deleteChapter(
currentProject.value.id,
numbersToDelete
)
currentProject.value = updatedProject // 更新 store
} catch (err) {
error.value = err instanceof Error ? err.message : '删除章节失败'
throw err
}
}
async function generateChapterOutline(startChapter: number, numChapters: number) {
error.value = null
try {
if (!currentProject.value) {
throw new Error('没有当前项目')
}
const updatedProject = await NovelAPI.generateChapterOutline(
currentProject.value.id,
startChapter,
numChapters
)
currentProject.value = updatedProject // 更新 store
} catch (err) {
error.value = err instanceof Error ? err.message : '生成大纲失败'
throw err
}
}
async function editChapterContent(projectId: string, chapterNumber: number, content: string) {
error.value = null
try {
const updatedProject = await NovelAPI.editChapterContent(projectId, chapterNumber, content)
currentProject.value = updatedProject // 更新 store
} catch (err) {
error.value = err instanceof Error ? err.message : '编辑章节内容失败'
throw err
}
}
function clearError() {
error.value = null
}
function setCurrentProject(project: NovelProject | null) {
currentProject.value = project
}
return {
// State
projects,
currentProject,
currentConversationState,
isLoading,
error,
// Getters
projectsCount,
hasCurrentProject,
// Actions
loadProjects,
createProject,
loadProject,
loadChapter,
sendConversation,
generateBlueprint,
saveBlueprint,
generateChapter,
evaluateChapter,
selectChapterVersion,
deleteProjects,
updateChapterOutline,
deleteChapter,
generateChapterOutline,
editChapterContent,
clearError,
setCurrentProject
}
})

View File

@@ -0,0 +1,15 @@
<template>
<div class="about">
<h1>This is an about page</h1>
</div>
</template>
<style>
@media (min-width: 1024px) {
.about {
min-height: 100vh;
display: flex;
align-items: center;
}
}
</style>

View File

@@ -0,0 +1,7 @@
<template>
<NovelDetailShell :is-admin="true" />
</template>
<script setup lang="ts">
import NovelDetailShell from '@/components/shared/NovelDetailShell.vue'
</script>

View File

@@ -0,0 +1,251 @@
<template>
<n-layout has-sider class="admin-layout">
<n-layout-sider
collapse-mode="width"
:collapsed="collapsed"
:collapsed-width="64"
:width="240"
bordered
show-trigger
@collapse="collapsed = true"
@expand="collapsed = false"
>
<div class="sider-header">
<span class="logo" v-if="!collapsed">Arboris 管理台</span>
<span class="logo-small" v-else>管理</span>
</div>
<n-menu
:value="activeKey"
:options="menuOptions"
:collapsed="collapsed"
:collapsed-width="64"
:accordion="true"
@update:value="handleMenuSelect"
/>
</n-layout-sider>
<n-layout>
<n-layout-header bordered class="admin-header">
<n-space align="center" justify="space-between" class="header-content">
<n-space align="center" :size="12">
<n-button
class="mobile-trigger"
quaternary
circle
size="small"
@click="collapsed = !collapsed"
>
<template #icon>
<span class="icon"></span>
</template>
</n-button>
<span class="header-title">{{ currentMenuLabel }}</span>
</n-space>
<n-space align="center" :size="10">
<span class="header-subtitle">高效掌控平台运行状态</span>
<n-button size="small" type="primary" ghost @click="goBack">
返回业务系统
</n-button>
</n-space>
</n-space>
</n-layout-header>
<n-layout-content class="admin-content">
<n-scrollbar class="content-scroll">
<component :is="activeComponent" />
</n-scrollbar>
</n-layout-content>
</n-layout>
</n-layout>
</template>
<script setup lang="ts">
import { computed, defineAsyncComponent, h, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import {
NButton,
NLayout,
NLayoutContent,
NLayoutHeader,
NLayoutSider,
NMenu,
NScrollbar,
NSpace,
type MenuOption
} from 'naive-ui'
import { useRoute, useRouter } from 'vue-router'
const collapsed = ref(false)
const activeKey = ref<MenuKey>('statistics')
const router = useRouter()
const route = useRoute()
type MenuKey =
| 'statistics'
| 'users'
| 'prompts'
| 'novels'
| 'logs'
| 'settings'
| 'password'
const components: Record<MenuKey, ReturnType<typeof defineAsyncComponent>> = {
statistics: defineAsyncComponent(() => import('../components/admin/Statistics.vue')),
users: defineAsyncComponent(() => import('../components/admin/UserManagement.vue')),
prompts: defineAsyncComponent(() => import('../components/admin/PromptManagement.vue')),
novels: defineAsyncComponent(() => import('../components/admin/NovelManagement.vue')),
logs: defineAsyncComponent(() => import('../components/admin/UpdateLogManagement.vue')),
settings: defineAsyncComponent(() => import('../components/admin/SettingsManagement.vue')),
password: defineAsyncComponent(() => import('../components/admin/PasswordManagement.vue'))
}
const iconRenderers: Record<MenuKey, () => any> = {
statistics: () => h('span', { class: 'menu-icon' }, '📊'),
users: () => h('span', { class: 'menu-icon' }, '👤'),
prompts: () => h('span', { class: 'menu-icon' }, '🗒️'),
novels: () => h('span', { class: 'menu-icon' }, '📚'),
logs: () => h('span', { class: 'menu-icon' }, '📝'),
settings: () => h('span', { class: 'menu-icon' }, '⚙️'),
password: () => h('span', { class: 'menu-icon' }, '🔒')
}
const menuOptions: MenuOption[] = [
{ key: 'statistics', label: '数据总览', icon: iconRenderers.statistics },
{ key: 'users', label: '用户管理', icon: iconRenderers.users },
{ key: 'prompts', label: '提示词管理', icon: iconRenderers.prompts },
{ key: 'novels', label: '小说项目', icon: iconRenderers.novels },
{ key: 'logs', label: '更新日志', icon: iconRenderers.logs },
{ key: 'settings', label: '系统配置', icon: iconRenderers.settings },
{ key: 'password', label: '安全中心', icon: iconRenderers.password }
]
const isMenuKey = (key: string): key is MenuKey => key in components
const syncActiveKeyWithRoute = () => {
const tab = route.query.tab
if (typeof tab === 'string' && isMenuKey(tab)) {
activeKey.value = tab
}
}
const handleMenuSelect = (key: string) => {
if (!isMenuKey(key)) {
return
}
activeKey.value = key
router.replace({ name: 'admin', query: { tab: key } })
}
const activeComponent = computed(() => components[activeKey.value])
const currentMenuLabel = computed(() => {
const match = menuOptions.find((option) => option.key === activeKey.value)
return match ? (match.label as string) : ''
})
const goBack = () => {
router.push('/')
}
const updateCollapsedByWidth = () => {
collapsed.value = window.innerWidth < 992
}
onMounted(() => {
updateCollapsedByWidth()
window.addEventListener('resize', updateCollapsedByWidth)
syncActiveKeyWithRoute()
})
onBeforeUnmount(() => {
window.removeEventListener('resize', updateCollapsedByWidth)
})
watch(
() => route.query.tab,
() => {
syncActiveKeyWithRoute()
}
)
</script>
<style scoped>
.admin-layout {
height: 100vh;
}
.sider-header {
height: 56px;
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
letter-spacing: 0.08em;
color: #1f2937;
}
.logo {
font-size: 1.1rem;
}
.logo-small {
font-size: 0.9rem;
}
.admin-header {
background: rgba(255, 255, 255, 0.92);
backdrop-filter: blur(8px);
padding: 0 20px;
}
.header-content {
width: 100%;
height: 64px;
}
.header-title {
font-size: 1.125rem;
font-weight: 600;
color: #1f2937;
}
.header-subtitle {
font-size: 0.95rem;
color: #6b7280;
}
.admin-content {
background: #f5f5f7;
}
.content-scroll {
height: calc(100vh - 64px);
padding: 24px;
box-sizing: border-box;
}
.menu-icon {
font-size: 1.1rem;
}
.mobile-trigger {
display: none;
}
@media (max-width: 991px) {
.content-scroll {
padding: 16px;
}
.mobile-trigger {
display: inline-flex;
}
.header-content {
flex-direction: column;
align-items: stretch;
gap: 12px !important;
}
.header-subtitle {
font-size: 0.9rem;
}
}
</style>

View File

@@ -0,0 +1,9 @@
<script setup lang="ts">
import TheWelcome from '../components/TheWelcome.vue'
</script>
<template>
<main>
<TheWelcome />
</main>
</template>

View File

@@ -0,0 +1,362 @@
<template>
<div class="flex items-center justify-center min-h-screen p-4">
<div class="w-full max-w-6xl mx-auto">
<!-- 灵感模式入口界面 -->
<div v-if="!conversationStarted" class="text-center p-8 bg-white/70 backdrop-blur-xl rounded-2xl shadow-lg fade-in">
<h1 class="text-4xl md:text-5xl font-bold text-gray-800">小说家的新篇章</h1>
<p class="text-lg text-gray-600 mt-4 mb-8">
准备好释放你的创造力了吗让AI引导你一步步构建出独一无二的故事世界
</p>
<button
@click="startConversation"
:disabled="novelStore.isLoading"
class="bg-indigo-500 text-white font-bold py-3 px-8 rounded-full hover:bg-indigo-600 transition-all duration-300 transform hover:scale-105 shadow-lg focus:outline-none focus:ring-4 focus:ring-indigo-300 disabled:opacity-50 disabled:cursor-not-allowed"
>
{{ novelStore.isLoading ? '正在准备...' : '开启灵感模式' }}
</button>
<button
@click="goBack"
class="mt-4 block mx-auto text-gray-500 hover:text-gray-800 transition-colors"
>
返回
</button>
</div>
<!-- 灵感模式交互界面 -->
<div
v-else-if="!showBlueprintConfirmation && !showBlueprint"
class="h-[90vh] max-h-[950px] flex flex-col bg-white rounded-2xl shadow-2xl overflow-hidden fade-in"
>
<!-- 头部 -->
<div class="p-4 border-b border-gray-200">
<div class="flex justify-between items-center">
<div class="flex items-center gap-2">
<span class="relative flex h-3 w-3">
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-indigo-400 opacity-75"></span>
<span class="relative inline-flex rounded-full h-3 w-3 bg-indigo-500"></span>
</span>
<span class="text-sm font-medium text-indigo-600">文思对话中...</span>
</div>
<div class="flex items-center gap-4">
<span v-if="currentTurn > 0" class="text-sm font-medium text-gray-500 bg-gray-100 px-2 py-1 rounded-md">
{{ currentTurn }}
</span>
<button
@click="handleRestart"
title="重新开始"
class="text-gray-400 hover:text-indigo-600 transition-colors"
>
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z" clip-rule="evenodd"></path>
</svg>
</button>
<button
@click="exitConversation"
title="返回首页"
class="text-gray-400 hover:text-gray-600 transition-colors"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
</div>
<!-- 聊天区域 -->
<div class="flex-1 p-6 overflow-y-auto space-y-6 relative" ref="chatArea">
<transition name="fade">
<InspirationLoading v-if="isInitialLoading" />
</transition>
<ChatBubble
v-for="(message, index) in chatMessages"
:key="index"
:message="message.content"
:type="message.type"
/>
</div>
<!-- 输入区域 -->
<div class="p-4 border-t border-gray-200 bg-gray-50">
<ConversationInput
:ui-control="currentUIControl"
:loading="novelStore.isLoading"
@submit="handleUserInput"
/>
</div>
</div>
<!-- 蓝图确认界面 -->
<BlueprintConfirmation
v-if="showBlueprintConfirmation"
:ai-message="confirmationMessage"
@blueprint-generated="handleBlueprintGenerated"
@back="backToConversation"
/>
<!-- 大纲展示界面 -->
<BlueprintDisplay
v-if="showBlueprint"
:blueprint="completedBlueprint"
:ai-message="blueprintMessage"
@confirm="handleConfirmBlueprint"
@regenerate="handleRegenerateBlueprint"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, nextTick, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useNovelStore } from '@/stores/novel'
import type { UIControl, Blueprint } from '@/api/novel'
import ChatBubble from '@/components/ChatBubble.vue'
import ConversationInput from '@/components/ConversationInput.vue'
import BlueprintConfirmation from '@/components/BlueprintConfirmation.vue'
import BlueprintDisplay from '@/components/BlueprintDisplay.vue'
import InspirationLoading from '@/components/InspirationLoading.vue'
import { globalAlert } from '@/composables/useAlert'
interface ChatMessage {
content: string
type: 'user' | 'ai'
}
const router = useRouter()
const route = useRoute()
const novelStore = useNovelStore()
const conversationStarted = ref(false)
const isInitialLoading = ref(false)
const showBlueprintConfirmation = ref(false)
const showBlueprint = ref(false)
const chatMessages = ref<ChatMessage[]>([])
const currentUIControl = ref<UIControl | null>(null)
const currentTurn = ref(0)
const completedBlueprint = ref<Blueprint | null>(null)
const confirmationMessage = ref('')
const blueprintMessage = ref('')
const chatArea = ref<HTMLElement>()
const goBack = () => {
router.push('/')
}
// 清空所有状态,开始新的灵感对话
const resetInspirationMode = () => {
conversationStarted.value = false
isInitialLoading.value = false
showBlueprintConfirmation.value = false
showBlueprint.value = false
chatMessages.value = []
currentUIControl.value = null
currentTurn.value = 0
completedBlueprint.value = null
confirmationMessage.value = ''
blueprintMessage.value = ''
// 清空 store 中的当前项目和对话状态
novelStore.setCurrentProject(null)
novelStore.currentConversationState = {}
}
const exitConversation = async () => {
const confirmed = await globalAlert.showConfirm('确定要退出灵感模式吗?当前进度可能会丢失。', '退出确认')
if (confirmed) {
resetInspirationMode()
router.push('/')
}
}
const handleRestart = async () => {
const confirmed = await globalAlert.showConfirm('确定要重新开始吗?当前对话内容将会丢失。', '重新开始确认')
if (confirmed) {
await startConversation()
}
}
const backToConversation = () => {
showBlueprintConfirmation.value = false
}
const startConversation = async () => {
// 重置所有状态,开始全新的对话
resetInspirationMode()
conversationStarted.value = true
isInitialLoading.value = true
try {
await novelStore.createProject('未命名灵感', '开始灵感模式')
// 发起第一次对话
await handleUserInput(null)
} catch (error) {
console.error('启动灵感模式失败:', error)
globalAlert.showError(`无法开始灵感模式: ${error instanceof Error ? error.message : '未知错误'}`, '启动失败')
resetInspirationMode() // 失败时重置回初始状态
}
}
const restoreConversation = async (projectId: string) => {
try {
await novelStore.loadProject(projectId)
const project = novelStore.currentProject
if (project && project.conversation_history) {
conversationStarted.value = true
chatMessages.value = project.conversation_history.map((item): ChatMessage | null => {
if (item.role === 'user') {
try {
const userInput = JSON.parse(item.content)
return { content: userInput.value, type: 'user' }
} catch {
return { content: item.content, type: 'user' }
}
} else { // assistant
try {
const assistantOutput = JSON.parse(item.content)
return { content: assistantOutput.ai_message, type: 'ai' }
} catch {
return { content: item.content, type: 'ai' }
}
}
}).filter((msg): msg is ChatMessage => msg !== null && msg.content !== null) // 过滤掉空的 user message
const lastAssistantMsgStr = project.conversation_history.filter(m => m.role === 'assistant').pop()?.content
if (lastAssistantMsgStr) {
const lastAssistantMsg = JSON.parse(lastAssistantMsgStr)
if (lastAssistantMsg.is_complete) {
// 如果对话已完成,直接显示蓝图确认界面
confirmationMessage.value = lastAssistantMsg.ai_message
showBlueprintConfirmation.value = true
} else {
// 否则,恢复对话
currentUIControl.value = lastAssistantMsg.ui_control
}
}
// 计算当前轮次
currentTurn.value = project.conversation_history.filter(m => m.role === 'assistant').length
await scrollToBottom()
}
} catch (error) {
console.error('恢复对话失败:', error)
globalAlert.showError(`无法恢复对话: ${error instanceof Error ? error.message : '未知错误'}`, '加载失败')
resetInspirationMode()
}
}
const handleUserInput = async (userInput: any) => {
try {
// 如果有用户输入,添加到聊天记录
if (userInput && userInput.value) {
chatMessages.value.push({
content: userInput.value,
type: 'user'
})
await scrollToBottom()
}
const response = await novelStore.sendConversation(userInput)
// 首次加载完成后,关闭加载动画
if (isInitialLoading.value) {
isInitialLoading.value = false
}
// 添加AI回复到聊天记录
chatMessages.value.push({
content: response.ai_message,
type: 'ai'
})
currentTurn.value++
await scrollToBottom()
if (response.is_complete && response.ready_for_blueprint) {
// 对话完成,显示蓝图确认界面
confirmationMessage.value = response.ai_message
showBlueprintConfirmation.value = true
} else if (response.is_complete) {
// 向后兼容:直接生成蓝图(如果后端还没更新)
await handleGenerateBlueprint()
} else {
// 继续对话
currentUIControl.value = response.ui_control
}
} catch (error) {
console.error('对话失败:', error)
// 确保在出错时也停止初始加载状态
if (isInitialLoading.value) {
isInitialLoading.value = false
}
globalAlert.showError(`抱歉与AI连接时遇到问题: ${error instanceof Error ? error.message : '未知错误'}`, '通信失败')
// 停止加载并返回初始界面
resetInspirationMode()
}
}
const handleGenerateBlueprint = async () => {
try {
const response = await novelStore.generateBlueprint()
handleBlueprintGenerated(response)
} catch (error) {
console.error('生成蓝图失败:', error)
globalAlert.showError(`生成蓝图失败: ${error instanceof Error ? error.message : '未知错误'}`, '生成失败')
}
}
const handleBlueprintGenerated = (response: any) => {
console.log('收到蓝图生成完成事件:', response)
completedBlueprint.value = response.blueprint
blueprintMessage.value = response.ai_message
showBlueprintConfirmation.value = false
showBlueprint.value = true
}
const handleRegenerateBlueprint = () => {
showBlueprint.value = false
showBlueprintConfirmation.value = true
}
const handleConfirmBlueprint = async () => {
if (!completedBlueprint.value) {
globalAlert.showError('蓝图数据缺失,请重新生成或稍后重试。', '保存失败')
return
}
try {
await novelStore.saveBlueprint(completedBlueprint.value)
// 跳转到写作工作台
if (novelStore.currentProject) {
router.push(`/novel/${novelStore.currentProject.id}`)
}
} catch (error) {
console.error('保存蓝图失败:', error)
globalAlert.showError(`保存蓝图失败: ${error instanceof Error ? error.message : '未知错误'}`, '保存失败')
}
}
const scrollToBottom = async () => {
await nextTick()
if (chatArea.value) {
chatArea.value.scrollTop = chatArea.value.scrollHeight
}
}
onMounted(() => {
const projectId = route.query.project_id as string
if (projectId) {
restoreConversation(projectId)
} else {
// 每次进入灵感模式都重置状态,确保没有缓存
resetInspirationMode()
}
})
</script>

View File

@@ -0,0 +1,108 @@
<template>
<div class="flex flex-col items-center justify-center min-h-screen p-4">
<div class="mb-12">
<TypewriterEffect text="拯 救 小 说 家" />
</div>
<div class="w-full max-w-sm p-8 space-y-8 bg-white/70 backdrop-blur-xl rounded-2xl shadow-xl">
<div>
<h2 class="text-2xl font-bold text-center text-gray-800">
欢迎回来
</h2>
<p class="mt-2 text-sm text-center text-gray-500">
登录以继续您的创作之旅
</p>
</div>
<form @submit.prevent="handleLogin" class="mt-8 space-y-6">
<div class="space-y-4">
<div>
<label for="username" class="sr-only">用户名</label>
<input v-model="username" id="username" name="username" type="text" required
class="w-full px-4 py-3 text-gray-700 bg-gray-100 border-2 border-gray-200 rounded-lg focus:outline-none focus:bg-white focus:border-blue-500 transition-all duration-300"
placeholder="用户名" />
</div>
<div>
<label for="password" class="sr-only">密码</label>
<input v-model="password" id="password" name="password" type="password" required
class="w-full px-4 py-3 text-gray-700 bg-gray-100 border-2 border-gray-200 rounded-lg focus:outline-none focus:bg-white focus:border-blue-500 transition-all duration-300"
placeholder="密码" />
</div>
</div>
<div v-if="error" class="text-sm font-medium text-center text-red-500">
{{ error }}
</div>
<div>
<button type="submit"
:disabled="isLoading"
class="w-full px-4 py-3 text-sm font-bold text-white bg-blue-600 rounded-lg hover:bg-blue-700 focus:outline-none focus:ring-4 focus:ring-blue-300 disabled:opacity-60 transition-all duration-300">
<span v-if="isLoading">正在登录...</span>
<span v-else>登录</span>
</button>
</div>
</form>
<div class="relative flex items-center justify-center my-6">
<div class="w-full border-t border-gray-200"></div>
<span class="absolute px-3 text-sm text-gray-400 bg-white"></span>
</div>
<div v-if="enableLinuxdoLogin">
<a href="/api/auth/linuxdo/login"
class="flex items-center justify-center w-full px-4 py-3 text-sm font-bold text-gray-700 bg-white border-2 border-gray-300 rounded-lg hover:bg-gray-50 focus:outline-none focus:ring-4 focus:ring-gray-200 transition-all duration-300">
<svg class="w-5 h-5 mr-2" aria-hidden="true" focusable="false" data-prefix="fab" data-icon="linux" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 496 512"><path fill="currentColor" d="M248 8C111 8 0 119 0 256s111 248 248 248 248-111 248-248S385 8 248 8zm0 448c-110.5 0-200-89.5-200-200S137.5 56 248 56s200 89.5 200 200-89.5 200-200 200z"></path></svg>
使用 Linux DO 登录
</a>
</div>
<p v-if="allowRegistration" class="mt-8 text-sm text-center text-gray-500">
还没有账户
<router-link to="/register" class="font-medium text-blue-600 hover:underline">
立即注册
</router-link>
</p>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { useAuthStore } from '@/stores/auth';
import TypewriterEffect from '@/components/TypewriterEffect.vue';
const username = ref('');
const password = ref('');
const error = ref('');
const isLoading = ref(false);
const router = useRouter();
const authStore = useAuthStore();
const allowRegistration = computed(() => authStore.allowRegistration);
const enableLinuxdoLogin = computed(() => authStore.enableLinuxdoLogin);
// 首屏自动拉取认证配置,确保登录页动态展示开关
onMounted(() => {
authStore.fetchAuthOptions().catch((error) => {
console.error('初始化认证配置失败', error);
});
});
const handleLogin = async () => {
error.value = '';
isLoading.value = true;
try {
const mustChange = await authStore.login(username.value, password.value);
const user = authStore.user;
if (user?.is_admin && (authStore.mustChangePassword || mustChange)) {
router.push({ name: 'admin', query: { tab: 'password' } });
} else {
router.push('/');
}
} catch (err) {
error.value = '登录失败,请检查您的用户名和密码。';
console.error(err);
} finally {
isLoading.value = false;
}
};
</script>

View File

@@ -0,0 +1,7 @@
<template>
<NovelDetailShell :is-admin="false" />
</template>
<script setup lang="ts">
import NovelDetailShell from '@/components/shared/NovelDetailShell.vue'
</script>

View File

@@ -0,0 +1,229 @@
<template>
<div class="flex items-center justify-center min-h-screen p-4">
<!-- 删除提示消息 -->
<div v-if="deleteMessage"
:class="[
'fixed top-4 right-4 z-60 px-4 py-3 rounded-lg shadow-lg transition-all duration-300',
deleteMessage.type === 'success' ? 'bg-green-500 text-white' : 'bg-red-500 text-white'
]">
<div class="flex items-center gap-2">
<svg v-if="deleteMessage.type === 'success'" class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"></path>
</svg>
<svg v-else class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clip-rule="evenodd"></path>
</svg>
<span>{{ deleteMessage.text }}</span>
</div>
</div>
<div class="w-full max-w-7xl mx-auto">
<div class="p-8 bg-white/95 backdrop-blur-sm rounded-2xl shadow-2xl fade-in">
<div class="flex justify-between items-center mb-8">
<div class="flex items-center space-x-4">
<h2 class="text-3xl font-bold text-gray-800">我的小说项目</h2>
<router-link v-if="authStore.user?.is_admin" to="/admin" class="text-sm text-indigo-600 hover:text-indigo-800">管理后台</router-link>
</div>
<button
@click="goBack"
class="text-gray-500 hover:text-gray-800 transition-colors"
>
返回
</button>
</div>
<!-- 加载状态 -->
<div v-if="novelStore.isLoading" class="flex justify-center items-center py-8">
<div class="loader"></div>
</div>
<!-- 错误状态 -->
<div v-else-if="novelStore.error" class="text-red-500 text-center py-8">
{{ novelStore.error }}
<button
@click="loadProjects"
class="block mt-4 mx-auto px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600 transition-all duration-300 transform hover:scale-105"
>
重试
</button>
</div>
<!-- 项目列表 -->
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<!-- 空状态 -->
<div v-if="novelStore.projects.length === 0" class="col-span-full text-center py-8">
<p class="text-gray-500 mb-4">还没有项目快去开启灵感模式创建一个吧</p>
<button
@click="goToInspiration"
class="px-6 py-3 bg-indigo-500 text-white rounded-lg hover:bg-indigo-600 transition-all duration-300 transform hover:scale-105"
>
开始创作
</button>
</div>
<!-- 项目卡片 -->
<ProjectCard
v-for="project in novelStore.projects"
:key="project.id"
:project="project"
@click="enterProject(project)"
@detail="viewProjectDetail"
@continue="enterProject"
@delete="handleDeleteProject"
/>
<!-- 创建新项目卡片 -->
<div
@click="goToInspiration"
class="flex items-center justify-center p-5 bg-transparent border-2 border-dashed border-gray-300 rounded-xl hover:bg-gray-50 hover:border-indigo-400 transition-colors duration-300 cursor-pointer group min-h-[180px]"
>
<div class="text-center text-gray-500 group-hover:text-indigo-500 transition-colors">
<svg
xmlns="http://www.w3.org/2000/svg"
class="mx-auto h-8 w-8 mb-2"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4" />
</svg>
<span class="font-semibold">创建新项目</span>
</div>
</div>
</div>
</div>
</div>
<!-- 删除确认对话框 -->
<div v-if="showDeleteDialog" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
<div class="bg-white rounded-2xl shadow-2xl max-w-md w-full">
<div class="p-6">
<div class="flex items-center gap-3 mb-4">
<div class="w-12 h-12 bg-red-100 rounded-full flex items-center justify-center">
<svg class="w-6 h-6 text-red-600" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M9 2a1 1 0 000 2h2a1 1 0 100-2H9z" clip-rule="evenodd"></path>
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8 7a1 1 0 012 0v4a1 1 0 11-2 0V7zM12 7a1 1 0 012 0v4a1 1 0 11-2 0V7z" clip-rule="evenodd"></path>
</svg>
</div>
<div>
<h3 class="text-lg font-semibold text-gray-900">确认删除</h3>
<p class="text-sm text-gray-600">此操作无法撤销</p>
</div>
</div>
<p class="text-gray-700 mb-6">
确定要删除项目 "{{ projectToDelete?.title }}" 所有相关数据将被永久删除
</p>
<div class="flex gap-3 justify-end">
<button
@click="cancelDelete"
class="px-4 py-2 text-gray-600 hover:text-gray-800 hover:bg-gray-100 rounded-lg transition-colors"
>
取消
</button>
<button
@click="confirmDelete"
:disabled="isDeleting"
class="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
<svg v-if="isDeleting" class="w-4 h-4 animate-spin" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z" clip-rule="evenodd"></path>
</svg>
{{ isDeleting ? '删除中...' : '确认删除' }}
</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useNovelStore } from '@/stores/novel'
import { useAuthStore } from '@/stores/auth'
import ProjectCard from '@/components/ProjectCard.vue'
import type { NovelProject, NovelProjectSummary } from '@/api/novel'
const router = useRouter()
const novelStore = useNovelStore()
const authStore = useAuthStore()
// 删除相关状态
const showDeleteDialog = ref(false)
const projectToDelete = ref<NovelProjectSummary | null>(null)
const isDeleting = ref(false)
const deleteMessage = ref<{type: 'success' | 'error', text: string} | null>(null)
const goBack = () => {
router.push('/')
}
const goToInspiration = () => {
router.push('/inspiration')
}
const viewProjectDetail = (projectId: string) => {
router.push(`/detail/${projectId}`)
}
const enterProject = (project: NovelProjectSummary) => {
if (project.title === '未命名灵感') {
router.push(`/inspiration?project_id=${project.id}`)
} else {
router.push(`/novel/${project.id}`)
}
}
const loadProjects = async () => {
await novelStore.loadProjects()
}
// 删除相关方法
const handleDeleteProject = (projectId: string) => {
const project = novelStore.projects.find(p => p.id === projectId)
if (project) {
projectToDelete.value = project
showDeleteDialog.value = true
}
}
const cancelDelete = () => {
showDeleteDialog.value = false
projectToDelete.value = null
}
const confirmDelete = async () => {
if (!projectToDelete.value) return
isDeleting.value = true
try {
await novelStore.deleteProjects([projectToDelete.value.id])
deleteMessage.value = { type: 'success', text: `项目 "${projectToDelete.value.title}" 已成功删除` }
showDeleteDialog.value = false
projectToDelete.value = null
// 3秒后清除消息
setTimeout(() => {
deleteMessage.value = null
}, 3000)
} catch (error) {
console.error('删除项目失败:', error)
deleteMessage.value = { type: 'error', text: '删除项目失败,请重试' }
// 3秒后清除消息
setTimeout(() => {
deleteMessage.value = null
}, 3000)
} finally {
isDeleting.value = false
}
}
onMounted(() => {
loadProjects()
})
</script>

View File

@@ -0,0 +1,219 @@
<template>
<div class="flex flex-col items-center justify-center min-h-screen p-4">
<div class="mb-12">
<TypewriterEffect text="拯救小说家" />
</div>
<div v-if="allowRegistration" class="w-full max-w-md p-8 space-y-8 bg-white/70 backdrop-blur-xl rounded-2xl shadow-xl">
<div>
<h2 class="text-2xl font-bold text-center text-gray-800">
加入我们
</h2>
<p class="mt-2 text-sm text-center text-gray-500">
开启您的创作新篇章
</p>
</div>
<form @submit.prevent="handleRegister" class="mt-8 space-y-6">
<div class="space-y-4">
<div>
<label for="username" class="sr-only">用户名</label>
<input v-model="username" id="username" name="username" type="text" required
class="w-full px-4 py-3 text-gray-700 bg-gray-100 border-2 border-gray-200 rounded-lg focus:outline-none focus:bg-white focus:border-blue-500 transition-all duration-300"
placeholder="用户名" />
</div>
<div>
<label for="email" class="sr-only">邮箱</label>
<input v-model="email" id="email" name="email" type="email" required
class="w-full px-4 py-3 text-gray-700 bg-gray-100 border-2 border-gray-200 rounded-lg focus:outline-none focus:bg-white focus:border-blue-500 transition-all duration-300"
placeholder="邮箱" />
</div>
<div class="flex space-x-2 items-center">
<input v-model="verificationCode" id="verificationCode" name="verificationCode" type="text" required
class="flex-1 px-4 py-3 text-gray-700 bg-gray-100 border-2 border-gray-200 rounded-lg focus:outline-none focus:bg-white focus:border-blue-500 transition-all duration-300"
placeholder="验证码" />
<button type="button" @click="sendCode" :disabled="countdown > 0 || sending"
class="px-4 py-3 text-sm font-bold text-white bg-green-600 rounded-lg hover:bg-green-700 focus:outline-none focus:ring-4 focus:ring-green-300 disabled:opacity-60 transition-all duration-300">
<span v-if="sending">发送中...</span>
<span v-else>{{ countdown > 0 ? countdown + '秒后重试' : '发送验证码' }}</span>
</button>
</div>
<div>
<label for="password" class="sr-only">密码</label>
<input v-model="password" id="password" name="password" type="password" required
class="w-full px-4 py-3 text-gray-700 bg-gray-100 border-2 border-gray-200 rounded-lg focus:outline-none focus:bg-white focus:border-blue-500 transition-all duration-300"
placeholder="密码" />
</div>
</div>
<div v-if="error" class="text-sm font-medium text-center text-red-500">
{{ error }}
</div>
<div v-if="success" class="text-sm font-medium text-center text-green-500">
{{ success }}
</div>
<div>
<button type="submit"
class="w-full px-4 py-3 text-sm font-bold text-white bg-blue-600 rounded-lg hover:bg-blue-700 focus:outline-none focus:ring-4 focus:ring-blue-300 disabled:opacity-60 transition-all duration-300">
注册
</button>
</div>
</form>
<p class="mt-8 text-sm text-center text-gray-500">
已有账户
<router-link to="/login" class="font-medium text-blue-600 hover:underline">
立即登录
</router-link>
</p>
</div>
<div v-else class="w-full max-w-md p-8 space-y-6 text-center bg-white/70 backdrop-blur-xl rounded-2xl shadow-xl">
<h2 class="text-xl font-bold text-gray-800">暂未开放注册</h2>
<p class="text-sm text-gray-500">请联系管理员或稍后再试</p>
<router-link to="/login" class="inline-block px-4 py-2 text-sm font-medium text-blue-600 hover:underline">
返回登录
</router-link>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { useAuthStore } from '@/stores/auth';
import TypewriterEffect from '@/components/TypewriterEffect.vue';
const username = ref('');
const email = ref('');
const verificationCode = ref('');
const password = ref('');
const countdown = ref(0);
const sending = ref(false);
const error = ref('');
const success = ref('');
const router = useRouter();
const authStore = useAuthStore();
const allowRegistration = computed(() => authStore.allowRegistration);
// 进入页面即拉取认证开关,避免展示无效注册表单
onMounted(async () => {
try {
await authStore.fetchAuthOptions();
} catch (error) {
console.error('加载认证开关失败', error);
}
if (!allowRegistration.value) {
success.value = '';
error.value = '当前已关闭注册,请稍后再试。';
}
});
const validateInput = () => {
// Password validation
if (password.value.length < 8) {
return '密码必须至少8个字符';
}
// Username validation
const usernameVal = username.value;
const hasChinese = /[\u4e00-\u9fa5]/.test(usernameVal);
const isNumeric = /^\d+$/.test(usernameVal);
const isAlphanumeric = /^[a-zA-Z0-9]+$/.test(usernameVal);
if (isNumeric) {
return '用户名不能是纯数字';
}
if (hasChinese && usernameVal.length <= 1) {
return '户名长度必须大于2个汉字';
}
if (isAlphanumeric && !hasChinese && usernameVal.length <= 6) {
return '用户名长度必须大于6个字母或数字';
}
return null; // No validation errors
};
const sendCode = async () => {
error.value = '';
success.value = '';
if (!allowRegistration.value) {
error.value = '当前已关闭注册,请联系管理员。';
return;
}
if (!email.value) {
error.value = '请输入邮箱';
return;
}
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email.value)) {
error.value = '邮箱格式不正确';
return;
}
sending.value = true;
try {
const res = await fetch(`/api/auth/send-code?email=${encodeURIComponent(email.value)}`, {
method: 'POST'
});
if (!res.ok) {
const errMsg = await res.json();
throw new Error(errMsg.detail || '发送验证码失败');
}
success.value = '验证码已发送,请查收邮箱';
// 等接口返回成功后再开始倒计时
countdown.value = 60;
const timer = setInterval(() => {
countdown.value--;
if (countdown.value <= 0) clearInterval(timer);
}, 1000);
} catch (err: any) {
error.value = err.message;
} finally {
sending.value = false;
}
};
const handleRegister = async () => {
error.value = '';
success.value = '';
const validationError = validateInput();
if (validationError) {
error.value = validationError;
return;
}
if (!allowRegistration.value) {
error.value = '当前已关闭注册,请联系管理员。';
return;
}
try {
const res = await fetch('/api/auth/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: username.value,
email: email.value,
password: password.value,
verification_code: verificationCode.value
})
});
if (!res.ok) {
const errMsg = await res.json();
throw new Error(errMsg.detail || '注册失败');
}
success.value = '注册成功!正在跳转到登录页面...';
setTimeout(() => {
router.push('/login');
}, 2000);
} catch (err: any) {
error.value = err.message || '注册失败,请稍后再试。';
console.error(err);
}
};
</script>

View File

@@ -0,0 +1,35 @@
<template>
<div class="min-h-screen p-4 relative">
<div class="absolute top-4 left-4">
<router-link
to="/"
class="px-4 py-2 text-gray-600 hover:text-gray-800 hover:bg-gray-100 rounded-lg transition-colors flex items-center gap-2"
>
&larr; 返回
</router-link>
</div>
<div class="flex flex-col md:flex-row max-w-6xl mx-auto mt-16">
<!-- Sidebar -->
<div class="w-full md:w-64 bg-white/70 backdrop-blur-xl rounded-2xl shadow-lg p-4 mb-4 md:mb-0 md:mr-8">
<h2 class="text-xl font-bold text-gray-800 mb-4">设置</h2>
<nav>
<ul>
<li class="px-4 py-2 bg-indigo-100 text-indigo-700 rounded-lg cursor-pointer">
LLM 配置
</li>
<!-- Add other settings links here in the future -->
</ul>
</nav>
</div>
<!-- Main Content -->
<div class="flex-1">
<LLMSettings />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import LLMSettings from '@/components/LLMSettings.vue';
</script>

View File

@@ -0,0 +1,170 @@
<template>
<div class="flex items-center justify-center min-h-screen p-4 relative">
<!-- Modal -->
<div v-if="showModal" class="fixed inset-0 bg-slate-900/60 z-50 flex items-center justify-center p-4 backdrop-blur-sm" @click.self="closeModal">
<div class="w-full max-w-4xl bg-white rounded-2xl shadow-xl overflow-hidden transform transition-all duration-300 flex flex-col max-h-[90vh]">
<div class="p-8 sm:p-10 pb-4">
<!-- 头部标题 -->
<header class="text-center mb-6">
<h1 class="text-2xl sm:text-3xl font-bold text-slate-800">更新日志</h1>
</header>
<!-- 加入交流群部分 (动态) -->
<div v-if="communityLog" class="bg-slate-50 border border-slate-200 rounded-xl p-4">
<div v-html="renderMarkdown(communityLog.content)"></div>
</div>
</div>
<div class="px-8 sm:px-10 pb-8 sm:pb-10 overflow-y-auto flex-1">
<!-- 日志条目列表 - 时间线样式 -->
<div class="flow-root">
<ul role="list" class="-mb-8">
<!-- Timeline Item -->
<li v-for="(log, index) in filteredUpdateLogs" :key="log.id">
<div class="relative pb-8">
<!-- 连接线 (除了最后一个) -->
<span v-if="index < filteredUpdateLogs.length - 1" class="absolute left-2.5 top-4 -ml-px h-full w-0.5 bg-slate-200" aria-hidden="true"></span>
<div class="relative flex items-start space-x-4">
<!-- 时间线上的圆点 -->
<div class="h-5 w-5 bg-blue-500 rounded-full flex items-center justify-center ring-8 ring-white mt-1"></div>
<!-- 卡片内容 -->
<div class="min-w-0 flex-1">
<div class="bg-slate-50/80 border border-slate-200/80 rounded-xl p-4">
<time class="text-sm font-semibold text-slate-600">{{ new Date(log.created_at).toLocaleDateString() }}</time>
<div class="mt-3 prose max-w-none prose-sm prose-slate" v-html="renderMarkdown(log.content)"></div>
</div>
</div>
</div>
</div>
</li>
</ul>
</div>
</div>
<!-- 底部操作按钮 -->
<footer class="bg-slate-50/70 px-8 py-4 border-t border-slate-200 mt-auto">
<div class="flex justify-end items-center space-x-4">
<button @click="hideModalToday" class="px-5 py-2 text-sm font-semibold text-slate-600 bg-transparent rounded-lg hover:bg-slate-200 transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-slate-400">
今日不再显示
</button>
<button @click="closeModal" class="px-5 py-2 text-sm font-semibold text-white bg-blue-600 rounded-lg hover:bg-blue-700 transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 shadow-sm">
关闭
</button>
</div>
</footer>
</div>
</div>
<div class="absolute top-4 right-4 flex space-x-2">
<router-link
to="/settings"
class="px-4 py-2 text-gray-600 hover:text-gray-800 hover:bg-gray-100 rounded-lg transition-colors flex items-center gap-2 cursor-pointer"
>
设置
</router-link>
<button
@click="handleLogout"
class="px-4 py-2 text-gray-600 hover:text-gray-800 hover:bg-gray-100 rounded-lg transition-colors flex items-center gap-2 cursor-pointer"
>
退出登录
</button>
</div>
<div class="w-full max-w-4xl mx-auto">
<div class="text-center p-8 fade-in">
<h1 class="text-4xl md:text-5xl font-bold text-gray-800 mb-4">拯救小说家创作中心</h1>
<p class="text-lg text-gray-600 mb-12">从一个新灵感开始或继续打磨你的世界</p>
<div class="grid md:grid-cols-2 gap-8 max-w-2xl mx-auto">
<!-- 灵感模式卡片 -->
<div
@click="goToInspiration"
class="group p-8 bg-white/70 backdrop-blur-xl rounded-2xl shadow-lg hover:shadow-2xl transition-all duration-300 transform hover:-translate-y-2 cursor-pointer"
>
<h2 class="text-2xl font-bold text-indigo-600 mb-3">灵感模式</h2>
<p class="text-gray-600">没有头绪让AI通过对话式引导帮你构建故事的雏形</p>
</div>
<!-- 小说工作台卡片 -->
<div
@click="goToWorkspace"
class="group p-8 bg-white/70 backdrop-blur-xl rounded-2xl shadow-lg hover:shadow-2xl transition-all duration-300 transform hover:-translate-y-2 cursor-pointer"
>
<h2 class="text-2xl font-bold text-teal-600 mb-3">小说工作台</h2>
<p class="text-gray-600">查看编辑和管理你所有的小说项目工程</p>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { marked } from 'marked'
import { useRouter } from 'vue-router'
import { useAuthStore } from '../stores/auth'
import { getLatestUpdates } from '../api/updates'
import type { UpdateLog } from '../api/updates'
marked.setOptions({
gfm: true, // 启用 GitHub 风格语法(表格/任务列表/自动链接 等)
breaks: true // 将单个换行视为 <br>(常见于后端返回的段落)
})
const renderMarkdown = (md: string) => marked.parse(md)
const router = useRouter()
const authStore = useAuthStore()
const showModal = ref(false)
const updateLogs = ref<UpdateLog[]>([])
// 查找包含“交流群”的日志
const communityLog = computed(() => {
return updateLogs.value.find(log => /交流群/.test(log.content))
})
// 过滤掉包含“交流群”的日志,用于时间线显示
const filteredUpdateLogs = computed(() => {
if (!communityLog.value) {
return updateLogs.value
}
return updateLogs.value.filter(log => log.id !== communityLog.value!.id)
})
onMounted(async () => {
const hideUntil = localStorage.getItem('hideAnnouncement')
if (hideUntil !== new Date().toDateString()) {
try {
updateLogs.value = await getLatestUpdates()
if (updateLogs.value.length > 0) {
showModal.value = true
}
} catch (error) {
console.error('Failed to fetch update logs:', error)
}
}
})
const closeModal = () => {
showModal.value = false
}
const hideModalToday = () => {
localStorage.setItem('hideAnnouncement', new Date().toDateString())
closeModal()
}
const handleLogout = () => {
authStore.logout()
router.push('/login')
}
const goToInspiration = () => {
router.push('/inspiration')
}
const goToWorkspace = () => {
router.push('/workspace')
}
</script>

View File

@@ -0,0 +1,647 @@
<template>
<div class="h-screen flex flex-col bg-gradient-to-br from-slate-50 via-blue-50 to-indigo-50">
<WDHeader
:project="project"
:progress="progress"
:completed-chapters="completedChapters"
:total-chapters="totalChapters"
@go-back="goBack"
@view-project-detail="viewProjectDetail"
@toggle-sidebar="toggleSidebar"
/>
<!-- 主要内容区域 -->
<div class="flex-1 w-full px-4 sm:px-6 lg:px-8 py-6 overflow-hidden">
<!-- 加载状态 -->
<div v-if="novelStore.isLoading" class="h-full flex justify-center items-center">
<div class="text-center">
<div class="w-12 h-12 border-4 border-indigo-200 border-t-indigo-600 rounded-full animate-spin mx-auto mb-4"></div>
<p class="text-gray-600">正在加载项目数据...</p>
</div>
</div>
<!-- 错误状态 -->
<div v-else-if="novelStore.error" class="text-center py-20">
<div class="bg-red-50 border border-red-200 rounded-xl p-8 max-w-md mx-auto">
<svg class="w-12 h-12 text-red-400 mx-auto mb-4" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clip-rule="evenodd"></path>
</svg>
<h3 class="text-lg font-semibold text-red-900 mb-2">加载失败</h3>
<p class="text-red-700 mb-4">{{ novelStore.error }}</p>
<button
@click="loadProject"
class="px-6 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors"
>
重新加载
</button>
</div>
</div>
<!-- 主要内容 -->
<div v-else-if="project" class="h-full flex gap-6">
<WDSidebar
:project="project"
:sidebar-open="sidebarOpen"
:selected-chapter-number="selectedChapterNumber"
:generating-chapter="generatingChapter"
:evaluating-chapter="evaluatingChapter"
:is-generating-outline="isGeneratingOutline"
@close-sidebar="closeSidebar"
@select-chapter="selectChapter"
@generate-chapter="generateChapter"
@edit-chapter="openEditChapterModal"
@delete-chapter="deleteChapter"
@generate-outline="generateOutline"
/>
<div class="flex-1 min-w-0">
<WDWorkspace
:project="project"
:selected-chapter-number="selectedChapterNumber"
:generating-chapter="generatingChapter"
:evaluating-chapter="evaluatingChapter"
:show-version-selector="showVersionSelector"
:chapter-generation-result="chapterGenerationResult"
:selected-version-index="selectedVersionIndex"
:available-versions="availableVersions"
:is-selecting-version="isSelectingVersion"
@regenerate-chapter="regenerateChapter"
@evaluate-chapter="evaluateChapter"
@hide-version-selector="hideVersionSelector"
@update:selected-version-index="selectedVersionIndex = $event"
@show-version-detail="showVersionDetail"
@confirm-version-selection="confirmVersionSelection"
@generate-chapter="generateChapter"
@show-evaluation-detail="showEvaluationDetailModal = true"
@fetch-chapter-status="fetchChapterStatus"
@edit-chapter="editChapterContent"
/>
</div>
</div>
</div>
<WDVersionDetailModal
:show="showVersionDetailModal"
:detail-version-index="detailVersionIndex"
:version="availableVersions[detailVersionIndex]"
:is-current="isCurrentVersion(detailVersionIndex)"
@close="closeVersionDetail"
@select-version="selectVersionFromDetail"
/>
<WDEvaluationDetailModal
:show="showEvaluationDetailModal"
:evaluation="selectedChapter?.evaluation || null"
@close="showEvaluationDetailModal = false"
/>
<WDEditChapterModal
:show="showEditChapterModal"
:chapter="editingChapter"
@close="showEditChapterModal = false"
@save="saveChapterChanges"
/>
<WDGenerateOutlineModal
:show="showGenerateOutlineModal"
@close="showGenerateOutlineModal = false"
@generate="handleGenerateOutline"
/>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useNovelStore } from '@/stores/novel'
import type { Chapter, ChapterOutline, ChapterGenerationResponse, ChapterVersion } from '@/api/novel'
import { globalAlert } from '@/composables/useAlert'
import Tooltip from '@/components/Tooltip.vue'
import WDHeader from '@/components/writing-desk/WDHeader.vue'
import WDSidebar from '@/components/writing-desk/WDSidebar.vue'
import WDWorkspace from '@/components/writing-desk/WDWorkspace.vue'
import WDVersionDetailModal from '@/components/writing-desk/WDVersionDetailModal.vue'
import WDEvaluationDetailModal from '@/components/writing-desk/WDEvaluationDetailModal.vue'
import WDEditChapterModal from '@/components/writing-desk/WDEditChapterModal.vue'
import WDGenerateOutlineModal from '@/components/writing-desk/WDGenerateOutlineModal.vue'
interface Props {
id: string
}
const props = defineProps<Props>()
const router = useRouter()
const novelStore = useNovelStore()
// 状态管理
const selectedChapterNumber = ref<number | null>(null)
const chapterGenerationResult = ref<ChapterGenerationResponse | null>(null)
const selectedVersionIndex = ref<number>(0)
const generatingChapter = ref<number | null>(null)
const sidebarOpen = ref(false)
const showVersionDetailModal = ref(false)
const detailVersionIndex = ref<number>(0)
const showEvaluationDetailModal = ref(false)
const showEditChapterModal = ref(false)
const editingChapter = ref<ChapterOutline | null>(null)
const isGeneratingOutline = ref(false)
const showGenerateOutlineModal = ref(false)
// 计算属性
const project = computed(() => novelStore.currentProject)
const selectedChapter = computed(() => {
if (!project.value || selectedChapterNumber.value === null) return null
return project.value.chapters.find(ch => ch.chapter_number === selectedChapterNumber.value) || null
})
const showVersionSelector = computed(() => {
if (!selectedChapter.value) return false
const status = selectedChapter.value.generation_status
return status === 'waiting_for_confirm' || status === 'evaluating' || status === 'evaluation_failed' || status === 'selecting'
})
const evaluatingChapter = computed(() => {
if (selectedChapter.value?.generation_status === 'evaluating') {
return selectedChapter.value.chapter_number
}
return null
})
const isSelectingVersion = computed(() => {
return selectedChapter.value?.generation_status === 'selecting'
})
const selectedChapterOutline = computed(() => {
if (!project.value?.blueprint?.chapter_outline || selectedChapterNumber.value === null) return null
return project.value.blueprint.chapter_outline.find(ch => ch.chapter_number === selectedChapterNumber.value) || null
})
const progress = computed(() => {
if (!project.value?.blueprint?.chapter_outline) return 0
const totalChapters = project.value.blueprint.chapter_outline.length
const completedChapters = project.value.chapters.filter(ch => ch.content).length
return Math.round((completedChapters / totalChapters) * 100)
})
const totalChapters = computed(() => {
return project.value?.blueprint?.chapter_outline?.length || 0
})
const completedChapters = computed(() => {
return project.value?.chapters?.filter(ch => ch.content)?.length || 0
})
const isCurrentVersion = (versionIndex: number) => {
if (!selectedChapter.value?.content || !availableVersions.value?.[versionIndex]?.content) return false
// 使用cleanVersionContent函数清理内容进行比较
const cleanCurrentContent = cleanVersionContent(selectedChapter.value.content)
const cleanVersionContentStr = cleanVersionContent(availableVersions.value[versionIndex].content)
return cleanCurrentContent === cleanVersionContentStr
}
const cleanVersionContent = (content: string): string => {
if (!content) return ''
// 尝试解析JSON看是否是完整的章节对象
try {
const parsed = JSON.parse(content)
if (parsed && typeof parsed === 'object' && parsed.content) {
// 如果是章节对象提取content字段
content = parsed.content
}
} catch (error) {
// 如果不是JSON继续处理字符串
}
// 去掉开头和结尾的引号
let cleaned = content.replace(/^"|"$/g, '')
// 处理转义字符
cleaned = cleaned.replace(/\\n/g, '\n') // 换行符
cleaned = cleaned.replace(/\\"/g, '"') // 引号
cleaned = cleaned.replace(/\\t/g, '\t') // 制表符
cleaned = cleaned.replace(/\\\\/g, '\\') // 反斜杠
return cleaned
}
const canGenerateChapter = (chapterNumber: number) => {
if (!project.value?.blueprint?.chapter_outline) return false
// 检查前面所有章节是否都已成功生成
const outlines = project.value.blueprint.chapter_outline.sort((a, b) => a.chapter_number - b.chapter_number)
for (const outline of outlines) {
if (outline.chapter_number >= chapterNumber) break
const chapter = project.value?.chapters.find(ch => ch.chapter_number === outline.chapter_number)
if (!chapter || chapter.generation_status !== 'successful') {
return false // 前面有章节未完成
}
}
// 检查当前章节是否已经完成
const currentChapter = project.value?.chapters.find(ch => ch.chapter_number === chapterNumber)
if (currentChapter && currentChapter.generation_status === 'successful') {
return true // 已完成的章节可以重新生成
}
return true // 前面章节都完成了,可以生成当前章节
}
const isChapterFailed = (chapterNumber: number) => {
if (!project.value?.chapters) return false
const chapter = project.value.chapters.find(ch => ch.chapter_number === chapterNumber)
return chapter && chapter.generation_status === 'failed'
}
const hasChapterInProgress = (chapterNumber: number) => {
if (!project.value?.chapters) return false
const chapter = project.value.chapters.find(ch => ch.chapter_number === chapterNumber)
// waiting_for_confirm状态表示等待选择版本 = 进行中状态
return chapter && chapter.generation_status === 'waiting_for_confirm'
}
// 可用版本列表 (合并生成结果和已有版本)
const availableVersions = computed(() => {
// 优先使用新生成的版本(对象数组格式)
if (chapterGenerationResult.value?.versions) {
console.log('使用生成结果版本:', chapterGenerationResult.value.versions)
return chapterGenerationResult.value.versions
}
// 使用章节已有的版本(字符串数组格式,需要转换为对象数组)
if (selectedChapter.value?.versions && Array.isArray(selectedChapter.value.versions)) {
console.log('原始章节版本 (字符串数组):', selectedChapter.value.versions)
// 将字符串数组转换为ChapterVersion对象数组
const convertedVersions = selectedChapter.value.versions.map((versionString, index) => {
console.log(`版本 ${index} 原始字符串:`, versionString)
try {
// 解析JSON字符串
const versionObj = JSON.parse(versionString)
console.log(`版本 ${index} 解析后的对象:`, versionObj)
// 提取content字段作为实际内容
const actualContent = versionObj.content || versionString
console.log(`版本 ${index} 实际内容:`, actualContent.substring(0, 100) + '...')
return {
content: actualContent,
style: '标准' // 默认风格
}
} catch (error) {
// 如果JSON解析失败直接使用原始字符串
console.log(`版本 ${index} JSON解析失败使用原始字符串:`, error)
return {
content: versionString,
style: '标准'
}
}
})
console.log('转换后的版本对象:', convertedVersions)
return convertedVersions
}
console.log('没有可用版本selectedChapter:', selectedChapter.value)
return []
})
// 方法
const goBack = () => {
router.push('/workspace')
}
const viewProjectDetail = () => {
if (project.value) {
router.push(`/detail/${project.value.id}`)
}
}
const toggleSidebar = () => {
sidebarOpen.value = !sidebarOpen.value
}
const closeSidebar = () => {
sidebarOpen.value = false
}
const loadProject = async () => {
try {
await novelStore.loadProject(props.id)
} catch (error) {
console.error('加载项目失败:', error)
}
}
const fetchChapterStatus = async () => {
if (selectedChapterNumber.value === null) {
return
}
try {
await novelStore.loadChapter(selectedChapterNumber.value)
console.log('Chapter status polled and updated.')
} catch (error) {
console.error('轮询章节状态失败:', error)
// 在这里可以决定是否要通知用户轮询失败
}
}
// 显示版本详情
const showVersionDetail = (versionIndex: number) => {
detailVersionIndex.value = versionIndex
showVersionDetailModal.value = true
}
// 关闭版本详情弹窗
const closeVersionDetail = () => {
showVersionDetailModal.value = false
}
// 隐藏版本选择器,返回内容视图
const hideVersionSelector = () => {
// Now controlled by computed property, but we can clear the generation result
chapterGenerationResult.value = null
selectedVersionIndex.value = 0
}
const selectChapter = (chapterNumber: number) => {
selectedChapterNumber.value = chapterNumber
chapterGenerationResult.value = null
selectedVersionIndex.value = 0
closeSidebar()
}
const generateChapter = async (chapterNumber: number) => {
// 检查是否可以生成该章节
if (!canGenerateChapter(chapterNumber) && !isChapterFailed(chapterNumber) && !hasChapterInProgress(chapterNumber)) {
globalAlert.showError('请按顺序生成章节,先完成前面的章节', '生成受限')
return
}
try {
generatingChapter.value = chapterNumber
selectedChapterNumber.value = chapterNumber
// 在本地更新章节状态为generating
if (project.value?.chapters) {
const chapter = project.value.chapters.find(ch => ch.chapter_number === chapterNumber)
if (chapter) {
chapter.generation_status = 'generating'
} else {
// If chapter does not exist, create a temporary one to show generating state
const outline = project.value.blueprint?.chapter_outline?.find(o => o.chapter_number === chapterNumber)
project.value.chapters.push({
chapter_number: chapterNumber,
title: outline?.title || '加载中...',
summary: outline?.summary || '',
content: '',
versions: [],
evaluation: null,
generation_status: 'generating'
} as Chapter)
}
}
await novelStore.generateChapter(chapterNumber)
// store 中的 project 已经被更新,所以我们不需要手动修改本地状态
// chapterGenerationResult 也不再需要,因为 availableVersions 会从更新后的 project.chapters 中获取数据
// showVersionSelector is now a computed property and will update automatically.
chapterGenerationResult.value = null
selectedVersionIndex.value = 0
} catch (error) {
console.error('生成章节失败:', error)
// 错误状态的本地更新仍然是必要的以立即反映UI
if (project.value?.chapters) {
const chapter = project.value.chapters.find(ch => ch.chapter_number === chapterNumber)
if (chapter) {
chapter.generation_status = 'failed'
}
}
globalAlert.showError(`生成章节失败: ${error instanceof Error ? error.message : '未知错误'}`, '生成失败')
} finally {
generatingChapter.value = null
}
}
const regenerateChapter = async () => {
if (selectedChapterNumber.value !== null) {
await generateChapter(selectedChapterNumber.value)
}
}
const selectVersion = async (versionIndex: number) => {
if (selectedChapterNumber.value === null || !availableVersions.value?.[versionIndex]?.content) {
return
}
try {
// 在本地立即更新状态以反映UI
if (project.value?.chapters) {
const chapter = project.value.chapters.find(ch => ch.chapter_number === selectedChapterNumber.value)
if (chapter) {
chapter.generation_status = 'selecting'
}
}
selectedVersionIndex.value = versionIndex
await novelStore.selectChapterVersion(selectedChapterNumber.value, versionIndex)
// 状态更新将由 store 自动触发,本地无需手动更新
// 轮询机制会处理状态变更,成功后会自动隐藏选择器
// showVersionSelector.value = false
chapterGenerationResult.value = null
globalAlert.showSuccess('版本已确认', '操作成功')
} catch (error) {
console.error('选择章节版本失败:', error)
// 错误状态下恢复章节状态
if (project.value?.chapters) {
const chapter = project.value.chapters.find(ch => ch.chapter_number === selectedChapterNumber.value)
if (chapter) {
chapter.generation_status = 'waiting_for_confirm' // Or the previous state
}
}
globalAlert.showError(`选择章节版本失败: ${error instanceof Error ? error.message : '未知错误'}`, '选择失败')
}
}
// 从详情弹窗中选择版本
const selectVersionFromDetail = async () => {
selectedVersionIndex.value = detailVersionIndex.value
await selectVersion(detailVersionIndex.value)
closeVersionDetail()
}
const confirmVersionSelection = async () => {
await selectVersion(selectedVersionIndex.value)
}
const openEditChapterModal = (chapter: ChapterOutline) => {
editingChapter.value = chapter
showEditChapterModal.value = true
}
const saveChapterChanges = async (updatedChapter: ChapterOutline) => {
try {
await novelStore.updateChapterOutline(updatedChapter)
globalAlert.showSuccess('章节大纲已更新', '保存成功')
} catch (error) {
console.error('更新章节大纲失败:', error)
globalAlert.showError(`更新章节大纲失败: ${error instanceof Error ? error.message : '未知错误'}`, '保存失败')
} finally {
showEditChapterModal.value = false
}
}
const evaluateChapter = async () => {
if (selectedChapterNumber.value !== null) {
try {
// 在本地更新章节状态为evaluating以立即反映在UI上
if (project.value?.chapters) {
const chapter = project.value.chapters.find(ch => ch.chapter_number === selectedChapterNumber.value)
if (chapter) {
chapter.generation_status = 'evaluating'
}
}
await novelStore.evaluateChapter(selectedChapterNumber.value)
// 评审完成后状态会通过store和轮询更新这里不需要额外操作
globalAlert.showSuccess('章节评审结果已生成', '评审成功')
} catch (error) {
console.error('评审章节失败:', error)
// 错误状态下恢复章节状态
if (project.value?.chapters) {
const chapter = project.value.chapters.find(ch => ch.chapter_number === selectedChapterNumber.value)
if (chapter) {
chapter.generation_status = 'successful' // 恢复为成功状态
}
}
globalAlert.showError(`评审章节失败: ${error instanceof Error ? error.message : '未知错误'}`, '评审失败')
}
}
}
const deleteChapter = async (chapterNumbers: number | number[]) => {
const numbersToDelete = Array.isArray(chapterNumbers) ? chapterNumbers : [chapterNumbers]
const confirmationMessage = numbersToDelete.length > 1
? `您确定要删除选中的 ${numbersToDelete.length} 个章节吗?这个操作无法撤销。`
: `您确定要删除第 ${numbersToDelete[0]} 章吗?这个操作无法撤销。`
if (window.confirm(confirmationMessage)) {
try {
await novelStore.deleteChapter(numbersToDelete)
globalAlert.showSuccess('章节已删除', '操作成功')
// If the currently selected chapter was deleted, unselect it
if (selectedChapterNumber.value && numbersToDelete.includes(selectedChapterNumber.value)) {
selectedChapterNumber.value = null
}
} catch (error) {
console.error('删除章节失败:', error)
globalAlert.showError(`删除章节失败: ${error instanceof Error ? error.message : '未知错误'}`, '删除失败')
}
}
}
const generateOutline = async () => {
showGenerateOutlineModal.value = true
}
const editChapterContent = async (data: { chapterNumber: number, content: string }) => {
if (!project.value) return
try {
await novelStore.editChapterContent(project.value.id, data.chapterNumber, data.content)
globalAlert.showSuccess('章节内容已更新', '保存成功')
} catch (error) {
console.error('编辑章节内容失败:', error)
globalAlert.showError(`编辑章节内容失败: ${error instanceof Error ? error.message : '未知错误'}`, '保存失败')
}
}
const handleGenerateOutline = async (numChapters: number) => {
if (!project.value) return
isGeneratingOutline.value = true
try {
const startChapter = (project.value.blueprint?.chapter_outline?.length || 0) + 1
await novelStore.generateChapterOutline(startChapter, numChapters)
globalAlert.showSuccess('新的章节大纲已生成', '操作成功')
} catch (error) {
console.error('生成大纲失败:', error)
globalAlert.showError(`生成大纲失败: ${error instanceof Error ? error.message : '未知错误'}`, '生成失败')
} finally {
isGeneratingOutline.value = false
}
}
onMounted(() => {
loadProject()
})
</script>
<style scoped>
/* 自定义样式 */
.line-clamp-1 {
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
}
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.line-clamp-3 {
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* 自定义滚动条 */
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 3px;
}
::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: #a1a1a1;
}
/* 动画效果 */
.fade-in {
animation: fadeIn 0.6s ease-out;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>

View File

@@ -0,0 +1,12 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
"exclude": ["src/**/__tests__/*"],
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"types": ["node"],
"paths": {
"@/*": ["./src/*"]
}
}
}

11
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,11 @@
{
"files": [],
"references": [
{
"path": "./tsconfig.node.json"
},
{
"path": "./tsconfig.app.json"
}
]
}

View File

@@ -0,0 +1,19 @@
{
"extends": "@tsconfig/node22/tsconfig.json",
"include": [
"vite.config.*",
"vitest.config.*",
"cypress.config.*",
"nightwatch.conf.*",
"playwright.config.*",
"eslint.config.*"
],
"compilerOptions": {
"noEmit": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"module": "ESNext",
"moduleResolution": "Bundler",
"types": ["node"]
}
}

28
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,28 @@
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import vueJsx from '@vitejs/plugin-vue-jsx'
import vueDevTools from 'vite-plugin-vue-devtools'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [
vue(),
vueJsx(),
vueDevTools(),
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
},
},
server: {
proxy: {
'/api': {
target: 'http://127.0.0.1:8000',
changeOrigin: true,
}
}
}
})