From e03d265a7a2826f8592ec2f5456620f1ddcad279 Mon Sep 17 00:00:00 2001 From: yuwangi Date: Fri, 10 Apr 2026 13:49:27 +0800 Subject: [PATCH] .3719011831386640:8bac2f2c56b9029ec34c166738928571_69d866ea59ede4fc2b1346c7.69d8672259ede4fc2b1346cd.69d867227c90226d17ec3218:Trae CN.T(2026/4/10 10:57:38) --- .../drizzle/0010_high_rocket_raccoon.sql | 1 + apps/backend/drizzle/meta/0010_snapshot.json | 1387 +++++++++++++++++ apps/backend/drizzle/meta/_journal.json | 7 + apps/backend/src/database/schema.ts | 1 + apps/backend/src/routes/chapter.routes.ts | 22 +- apps/backend/src/types/index.ts | 18 +- apps/frontend/app/chapters/edit/page.tsx | 927 ++++++----- apps/frontend/app/stats/page.tsx | 6 +- 8 files changed, 2014 insertions(+), 355 deletions(-) create mode 100644 apps/backend/drizzle/0010_high_rocket_raccoon.sql create mode 100644 apps/backend/drizzle/meta/0010_snapshot.json diff --git a/apps/backend/drizzle/0010_high_rocket_raccoon.sql b/apps/backend/drizzle/0010_high_rocket_raccoon.sql new file mode 100644 index 0000000..47a1607 --- /dev/null +++ b/apps/backend/drizzle/0010_high_rocket_raccoon.sql @@ -0,0 +1 @@ +ALTER TABLE "chapters" ADD COLUMN "content_modified_at" timestamp; \ No newline at end of file diff --git a/apps/backend/drizzle/meta/0010_snapshot.json b/apps/backend/drizzle/meta/0010_snapshot.json new file mode 100644 index 0000000..30b95f3 --- /dev/null +++ b/apps/backend/drizzle/meta/0010_snapshot.json @@ -0,0 +1,1387 @@ +{ + "id": "f787d141-27ea-4dec-adbf-5de1a8d5c948", + "prevId": "542524b8-e468-4b60-9b32-6431cc04097f", + "version": "5", + "dialect": "pg", + "tables": { + "account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "accountId": { + "name": "accountId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "providerId": { + "name": "providerId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "accessToken": { + "name": "accessToken", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refreshToken": { + "name": "refreshToken", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "idToken": { + "name": "idToken", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "account_userId_user_id_fk": { + "name": "account_userId_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "ai_configs": { + "name": "ai_configs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "api_key": { + "name": "api_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "base_url": { + "name": "base_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parameters": { + "name": "parameters", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "is_default": { + "name": "is_default", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "ai_configs_user_id_user_id_fk": { + "name": "ai_configs_user_id_user_id_fk", + "tableFrom": "ai_configs", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "chapter_snapshots": { + "name": "chapter_snapshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chapter_id": { + "name": "chapter_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "novel_id": { + "name": "novel_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "word_count": { + "name": "word_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "chapter_snapshots_chapter_id_chapters_id_fk": { + "name": "chapter_snapshots_chapter_id_chapters_id_fk", + "tableFrom": "chapter_snapshots", + "tableTo": "chapters", + "columnsFrom": ["chapter_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chapter_snapshots_novel_id_novels_id_fk": { + "name": "chapter_snapshots_novel_id_novels_id_fk", + "tableFrom": "chapter_snapshots", + "tableTo": "novels", + "columnsFrom": ["novel_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "chapters": { + "name": "chapters", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "volume_id": { + "name": "volume_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "novel_id": { + "name": "novel_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "outline": { + "name": "outline", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "detail_outline": { + "name": "detail_outline", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "word_count": { + "name": "word_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "status": { + "name": "status", + "type": "chapter_status", + "primaryKey": false, + "notNull": false, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "content_modified_at": { + "name": "content_modified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "chapters_volume_id_volumes_id_fk": { + "name": "chapters_volume_id_volumes_id_fk", + "tableFrom": "chapters", + "tableTo": "volumes", + "columnsFrom": ["volume_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chapters_novel_id_novels_id_fk": { + "name": "chapters_novel_id_novels_id_fk", + "tableFrom": "chapters", + "tableTo": "novels", + "columnsFrom": ["novel_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "characters": { + "name": "characters", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "novel_id": { + "name": "novel_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "personality": { + "name": "personality", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "abilities": { + "name": "abilities", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "relationships": { + "name": "relationships", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "current_state": { + "name": "current_state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "characters_novel_id_novels_id_fk": { + "name": "characters_novel_id_novels_id_fk", + "tableFrom": "characters", + "tableTo": "novels", + "columnsFrom": ["novel_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "knowledge_bases": { + "name": "knowledge_bases", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "novel_id": { + "name": "novel_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "knowledge_bases_novel_id_novels_id_fk": { + "name": "knowledge_bases_novel_id_novels_id_fk", + "tableFrom": "knowledge_bases", + "tableTo": "novels", + "columnsFrom": ["novel_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "knowledge_documents": { + "name": "knowledge_documents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_type": { + "name": "file_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "embedding": { + "name": "embedding", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "knowledge_documents_knowledge_base_id_knowledge_bases_id_fk": { + "name": "knowledge_documents_knowledge_base_id_knowledge_bases_id_fk", + "tableFrom": "knowledge_documents", + "tableTo": "knowledge_bases", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "novels": { + "name": "novels", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cover_url": { + "name": "cover_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "genre": { + "name": "genre", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "style": { + "name": "style", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "writing_style_rules": { + "name": "writing_style_rules", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_audience": { + "name": "target_audience", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "target_words": { + "name": "target_words", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 100000 + }, + "min_chapter_words": { + "name": "min_chapter_words", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 3000 + }, + "background": { + "name": "background", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "world_settings": { + "name": "world_settings", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "current_outline_version": { + "name": "current_outline_version", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 1 + }, + "status": { + "name": "status", + "type": "novel_status", + "primaryKey": false, + "notNull": false, + "default": "'draft'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "novels_user_id_user_id_fk": { + "name": "novels_user_id_user_id_fk", + "tableFrom": "novels", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "outline_versions": { + "name": "outline_versions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "novel_id": { + "name": "novel_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "generation_mode": { + "name": "generation_mode", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "generation_context": { + "name": "generation_context", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "is_locked": { + "name": "is_locked", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "outline_versions_novel_id_novels_id_fk": { + "name": "outline_versions_novel_id_novels_id_fk", + "tableFrom": "outline_versions", + "tableTo": "novels", + "columnsFrom": ["novel_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "plot_sandboxes": { + "name": "plot_sandboxes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "novel_id": { + "name": "novel_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "premise": { + "name": "premise", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "plot_sandboxes_novel_id_novels_id_fk": { + "name": "plot_sandboxes_novel_id_novels_id_fk", + "tableFrom": "plot_sandboxes", + "tableTo": "novels", + "columnsFrom": ["novel_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "plot_threads": { + "name": "plot_threads", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "novel_id": { + "name": "novel_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "plot_thread_status", + "primaryKey": false, + "notNull": false, + "default": "'open'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "plot_threads_novel_id_novels_id_fk": { + "name": "plot_threads_novel_id_novels_id_fk", + "tableFrom": "plot_threads", + "tableTo": "novels", + "columnsFrom": ["novel_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ipAddress": { + "name": "ipAddress", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "userAgent": { + "name": "userAgent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "session_userId_user_id_fk": { + "name": "session_userId_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "tasks": { + "name": "tasks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "novel_id": { + "name": "novel_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "chapter_id": { + "name": "chapter_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "task_type", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "task_status", + "primaryKey": false, + "notNull": false, + "default": "'queued'" + }, + "progress": { + "name": "progress", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "tasks_novel_id_novels_id_fk": { + "name": "tasks_novel_id_novels_id_fk", + "tableFrom": "tasks", + "tableTo": "novels", + "columnsFrom": ["novel_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tasks_chapter_id_chapters_id_fk": { + "name": "tasks_chapter_id_chapters_id_fk", + "tableFrom": "tasks", + "tableTo": "chapters", + "columnsFrom": ["chapter_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "timeline_events": { + "name": "timeline_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "novel_id": { + "name": "novel_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "time_label": { + "name": "time_label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "timeline_events_novel_id_novels_id_fk": { + "name": "timeline_events_novel_id_novels_id_fk", + "tableFrom": "timeline_events", + "tableTo": "novels", + "columnsFrom": ["novel_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "emailVerified": { + "name": "emailVerified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + } + }, + "verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "volumes": { + "name": "volumes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "novel_id": { + "name": "novel_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "volumes_novel_id_novels_id_fk": { + "name": "volumes_novel_id_novels_id_fk", + "tableFrom": "volumes", + "tableTo": "novels", + "columnsFrom": ["novel_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + } + }, + "enums": { + "chapter_status": { + "name": "chapter_status", + "values": { + "pending": "pending", + "generating": "generating", + "completed": "completed", + "failed": "failed" + } + }, + "novel_status": { + "name": "novel_status", + "values": { + "draft": "draft", + "generating": "generating", + "completed": "completed", + "archived": "archived" + } + }, + "plot_thread_status": { + "name": "plot_thread_status", + "values": { + "open": "open", + "resolved": "resolved", + "dropped": "dropped" + } + }, + "task_status": { + "name": "task_status", + "values": { + "queued": "queued", + "running": "running", + "completed": "completed", + "failed": "failed", + "cancelled": "cancelled" + } + }, + "task_type": { + "name": "task_type", + "values": { + "outline": "outline", + "title": "title", + "volume_planning": "volume_planning", + "chapter_planning": "chapter_planning", + "chapter_outline": "chapter_outline", + "chapter_detail": "chapter_detail", + "content": "content", + "consistency_check": "consistency_check" + } + } + }, + "schemas": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/apps/backend/drizzle/meta/_journal.json b/apps/backend/drizzle/meta/_journal.json index 8c72202..785ec48 100644 --- a/apps/backend/drizzle/meta/_journal.json +++ b/apps/backend/drizzle/meta/_journal.json @@ -71,6 +71,13 @@ "when": 1772625679467, "tag": "0009_typical_thor_girl", "breakpoints": true + }, + { + "idx": 10, + "version": "5", + "when": 1775799710167, + "tag": "0010_high_rocket_raccoon", + "breakpoints": true } ] } diff --git a/apps/backend/src/database/schema.ts b/apps/backend/src/database/schema.ts index 73a44b6..2818cdd 100644 --- a/apps/backend/src/database/schema.ts +++ b/apps/backend/src/database/schema.ts @@ -262,6 +262,7 @@ export const chapters = pgTable("chapters", { status: chapterStatusEnum("status").default("pending"), createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at").defaultNow().notNull(), + contentModifiedAt: timestamp("content_modified_at"), // Track when actual writing content changes (for daily word count stats) }); // Chapter Snapshots table (Version History) diff --git a/apps/backend/src/routes/chapter.routes.ts b/apps/backend/src/routes/chapter.routes.ts index 2cbb9f4..43436ca 100644 --- a/apps/backend/src/routes/chapter.routes.ts +++ b/apps/backend/src/routes/chapter.routes.ts @@ -98,6 +98,25 @@ router.patch("/:id", async (req: AuthRequest, res, next) => { const { id } = req.params; const { title, content, outline } = req.body; + const existing = await db.query.chapters.findFirst({ + where: eq(schema.chapters.id, id), + }); + + if (!existing) { + res.status(404).json({ error: "Chapter not found" }); + return; + } + + const contentChanged = existing.content !== content; + const titleChanged = existing.title !== title; + const outlineChanged = existing.outline !== outline; + + if (!contentChanged && !titleChanged && !outlineChanged) { + res.json(existing); + return; + } + + const now = new Date(); const [chapter] = await db .update(schema.chapters) .set({ @@ -105,7 +124,8 @@ router.patch("/:id", async (req: AuthRequest, res, next) => { content, outline, wordCount: content ? content.length : undefined, - updatedAt: new Date(), + updatedAt: now, + contentModifiedAt: contentChanged ? now : existing.contentModifiedAt, }) .where(eq(schema.chapters.id, id)) .returning(); diff --git a/apps/backend/src/types/index.ts b/apps/backend/src/types/index.ts index 897c274..3ff1778 100644 --- a/apps/backend/src/types/index.ts +++ b/apps/backend/src/types/index.ts @@ -13,7 +13,7 @@ export interface Novel { powerSystem?: string; forbiddenRules?: string[]; }; - status: 'draft' | 'generating' | 'completed'; + status: "draft" | "generating" | "completed"; createdAt: Date; updatedAt: Date; } @@ -41,17 +41,25 @@ export interface Chapter { detailOutline?: string; content?: string; wordCount: number; - status: 'pending' | 'generating' | 'completed' | 'failed'; + status: "pending" | "generating" | "completed" | "failed"; createdAt: Date; updatedAt: Date; + contentModifiedAt: Date | null; } export interface Task { id: string; novelId: string; chapterId?: string; - type: 'outline' | 'title' | 'chapter_planning' | 'chapter_outline' | 'chapter_detail' | 'content' | 'consistency'; - status: 'queued' | 'running' | 'completed' | 'failed'; + type: + | "outline" + | "title" + | "chapter_planning" + | "chapter_outline" + | "chapter_detail" + | "content" + | "consistency"; + status: "queued" | "running" | "completed" | "failed"; progress: number; result?: any; error?: string; @@ -62,7 +70,7 @@ export interface Task { export interface AIConfig { id: string; userId: string; - provider: 'openai' | 'anthropic' | 'deepseek'; + provider: "openai" | "anthropic" | "deepseek"; model: string; apiKey: string; baseUrl?: string; diff --git a/apps/frontend/app/chapters/edit/page.tsx b/apps/frontend/app/chapters/edit/page.tsx index a95afe5..752349b 100644 --- a/apps/frontend/app/chapters/edit/page.tsx +++ b/apps/frontend/app/chapters/edit/page.tsx @@ -1,24 +1,38 @@ -'use client'; +"use client"; // Note: In a pure 'use client' file, Next.js handles this via Suspense which is wrapping ChapterEditor - -import { useState, useEffect, useCallback, useRef, Suspense } from 'react'; -import { useSearchParams, useRouter } from 'next/navigation'; -import { useTheme } from 'next-themes'; -import { Button } from '@/components/ui/button'; -import { tasksAPI, novelsAPI } from '@/lib/api'; -import { - Loader2, Save, ArrowLeft, CheckCircle, AlertCircle, - Sparkles, Settings, Type, PanelRightOpen, PanelRightClose, - BookOpen, Home, ChevronRight, Menu, Maximize2, Minimize2, History, UserCheck -} from 'lucide-react'; -import { toast } from 'sonner'; -import { ChatPanel } from '@/components/editor/ChatPanel'; -import { ChapterListSidebar } from '@/components/editor/ChapterListSidebar'; -import { SelectionMenu } from '@/components/editor/SelectionMenu'; -import { HistoryPanel } from '@/components/editor/HistoryPanel'; -import { getSelectionCoordinates } from '@/lib/textarea-utils'; +import { useState, useEffect, useCallback, useRef, Suspense } from "react"; +import { useSearchParams, useRouter } from "next/navigation"; +import { useTheme } from "next-themes"; +import { Button } from "@/components/ui/button"; +import { tasksAPI, novelsAPI } from "@/lib/api"; +import { + Loader2, + Save, + ArrowLeft, + CheckCircle, + AlertCircle, + Sparkles, + Settings, + Type, + PanelRightOpen, + PanelRightClose, + BookOpen, + Home, + ChevronRight, + Menu, + Maximize2, + Minimize2, + History, + UserCheck, +} from "lucide-react"; +import { toast } from "sonner"; +import { ChatPanel } from "@/components/editor/ChatPanel"; +import { ChapterListSidebar } from "@/components/editor/ChapterListSidebar"; +import { SelectionMenu } from "@/components/editor/SelectionMenu"; +import { HistoryPanel } from "@/components/editor/HistoryPanel"; +import { getSelectionCoordinates } from "@/lib/textarea-utils"; import { DropdownMenu, DropdownMenuContent, @@ -33,9 +47,9 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; -import { DialogTrigger } from '@radix-ui/react-dialog'; -import { cn } from '@/lib/utils'; -import Link from 'next/link'; +import { DialogTrigger } from "@radix-ui/react-dialog"; +import { cn } from "@/lib/utils"; +import Link from "next/link"; interface Chapter { id: string; @@ -68,14 +82,14 @@ interface Novel { }[]; } -type FontSize = 'sm' | 'base' | 'lg' | 'xl'; -type FontFamily = 'sans' | 'serif' | 'mono'; +type FontSize = "sm" | "base" | "lg" | "xl"; +type FontFamily = "sans" | "serif" | "mono"; function ChapterEditor() { const searchParams = useSearchParams(); const router = useRouter(); - const chapterId = searchParams.get('id') as string; - + const chapterId = searchParams.get("id") as string; + // Focus Mode State (Moved to top) const [isFocusMode, setIsFocusMode] = useState(false); @@ -84,26 +98,28 @@ function ChapterEditor() { setIsFocusMode(!isFocusMode); // Auto-close panels when entering focus mode if (!isFocusMode) { - setIsLeftSidebarOpen(false); - setIsRightPanelOpen(false); + setIsLeftSidebarOpen(false); + setIsRightPanelOpen(false); } else { - setIsLeftSidebarOpen(true); - setIsRightPanelOpen(true); + setIsLeftSidebarOpen(true); + setIsRightPanelOpen(true); } }; const [chapter, setChapter] = useState(null); const [novel, setNovel] = useState(null); - const [content, setContent] = useState(''); - const [title, setTitle] = useState(''); - const [outline, setOutline] = useState(''); // Chapter Outline - const [workOutline, setWorkOutline] = useState(''); // Novel Work Outline - + const [content, setContent] = useState(""); + const [title, setTitle] = useState(""); + const [outline, setOutline] = useState(""); // Chapter Outline + const [workOutline, setWorkOutline] = useState(""); // Novel Work Outline + const [isLoading, setIsLoading] = useState(true); const [isSaving, setIsSaving] = useState(false); - const [saveStatus, setSaveStatus] = useState<'saved' | 'saving' | 'unsaved' | 'error'>('saved'); - const [lastSavedContent, setLastSavedContent] = useState(''); - + const [saveStatus, setSaveStatus] = useState< + "saved" | "saving" | "unsaved" | "error" + >("saved"); + const [lastSavedContent, setLastSavedContent] = useState(""); + // Side Panel State const [isRightPanelOpen, setIsRightPanelOpen] = useState(false); const [isLeftSidebarOpen, setIsLeftSidebarOpen] = useState(true); @@ -111,16 +127,20 @@ function ChapterEditor() { // Settings State const { theme, setTheme } = useTheme(); - const [fontSize, setFontSize] = useState('lg'); - const [fontFamily, setFontFamily] = useState('serif'); + const [fontSize, setFontSize] = useState("lg"); + const [fontFamily, setFontFamily] = useState("serif"); // Modals const [isWorkOutlineOpen, setIsWorkOutlineOpen] = useState(false); const [isChapterOutlineOpen, setIsChapterOutlineOpen] = useState(false); - + // OOC Check State const [isOocChecking, setIsOocChecking] = useState(false); - const [oocResult, setOocResult] = useState<{ passed: boolean; issues: string[]; suggestions?: string[] } | null>(null); + const [oocResult, setOocResult] = useState<{ + passed: boolean; + issues: string[]; + suggestions?: string[]; + } | null>(null); const [isOocModalOpen, setIsOocModalOpen] = useState(false); const contentRef = useRef(content); @@ -136,14 +156,15 @@ function ChapterEditor() { }, [content]); // Track daily word count: accumulate typed chars into localStorage + // Note: Backend only counts chapters with actual content changes useEffect(() => { const len = content.length; const prev = prevContentLengthRef.current; const delta = len - prev; if (delta > 0 && prev > 0) { - const today = new Date().toISOString().split('T')[0]; + const today = new Date().toISOString().split("T")[0]; const key = `writing-words-${today}`; - const existing = parseInt(localStorage.getItem(key) || '0', 10); + const existing = parseInt(localStorage.getItem(key) || "0", 10); localStorage.setItem(key, String(existing + delta)); } prevContentLengthRef.current = len; @@ -151,14 +172,20 @@ function ChapterEditor() { // Load Settings useEffect(() => { - const savedFontSize = localStorage.getItem('editor-font-size') as FontSize; - const savedFontFamily = localStorage.getItem('editor-font-family') as FontFamily; - + const savedFontSize = localStorage.getItem("editor-font-size") as FontSize; + const savedFontFamily = localStorage.getItem( + "editor-font-family", + ) as FontFamily; + if (savedFontSize) setFontSize(savedFontSize); if (savedFontFamily) setFontFamily(savedFontFamily); }, []); - const updateSetting = (key: string, value: string, setter: (val: any) => void) => { + const updateSetting = ( + key: string, + value: string, + setter: (val: any) => void, + ) => { setter(value); localStorage.setItem(key, value); }; @@ -167,10 +194,10 @@ function ChapterEditor() { const handleRewrite = (text: string, prompt?: string) => { if (!isRightPanelOpen) setIsRightPanelOpen(true); // Determine prompt based on input - const finalPrompt = prompt + const finalPrompt = prompt ? `请根据以下要求改写这段文字:\n\n【要求】:${prompt}\n\n【原文】:${text}` : `请润色这段文字,使其更加生动流畅:\n\n${text}`; - + // Slight delay to ensure panel is open setTimeout(() => { chatPanelRef.current?.sendMessage(finalPrompt); @@ -188,30 +215,31 @@ function ChapterEditor() { const handleAutoExpand = () => { if (!isRightPanelOpen) setIsRightPanelOpen(true); // Get last paragraph or last few chars as context - const context = content.slice(-500); + const context = content.slice(-500); const prompt = `请接着上文继续扩写,保持风格一致,推动剧情发展:\n\n上文片段:\n${context}`; setTimeout(() => { - chatPanelRef.current?.sendMessage(prompt); + chatPanelRef.current?.sendMessage(prompt); }, 100); }; const handleOocCheck = async () => { if (!content.trim()) { - toast.warning('正文为空,无法检测'); + toast.warning("正文为空,无法检测"); return; } - + setIsOocChecking(true); setOocResult(null); try { // Check the latest written content (e.g., last 2000 chars) for performance, or full content - const textToCheck = content.length > 3000 ? content.slice(-3000) : content; + const textToCheck = + content.length > 3000 ? content.slice(-3000) : content; const res = await tasksAPI.checkOoc(chapterId, textToCheck); setOocResult(res.data); setIsOocModalOpen(true); } catch (error: any) { - console.error('OOC Check failed:', error); - toast.error(error.message || '角色一致性检测失败'); + console.error("OOC Check failed:", error); + toast.error(error.message || "角色一致性检测失败"); } finally { setIsOocChecking(false); } @@ -224,13 +252,14 @@ function ChapterEditor() { // 1. Fetch Chapter const chapterRes = await tasksAPI.getChapter(chapterId); const chapterData = chapterRes.data; - + setChapter(chapterData); setTitle(chapterData.title); - // Ensure content has initial indent if empty? Maybe not enforced on load, only input. - setContent(chapterData.content || ''); - setLastSavedContent(chapterData.content || ''); - setOutline(chapterData.outline || ''); + const initialContent = chapterData.content || ""; + setContent(initialContent); + setLastSavedContent(initialContent); + prevContentLengthRef.current = initialContent.length; + setOutline(chapterData.outline || ""); // 2. Fetch Novel Structure const novelRes = await novelsAPI.get(chapterData.novelId); @@ -240,26 +269,35 @@ function ChapterEditor() { // 3. Fetch Outline Versions (if not included in novel response, try separate endpoint) // Attempt to get outline from novel data if available, or fetch if (novelData.outlineVersions && novelData.outlineVersions.length > 0) { - // Sort by createdAt desc - const sorted = [...novelData.outlineVersions].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); - setWorkOutline(sorted[0].content); + // Sort by createdAt desc + const sorted = [...novelData.outlineVersions].sort( + (a, b) => + new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), + ); + setWorkOutline(sorted[0].content); } else { - // Try fetching separate endpoint if applicable - try { - const outlineRes = await novelsAPI.getOutlineVersions(chapterData.novelId); - if (outlineRes.data && outlineRes.data.length > 0) { - const sorted = [...outlineRes.data].sort((a: any, b: any) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); - setWorkOutline(sorted[0].content); - } - } catch (e) { - console.warn("Failed to fetch outline versions", e); - } + // Try fetching separate endpoint if applicable + try { + const outlineRes = await novelsAPI.getOutlineVersions( + chapterData.novelId, + ); + if (outlineRes.data && outlineRes.data.length > 0) { + const sorted = [...outlineRes.data].sort( + (a: any, b: any) => + new Date(b.createdAt).getTime() - + new Date(a.createdAt).getTime(), + ); + setWorkOutline(sorted[0].content); + } + } catch (e) { + console.warn("Failed to fetch outline versions", e); + } } setIsLoading(false); } catch (error) { - console.error('Failed to load data:', error); - toast.error('加载数据失败'); + console.error("Failed to load data:", error); + toast.error("加载数据失败"); setIsLoading(false); } }; @@ -267,44 +305,47 @@ function ChapterEditor() { }, [chapterId]); // Save Logic - const saveContent = useCallback(async (manual = false) => { - if (!chapter) return; - - if (!manual && contentRef.current === lastSavedContent) { - if (manual) toast.success('已是最新内容'); - return; - } + const saveContent = useCallback( + async (manual = false) => { + if (!chapter) return; - setSaveStatus('saving'); - setIsSaving(true); - - try { - await tasksAPI.updateChapter(chapter.id, { - content: contentRef.current, - title: title - }); - - setLastSavedContent(contentRef.current); - setSaveStatus('saved'); - if (manual) toast.success('保存成功'); - } catch (error) { - console.error('Failed to save:', error); - setSaveStatus('error'); - if (manual) toast.error('保存失败'); - } finally { - setIsSaving(false); - } - }, [chapter, lastSavedContent, title]); + if (!manual && contentRef.current === lastSavedContent) { + if (manual) toast.success("已是最新内容"); + return; + } + + setSaveStatus("saving"); + setIsSaving(true); + + try { + await tasksAPI.updateChapter(chapter.id, { + content: contentRef.current, + title: title, + }); + + setLastSavedContent(contentRef.current); + setSaveStatus("saved"); + if (manual) toast.success("保存成功"); + } catch (error) { + console.error("Failed to save:", error); + setSaveStatus("error"); + if (manual) toast.error("保存失败"); + } finally { + setIsSaving(false); + } + }, + [chapter, lastSavedContent, title], + ); // Delayed Auto-save useEffect(() => { if (content !== lastSavedContent) { - setSaveStatus('unsaved'); + setSaveStatus("unsaved"); if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current); saveTimeoutRef.current = setTimeout(() => saveContent(), 2000); } return () => { - if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current); + if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current); }; }, [content, lastSavedContent, saveContent]); @@ -313,21 +354,22 @@ function ChapterEditor() { if (isFocusMode && textareaRef.current) { const textarea = textareaRef.current; const { selectionStart } = textarea; - + // Use requestAnimationFrame to ensure we scroll after render requestAnimationFrame(() => { - const coords = getSelectionCoordinates(textarea, selectionStart); - const lineHeight = coords.height; - const textareaHeight = textarea.clientHeight; - - // Calculate desired scroll top to center the cursor - // Target: cursorTop at 40% of screen height (slightly above center) - const targetScrollTop = coords.top - (textareaHeight * 0.4) + (lineHeight / 2); - - textarea.scrollTo({ - top: Math.max(0, targetScrollTop), - behavior: 'smooth' - }); + const coords = getSelectionCoordinates(textarea, selectionStart); + const lineHeight = coords.height; + const textareaHeight = textarea.clientHeight; + + // Calculate desired scroll top to center the cursor + // Target: cursorTop at 40% of screen height (slightly above center) + const targetScrollTop = + coords.top - textareaHeight * 0.4 + lineHeight / 2; + + textarea.scrollTo({ + top: Math.max(0, targetScrollTop), + behavior: "smooth", + }); }); } }, [content, isFocusMode]); @@ -336,41 +378,58 @@ function ChapterEditor() { useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { // Save - if ((e.metaKey || e.ctrlKey) && e.key === 's') { + if ((e.metaKey || e.ctrlKey) && e.key === "s") { e.preventDefault(); saveContent(true); } }; - window.addEventListener('keydown', handleKeyDown); - return () => window.removeEventListener('keydown', handleKeyDown); + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); }, [saveContent]); // Handle Textarea KeyDown for Indentation - const handleTextareaKeyDown = (e: React.KeyboardEvent) => { - if (e.key === 'Enter') { + const handleTextareaKeyDown = ( + e: React.KeyboardEvent, + ) => { + if (e.key === "Enter") { e.preventDefault(); const textarea = e.currentTarget; const start = textarea.selectionStart; const end = textarea.selectionEnd; const value = textarea.value; - + // Insert newline + 2 full-width spaces - const insertion = '\n\u3000\u3000'; - const newValue = value.substring(0, start) + insertion + value.substring(end); - + const insertion = "\n\u3000\u3000"; + const newValue = + value.substring(0, start) + insertion + value.substring(end); + setContent(newValue); - + // Move cursor setTimeout(() => { - textarea.selectionStart = textarea.selectionEnd = start + insertion.length; + textarea.selectionStart = textarea.selectionEnd = + start + insertion.length; }, 0); } }; const getTextareaClass = () => { - const base = "w-full h-full p-8 md:p-12 lg:px-24 resize-none focus:outline-none bg-transparent leading-relaxed transition-all duration-300 selection:bg-primary/20"; - const font = fontFamily === 'mono' ? 'font-mono' : fontFamily === 'sans' ? 'font-sans' : 'font-serif'; - const size = fontSize === 'sm' ? 'text-base' : fontSize === 'base' ? 'text-lg' : fontSize === 'lg' ? 'text-xl' : 'text-2xl'; + const base = + "w-full h-full p-8 md:p-12 lg:px-24 resize-none focus:outline-none bg-transparent leading-relaxed transition-all duration-300 selection:bg-primary/20"; + const font = + fontFamily === "mono" + ? "font-mono" + : fontFamily === "sans" + ? "font-sans" + : "font-serif"; + const size = + fontSize === "sm" + ? "text-base" + : fontSize === "base" + ? "text-lg" + : fontSize === "lg" + ? "text-xl" + : "text-2xl"; return `${base} ${font} ${size}`; }; @@ -387,106 +446,116 @@ function ChapterEditor() {

章节不存在

- +
); } // Find Volume Name - const currentVolume = novel?.volumes.find(v => v.id === chapter.volumeId); + const currentVolume = novel?.volumes.find((v) => v.id === chapter.volumeId); // Prepare sorted volumes for sidebar - const sortedVolumes = novel?.volumes - .sort((a, b) => a.order - b.order) - .map(v => ({ - id: v.id, - title: v.title, - order: v.order, - chapters: v.chapters - .sort((a: any, b: any) => a.order - b.order) - .map((c: any, index: number) => ({ - id: c.id, - title: `第${c.order || index + 1}章 ${c.title}`, // Add Chapter Prefix - volumeId: v.id - })) - })) || []; - + const sortedVolumes = + novel?.volumes + .sort((a, b) => a.order - b.order) + .map((v) => ({ + id: v.id, + title: v.title, + order: v.order, + chapters: v.chapters + .sort((a: any, b: any) => a.order - b.order) + .map((c: any, index: number) => ({ + id: c.id, + title: `第${c.order || index + 1}章 ${c.title}`, // Add Chapter Prefix + volumeId: v.id, + })), + })) || []; return (
- {/* Focus Mode Exit Button (Floating) */} {isFocusMode && ( - + )} {/* 1. Left Sidebar (Chapter List) - 260px */} -
{novel && (
-
- - {novel.title} -
-
- -
+
+ + {novel.title} +
+
+ +
)}
{/* 2. Center (Editor) - Flex 1 */}
- {/* Header - Hidden in Focus Mode */} -
- {/* Left: Breadcrumbs */}
- - + - - - {novel?.title || '...'} + + + {novel?.title || "..."} - + - {currentVolume?.title || '...'} + {currentVolume?.title || "..."} @@ -497,57 +566,66 @@ function ChapterEditor() { {/* Right: Actions */}
- {/* Outline Dialogs (Consolidated) */} - 大纲查看 - - -
- 作品大纲 -
-
- - - 作品大纲 - -
- {workOutline || '暂无作品大纲內容'} -
-
+ + +
+ 作品大纲 +
+
+ + + 作品大纲 + +
+ {workOutline || "暂无作品大纲內容"} +
+
+
+ + +
+ 本章大纲 +
+
+ + + 本章大纲 + +
+ {outline || "暂无章节大纲"} +
+
- - -
- 本章大纲 -
-
- - - 本章大纲 - -
- {outline || '暂无章节大纲'} -
-
-
- + {/* AI Assistant Menu */} - - + 一键扩写 - { e.preventDefault(); // Prevent closing immediately so we can show loading if needed handleOocCheck(); - }} + }} disabled={isOocChecking} className="gap-2 cursor-pointer" > - {isOocChecking ? : } + {isOocChecking ? ( + + ) : ( + + )} OOC 检测 @@ -586,29 +671,47 @@ function ChapterEditor() {
{oocResult ? (
-
+
{oocResult.passed ? ( - <> 恭喜!当前片段未发现明显的角色崩坏 (OOC)。 + <> + {" "} + 恭喜!当前片段未发现明显的角色崩坏 (OOC)。 + ) : ( - <> 警告:发现可能存在的 OOC 行为! + <> + {" "} + 警告:发现可能存在的 OOC 行为! + )}
- - {!oocResult.passed && oocResult.issues && oocResult.issues.length > 0 && ( -
-

存在的问题:

-
    - {oocResult.issues.map((issue, i) => ( -
  • {issue}
  • - ))} -
-
- )} + + {!oocResult.passed && + oocResult.issues && + oocResult.issues.length > 0 && ( +
+

+ {" "} + 存在的问题: +

+
    + {oocResult.issues.map((issue, i) => ( +
  • {issue}
  • + ))} +
+
+ )}
) : ( -
+
+ +
)}
@@ -616,26 +719,45 @@ function ChapterEditor() { {/* Save Status (Compact) */}
- 字数: {content.length.toLocaleString()} - {content.length} - -
- - {saveStatus === 'saved' && } - {saveStatus === 'saving' && } - {saveStatus === 'unsaved' && } - {saveStatus === 'error' && } + + 字数: {content.length.toLocaleString()} + + {content.length} + +
+ + {saveStatus === "saved" && ( + + + + )} + {saveStatus === "saving" && ( + + + + )} + {saveStatus === "unsaved" && ( + + )} + {saveStatus === "error" && ( + + + + )}
{/* Focus Mode Toggle */} - {/* Settings */} @@ -648,57 +770,132 @@ function ChapterEditor() { 显示设置 - +
主题
- - + +
-
字号
-
- {['sm', 'base', 'lg', 'xl'].map((s) => ( - - ))} -
+
+ 字号 +
+
+ {["sm", "base", "lg", "xl"].map((s) => ( + + ))} +
- +
-
字体
-
- - -
+
+ 字体 +
+
+ + +
{/* Panel Toggle */} - {/* History Toggle */} @@ -711,10 +908,19 @@ function ChapterEditor() { > - + {/* Main Save Button */} -
@@ -722,40 +928,54 @@ function ChapterEditor() { {/* Editor Area */}
- {/* Floating Exit Button for Focus Mode */} {isFocusMode && ( -
-
- {content.length.toLocaleString()} 字 - {saveStatus === 'saved' && } - {saveStatus === 'saving' && } - {saveStatus === 'unsaved' && } - {saveStatus === 'error' && } -
- -
+
+
+ {content.length.toLocaleString()} 字 + {saveStatus === "saved" && ( + + )} + {saveStatus === "saving" && ( + + )} + {saveStatus === "unsaved" && ( + + )} + {saveStatus === "error" && ( + + )} +
+ +
)} - -
+
{isFocusMode && ( -

+

{chapter?.title}

)} @@ -773,32 +993,39 @@ function ChapterEditor() {
{/* 3. Right Panel (AI Assistant) - 350px */} -
{/* Header for Right Panel */}
-
- - AI 助手 -
- +
+ + AI 助手 +
+
- +
- +
@@ -807,7 +1034,9 @@ function ChapterEditor() {
{ setContent(restoredContent); setLastSavedContent(restoredContent); - setSaveStatus('saved'); + setSaveStatus("saved"); }} className="h-full" /> @@ -827,9 +1056,13 @@ function ChapterEditor() { export default function ChapterEditorPage() { return ( - - -
}> + + +
+ } + > ); diff --git a/apps/frontend/app/stats/page.tsx b/apps/frontend/app/stats/page.tsx index 5bfbaad..170f80b 100644 --- a/apps/frontend/app/stats/page.tsx +++ b/apps/frontend/app/stats/page.tsx @@ -31,6 +31,7 @@ interface Chapter { order: number; status: string; updatedAt: string; + contentModifiedAt: string | null; } interface Volume { @@ -156,10 +157,11 @@ export default function StatsPage() { ).length; const totalNovels = novels.length; - // Calculate today's words from database (chapters updated today) + // Calculate today's words from database (chapters with content modified today) + // Use contentModifiedAt (tracks ONLY actual content changes, not title/outline edits) const todayWords = useMemo(() => { return allChapters.reduce((sum, chapter) => { - if (chapter.updatedAt && isToday(chapter.updatedAt)) { + if (chapter.contentModifiedAt && isToday(chapter.contentModifiedAt)) { return sum + (chapter.wordCount || 0); } return sum;