Add files via upload

This commit is contained in:
Pony
2025-06-18 18:47:07 +08:00
committed by GitHub
parent 6928d7da10
commit 971a220018
22 changed files with 24305 additions and 217 deletions
+247 -151
View File
@@ -1,203 +1,299 @@
# 📚 91AI小说生成器
# 📚 91写作 - AI智能小说创作工具
> 一个基于 Vue 3 + Element Plus 的智能小说创作工具,集成多种 AI 模型,助力作者高效创作
> 基于 Vue 3 + Element Plus 的专业AI小说创作平台,集成先进AI模型,提供完整的创作工具链
[![Vue](https://img.shields.io/badge/Vue-3.3.8-4FC08D?style=flat-square&logo=vue.js)](https://vuejs.org/)
[![Element Plus](https://img.shields.io/badge/Element%20Plus-2.4.2-409EFF?style=flat-square&logo=element)](https://element-plus.org/)
[![Vite](https://img.shields.io/badge/Vite-4.5.0-646CFF?style=flat-square&logo=vite)](https://vitejs.dev/)
[![License](https://img.shields.io/badge/License-MIT-green?style=flat-square)](LICENSE)
<img src=".\image\image-20250611102429512.png" />
<img src=".\image\微信截图_20250618183833.png" />
## 🎉 演示
## 🎉 在线演示
- https://xiezuo.91hub.vip
- 🌐 **演示地址**: https://xiezuo.91hub.vip
- 📱 **支持设备**: 浏览器、PC
## ✨ 项目特色
## ✨ 核心特色
- 🤖 **AI 智能创作** - 集成多种 AI 模型,支持大纲生成、章节续写、人物创建
- 📝 **富文本编辑** - 基于 WangEditor 的专业写作环境
- 🎨 **模板系统** - 内置 7 种小说类型模板,支持自定义模板
- 👥 **人物管理** - 智能人物档案管理,AI 辅助角色设定
- 🌍 **世界观构建** - 系统化的世界观设定工具
- 📊 **写作统计** - 实时字数统计、阅读时间估算
- 💾 **数据管理** - 本地存储,支持导入导出
- 🎯 **语料库** - 个人写作素材库管理
### 🌈 **产品声明**
- 91写作为纯前端项目,所有数据均保存在本地,不提供云同步服务
- 本项目大模型API全部为用户自行配置,不提供公共API服务
- 本项目内置提示词均为预设演示,可以配置自己的提示词库来使用
## 🚀 功能亮点
### 🤖 **智能创作引擎**
- 支持集成主流AI模型(GPT、Claude、Gemini、DeepSeek等OpenAi格式API
- 上下文感知的智能续写
- 多样化的小说生成算法
- 多模型切换,适应不同创作需求
### 🎭 多样化模板支持
- **玄幻小说** - 修仙异世界,宏大叙事
- **都市言情** - 现代爱情,细腻温馨
- **悬疑推理** - 烧脑情节,逻辑严密
- **科幻未来** - 科技想象,未来探索
- **历史穿越** - 古韵悠长,历史厚重
- **校园青春** - 青春活力,纯真美好
- **武侠江湖** - 豪情万丈,侠义精神
### 🎨 **完整创作工具链**
- 从构思到成文的全流程支持
- 专业的富文本编辑环境
- 智能大纲生成与章节管理
- 实时写作统计与目标跟踪
### 🤖 AI 智能助手
- **大纲生成** - 根据关键词智能生成小说大纲
- **章节续写** - AI 辅助章节内容创作
- **人物生成** - 自动创建角色档案和性格特征
- **世界观构建** - AI 辅助世界观设定创建
- **智能对话** - 与 AI 实时交流创作灵感
### 🌍 **世界观构建系统**
- 复杂世界观模板化管理
- AI辅助世界设定生成
- 格式化模板确保一致性
- 科幻修仙等特殊题材专业支持
### 📊 专业写作工具
- **实时统计** - 字数、段落、阅读时间统计
- **章节管理** - 分章节组织,便于管理长篇作品
- **富文本编辑** - 支持格式化、图片插入等
- **导入导出** - 支持多种格式的文档导入导出
### 📊 **数据管理中心**
- 本地化数据存储
- 分类导入导出功能
- 云端同步(计划中)
- 完整的备份恢复机制
## 🚀 主要功能
### 📖 **小说管理**
- **项目创建**: 多类型小说模板,一键生成项目结构
- **元数据管理**: 标题、封面、简介、标签、状态管理
- **章节编辑**: 专业的写作编辑器,支持Markdown和富文本
- **版本控制**: 自动保存,防止内容丢失
- **统计分析**: 字数统计、阅读时间估算、创作进度
### 🎯 **写作目标**
- **目标设定**: 每日/每周/每月字数目标
- **进度跟踪**: 实时进度监控,完成率可视化
- **连续记录**: 写作天数统计,培养创作习惯
- **成就系统**: 目标完成奖励,激励持续创作
- **数据同步**: 多页面实时数据同步
### 🎭 **动态类型管理**
- **预设类型**: 玄幻、都市、历史、科幻、武侠、言情等
- **自定义类型**: 用户可创建专属小说类型
- **类型配置**: 标签、提示词、示例作品管理
- **使用统计**: 类型使用频率跟踪
- **智能推荐**: 基于使用习惯的类型推荐
### 💬 **智能提示词库**
- **分类管理**: 大纲生成、正文创作、对话场景等
- **专业模板**:
- 基础正文:标准章节内容生成
- 对话生成:以对话为主的内容
- 场景描写:环境氛围渲染
- 动作情节:冲突和动作描述
- 心理描写:内心活动刻画
- **变量系统**: 支持动态变量替换
- **使用统计**: 提示词效果追踪
- **模板导入**: 世界观模板和格式模板一键插入
### 🌟 **世界观构建**
- **复杂设定支持**: 科幻修仙、赛博朋克等复杂世界观
- **模板化管理**:
- 核心设定(技术水平、社会结构、特殊机制)
- 关键元素(重要物品、势力组织、地理环境)
- 故事背景(历史事件、主要冲突、发展趋势)
- **一致性检查**: AI驱动的世界观一致性验证
- **格式化输出**: 标准化的世界观描述格式
### ⚙️ **系统设置**
- **API配置**: 多AI服务商支持,灵活切换
- **数据管理**:
- 分类导出:小说数据、提示词库、类型设置、API配置
- 选择性导入:支持部分数据导入
- 数据概览:存储空间使用情况
- 安全清理:分级数据清理选项
### 📊 **Token计费管理**
- **使用统计**: 实时Token消耗跟踪
- **成本分析**: 按模型、按功能的成本分析
- **预算控制**: 使用限额设置
- **账单详情**: 详细的计费记录
## 🛠️ 技术栈
- **前端框架**: Vue 3.3.8 (Composition API)
- **UI 组件库**: Element Plus 2.4.2
- **状态管理**: Pinia 2.1.7
- **路由管理**: Vue Router 4.2.5
- **构建工具**: Vite 4.5.0
- **富文本编辑器**: WangEditor 5.1.23
- **HTTP 客户端**: Axios 1.6.0
- **代码高亮**: Highlight.js 11.9.0
- **Markdown 解析**: Marked 9.1.6
### 前端框架
- **Vue 3.3.8** - 现代响应式框架 (Composition API)
- **Element Plus 2.4.2** - 企业级UI组件库
- **Vue Router 4.2.5** - 官方路由管理
- **Pinia 2.1.7** - 新一代状态管理
### 开发工具
- **Vite 4.5.0** - 极速构建工具
- **TypeScript** - 类型安全的JavaScript
- **ESLint + Prettier** - 代码质量保证
### 编辑器与工具
- **WangEditor 5.1.23** - 专业富文本编辑器
- **Marked 9.1.6** - Markdown解析器
- **Highlight.js 11.9.0** - 代码高亮
- **Axios 1.6.0** - HTTP客户端
### AI服务集成
- **OpenAI GPT系列** - GPT-3.5/4/4o
- **Anthropic Claude** - Claude-3/3.5/4
- **Google Gemini** - Gemini-1.5/2.0-pro
- **国产大模型** - DeepSeek、通义千问、文心一言等
## 📦 快速开始
### 环境要求
- Node.js >= 16.0.0
- npm >= 8.0.0 或 pnpm >= 7.0.0
### 安装依赖
```bash
# 使用 npm
npm install
Node.js >= 16.0.0
npm >= 8.0.0 或 pnpm >= 7.0.0 (推荐)
```
# 或使用 pnpm (推荐)
### 安装与运行
```bash
# 克隆仓库
git clone https://github.com/your-username/91-writer.git
cd 91-writer
# 安装依赖
pnpm install
```
### 启动开发服务器
```bash
# 使用 npm
npm run dev
# 或使用 pnpm
# 启动开发服务器
pnpm dev
```
### 构建生产版本
```bash
# 使用 npm
npm run build
# 或使用 pnpm
# 构建生产版本
pnpm build
```
## ⚙️ 配置说明
### 首次使用
1. **配置AI服务**: 点击右上角「API配置」,添加您的API密钥
2. **创建项目**: 选择小说类型,输入基本信息
3. **设定目标**: 在写作目标页面创建您的创作计划
4. **开始创作**: 使用AI辅助工具开始您的创作之旅
### API 配置
1. 点击右上角「API配置」按钮
2. 选择 AI 服务提供商(OpenAI、Claude、国产大模型等)
3. 填入 API Key 和相关配置
4. 测试连接确保配置正确
## ⚙️ 配置指南
### 支持的 AI 服务
- OpenAI GPT 系列
- Anthropic Claude
- 国产大模型(DeepSeek、通义千问、文心一言等)
- 自定义 API 接口
- 推荐使用Gemini2.5pro、Claude3.7/4
### AI服务配置
支持的主要AI服务商(仅支持OpenAi接入格式):
## 📖 使用指南
| 服务商 | 模型推荐 | 特点 |
|--------|----------|------|
| OpenAI | GPT-4o, GPT-4-turbo | 通用性强,创作质量高 |
| Anthropic | Claude-3.5-Sonnet | 长文本处理,逻辑性强 |
| Google | Gemini-2.0-pro | 多模态支持,响应速度快 |
| DeepSeek | DeepSeek-V3 | 中文优化,性价比高 |
### 1. 创建新小说
1. 选择合适的小说模板
2. 输入关键词和基本设定
3. 使用 AI 生成大纲
4. 开始章节创作
### 推荐配置
- **新手作者**: Gemini-2.0-pro
- **专业作者**: Claude-3.5-Sonnet + GPT-4o
- **预算有限**: DeepSeek-V3 + 通义千问
- **长篇创作**: Claude-3.5-Sonnet (200K上下文)
### 2. 人物管理
1. 点击「人物管理」添加角色
2. 填写人物基本信息
3. 使用 AI 生成人物特征
4. 在创作中引用人物设定
## 🎨 使用场景
### 3. 世界观构建
1. 添加世界观设定
2. 描述世界规则和背景
3. 使用 AI 完善设定细节
4. 确保创作一致性
### 🌟 **长篇小说创作**
```
1. 选择类型模板 → 2. AI生成大纲 → 3. 章节式创作 → 4. 实时统计跟踪
```
### 4. AI 辅助创作
1. 在编辑器中选择需要续写的位置
2. 点击「AI 续写」按钮
3. AI 会根据上下文生成内容
4. 选择满意的内容插入
### 🚀 **短篇快速创作**
```
1. 设定写作目标 → 2. 使用专业提示词 → 3. AI辅助续写 → 4. 一键导出
```
## 🎯 项目亮点
### 🌍 **复杂世界观构建**
```
1. 世界观模板 → 2. 核心设定填写 → 3. AI完善细节 → 4. 一致性检查
```
### 🧠 智能化程度高
- 深度集成 AI 能力,覆盖创作全流程
- 上下文感知,生成内容更贴合故事发展
- 多模型支持,适应不同创作需求
### 🎯 **目标导向创作**
```
1. 制定写作计划 → 2. 设定日/周/月目标 → 3. 进度实时跟踪 → 4. 成就激励
```
### 🎨 用户体验优秀
- 现代化 UI 设计,操作直观简洁
- 响应式布局,支持多设备使用
- 实时保存,防止创作内容丢失
## 🌟 高级功能
### 🔧 功能完整丰富
- 从构思到成文的完整创作链路
- 专业的写作辅助工具集
- 灵活的模板和设定系统
### 🧠 **AI创作引擎**
- **上下文感知**: 基于前文内容的智能续写
- **风格一致性**: 保持作者独特的写作风格
- **情节连贯性**: 确保故事逻辑的连续性
- **多样化输出**: 提供多个创作方案选择
### 🚀 技术架构先进
- Vue 3 + Composition API,代码组织清晰
- Vite 构建,开发体验流畅
- 模块化设计,易于扩展维护
### 📊 **数据分析**
- **创作习惯分析**: 最佳创作时间、效率统计
- **内容质量评估**: AI驱动的文本质量分析
- **读者反馈集成**: 支持外部反馈数据导入
- **趋势预测**: 基于数据的创作建议
## 🤝 贡献指南
### 🔧 **扩展性**
- **插件系统**: 支持第三方插件扩展
- **API开放**: 提供开发者API接口
- **主题定制**: 支持界面主题自定义
- **云端同步**: 多设备数据同步(开发中)
我们欢迎所有形式的贡献!
## 🤝 社区与支持
### 如何贡献
1. Fork 本仓库
2. 创建特性分支 (`git checkout -b feature/AmazingFeature`)
3. 提交更改 (`git commit -m 'Add some AmazingFeature'`)
4. 推送到分支 (`git push origin feature/AmazingFeature`)
5. 开启 Pull Request
### 贡献类型
- 🐛 Bug 修复
- ✨ 新功能开发
- 📝 文档改进
- 🎨 UI/UX 优化
- 🔧 代码重构
- 🌐 国际化支持
## 📄 开源协议
本项目基于 [MIT License](LICENSE) 开源协议。
## 🙏 致谢
感谢以下开源项目的支持:
- [Vue.js](https://vuejs.org/) - 渐进式 JavaScript 框架
- [Element Plus](https://element-plus.org/) - Vue 3 组件库
- [WangEditor](https://www.wangeditor.com/) - 富文本编辑器
- [Vite](https://vitejs.dev/) - 下一代前端构建工具
## 📞 联系我们
- 🐛 [提交 Issue](../../issues)
- 💡 [功能建议](../../discussions)
- 📧 邮箱:[1090879115@qq.com]
- 🐧 QQ群
### 📞 联系方式
- 🐛 **Bug反馈**: [GitHub Issues](../../issues)
- 💡 **功能建议**: [GitHub Discussions](../../discussions)
- 📧 **邮箱支持**: 1090879115@qq.com
- 🐧 **QQ交流群**:
<img src=".\image\qrcode_1749609318081.jpg" style="zoom: 25%;" />
### 🤝 贡献指南
我们欢迎所有形式的贡献!
**贡献类型**:
- 🐛 Bug修复与问题报告
- ✨ 新功能开发与建议
- 📝 文档完善与翻译
- 🎨 UI/UX设计优化
- 🔧 代码重构与性能优化
- 🌐 国际化支持
**贡献流程**:
1. Fork项目 → 2. 创建分支 → 3. 提交更改 → 4. 发起PR → 5. 代码审核
### 🏆 贡献者名单
感谢所有为91写作做出贡献的开发者!
## 📈 更新日志
### 🎯 **v0.2.0**
- [ ] 所有0.1.0的功能重构
- [ ] 新增提示词库管理
- [ ] 新增分类管理
- [ ] 新增写作目标设置
- [ ] 新增API调用token计费管理
- [ ] 新增首页仪表盘
### 🎯 **v0.1.0**
- [ ] 基础编辑器功能
- [ ] 基础Ai生成正文
- [ ] 基础写作模版、小说大纲智能生成
- [ ] 人物管理、世界观设定、写作进度
- [ ] 灵感记录集
- [ ] 文章摘要
- [ ] 文章统计
- [ ] 语料库
- [ ] API配置
## 📄 开源协议
本项目基于 **MIT License** 开源协议发布。详见 [LICENSE](LICENSE) 文件。
## 🙏 致谢
感谢以下优秀的开源项目:
| 项目 | 作用 | 官网 |
|------|------|------|
| Vue.js | 前端框架 | https://vuejs.org/ |
| Element Plus | UI组件库 | https://element-plus.org/ |
| Vite | 构建工具 | https://vitejs.dev/ |
| WangEditor | 富文本编辑器 | https://www.wangeditor.com/ |
特别感谢所有AI服务提供商为创作者提供的强大技术支持。
---
⭐ 如果这个项目对你有帮助,请给我们一个 Star!
<div align="center">
🚀 让 AI 助力你的创作之旅!
**🌟 如果这个项目对您有帮助,请给个Star支持一下!**
[![Star History Chart](https://api.star-history.com/svg?repos=your-username/91-writer&type=Date)](https://star-history.com/#your-username/91-writer&Date)
</div>
---
*最后更新: 2025年6月18日*
Binary file not shown.

After

Width:  |  Height:  |  Size: 371 KiB

+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AI小说作工具</title>
<title>91写作 - AI智能小说作工具</title>
</head>
<body>
<div id="app"></div>
+3819
View File
File diff suppressed because it is too large Load Diff
+821
View File
@@ -0,0 +1,821 @@
<template>
<div class="backup-manager">
<div class="backup-header">
<h3>💾 备份管理</h3>
<div class="header-actions">
<el-button type="primary" @click="createBackup">
<el-icon><FolderAdd /></el-icon>
创建备份
</el-button>
<el-button @click="importBackup">
<el-icon><Upload /></el-icon>
导入备份
</el-button>
</div>
</div>
<!-- 备份统计 -->
<div class="backup-stats">
<el-row :gutter="16">
<el-col :span="6">
<div class="stat-card">
<div class="stat-icon">
<el-icon><Files /></el-icon>
</div>
<div class="stat-content">
<div class="stat-number">{{ backups.length }}</div>
<div class="stat-label">总备份数</div>
</div>
</div>
</el-col>
<el-col :span="6">
<div class="stat-card">
<div class="stat-icon">
<el-icon><Clock /></el-icon>
</div>
<div class="stat-content">
<div class="stat-number">{{ lastBackupDays }}</div>
<div class="stat-label">距上次备份</div>
</div>
</div>
</el-col>
<el-col :span="6">
<div class="stat-card">
<div class="stat-icon">
<el-icon><Coin /></el-icon>
</div>
<div class="stat-content">
<div class="stat-number">{{ totalBackupSize }}</div>
<div class="stat-label">总大小</div>
</div>
</div>
</el-col>
<el-col :span="6">
<div class="stat-card">
<div class="stat-icon">
<el-icon><Setting /></el-icon>
</div>
<div class="stat-content">
<div class="stat-number">{{ autoBackupEnabled ? '开启' : '关闭' }}</div>
<div class="stat-label">自动备份</div>
</div>
</div>
</el-col>
</el-row>
</div>
<!-- 自动备份设置 -->
<el-card class="auto-backup-card" shadow="hover">
<template #header>
<div class="card-header">
<span> 自动备份设置</span>
<el-switch
v-model="autoBackupEnabled"
@change="toggleAutoBackup"
active-text="开启"
inactive-text="关闭"
/>
</div>
</template>
<div v-if="autoBackupEnabled" class="auto-backup-settings">
<el-row :gutter="16">
<el-col :span="8">
<div class="setting-item">
<label>备份频率</label>
<el-select v-model="autoBackupFrequency" @change="saveAutoBackupSettings">
<el-option label="每小时" value="hourly" />
<el-option label="每天" value="daily" />
<el-option label="每周" value="weekly" />
</el-select>
</div>
</el-col>
<el-col :span="8">
<div class="setting-item">
<label>保留数量</label>
<el-input-number
v-model="maxBackupCount"
:min="1"
:max="50"
@change="saveAutoBackupSettings"
/>
</div>
</el-col>
<el-col :span="8">
<div class="setting-item">
<label>下次备份</label>
<div class="next-backup-time">{{ nextBackupTime }}</div>
</div>
</el-col>
</el-row>
</div>
</el-card>
<!-- 备份列表 -->
<div class="backup-list">
<div class="list-header">
<h4>📋 备份列表</h4>
<div class="list-actions">
<el-input
v-model="searchKeyword"
placeholder="搜索备份..."
size="small"
style="width: 200px;"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
<el-button size="small" @click="cleanupOldBackups">
<el-icon><Delete /></el-icon>
清理旧备份
</el-button>
</div>
</div>
<div v-if="filteredBackups.length === 0" class="empty-state">
<el-empty description="暂无备份文件" />
</div>
<div v-else class="backups-table">
<el-table :data="filteredBackups" stripe>
<el-table-column prop="name" label="备份名称" min-width="200">
<template #default="{ row }">
<div class="backup-name">
<el-icon><Document /></el-icon>
<span>{{ row.name }}</span>
<el-tag v-if="row.type === 'auto'" type="info" size="small">自动</el-tag>
<el-tag v-else type="primary" size="small">手动</el-tag>
</div>
</template>
</el-table-column>
<el-table-column prop="description" label="描述" min-width="150">
<template #default="{ row }">
<span>{{ row.description || '无描述' }}</span>
</template>
</el-table-column>
<el-table-column prop="size" label="大小" width="100">
<template #default="{ row }">
<span>{{ formatFileSize(row.size) }}</span>
</template>
</el-table-column>
<el-table-column prop="createdAt" label="创建时间" width="180">
<template #default="{ row }">
<span>{{ formatDateTime(row.createdAt) }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="200">
<template #default="{ row }">
<div class="table-actions">
<el-button type="text" size="small" @click="restoreBackup(row)">
<el-icon><RefreshRight /></el-icon>
恢复
</el-button>
<el-button type="text" size="small" @click="downloadBackup(row)">
<el-icon><Download /></el-icon>
下载
</el-button>
<el-button type="text" size="small" @click="viewBackupDetails(row)">
<el-icon><View /></el-icon>
详情
</el-button>
<el-button type="text" size="small" @click="deleteBackup(row.id)" class="danger">
<el-icon><Delete /></el-icon>
删除
</el-button>
</div>
</template>
</el-table-column>
</el-table>
</div>
</div>
<!-- 创建备份对话框 -->
<el-dialog v-model="showCreateBackupDialog" title="创建备份" width="500px">
<el-form :model="backupForm" :rules="backupRules" ref="backupFormRef" label-width="80px">
<el-form-item label="备份名称" prop="name">
<el-input v-model="backupForm.name" placeholder="输入备份名称" />
</el-form-item>
<el-form-item label="备份描述">
<el-input
v-model="backupForm.description"
type="textarea"
:rows="3"
placeholder="描述这次备份的内容或原因"
/>
</el-form-item>
<el-form-item label="备份内容">
<el-checkbox-group v-model="backupForm.content">
<el-checkbox label="novel">小说内容</el-checkbox>
<el-checkbox label="chapters">章节管理</el-checkbox>
<el-checkbox label="templates">模板数据</el-checkbox>
<el-checkbox label="corpus">语料库</el-checkbox>
<el-checkbox label="characters">角色设定</el-checkbox>
<el-checkbox label="worldSettings">世界观设定</el-checkbox>
<el-checkbox label="goals">写作目标</el-checkbox>
<el-checkbox label="settings">应用设置</el-checkbox>
</el-checkbox-group>
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="showCreateBackupDialog = false">取消</el-button>
<el-button type="primary" @click="confirmCreateBackup" :loading="creating">创建</el-button>
</span>
</template>
</el-dialog>
<!-- 备份详情对话框 -->
<el-dialog v-model="showBackupDetailsDialog" title="备份详情" width="600px">
<div v-if="selectedBackup" class="backup-details">
<div class="detail-section">
<h4>基本信息</h4>
<el-descriptions :column="2" border>
<el-descriptions-item label="备份名称">{{ selectedBackup.name }}</el-descriptions-item>
<el-descriptions-item label="备份类型">
<el-tag :type="selectedBackup.type === 'auto' ? 'info' : 'primary'">
{{ selectedBackup.type === 'auto' ? '自动备份' : '手动备份' }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="文件大小">{{ formatFileSize(selectedBackup.size) }}</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ formatDateTime(selectedBackup.createdAt) }}</el-descriptions-item>
<el-descriptions-item label="描述" :span="2">{{ selectedBackup.description || '无描述' }}</el-descriptions-item>
</el-descriptions>
</div>
<div class="detail-section">
<h4>备份内容</h4>
<div class="content-list">
<div v-for="item in selectedBackup.contentList" :key="item.key" class="content-item">
<el-icon><Document /></el-icon>
<span>{{ item.name }}</span>
<span class="content-size">({{ formatFileSize(item.size) }})</span>
</div>
</div>
</div>
</div>
</el-dialog>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useNovelStore } from '@/stores/novel'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
FolderAdd, Upload, Files, Clock, Coin, Setting, Search, Delete,
Document, RefreshRight, Download, View
} from '@element-plus/icons-vue'
const novelStore = useNovelStore()
// 响应式数据
const backups = ref([])
const searchKeyword = ref('')
const autoBackupEnabled = ref(false)
const autoBackupFrequency = ref('daily')
const maxBackupCount = ref(10)
const showCreateBackupDialog = ref(false)
const showBackupDetailsDialog = ref(false)
const selectedBackup = ref(null)
const creating = ref(false)
const backupFormRef = ref()
const backupForm = ref({
name: '',
description: '',
content: ['novel', 'chapters', 'templates', 'corpus']
})
const backupRules = {
name: [
{ required: true, message: '请输入备份名称', trigger: 'blur' }
]
}
// 计算属性
const filteredBackups = computed(() => {
if (!searchKeyword.value) return backups.value
return backups.value.filter(backup =>
backup.name.toLowerCase().includes(searchKeyword.value.toLowerCase()) ||
(backup.description && backup.description.toLowerCase().includes(searchKeyword.value.toLowerCase()))
)
})
const lastBackupDays = computed(() => {
if (backups.value.length === 0) return '无'
const lastBackup = backups.value[0]
const days = Math.floor((Date.now() - new Date(lastBackup.createdAt)) / (1000 * 60 * 60 * 24))
return days === 0 ? '今天' : `${days}天前`
})
const totalBackupSize = computed(() => {
const total = backups.value.reduce((sum, backup) => sum + backup.size, 0)
return formatFileSize(total)
})
const nextBackupTime = computed(() => {
if (!autoBackupEnabled.value) return '未开启'
// 这里应该根据频率计算下次备份时间
return '2小时后'
})
// 方法
const createBackup = () => {
backupForm.value.name = `备份_${new Date().toLocaleString().replace(/[/:]/g, '-')}`
showCreateBackupDialog.value = true
}
const confirmCreateBackup = async () => {
try {
await backupFormRef.value.validate()
creating.value = true
// 收集要备份的数据
const backupData = {}
const contentList = []
if (backupForm.value.content.includes('novel')) {
backupData.novel = novelStore.currentNovel
contentList.push({ key: 'novel', name: '小说内容', size: new Blob([novelStore.currentNovel || '']).size })
}
if (backupForm.value.content.includes('chapters')) {
const chapters = JSON.parse(localStorage.getItem('novel_chapters') || '[]')
backupData.chapters = chapters
contentList.push({ key: 'chapters', name: '章节管理', size: new Blob([JSON.stringify(chapters)]).size })
}
if (backupForm.value.content.includes('templates')) {
backupData.templates = novelStore.templates
contentList.push({ key: 'templates', name: '模板数据', size: new Blob([JSON.stringify(novelStore.templates)]).size })
}
if (backupForm.value.content.includes('corpus')) {
backupData.corpus = novelStore.corpus
contentList.push({ key: 'corpus', name: '语料库', size: new Blob([JSON.stringify(novelStore.corpus)]).size })
}
if (backupForm.value.content.includes('characters')) {
backupData.characters = novelStore.characters
contentList.push({ key: 'characters', name: '角色设定', size: new Blob([JSON.stringify(novelStore.characters)]).size })
}
if (backupForm.value.content.includes('worldSettings')) {
backupData.worldSettings = novelStore.worldSettings
contentList.push({ key: 'worldSettings', name: '世界观设定', size: new Blob([JSON.stringify(novelStore.worldSettings)]).size })
}
if (backupForm.value.content.includes('goals')) {
const goals = JSON.parse(localStorage.getItem('writingGoals') || '[]')
backupData.goals = goals
contentList.push({ key: 'goals', name: '写作目标', size: new Blob([JSON.stringify(goals)]).size })
}
if (backupForm.value.content.includes('settings')) {
const settings = {
apiConfig: novelStore.apiConfig,
autoBackupSettings: { autoBackupEnabled: autoBackupEnabled.value, autoBackupFrequency: autoBackupFrequency.value, maxBackupCount: maxBackupCount.value }
}
backupData.settings = settings
contentList.push({ key: 'settings', name: '应用设置', size: new Blob([JSON.stringify(settings)]).size })
}
// 创建备份记录
const backup = {
id: Date.now(),
name: backupForm.value.name,
description: backupForm.value.description,
type: 'manual',
size: new Blob([JSON.stringify(backupData)]).size,
data: backupData,
contentList: contentList,
createdAt: new Date()
}
backups.value.unshift(backup)
saveBackups()
showCreateBackupDialog.value = false
resetBackupForm()
ElMessage.success('备份创建成功')
} catch (error) {
console.error('创建备份失败:', error)
ElMessage.error('创建备份失败')
} finally {
creating.value = false
}
}
const restoreBackup = async (backup) => {
try {
await ElMessageBox.confirm(
`确定要恢复备份 "${backup.name}" 吗?这将覆盖当前的数据。`,
'确认恢复',
{ type: 'warning' }
)
const data = backup.data
// 恢复各种数据
if (data.novel) {
novelStore.setCurrentNovel(data.novel)
}
if (data.chapters) {
localStorage.setItem('novel_chapters', JSON.stringify(data.chapters))
}
if (data.templates) {
novelStore.templates = data.templates
}
if (data.corpus) {
novelStore.corpus = data.corpus
}
if (data.characters) {
novelStore.characters = data.characters
}
if (data.worldSettings) {
novelStore.worldSettings = data.worldSettings
}
if (data.goals) {
localStorage.setItem('writingGoals', JSON.stringify(data.goals))
}
if (data.settings) {
if (data.settings.apiConfig) {
Object.assign(novelStore.apiConfig, data.settings.apiConfig)
}
if (data.settings.autoBackupSettings) {
autoBackupEnabled.value = data.settings.autoBackupSettings.autoBackupEnabled
autoBackupFrequency.value = data.settings.autoBackupSettings.autoBackupFrequency
maxBackupCount.value = data.settings.autoBackupSettings.maxBackupCount
}
}
ElMessage.success('备份恢复成功')
} catch {
// 用户取消
}
}
const downloadBackup = (backup) => {
const data = JSON.stringify(backup.data, null, 2)
const blob = new Blob([data], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${backup.name}.json`
a.click()
URL.revokeObjectURL(url)
ElMessage.success('备份下载成功')
}
const viewBackupDetails = (backup) => {
selectedBackup.value = backup
showBackupDetailsDialog.value = true
}
const deleteBackup = async (backupId) => {
try {
await ElMessageBox.confirm('确定要删除这个备份吗?', '确认删除', {
type: 'warning'
})
backups.value = backups.value.filter(backup => backup.id !== backupId)
saveBackups()
ElMessage.success('备份删除成功')
} catch {
// 用户取消
}
}
const importBackup = () => {
const input = document.createElement('input')
input.type = 'file'
input.accept = '.json'
input.onchange = (e) => {
const file = e.target.files[0]
if (file) {
const reader = new FileReader()
reader.onload = (e) => {
try {
const backupData = JSON.parse(e.target.result)
// 创建导入的备份记录
const backup = {
id: Date.now(),
name: `导入_${file.name.replace('.json', '')}`,
description: '从文件导入的备份',
type: 'manual',
size: file.size,
data: backupData,
contentList: Object.keys(backupData).map(key => ({
key,
name: getContentName(key),
size: new Blob([JSON.stringify(backupData[key])]).size
})),
createdAt: new Date()
}
backups.value.unshift(backup)
saveBackups()
ElMessage.success('备份导入成功')
} catch (error) {
ElMessage.error('导入失败,文件格式错误')
}
}
reader.readAsText(file)
}
}
input.click()
}
const cleanupOldBackups = async () => {
try {
await ElMessageBox.confirm(
`确定要清理超过 ${maxBackupCount.value} 个的旧备份吗?`,
'确认清理',
{ type: 'warning' }
)
if (backups.value.length > maxBackupCount.value) {
const removed = backups.value.length - maxBackupCount.value
backups.value = backups.value.slice(0, maxBackupCount.value)
saveBackups()
ElMessage.success(`已清理 ${removed} 个旧备份`)
} else {
ElMessage.info('没有需要清理的备份')
}
} catch {
// 用户取消
}
}
const toggleAutoBackup = (enabled) => {
autoBackupEnabled.value = enabled
saveAutoBackupSettings()
ElMessage.success(enabled ? '自动备份已开启' : '自动备份已关闭')
}
const saveAutoBackupSettings = () => {
const settings = {
autoBackupEnabled: autoBackupEnabled.value,
autoBackupFrequency: autoBackupFrequency.value,
maxBackupCount: maxBackupCount.value
}
localStorage.setItem('auto_backup_settings', JSON.stringify(settings))
}
const loadAutoBackupSettings = () => {
try {
const saved = localStorage.getItem('auto_backup_settings')
if (saved) {
const settings = JSON.parse(saved)
autoBackupEnabled.value = settings.autoBackupEnabled || false
autoBackupFrequency.value = settings.autoBackupFrequency || 'daily'
maxBackupCount.value = settings.maxBackupCount || 10
}
} catch (error) {
console.error('加载自动备份设置失败:', error)
}
}
const resetBackupForm = () => {
backupForm.value = {
name: '',
description: '',
content: ['novel', 'chapters', 'templates', 'corpus']
}
}
const saveBackups = () => {
// 只保存备份元数据,不保存实际数据(太大)
const backupMeta = backups.value.map(backup => ({
...backup,
data: undefined // 移除数据部分
}))
localStorage.setItem('backup_list', JSON.stringify(backupMeta))
}
const loadBackups = () => {
try {
const saved = localStorage.getItem('backup_list')
if (saved) {
backups.value = JSON.parse(saved)
}
} catch (error) {
console.error('加载备份列表失败:', error)
}
}
const formatFileSize = (bytes) => {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
const formatDateTime = (date) => {
if (!date) return ''
return new Date(date).toLocaleString()
}
const getContentName = (key) => {
const nameMap = {
novel: '小说内容',
chapters: '章节管理',
templates: '模板数据',
corpus: '语料库',
characters: '角色设定',
worldSettings: '世界观设定',
goals: '写作目标',
settings: '应用设置'
}
return nameMap[key] || key
}
// 生命周期
onMounted(() => {
loadBackups()
loadAutoBackupSettings()
})
</script>
<style scoped>
.backup-manager {
padding: 20px;
}
.backup-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.header-actions {
display: flex;
gap: 12px;
}
.backup-stats {
margin-bottom: 20px;
}
.stat-card {
display: flex;
align-items: center;
padding: 20px;
background: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
transition: transform 0.2s;
}
.stat-card:hover {
transform: translateY(-2px);
}
.stat-icon {
width: 48px;
height: 48px;
border-radius: 50%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
align-items: center;
justify-content: center;
margin-right: 16px;
font-size: 20px;
color: white;
}
.stat-content {
flex: 1;
}
.stat-number {
font-size: 24px;
font-weight: bold;
color: #333;
margin-bottom: 4px;
}
.stat-label {
font-size: 14px;
color: #666;
}
.auto-backup-card {
margin-bottom: 20px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.auto-backup-settings {
padding-top: 16px;
}
.setting-item {
display: flex;
flex-direction: column;
gap: 8px;
}
.setting-item label {
font-size: 14px;
color: #606266;
font-weight: 500;
}
.next-backup-time {
font-size: 14px;
color: #409eff;
font-weight: 500;
}
.list-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.list-actions {
display: flex;
gap: 12px;
align-items: center;
}
.backup-name {
display: flex;
align-items: center;
gap: 8px;
}
.table-actions {
display: flex;
gap: 8px;
}
.table-actions .danger {
color: #f56c6c;
}
.backup-details {
max-height: 500px;
overflow-y: auto;
}
.detail-section {
margin-bottom: 24px;
}
.detail-section h4 {
margin-bottom: 12px;
color: #303133;
}
.content-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.content-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: #f8f9fa;
border-radius: 4px;
}
.content-size {
margin-left: auto;
font-size: 12px;
color: #909399;
}
.empty-state {
text-align: center;
padding: 40px;
color: #909399;
}
</style>
+628
View File
@@ -0,0 +1,628 @@
<template>
<div class="chapter-manager">
<!-- 章节列表 -->
<div class="chapter-list-section">
<div class="section-header">
<h3>📖 章节管理</h3>
<div class="header-actions">
<el-button type="primary" size="small" @click="showAddChapterDialog = true">
<el-icon><Plus /></el-icon>
新增章节
</el-button>
<el-button size="small" @click="exportChapters" :disabled="chapters.length === 0">
<el-icon><Download /></el-icon>
导出章节
</el-button>
<el-button size="small" @click="importChapters">
<el-icon><Upload /></el-icon>
导入章节
</el-button>
</div>
</div>
<!-- 章节统计 -->
<div class="chapter-stats">
<el-row :gutter="16">
<el-col :span="6">
<div class="stat-item">
<div class="stat-number">{{ chapters.length }}</div>
<div class="stat-label">总章节数</div>
</div>
</el-col>
<el-col :span="6">
<div class="stat-item">
<div class="stat-number">{{ completedChapters }}</div>
<div class="stat-label">已完成</div>
</div>
</el-col>
<el-col :span="6">
<div class="stat-item">
<div class="stat-number">{{ totalWords }}</div>
<div class="stat-label">总字数</div>
</div>
</el-col>
<el-col :span="6">
<div class="stat-item">
<div class="stat-number">{{ Math.round(completionRate) }}%</div>
<div class="stat-label">完成率</div>
</div>
</el-col>
</el-row>
</div>
<!-- 章节列表 -->
<div class="chapters-container">
<div v-if="chapters.length === 0" class="empty-state">
<el-empty description="暂无章节,点击新增章节开始创作" />
</div>
<div v-else class="chapters-grid">
<div
v-for="(chapter, index) in chapters"
:key="chapter.id"
class="chapter-card"
:class="{ 'selected': selectedChapter?.id === chapter.id }"
@click="selectChapter(chapter)"
>
<div class="chapter-header">
<div class="chapter-number">{{ index + 1 }}</div>
<div class="chapter-actions">
<el-dropdown trigger="click">
<el-button type="text" size="small">
<el-icon><MoreFilled /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="editChapter(chapter)">
<el-icon><Edit /></el-icon>
编辑
</el-dropdown-item>
<el-dropdown-item @click="duplicateChapter(chapter)">
<el-icon><CopyDocument /></el-icon>
复制
</el-dropdown-item>
<el-dropdown-item @click="moveChapterUp(index)" :disabled="index === 0">
<el-icon><ArrowUp /></el-icon>
上移
</el-dropdown-item>
<el-dropdown-item @click="moveChapterDown(index)" :disabled="index === chapters.length - 1">
<el-icon><ArrowDown /></el-icon>
下移
</el-dropdown-item>
<el-dropdown-item divided @click="deleteChapter(chapter.id)">
<el-icon><Delete /></el-icon>
删除
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
<div class="chapter-title">{{ chapter.title }}</div>
<div class="chapter-summary">{{ chapter.summary || '暂无简介' }}</div>
<div class="chapter-meta">
<div class="meta-item">
<el-icon><Document /></el-icon>
<span>{{ chapter.wordCount || 0 }} </span>
</div>
<div class="meta-item">
<el-icon><Clock /></el-icon>
<span>{{ formatDate(chapter.updatedAt) }}</span>
</div>
<div class="meta-item">
<el-tag
:type="chapter.status === 'completed' ? 'success' : chapter.status === 'writing' ? 'warning' : 'info'"
size="small"
>
{{ getStatusText(chapter.status) }}
</el-tag>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 章节详情 -->
<div v-if="selectedChapter" class="chapter-detail-section">
<el-card shadow="hover">
<template #header>
<div class="detail-header">
<span>{{ selectedChapter.title }}</span>
<div class="detail-actions">
<el-button type="primary" size="small" @click="generateChapterContent">
<el-icon><MagicStick /></el-icon>
AI生成内容
</el-button>
<el-button size="small" @click="addToEditor" :disabled="!selectedChapter.content">
<el-icon><EditPen /></el-icon>
添加到编辑器
</el-button>
</div>
</div>
</template>
<div class="chapter-content">
<div class="content-section">
<h4>章节大纲</h4>
<p>{{ selectedChapter.outline || '暂无大纲' }}</p>
</div>
<div class="content-section" v-if="selectedChapter.content">
<h4>章节内容</h4>
<div class="content-preview">{{ selectedChapter.content }}</div>
</div>
<div class="content-section" v-if="selectedChapter.notes">
<h4>创作笔记</h4>
<p>{{ selectedChapter.notes }}</p>
</div>
</div>
</el-card>
</div>
<!-- 新增/编辑章节对话框 -->
<el-dialog
v-model="showAddChapterDialog"
:title="editingChapter ? '编辑章节' : '新增章节'"
width="600px"
>
<el-form :model="chapterForm" :rules="chapterRules" ref="chapterFormRef" label-width="80px">
<el-form-item label="章节标题" prop="title">
<el-input v-model="chapterForm.title" placeholder="请输入章节标题" />
</el-form-item>
<el-form-item label="章节简介" prop="summary">
<el-input
v-model="chapterForm.summary"
type="textarea"
:rows="3"
placeholder="请输入章节简介"
/>
</el-form-item>
<el-form-item label="章节大纲" prop="outline">
<el-input
v-model="chapterForm.outline"
type="textarea"
:rows="4"
placeholder="请输入章节大纲"
/>
</el-form-item>
<el-form-item label="创作笔记">
<el-input
v-model="chapterForm.notes"
type="textarea"
:rows="3"
placeholder="记录创作想法和注意事项"
/>
</el-form-item>
<el-form-item label="章节状态">
<el-select v-model="chapterForm.status" placeholder="选择章节状态">
<el-option label="计划中" value="planned" />
<el-option label="写作中" value="writing" />
<el-option label="已完成" value="completed" />
</el-select>
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="showAddChapterDialog = false">取消</el-button>
<el-button type="primary" @click="saveChapter">保存</el-button>
</span>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useNovelStore } from '@/stores/novel'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
Plus, Download, Upload, MoreFilled, Edit, CopyDocument,
ArrowUp, ArrowDown, Delete, Document, Clock, MagicStick,
EditPen
} from '@element-plus/icons-vue'
const novelStore = useNovelStore()
// 响应式数据
const chapters = ref([])
const selectedChapter = ref(null)
const showAddChapterDialog = ref(false)
const editingChapter = ref(null)
const chapterFormRef = ref()
const chapterForm = ref({
title: '',
summary: '',
outline: '',
notes: '',
status: 'planned'
})
const chapterRules = {
title: [
{ required: true, message: '请输入章节标题', trigger: 'blur' }
]
}
// 计算属性
const completedChapters = computed(() => {
return chapters.value.filter(chapter => chapter.status === 'completed').length
})
const totalWords = computed(() => {
return chapters.value.reduce((total, chapter) => total + (chapter.wordCount || 0), 0)
})
const completionRate = computed(() => {
if (chapters.value.length === 0) return 0
return (completedChapters.value / chapters.value.length) * 100
})
// 方法
const selectChapter = (chapter) => {
selectedChapter.value = chapter
}
const editChapter = (chapter) => {
editingChapter.value = chapter
chapterForm.value = { ...chapter }
showAddChapterDialog.value = true
}
const duplicateChapter = (chapter) => {
const newChapter = {
...chapter,
id: Date.now(),
title: chapter.title + ' (副本)',
status: 'planned',
createdAt: new Date(),
updatedAt: new Date()
}
chapters.value.push(newChapter)
saveChapters()
ElMessage.success('章节复制成功')
}
const moveChapterUp = (index) => {
if (index > 0) {
const temp = chapters.value[index]
chapters.value[index] = chapters.value[index - 1]
chapters.value[index - 1] = temp
saveChapters()
}
}
const moveChapterDown = (index) => {
if (index < chapters.value.length - 1) {
const temp = chapters.value[index]
chapters.value[index] = chapters.value[index + 1]
chapters.value[index + 1] = temp
saveChapters()
}
}
const deleteChapter = async (chapterId) => {
try {
await ElMessageBox.confirm('确定要删除这个章节吗?', '确认删除', {
type: 'warning'
})
chapters.value = chapters.value.filter(chapter => chapter.id !== chapterId)
if (selectedChapter.value?.id === chapterId) {
selectedChapter.value = null
}
saveChapters()
ElMessage.success('章节删除成功')
} catch {
// 用户取消删除
}
}
const saveChapter = async () => {
try {
await chapterFormRef.value.validate()
if (editingChapter.value) {
// 编辑现有章节
const index = chapters.value.findIndex(c => c.id === editingChapter.value.id)
if (index !== -1) {
chapters.value[index] = {
...chapters.value[index],
...chapterForm.value,
updatedAt: new Date()
}
}
} else {
// 新增章节
const newChapter = {
id: Date.now(),
...chapterForm.value,
wordCount: 0,
createdAt: new Date(),
updatedAt: new Date()
}
chapters.value.push(newChapter)
}
saveChapters()
showAddChapterDialog.value = false
editingChapter.value = null
resetForm()
ElMessage.success(editingChapter.value ? '章节更新成功' : '章节创建成功')
} catch (error) {
console.error('保存章节失败:', error)
}
}
const resetForm = () => {
chapterForm.value = {
title: '',
summary: '',
outline: '',
notes: '',
status: 'planned'
}
}
const generateChapterContent = async () => {
if (!selectedChapter.value) return
try {
// 这里调用AI生成内容的逻辑
ElMessage.info('AI内容生成功能开发中...')
} catch (error) {
ElMessage.error('生成内容失败')
}
}
const addToEditor = () => {
if (selectedChapter.value?.content) {
novelStore.setCurrentNovel(selectedChapter.value.content)
ElMessage.success('章节内容已添加到编辑器')
}
}
const exportChapters = () => {
const data = JSON.stringify(chapters.value, null, 2)
const blob = new Blob([data], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `chapters_${new Date().toISOString().split('T')[0]}.json`
a.click()
URL.revokeObjectURL(url)
ElMessage.success('章节导出成功')
}
const importChapters = () => {
const input = document.createElement('input')
input.type = 'file'
input.accept = '.json'
input.onchange = (e) => {
const file = e.target.files[0]
if (file) {
const reader = new FileReader()
reader.onload = (e) => {
try {
const importedChapters = JSON.parse(e.target.result)
chapters.value = [...chapters.value, ...importedChapters]
saveChapters()
ElMessage.success('章节导入成功')
} catch (error) {
ElMessage.error('导入失败,文件格式错误')
}
}
reader.readAsText(file)
}
}
input.click()
}
const saveChapters = () => {
localStorage.setItem('novel_chapters', JSON.stringify(chapters.value))
}
const loadChapters = () => {
try {
const saved = localStorage.getItem('novel_chapters')
if (saved) {
chapters.value = JSON.parse(saved)
}
} catch (error) {
console.error('加载章节失败:', error)
}
}
const formatDate = (date) => {
if (!date) return ''
return new Date(date).toLocaleDateString()
}
const getStatusText = (status) => {
const statusMap = {
planned: '计划中',
writing: '写作中',
completed: '已完成'
}
return statusMap[status] || '未知'
}
// 生命周期
onMounted(() => {
loadChapters()
})
</script>
<style scoped>
.chapter-manager {
display: flex;
gap: 20px;
height: 100%;
}
.chapter-list-section {
flex: 1;
min-width: 0;
}
.chapter-detail-section {
width: 400px;
flex-shrink: 0;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.header-actions {
display: flex;
gap: 8px;
}
.chapter-stats {
margin-bottom: 20px;
}
.stat-item {
text-align: center;
padding: 16px;
background: #f8f9fa;
border-radius: 8px;
}
.stat-number {
font-size: 24px;
font-weight: bold;
color: #409eff;
margin-bottom: 4px;
}
.stat-label {
font-size: 14px;
color: #666;
}
.chapters-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 16px;
}
.chapter-card {
border: 1px solid #e4e7ed;
border-radius: 8px;
padding: 16px;
cursor: pointer;
transition: all 0.3s;
background: white;
}
.chapter-card:hover {
border-color: #409eff;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
}
.chapter-card.selected {
border-color: #409eff;
background: #f0f9ff;
}
.chapter-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.chapter-number {
font-size: 12px;
color: #909399;
background: #f4f4f5;
padding: 2px 8px;
border-radius: 4px;
}
.chapter-title {
font-size: 16px;
font-weight: bold;
margin-bottom: 8px;
color: #303133;
}
.chapter-summary {
font-size: 14px;
color: #606266;
margin-bottom: 12px;
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.chapter-meta {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 12px;
color: #909399;
}
.meta-item {
display: flex;
align-items: center;
gap: 4px;
}
.detail-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.detail-actions {
display: flex;
gap: 8px;
}
.chapter-content {
max-height: 600px;
overflow-y: auto;
}
.content-section {
margin-bottom: 20px;
}
.content-section h4 {
margin-bottom: 8px;
color: #303133;
}
.content-preview {
background: #f8f9fa;
padding: 12px;
border-radius: 4px;
max-height: 200px;
overflow-y: auto;
line-height: 1.6;
}
.empty-state {
text-align: center;
padding: 40px;
}
.mr-2 {
margin-right: 8px;
}
</style>
+950
View File
@@ -0,0 +1,950 @@
<template>
<div class="writing-goals">
<div class="goals-header">
<h3>🎯 写作目标</h3>
<el-button type="primary" size="small" @click="showAddGoalDialog = true">
<el-icon><Plus /></el-icon>
新增目标
</el-button>
</div>
<!-- 目标统计概览 -->
<div class="goals-overview">
<el-row :gutter="16">
<el-col :span="6">
<div class="overview-card">
<div class="card-icon daily">
<el-icon><Calendar /></el-icon>
</div>
<div class="card-content">
<div class="card-title">今日目标</div>
<div class="card-value">{{ todayProgress.current }}/{{ todayProgress.target }}</div>
<div class="card-subtitle">{{ todayProgress.unit }}</div>
</div>
</div>
</el-col>
<el-col :span="6">
<div class="overview-card">
<div class="card-icon weekly">
<el-icon><Clock /></el-icon>
</div>
<div class="card-content">
<div class="card-title">本周目标</div>
<div class="card-value">{{ weeklyProgress.current }}/{{ weeklyProgress.target }}</div>
<div class="card-subtitle">{{ weeklyProgress.unit }}</div>
</div>
</div>
</el-col>
<el-col :span="6">
<div class="overview-card">
<div class="card-icon monthly">
<el-icon><TrendCharts /></el-icon>
</div>
<div class="card-content">
<div class="card-title">本月目标</div>
<div class="card-value">{{ monthlyProgress.current }}/{{ monthlyProgress.target }}</div>
<div class="card-subtitle">{{ monthlyProgress.unit }}</div>
</div>
</div>
</el-col>
<el-col :span="6">
<div class="overview-card">
<div class="card-icon streak">
<el-icon><Trophy /></el-icon>
</div>
<div class="card-content">
<div class="card-title">连续天数</div>
<div class="card-value">{{ currentStreak }}</div>
<div class="card-subtitle"></div>
</div>
</div>
</el-col>
</el-row>
</div>
<!-- 活跃目标列表 -->
<div class="active-goals">
<div class="active-goals-header">
<h4>📋 活跃目标</h4>
<div class="goals-controls">
<el-button
type="text"
size="small"
@click="toggleSortMode"
:class="{ 'sort-active': sortMode }"
>
<el-icon><Rank /></el-icon>
{{ sortMode ? '完成排序' : '调整排序' }}
</el-button>
</div>
</div>
<div v-if="activeGoals.length === 0" class="empty-state">
<el-empty description="暂无活跃目标,创建一个开始吧!" />
</div>
<div v-else class="goals-list">
<div v-if="sortMode" class="sort-tip">
<el-alert
title="拖拽目标卡片可以调整在首页的显示顺序"
type="info"
:closable="false"
show-icon
/>
</div>
<transition-group
name="list"
tag="div"
class="sortable-goals"
>
<div
v-for="(goal, index) in activeGoals"
:key="goal.id"
class="goal-item"
:class="{ 'sortable': sortMode }"
:draggable="sortMode"
@dragstart="onDragStart($event, index)"
@dragover="onDragOver"
@drop="onDrop($event, index)"
>
<div class="goal-header">
<div class="goal-info">
<span class="goal-title">{{ goal.title }}</span>
<el-tag :type="getGoalTypeColor(goal.type)" size="small">{{ getGoalTypeText(goal.type) }}</el-tag>
</div>
<div class="goal-actions">
<el-button type="text" size="small" @click="updateProgress(goal)">
<el-icon><Edit /></el-icon>
更新进度
</el-button>
<el-dropdown trigger="click">
<el-button type="text" size="small">
<el-icon><MoreFilled /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="editGoal(goal)">
<el-icon><Edit /></el-icon>
编辑目标
</el-dropdown-item>
<el-dropdown-item @click="pauseGoal(goal)">
<el-icon><VideoPause /></el-icon>
暂停目标
</el-dropdown-item>
<el-dropdown-item divided @click="deleteGoal(goal.id)">
<el-icon><Delete /></el-icon>
删除目标
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
<div class="goal-description">{{ goal.description }}</div>
<div class="goal-progress">
<div class="progress-info">
<span>{{ goal.currentValue }}/{{ goal.targetValue }} {{ goal.unit }}</span>
<span class="progress-percentage">{{ Math.round((goal.currentValue / goal.targetValue) * 100) }}%</span>
</div>
<el-progress
:percentage="Math.round((goal.currentValue / goal.targetValue) * 100)"
:status="goal.currentValue >= goal.targetValue ? 'success' : undefined"
/>
</div>
<div class="goal-meta">
<div class="meta-item">
<el-icon><Calendar /></el-icon>
<span>{{ formatDateRange(goal.startDate, goal.endDate) }}</span>
</div>
<div class="meta-item">
<el-icon><Clock /></el-icon>
<span>剩余 {{ getRemainingDays(goal.endDate) }} </span>
</div>
</div>
</div>
</transition-group>
</div>
</div>
<!-- 历史目标 -->
<div class="historical-goals">
<el-collapse>
<el-collapse-item title="📈 历史目标" name="history">
<div v-if="completedGoals.length === 0" class="empty-state">
<p>暂无已完成的目标</p>
</div>
<div v-else class="goals-list">
<div v-for="goal in completedGoals" :key="goal.id" class="goal-item completed">
<div class="goal-header">
<div class="goal-info">
<span class="goal-title">{{ goal.title }}</span>
<el-tag type="success" size="small">已完成</el-tag>
</div>
<div class="completion-date">
完成于 {{ formatDate(goal.completedAt) }}
</div>
</div>
<div class="goal-description">{{ goal.description }}</div>
<div class="goal-result">
最终完成{{ goal.currentValue }}/{{ goal.targetValue }} {{ goal.unit }}
({{ Math.round((goal.currentValue / goal.targetValue) * 100) }}%)
</div>
</div>
</div>
</el-collapse-item>
</el-collapse>
</div>
<!-- 新增/编辑目标对话框 -->
<el-dialog
v-model="showAddGoalDialog"
:title="editingGoal ? '编辑目标' : '新增写作目标'"
width="500px"
>
<el-form :model="goalForm" :rules="goalRules" ref="goalFormRef" label-width="80px">
<el-form-item label="目标标题" prop="title">
<el-input v-model="goalForm.title" placeholder="例如:每日写作1000字" />
</el-form-item>
<el-form-item label="目标类型" prop="type">
<el-select v-model="goalForm.type" placeholder="选择目标类型">
<el-option label="每日目标" value="daily" />
<el-option label="每周目标" value="weekly" />
<el-option label="每月目标" value="monthly" />
<el-option label="自定义期间" value="custom" />
</el-select>
</el-form-item>
<el-form-item label="目标描述">
<el-input
v-model="goalForm.description"
type="textarea"
:rows="2"
placeholder="描述你的写作目标"
/>
</el-form-item>
<el-form-item label="目标数值" prop="targetValue">
<el-input-number
v-model="goalForm.targetValue"
:min="1"
:max="100000"
placeholder="目标数值"
/>
</el-form-item>
<el-form-item label="计量单位" prop="unit">
<el-select v-model="goalForm.unit" placeholder="选择单位">
<el-option label="字" value="字" />
<el-option label="页" value="页" />
<el-option label="章节" value="章节" />
<el-option label="小时" value="小时" />
</el-select>
</el-form-item>
<el-form-item v-if="goalForm.type === 'custom'" label="时间范围" prop="dateRange">
<el-date-picker
v-model="goalForm.dateRange"
type="daterange"
range-separator=""
start-placeholder="开始日期"
end-placeholder="结束日期"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
/>
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="showAddGoalDialog = false">取消</el-button>
<el-button type="primary" @click="saveGoal">保存</el-button>
</span>
</template>
</el-dialog>
<!-- 更新进度对话框 -->
<el-dialog v-model="showProgressDialog" title="更新进度" width="400px">
<el-form :model="progressForm" label-width="80px">
<el-form-item label="当前进度">
<el-input-number
v-model="progressForm.value"
:min="0"
:max="progressForm.maxValue"
/>
<span class="ml-2">{{ progressForm.unit }}</span>
</el-form-item>
<el-form-item label="进度说明">
<el-input
v-model="progressForm.note"
type="textarea"
:rows="2"
placeholder="记录今天的写作情况"
/>
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="showProgressDialog = false">取消</el-button>
<el-button type="primary" @click="saveProgress">保存</el-button>
</span>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
Plus, Calendar, Clock, TrendCharts, Trophy, Edit, MoreFilled,
VideoPause, Delete, Rank
} from '@element-plus/icons-vue'
// 响应式数据
const goals = ref([])
const showAddGoalDialog = ref(false)
const showProgressDialog = ref(false)
const editingGoal = ref(null)
const updatingGoal = ref(null)
const goalFormRef = ref()
// 排序相关
const sortMode = ref(false)
const draggedIndex = ref(null)
const goalForm = ref({
title: '',
type: 'daily',
description: '',
targetValue: 1000,
unit: '字',
dateRange: null
})
const progressForm = ref({
value: 0,
maxValue: 0,
unit: '',
note: ''
})
const goalRules = {
title: [
{ required: true, message: '请输入目标标题', trigger: 'blur' }
],
type: [
{ required: true, message: '请选择目标类型', trigger: 'change' }
],
targetValue: [
{ required: true, message: '请输入目标数值', trigger: 'blur' }
],
unit: [
{ required: true, message: '请选择计量单位', trigger: 'change' }
]
}
// 计算属性
const activeGoals = computed(() => {
const active = goals.value.filter(goal => goal.status === 'active')
// 按优先级排序(priority字段,数字越小优先级越高),如果没有priority则按创建时间排序
return active.sort((a, b) => {
if (a.priority !== undefined && b.priority !== undefined) {
return a.priority - b.priority
}
if (a.priority !== undefined) return -1
if (b.priority !== undefined) return 1
return new Date(a.createdAt || 0) - new Date(b.createdAt || 0)
})
})
const completedGoals = computed(() => {
return goals.value.filter(goal => goal.status === 'completed')
})
const todayProgress = computed(() => {
const dailyGoals = activeGoals.value.filter(goal => goal.type === 'daily')
if (dailyGoals.length === 0) return { current: 0, target: 0, unit: '字' }
const goal = dailyGoals[0]
return {
current: goal.currentValue,
target: goal.targetValue,
unit: goal.unit
}
})
const weeklyProgress = computed(() => {
const weeklyGoals = activeGoals.value.filter(goal => goal.type === 'weekly')
if (weeklyGoals.length === 0) return { current: 0, target: 0, unit: '字' }
const goal = weeklyGoals[0]
return {
current: goal.currentValue,
target: goal.targetValue,
unit: goal.unit
}
})
const monthlyProgress = computed(() => {
const monthlyGoals = activeGoals.value.filter(goal => goal.type === 'monthly')
if (monthlyGoals.length === 0) return { current: 0, target: 0, unit: '字' }
const goal = monthlyGoals[0]
return {
current: goal.currentValue,
target: goal.targetValue,
unit: goal.unit
}
})
const currentStreak = computed(() => {
// 计算连续完成目标的天数
return 7 // 示例数据
})
// 方法
const editGoal = (goal) => {
editingGoal.value = goal
goalForm.value = {
title: goal.title,
type: goal.type,
description: goal.description,
targetValue: goal.targetValue,
unit: goal.unit,
dateRange: goal.type === 'custom' ? [goal.startDate, goal.endDate] : null
}
showAddGoalDialog.value = true
}
const updateProgress = (goal) => {
updatingGoal.value = goal
progressForm.value = {
value: goal.currentValue,
maxValue: goal.targetValue,
unit: goal.unit,
note: ''
}
showProgressDialog.value = true
}
const pauseGoal = async (goal) => {
try {
await ElMessageBox.confirm('确定要暂停这个目标吗?', '确认暂停', {
type: 'warning'
})
const index = goals.value.findIndex(g => g.id === goal.id)
if (index !== -1) {
goals.value[index].status = 'paused'
saveGoals()
ElMessage.success('目标已暂停')
}
} catch {
// 用户取消
}
}
const deleteGoal = async (goalId) => {
try {
await ElMessageBox.confirm('确定要删除这个目标吗?', '确认删除', {
type: 'warning'
})
goals.value = goals.value.filter(goal => goal.id !== goalId)
saveGoals()
ElMessage.success('目标删除成功')
} catch {
// 用户取消
}
}
const saveGoal = async () => {
try {
await goalFormRef.value.validate()
const goalData = { ...goalForm.value }
// 设置日期范围
if (goalData.type === 'daily') {
goalData.startDate = new Date().toISOString().split('T')[0]
goalData.endDate = new Date().toISOString().split('T')[0]
} else if (goalData.type === 'weekly') {
const now = new Date()
const startOfWeek = new Date(now.setDate(now.getDate() - now.getDay()))
const endOfWeek = new Date(now.setDate(now.getDate() - now.getDay() + 6))
goalData.startDate = startOfWeek.toISOString().split('T')[0]
goalData.endDate = endOfWeek.toISOString().split('T')[0]
} else if (goalData.type === 'monthly') {
const now = new Date()
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1)
const endOfMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0)
goalData.startDate = startOfMonth.toISOString().split('T')[0]
goalData.endDate = endOfMonth.toISOString().split('T')[0]
} else if (goalData.type === 'custom' && goalData.dateRange) {
goalData.startDate = goalData.dateRange[0]
goalData.endDate = goalData.dateRange[1]
}
delete goalData.dateRange
if (editingGoal.value) {
// 编辑现有目标
const index = goals.value.findIndex(g => g.id === editingGoal.value.id)
if (index !== -1) {
goals.value[index] = {
...goals.value[index],
...goalData,
updatedAt: new Date()
}
}
} else {
// 新增目标
const newGoal = {
id: Date.now(),
...goalData,
currentValue: 0,
status: 'active',
priority: goals.value.filter(g => g.status === 'active').length, // 设置为最低优先级
createdAt: new Date(),
updatedAt: new Date()
}
goals.value.push(newGoal)
}
saveGoals()
showAddGoalDialog.value = false
editingGoal.value = null
resetGoalForm()
ElMessage.success(editingGoal.value ? '目标更新成功' : '目标创建成功')
} catch (error) {
console.error('保存目标失败:', error)
}
}
const saveProgress = () => {
if (updatingGoal.value) {
const index = goals.value.findIndex(g => g.id === updatingGoal.value.id)
if (index !== -1) {
goals.value[index].currentValue = progressForm.value.value
goals.value[index].updatedAt = new Date()
// 检查是否完成目标
if (progressForm.value.value >= goals.value[index].targetValue) {
goals.value[index].status = 'completed'
goals.value[index].completedAt = new Date()
ElMessage.success('🎉 恭喜!目标已完成!')
} else {
ElMessage.success('进度更新成功')
}
saveGoals()
}
}
showProgressDialog.value = false
updatingGoal.value = null
}
const resetGoalForm = () => {
goalForm.value = {
title: '',
type: 'daily',
description: '',
targetValue: 1000,
unit: '字',
dateRange: null
}
}
// 排序相关方法
const toggleSortMode = () => {
sortMode.value = !sortMode.value
if (!sortMode.value) {
// 退出排序模式时保存排序结果
saveGoals()
}
}
const onDragStart = (event, index) => {
draggedIndex.value = index
event.dataTransfer.effectAllowed = 'move'
}
const onDragOver = (event) => {
event.preventDefault()
event.dataTransfer.dropEffect = 'move'
}
const onDrop = (event, targetIndex) => {
event.preventDefault()
if (draggedIndex.value !== null && draggedIndex.value !== targetIndex) {
const activeGoalsList = activeGoals.value
// 重新安排优先级
const draggedGoal = activeGoalsList[draggedIndex.value]
activeGoalsList.splice(draggedIndex.value, 1)
activeGoalsList.splice(targetIndex, 0, draggedGoal)
// 更新所有活跃目标的优先级
activeGoalsList.forEach((goal, index) => {
const goalInStore = goals.value.find(g => g.id === goal.id)
if (goalInStore) {
goalInStore.priority = index
}
})
ElMessage.success('排序已更新')
}
draggedIndex.value = null
}
const saveGoals = () => {
localStorage.setItem('writingGoals', JSON.stringify(goals.value))
console.log('目标数据已保存:', goals.value)
// 触发storage事件,通知其他页面数据已更新
const event = new StorageEvent('storage', {
key: 'writingGoals',
newValue: JSON.stringify(goals.value),
oldValue: null,
storageArea: localStorage
})
window.dispatchEvent(event)
// 如果首页的刷新函数存在,直接调用
if (window.refreshHomeData) {
window.refreshHomeData()
}
}
const loadGoals = () => {
try {
const saved = localStorage.getItem('writingGoals')
if (saved) {
goals.value = JSON.parse(saved)
// 兼容性处理:为没有priority字段的目标添加priority
let needsSave = false
goals.value.forEach((goal, index) => {
if (goal.priority === undefined) {
goal.priority = index
needsSave = true
}
})
if (needsSave) {
saveGoals()
}
}
} catch (error) {
console.error('加载目标失败:', error)
}
}
const getGoalTypeText = (type) => {
const typeMap = {
daily: '每日',
weekly: '每周',
monthly: '每月',
custom: '自定义'
}
return typeMap[type] || '未知'
}
const getGoalTypeColor = (type) => {
const colorMap = {
daily: 'primary',
weekly: 'success',
monthly: 'warning',
custom: 'info'
}
return colorMap[type] || 'info'
}
const formatDate = (date) => {
if (!date) return ''
return new Date(date).toLocaleDateString()
}
const formatDateRange = (startDate, endDate) => {
if (!startDate || !endDate) return ''
return `${formatDate(startDate)} - ${formatDate(endDate)}`
}
const getRemainingDays = (endDate) => {
if (!endDate) return 0
const end = new Date(endDate)
const now = new Date()
const diffTime = end - now
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24))
return Math.max(0, diffDays)
}
// 生命周期
onMounted(() => {
loadGoals()
})
</script>
<style scoped>
.writing-goals {
padding: 20px;
}
.goals-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.goals-overview {
margin-bottom: 30px;
}
.overview-card {
display: flex;
align-items: center;
padding: 20px;
background: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
transition: transform 0.2s;
}
.overview-card:hover {
transform: translateY(-2px);
}
.card-icon {
width: 48px;
height: 48px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin-right: 16px;
font-size: 20px;
color: white;
}
.card-icon.daily {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.card-icon.weekly {
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
}
.card-icon.monthly {
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
}
.card-icon.streak {
background: linear-gradient(135deg, #43e97b 0%, #38f9d7 100%);
}
.card-content {
flex: 1;
}
.card-title {
font-size: 14px;
color: #666;
margin-bottom: 4px;
}
.card-value {
font-size: 24px;
font-weight: bold;
color: #333;
margin-bottom: 2px;
}
.card-subtitle {
font-size: 12px;
color: #999;
}
.active-goals {
margin-bottom: 30px;
}
.goals-list {
display: flex;
flex-direction: column;
gap: 16px;
}
.goal-item {
background: white;
border: 1px solid #e4e7ed;
border-radius: 8px;
padding: 20px;
transition: all 0.3s;
}
.goal-item:hover {
border-color: #409eff;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
}
.goal-item.completed {
background: #f0f9ff;
border-color: #67c23a;
}
.goal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.goal-info {
display: flex;
align-items: center;
gap: 12px;
}
.goal-title {
font-size: 16px;
font-weight: bold;
color: #303133;
}
.goal-actions {
display: flex;
gap: 8px;
}
.goal-description {
color: #606266;
margin-bottom: 16px;
line-height: 1.5;
}
.goal-progress {
margin-bottom: 12px;
}
.progress-info {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
font-size: 14px;
}
.progress-percentage {
font-weight: bold;
color: #409eff;
}
.goal-meta {
display: flex;
gap: 20px;
font-size: 12px;
color: #909399;
}
.meta-item {
display: flex;
align-items: center;
gap: 4px;
}
.completion-date {
font-size: 12px;
color: #67c23a;
}
.goal-result {
font-size: 14px;
color: #606266;
background: #f8f9fa;
padding: 8px 12px;
border-radius: 4px;
margin-top: 8px;
}
.empty-state {
text-align: center;
padding: 40px;
color: #909399;
}
.ml-2 {
margin-left: 8px;
}
/* 排序相关样式 */
.active-goals-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.goals-controls {
display: flex;
gap: 8px;
}
.sort-active {
background: #409eff !important;
color: white !important;
}
.sort-tip {
margin-bottom: 16px;
}
.sortable-goals {
display: flex;
flex-direction: column;
gap: 16px;
}
.goal-item.sortable {
cursor: move;
border: 2px dashed transparent;
transition: all 0.3s ease;
}
.goal-item.sortable:hover {
border-color: #409eff;
box-shadow: 0 4px 12px rgba(64, 158, 255, 0.2);
}
.goal-item.sortable[draggable="true"]:active {
opacity: 0.8;
transform: rotate(2deg);
}
/* 拖拽动画 */
.list-move,
.list-enter-active,
.list-leave-active {
transition: all 0.3s ease;
}
.list-enter-from,
.list-leave-to {
opacity: 0;
transform: translateX(30px);
}
.list-leave-active {
position: absolute;
width: 100%;
}
</style>
+65 -3
View File
@@ -1,16 +1,78 @@
import { createRouter, createWebHistory } from 'vue-router'
import Dashboard from '../views/Dashboard.vue'
import HomePage from '../views/HomePage.vue'
import PromptsLibrary from '../views/PromptsLibrary.vue'
import NovelManagement from '../views/NovelManagement.vue'
import WritingGoals from '../views/WritingGoals.vue'
import TokenBilling from '../views/TokenBilling.vue'
import ApiConfig from '../views/ApiConfig.vue'
import Settings from '../views/Settings.vue'
import ChapterManagement from '../views/ChapterManagement.vue'
import Writer from '../views/Writer.vue'
import Home from '../views/Home.vue'
import GenreManagement from '../views/GenreManagement.vue'
const routes = [
{
path: '/',
name: 'Home',
component: Home
component: Dashboard,
children: [
{
path: '',
name: 'HomePage',
component: HomePage
},
{
path: 'prompts',
name: 'PromptsLibrary',
component: PromptsLibrary
},
{
path: 'novels',
name: 'NovelManagement',
component: NovelManagement
},
{
path: 'goals',
name: 'WritingGoals',
component: WritingGoals
},
{
path: 'billing',
name: 'TokenBilling',
component: TokenBilling
},
{
path: 'config',
name: 'ApiConfig',
component: ApiConfig
},
{
path: 'settings',
name: 'Settings',
component: Settings
},
{
path: 'chapters',
name: 'ChapterManagement',
component: ChapterManagement
},
{
path: 'writer',
name: 'Writer',
component: Writer
},
{
path: 'genres',
name: 'GenreManagement',
component: GenreManagement
}
]
}
]
const router = createRouter({
history: createWebHistory(),
history: createWebHistory(import.meta.env.BASE_URL),
routes
})
+203 -61
View File
@@ -1,4 +1,5 @@
import apiConfig from '../config/api.json'
import billingService from './billing.js'
class APIService {
constructor() {
@@ -78,8 +79,19 @@ class APIService {
// 生成文本内容
async generateText(prompt, options = {}) {
const model = options.model || this.config.selectedModel || this.config.defaultModel || 'gpt-3.5-turbo'
// 估算输入token数量
const estimatedInputTokens = billingService.estimateTokens(prompt)
const estimatedCost = billingService.calculateCost(model, estimatedInputTokens, 500) // 预估500个输出token
// 检查余额
if (!billingService.checkBalance(estimatedCost)) {
throw new Error('账户余额不足,请先充值')
}
const requestBody = {
model: options.model || this.config.selectedModel || this.config.defaultModel || 'gpt-3.5-turbo',
model: model,
messages: [
{
role: 'user',
@@ -91,19 +103,72 @@ class APIService {
stream: false
}
const response = await this.makeRequest('/chat/completions', {
body: JSON.stringify(requestBody)
})
try {
const response = await this.makeRequest('/chat/completions', {
body: JSON.stringify(requestBody)
})
return response.choices[0]?.message?.content || ''
const content = response.choices[0]?.message?.content || ''
const usage = response.usage
// 记录实际的token使用情况
if (usage) {
billingService.recordAPICall({
type: options.type || 'generation',
model: model,
content: prompt,
response: content,
inputTokens: usage.prompt_tokens || 0,
outputTokens: usage.completion_tokens || 0,
status: 'success'
})
} else {
// 如果API没有返回usage信息,使用估算值
const outputTokens = billingService.estimateTokens(content)
billingService.recordAPICall({
type: options.type || 'generation',
model: model,
content: prompt,
response: content,
inputTokens: estimatedInputTokens,
outputTokens: outputTokens,
status: 'success'
})
}
return content
} catch (error) {
// 记录失败的API调用
billingService.recordAPICall({
type: options.type || 'generation',
model: model,
content: prompt,
response: '',
inputTokens: estimatedInputTokens,
outputTokens: 0,
status: 'failed'
})
throw error
}
}
// 流式生成文本内容
async generateTextStream(prompt, options = {}, onChunk = null) {
console.log('开始流式生成,prompt:', prompt.substring(0, 100) + '...') // 调试日志
const model = options.model || this.config.selectedModel || this.config.defaultModel || 'gpt-3.5-turbo'
// 估算输入token数量
const estimatedInputTokens = billingService.estimateTokens(prompt)
const estimatedCost = billingService.calculateCost(model, estimatedInputTokens, 800) // 预估800个输出token
// 检查余额
if (!billingService.checkBalance(estimatedCost)) {
throw new Error('账户余额不足,请先充值')
}
const requestBody = {
model: options.model || this.config.selectedModel || this.config.defaultModel || 'gpt-3.5-turbo',
model: model,
messages: [
{
role: 'user',
@@ -120,71 +185,103 @@ class APIService {
const url = this.buildURL('/chat/completions')
const headers = this.buildHeaders()
const response = await fetch(url, {
method: 'POST',
headers,
body: JSON.stringify(requestBody)
})
console.log('API响应状态:', response.status) // 调试日志
if (!response.ok) {
const errorData = await response.json()
throw new Error(`API请求失败: ${response.status} - ${errorData.error?.message || '未知错误'}`)
}
const reader = response.body.getReader()
const decoder = new TextDecoder()
let fullContent = ''
let hasError = false
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
const response = await fetch(url, {
method: 'POST',
headers,
body: JSON.stringify(requestBody)
})
console.log('API响应状态:', response.status) // 调试日志
const chunk = decoder.decode(value, { stream: true })
const lines = chunk.split('\n')
if (!response.ok) {
const errorData = await response.json()
hasError = true
throw new Error(`API请求失败: ${response.status} - ${errorData.error?.message || '未知错误'}`)
}
for (const line of lines) {
const trimmedLine = line.trim()
if (trimmedLine.startsWith('data: ')) {
const data = trimmedLine.slice(6).trim()
if (data === '[DONE]') {
console.log('流式生成完成,总内容长度:', fullContent.length) // 调试日志
return fullContent
}
// 跳过空数据
if (!data || data === '') {
continue
}
try {
const parsed = JSON.parse(data)
const content = parsed.choices?.[0]?.delta?.content || ''
if (content) {
fullContent += content
console.log('接收到内容片段:', content) // 调试日志
if (onChunk) {
onChunk(content, fullContent)
}
const reader = response.body.getReader()
const decoder = new TextDecoder()
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
const chunk = decoder.decode(value, { stream: true })
const lines = chunk.split('\n')
for (const line of lines) {
const trimmedLine = line.trim()
if (trimmedLine.startsWith('data: ')) {
const data = trimmedLine.slice(6).trim()
if (data === '[DONE]') {
console.log('流式生成完成,总内容长度:', fullContent.length) // 调试日志
break
}
// 跳过空数据
if (!data || data === '') {
continue
}
try {
const parsed = JSON.parse(data)
const content = parsed.choices?.[0]?.delta?.content || ''
if (content) {
fullContent += content
console.log('接收到内容片段:', content) // 调试日志
if (onChunk) {
onChunk(content, fullContent)
}
}
} catch (e) {
console.log('解析数据失败,原始数据:', JSON.stringify(data), '错误:', e.message) // 调试日志
// 继续处理其他数据,不中断流式处理
}
} catch (e) {
console.log('解析数据失败,原始数据:', JSON.stringify(data), '错误:', e.message) // 调试日志
// 继续处理其他数据,不中断流式处理
}
}
}
} catch (streamError) {
console.error('流式读取错误:', streamError)
hasError = true
throw streamError
} finally {
reader.releaseLock()
}
} catch (streamError) {
console.error('流式读取错误:', streamError)
throw streamError
} finally {
reader.releaseLock()
}
return fullContent
// 流式生成成功,记录token使用
const outputTokens = billingService.estimateTokens(fullContent)
billingService.recordAPICall({
type: options.type || 'generation',
model: model,
content: prompt,
response: fullContent,
inputTokens: estimatedInputTokens,
outputTokens: outputTokens,
status: 'success'
})
return fullContent
} catch (error) {
// 只有在发生错误时才记录失败调用
if (hasError) {
billingService.recordAPICall({
type: options.type || 'generation',
model: model,
content: prompt,
response: fullContent,
inputTokens: estimatedInputTokens,
outputTokens: billingService.estimateTokens(fullContent),
status: 'failed'
})
}
throw error
}
}
// 生成小说大纲
@@ -272,6 +369,51 @@ class APIService {
return await this.generateText(prompt)
}
// 流式生成章节内容
async generateChapterContentStream(chapterTitle, chapterOutline, previousContent = '', template = null, characters = [], worldSettings = [], onChunk = null) {
const templateInfo = template ? `\n写作风格:${template.style}\n写作提示:${template.writingTips}` : ''
const contextInfo = previousContent ? `\n前文内容参考:${previousContent.slice(-500)}` : ''
// 构建人物信息
let charactersInfo = ''
if (characters.length > 0) {
charactersInfo = '\n\n人物设定:'
characters.forEach(char => {
charactersInfo += `\n- ${char.name}${char.description}`
if (char.traits && char.traits.length > 0) {
charactersInfo += ` (特点:${char.traits.join('、')})`
}
})
}
// 构建世界观信息
let worldInfo = ''
if (worldSettings.length > 0) {
worldInfo = '\n\n世界观设定:'
worldSettings.forEach(setting => {
worldInfo += `\n- ${setting.title}${setting.description}`
})
}
const prompt = `请根据以下信息生成小说章节内容:
章节标题:${chapterTitle}
章节大纲:${chapterOutline}${templateInfo}${contextInfo}${charactersInfo}${worldInfo}
要求:
1. 字数控制在800-1200字
2. 内容要生动有趣,符合章节大纲
3. 语言流畅,描写细腻
4. 如果有前文内容,要保持连贯性
5. 符合所选模板的风格特点
6. 充分利用提供的人物设定和世界观设定
7. 确保人物行为符合其性格特点
8. 场景描写要符合世界观设定
请直接输出章节内容:`
return await this.generateTextStream(prompt, {}, onChunk)
}
// AI对话功能
async chatWithAI(message, chatHistory = []) {
const messages = [
+288
View File
@@ -0,0 +1,288 @@
class BillingService {
constructor() {
this.initializeStorage()
}
// 初始化本地存储
initializeStorage() {
if (!localStorage.getItem('account_balance')) {
localStorage.setItem('account_balance', '100.00') // 初始赠送100元
}
if (!localStorage.getItem('billing_records')) {
localStorage.setItem('billing_records', JSON.stringify([]))
}
if (!localStorage.getItem('token_usage_stats')) {
localStorage.setItem('token_usage_stats', JSON.stringify({
totalInputTokens: 0,
totalOutputTokens: 0,
totalCost: 0,
lastResetDate: new Date().toISOString()
}))
}
}
// 模型价格配置(每1000个token的价格,单位:人民币)
getModelPricing() {
return {
'gpt-4': {
input: 0.21, // $0.03 * 7 (汇率)
output: 0.42 // $0.06 * 7
},
'gpt-4-turbo': {
input: 0.07, // $0.01 * 7
output: 0.21 // $0.03 * 7
},
'gpt-3.5-turbo': {
input: 0.0035, // $0.0005 * 7
output: 0.0105 // $0.0015 * 7
},
'claude-3-opus': {
input: 0.105, // $0.015 * 7
output: 0.525 // $0.075 * 7
},
'claude-3-sonnet': {
input: 0.021, // $0.003 * 7
output: 0.105 // $0.015 * 7
},
'claude-3-haiku': {
input: 0.0014, // $0.00025 * 7
output: 0.007 // $0.00125 * 7
},
'default': {
input: 0.007, // 默认价格
output: 0.014
}
}
}
// 计算token使用费用
calculateCost(model, inputTokens, outputTokens) {
const pricing = this.getModelPricing()
const modelPricing = pricing[model] || pricing['default']
const inputCost = (inputTokens / 1000) * modelPricing.input
const outputCost = (outputTokens / 1000) * modelPricing.output
return inputCost + outputCost
}
// 估算prompt的token数量(粗略估算,1个中文字符约等于1.5个token)
estimateTokens(text) {
if (!text) return 0
// 简单的token估算逻辑
const chineseChars = (text.match(/[\u4e00-\u9fa5]/g) || []).length
const englishWords = (text.match(/[a-zA-Z]+/g) || []).length
const otherChars = text.length - chineseChars - englishWords
// 中文字符 * 1.5 + 英文单词 * 1.3 + 其他字符 * 0.5
return Math.ceil(chineseChars * 1.5 + englishWords * 1.3 + otherChars * 0.5)
}
// 获取账户余额
getAccountBalance() {
return parseFloat(localStorage.getItem('account_balance') || '0')
}
// 检查余额是否足够
checkBalance(estimatedCost) {
const balance = this.getAccountBalance()
return balance >= estimatedCost
}
// 扣除费用
deductBalance(amount) {
const currentBalance = this.getAccountBalance()
const newBalance = Math.max(0, currentBalance - amount)
localStorage.setItem('account_balance', newBalance.toString())
return newBalance
}
// 充值余额
addBalance(amount) {
const currentBalance = this.getAccountBalance()
const newBalance = currentBalance + amount
localStorage.setItem('account_balance', newBalance.toString())
return newBalance
}
// 记录API调用
recordAPICall(params) {
try {
const records = this.getBillingRecords()
const cost = this.calculateCost(params.model, params.inputTokens, params.outputTokens)
const record = {
id: Date.now() + Math.random(),
timestamp: new Date().toISOString(),
type: params.type || 'generation',
model: params.model,
content: params.content?.substring(0, 200) || '', // 只保存前200个字符
response: params.response?.substring(0, 500) || '', // 只保存前500个字符
inputTokens: params.inputTokens || 0,
outputTokens: params.outputTokens || 0,
totalTokens: (params.inputTokens || 0) + (params.outputTokens || 0),
cost: cost,
status: params.status || 'success'
}
records.unshift(record) // 最新记录放在前面
// 只保留最近1000条记录
if (records.length > 1000) {
records.splice(1000)
}
localStorage.setItem('billing_records', JSON.stringify(records))
// 扣除费用
this.deductBalance(cost)
// 更新统计信息
this.updateUsageStats(params.inputTokens || 0, params.outputTokens || 0, cost)
console.log(`API调用记录:模型=${params.model}, 输入=${params.inputTokens}tokens, 输出=${params.outputTokens}tokens, 费用=¥${cost.toFixed(4)}`)
return record
} catch (error) {
console.error('记录API调用失败:', error)
return null
}
}
// 获取计费记录
getBillingRecords() {
try {
const records = localStorage.getItem('billing_records')
return records ? JSON.parse(records) : []
} catch (error) {
console.error('获取计费记录失败:', error)
return []
}
}
// 更新使用统计
updateUsageStats(inputTokens, outputTokens, cost) {
try {
const stats = JSON.parse(localStorage.getItem('token_usage_stats') || '{}')
stats.totalInputTokens = (stats.totalInputTokens || 0) + inputTokens
stats.totalOutputTokens = (stats.totalOutputTokens || 0) + outputTokens
stats.totalCost = (stats.totalCost || 0) + cost
stats.lastUpdateDate = new Date().toISOString()
localStorage.setItem('token_usage_stats', JSON.stringify(stats))
} catch (error) {
console.error('更新使用统计失败:', error)
}
}
// 获取使用统计
getUsageStats() {
try {
const stats = localStorage.getItem('token_usage_stats')
return stats ? JSON.parse(stats) : {
totalInputTokens: 0,
totalOutputTokens: 0,
totalCost: 0,
lastResetDate: new Date().toISOString()
}
} catch (error) {
console.error('获取使用统计失败:', error)
return {
totalInputTokens: 0,
totalOutputTokens: 0,
totalCost: 0,
lastResetDate: new Date().toISOString()
}
}
}
// 获取今日使用统计
getTodayStats() {
const records = this.getBillingRecords()
const today = new Date().toDateString()
const todayRecords = records.filter(record =>
new Date(record.timestamp).toDateString() === today
)
return {
tokenCount: todayRecords.reduce((sum, record) => sum + record.totalTokens, 0),
cost: todayRecords.reduce((sum, record) => sum + record.cost, 0),
requestCount: todayRecords.length
}
}
// 获取最近N天的使用趋势
getUsageTrend(days = 7) {
const records = this.getBillingRecords()
const trend = []
for (let i = days - 1; i >= 0; i--) {
const date = new Date()
date.setDate(date.getDate() - i)
const dateString = date.toDateString()
const dayRecords = records.filter(record =>
new Date(record.timestamp).toDateString() === dateString
)
trend.push({
date: dateString,
tokenCount: dayRecords.reduce((sum, record) => sum + record.totalTokens, 0),
cost: dayRecords.reduce((sum, record) => sum + record.cost, 0),
requestCount: dayRecords.length
})
}
return trend
}
// 清除过期记录(保留最近30天)
cleanOldRecords() {
try {
const records = this.getBillingRecords()
const thirtyDaysAgo = new Date()
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30)
const filteredRecords = records.filter(record =>
new Date(record.timestamp) > thirtyDaysAgo
)
localStorage.setItem('billing_records', JSON.stringify(filteredRecords))
console.log(`清理了 ${records.length - filteredRecords.length} 条过期记录`)
} catch (error) {
console.error('清理过期记录失败:', error)
}
}
// 导出计费数据
exportBillingData(format = 'json') {
const records = this.getBillingRecords()
const stats = this.getUsageStats()
const exportData = {
exportTime: new Date().toISOString(),
accountBalance: this.getAccountBalance(),
usageStats: stats,
records: records
}
if (format === 'json') {
return JSON.stringify(exportData, null, 2)
} else if (format === 'csv') {
// 简单的CSV格式
let csv = 'timestamp,type,model,inputTokens,outputTokens,totalTokens,cost,status\n'
records.forEach(record => {
csv += `${record.timestamp},${record.type},${record.model},${record.inputTokens},${record.outputTokens},${record.totalTokens},${record.cost},${record.status}\n`
})
return csv
}
return exportData
}
}
export default new BillingService()
File diff suppressed because it is too large Load Diff
+827
View File
@@ -0,0 +1,827 @@
<template>
<div class="chapter-management">
<!-- 页面头部 -->
<div class="page-header">
<div class="header-content">
<h1>📖 章节管理</h1>
<p>管理您的小说章节编辑和组织内容</p>
</div>
<div class="header-actions">
<el-button type="primary" @click="showCreateDialog = true" :disabled="!selectedNovel">
<el-icon><Plus /></el-icon>
新建章节
</el-button>
</div>
</div>
<!-- 小说选择 -->
<div class="novel-selector">
<el-card shadow="never">
<div class="selector-content">
<div class="selector-left">
<span class="selector-label">选择小说</span>
<el-select
v-model="selectedNovelId"
placeholder="请选择要管理的小说"
style="width: 300px;"
@change="handleNovelChange"
>
<el-option
v-for="novel in novels"
:key="novel.id"
:label="novel.title"
:value="novel.id"
>
<div class="novel-option">
<span class="novel-title">{{ novel.title }}</span>
<span class="novel-info">{{ (novel.chapterList || []).length }} · {{ formatNumber(novel.wordCount || 0) }}</span>
</div>
</el-option>
</el-select>
</div>
<div class="selector-right" v-if="selectedNovel">
<div class="novel-stats">
<div class="stat-item">
<span class="stat-label">总章节</span>
<span class="stat-value">{{ (selectedNovel.chapterList || []).length }}</span>
</div>
<div class="stat-item">
<span class="stat-label">总字数</span>
<span class="stat-value">{{ formatNumber(selectedNovel.wordCount || 0) }}</span>
</div>
</div>
</div>
</div>
</el-card>
</div>
<!-- 章节列表 -->
<div class="chapters-section" v-if="selectedNovel">
<el-card shadow="never">
<template #header>
<div class="card-header">
<span>📚 {{ selectedNovel.title }} - 章节列表</span>
<div class="header-actions">
<el-button size="small" @click="sortChapters">排序</el-button>
<el-button size="small" @click="batchEdit">批量编辑</el-button>
</div>
</div>
</template>
<div class="chapters-list">
<div
v-for="(chapter, index) in chapters"
:key="chapter.id"
class="chapter-item"
:class="{ 'selected': selectedChapters.includes(chapter.id) }"
>
<div class="chapter-checkbox">
<el-checkbox
v-model="selectedChapters"
:label="chapter.id"
/>
</div>
<div class="chapter-number">
{{ index + 1 }}
</div>
<div class="chapter-content">
<div class="chapter-title">
<h4>{{ chapter.title }}</h4>
<div class="chapter-status">
<el-tag
:type="getChapterStatusType(chapter.status)"
size="small"
>
{{ getChapterStatusText(chapter.status) }}
</el-tag>
</div>
</div>
<div class="chapter-summary" v-if="chapter.summary">
{{ chapter.summary }}
</div>
<div class="chapter-meta">
<div class="meta-item">
<el-icon><EditPen /></el-icon>
<span>{{ chapter.wordCount }}</span>
</div>
<div class="meta-item">
<el-icon><Calendar /></el-icon>
<span>{{ formatDate(chapter.updatedAt) }}</span>
</div>
<div class="meta-item" v-if="chapter.aiGenerated">
<el-icon><EditPen /></el-icon>
<span>AI辅助</span>
</div>
</div>
</div>
<div class="chapter-actions">
<el-button type="text" size="small" @click="editChapter(chapter)">
<el-icon><Edit /></el-icon>
编辑
</el-button>
<el-button type="text" size="small" @click="viewChapter(chapter)">
<el-icon><View /></el-icon>
预览
</el-button>
<el-dropdown trigger="click">
<el-button type="text" size="small">
<el-icon><MoreFilled /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="duplicateChapter(chapter)">
<el-icon><CopyDocument /></el-icon>
复制章节
</el-dropdown-item>
<el-dropdown-item @click="moveChapter(chapter, 'up')" :disabled="index === 0">
<el-icon><ArrowUp /></el-icon>
上移
</el-dropdown-item>
<el-dropdown-item @click="moveChapter(chapter, 'down')" :disabled="index === chapters.length - 1">
<el-icon><ArrowDown /></el-icon>
下移
</el-dropdown-item>
<el-dropdown-item divided @click="deleteChapter(chapter)">
<el-icon><Delete /></el-icon>
删除
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
<div v-if="chapters.length === 0" class="empty-chapters">
<el-empty description="暂无章节,开始创建您的第一个章节吧!">
<el-button type="primary" @click="showCreateDialog = true">创建章节</el-button>
</el-empty>
</div>
</div>
</el-card>
</div>
<!-- 未选择小说时的提示 -->
<div v-else class="no-novel-selected">
<el-empty description="请先选择一部小说来管理章节">
<el-button type="primary" @click="$router.push('/novels')">前往小说管理</el-button>
</el-empty>
</div>
<!-- 创建/编辑章节对话框 -->
<el-dialog
v-model="showCreateDialog"
:title="editingChapter ? '编辑章节' : '创建新章节'"
width="800px"
>
<el-form :model="chapterForm" :rules="chapterRules" ref="chapterFormRef" label-width="80px">
<el-form-item label="章节标题" prop="title">
<el-input
v-model="chapterForm.title"
placeholder="请输入章节标题"
maxlength="100"
show-word-limit
/>
</el-form-item>
<el-form-item label="章节摘要">
<el-input
v-model="chapterForm.summary"
type="textarea"
placeholder="简要描述本章节内容(可选)"
:rows="3"
maxlength="500"
show-word-limit
/>
</el-form-item>
<el-form-item label="章节状态">
<el-radio-group v-model="chapterForm.status">
<el-radio label="draft">草稿</el-radio>
<el-radio label="writing">写作中</el-radio>
<el-radio label="completed">已完成</el-radio>
<el-radio label="published">已发布</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="章节内容" prop="content">
<el-input
v-model="chapterForm.content"
type="textarea"
placeholder="开始写作您的章节内容..."
:rows="15"
show-word-limit
/>
</el-form-item>
<el-form-item label="标签">
<el-input
v-model="tagInput"
placeholder="输入标签后按回车添加"
@keyup.enter="addChapterTag"
>
<template #append>
<el-button @click="addChapterTag">添加</el-button>
</template>
</el-input>
<div class="tags-display" v-if="chapterForm.tags.length > 0">
<el-tag
v-for="(tag, index) in chapterForm.tags"
:key="index"
closable
@close="removeChapterTag(index)"
style="margin: 5px 5px 0 0;"
>
{{ tag }}
</el-tag>
</div>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showCreateDialog = false">取消</el-button>
<el-button type="primary" @click="saveChapter">保存</el-button>
</template>
</el-dialog>
<!-- 章节预览对话框 -->
<el-dialog v-model="showPreviewDialog" title="章节预览" width="900px">
<div v-if="previewChapter" class="chapter-preview">
<div class="preview-header">
<h2>{{ previewChapter.title }}</h2>
<div class="preview-meta">
<span>字数{{ previewChapter.wordCount }}</span>
<span>状态{{ getChapterStatusText(previewChapter.status) }}</span>
<span>更新{{ formatDate(previewChapter.updatedAt) }}</span>
</div>
</div>
<div class="preview-content">
<p v-for="(paragraph, index) in previewChapter.content.split('\n')" :key="index">
{{ paragraph }}
</p>
</div>
</div>
</el-dialog>
</div>
</template>
<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
Plus, EditPen, Calendar, Edit, View, MoreFilled,
CopyDocument, ArrowUp, ArrowDown, Delete
} from '@element-plus/icons-vue'
import { useRouter } from 'vue-router'
const router = useRouter()
// 响应式数据
const selectedNovelId = ref(null)
const showCreateDialog = ref(false)
const showPreviewDialog = ref(false)
const editingChapter = ref(null)
const previewChapter = ref(null)
const selectedChapters = ref([])
const tagInput = ref('')
const chapterFormRef = ref()
// 小说数据 - 从localStorage加载真实数据
const novels = ref([])
// 章节数据
const chapters = ref([])
// 表单数据
const chapterForm = ref({
title: '',
summary: '',
content: '',
status: 'draft',
tags: []
})
// 表单验证规则
const chapterRules = {
title: [
{ required: true, message: '请输入章节标题', trigger: 'blur' },
{ min: 1, max: 100, message: '标题长度在 1 到 100 个字符', trigger: 'blur' }
],
content: [
{ required: true, message: '请输入章节内容', trigger: 'blur' }
]
}
// 计算属性
const selectedNovel = computed(() => {
return novels.value.find(novel => novel.id === selectedNovelId.value)
})
// 方法
const loadNovels = () => {
try {
const saved = localStorage.getItem('novels')
if (saved) {
const parsedNovels = JSON.parse(saved)
novels.value = parsedNovels.map(novel => ({
...novel,
createdAt: new Date(novel.createdAt),
updatedAt: new Date(novel.updatedAt)
}))
}
} catch (error) {
console.error('加载小说数据失败:', error)
novels.value = []
}
}
const formatNumber = (num) => {
if (num >= 10000) {
return (num / 10000).toFixed(1) + '万'
}
return num.toLocaleString()
}
const formatDate = (date) => {
return new Date(date).toLocaleDateString('zh-CN')
}
const getChapterStatusType = (status) => {
const typeMap = {
draft: '',
writing: 'warning',
completed: 'success',
published: 'info'
}
return typeMap[status] || ''
}
const getChapterStatusText = (status) => {
const textMap = {
draft: '草稿',
writing: '写作中',
completed: '已完成',
published: '已发布'
}
return textMap[status] || '未知'
}
const handleNovelChange = (novelId) => {
loadChapters(novelId)
}
const loadChapters = (novelId) => {
const novel = novels.value.find(n => n.id === novelId)
if (novel && novel.chapterList) {
chapters.value = novel.chapterList.map(chapter => ({
...chapter,
createdAt: new Date(chapter.createdAt),
updatedAt: new Date(chapter.updatedAt)
}))
} else {
chapters.value = []
}
}
const saveChaptersToNovel = () => {
if (!selectedNovelId.value) return
try {
const novels = JSON.parse(localStorage.getItem('novels') || '[]')
const novelIndex = novels.findIndex(n => n.id === selectedNovelId.value)
if (novelIndex > -1) {
// 更新章节列表
novels[novelIndex].chapterList = chapters.value
// 重新计算总字数
novels[novelIndex].wordCount = chapters.value.reduce((sum, ch) => sum + (ch.wordCount || 0), 0)
// 更新章节数(兼容性)
novels[novelIndex].chapters = chapters.value.length
// 更新修改时间
novels[novelIndex].updatedAt = new Date()
localStorage.setItem('novels', JSON.stringify(novels))
// 同步更新本地的novels数据
loadNovels()
}
} catch (error) {
console.error('保存章节数据失败:', error)
ElMessage.error('保存失败')
}
}
const editChapter = (chapter) => {
// 跳转到Writer页面进行编辑
router.push(`/writer?novelId=${selectedNovelId.value}&chapterId=${chapter.id}`)
}
const viewChapter = (chapter) => {
previewChapter.value = chapter
showPreviewDialog.value = true
}
const duplicateChapter = (chapter) => {
const newChapter = {
...chapter,
id: Date.now(),
title: chapter.title + ' (副本)',
status: 'draft',
createdAt: new Date(),
updatedAt: new Date()
}
chapters.value.push(newChapter)
// 保存到localStorage
saveChaptersToNovel()
ElMessage.success('章节复制成功')
}
const moveChapter = (chapter, direction) => {
const index = chapters.value.findIndex(c => c.id === chapter.id)
if (direction === 'up' && index > 0) {
[chapters.value[index], chapters.value[index - 1]] = [chapters.value[index - 1], chapters.value[index]]
// 保存到localStorage
saveChaptersToNovel()
ElMessage.success('章节上移成功')
} else if (direction === 'down' && index < chapters.value.length - 1) {
[chapters.value[index], chapters.value[index + 1]] = [chapters.value[index + 1], chapters.value[index]]
// 保存到localStorage
saveChaptersToNovel()
ElMessage.success('章节下移成功')
}
}
const deleteChapter = (chapter) => {
ElMessageBox.confirm(
`确定要删除章节「${chapter.title}」吗?此操作不可恢复。`,
'确认删除',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}
).then(() => {
const index = chapters.value.findIndex(c => c.id === chapter.id)
if (index > -1) {
chapters.value.splice(index, 1)
// 保存到localStorage
saveChaptersToNovel()
ElMessage.success('章节删除成功')
}
})
}
const saveChapter = () => {
chapterFormRef.value.validate((valid) => {
if (valid) {
const wordCount = chapterForm.value.content.replace(/<[^>]*>/g, '').length
if (editingChapter.value) {
// 编辑现有章节
const index = chapters.value.findIndex(c => c.id === editingChapter.value.id)
if (index > -1) {
chapters.value[index] = {
...chapters.value[index],
...chapterForm.value,
wordCount,
updatedAt: new Date()
}
}
ElMessage.success('章节更新成功')
} else {
// 创建新章节
const newChapter = {
id: Date.now(),
...chapterForm.value,
wordCount,
createdAt: new Date(),
updatedAt: new Date(),
aiGenerated: false,
status: chapterForm.value.status || 'draft'
}
chapters.value.push(newChapter)
ElMessage.success('章节创建成功')
}
// 保存到localStorage
saveChaptersToNovel()
showCreateDialog.value = false
resetForm()
}
})
}
const resetForm = () => {
chapterForm.value = {
title: '',
summary: '',
content: '',
status: 'draft',
tags: []
}
editingChapter.value = null
tagInput.value = ''
}
const addChapterTag = () => {
if (tagInput.value.trim() && !chapterForm.value.tags.includes(tagInput.value.trim())) {
chapterForm.value.tags.push(tagInput.value.trim())
tagInput.value = ''
}
}
const removeChapterTag = (index) => {
chapterForm.value.tags.splice(index, 1)
}
const sortChapters = () => {
ElMessage.info('章节排序功能开发中...')
}
const batchEdit = () => {
if (selectedChapters.value.length === 0) {
ElMessage.warning('请先选择要编辑的章节')
return
}
ElMessage.info('批量编辑功能开发中...')
}
// 生命周期
onMounted(() => {
// 如果有默认小说,自动选择
if (novels.value.length > 0) {
selectedNovelId.value = novels.value[0].id
loadChapters(selectedNovelId.value)
}
})
// 监听对话框关闭
watch(showCreateDialog, (newVal) => {
if (!newVal) {
resetForm()
}
})
// 生命周期
onMounted(() => {
loadNovels()
})
</script>
<style scoped>
.chapter-management {
padding: 0;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 20px;
}
.header-content h1 {
margin: 0 0 5px 0;
font-size: 24px;
font-weight: 600;
color: #303133;
}
.header-content p {
margin: 0;
color: #606266;
font-size: 14px;
}
.novel-selector {
margin-bottom: 20px;
}
.selector-content {
display: flex;
justify-content: space-between;
align-items: center;
}
.selector-left {
display: flex;
align-items: center;
gap: 10px;
}
.selector-label {
font-weight: 600;
color: #303133;
}
.novel-option {
display: flex;
flex-direction: column;
}
.novel-title {
font-weight: 600;
}
.novel-info {
font-size: 12px;
color: #909399;
}
.novel-stats {
display: flex;
gap: 20px;
}
.stat-item {
display: flex;
align-items: center;
gap: 5px;
}
.stat-label {
color: #606266;
font-size: 14px;
}
.stat-value {
font-weight: 600;
color: #303133;
}
.chapters-section {
margin-bottom: 20px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
font-weight: 600;
}
.chapters-list {
display: flex;
flex-direction: column;
gap: 15px;
}
.chapter-item {
display: flex;
align-items: flex-start;
gap: 15px;
padding: 15px;
border: 1px solid #e4e7ed;
border-radius: 8px;
transition: all 0.3s;
}
.chapter-item:hover {
border-color: #409eff;
background-color: #f0f9ff;
}
.chapter-item.selected {
border-color: #409eff;
background-color: #f0f9ff;
}
.chapter-checkbox {
flex-shrink: 0;
padding-top: 2px;
}
.chapter-number {
flex-shrink: 0;
width: 60px;
text-align: center;
font-weight: 600;
color: #409eff;
background: #f0f9ff;
padding: 5px;
border-radius: 4px;
font-size: 12px;
}
.chapter-content {
flex: 1;
}
.chapter-title {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.chapter-title h4 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: #303133;
}
.chapter-summary {
color: #606266;
font-size: 14px;
line-height: 1.4;
margin-bottom: 10px;
}
.chapter-meta {
display: flex;
gap: 15px;
font-size: 12px;
color: #909399;
}
.meta-item {
display: flex;
align-items: center;
gap: 4px;
}
.chapter-actions {
flex-shrink: 0;
display: flex;
align-items: center;
gap: 5px;
}
.no-novel-selected {
padding: 60px 0;
}
.tags-display {
margin-top: 10px;
}
.chapter-preview {
max-height: 600px;
overflow-y: auto;
}
.preview-header {
margin-bottom: 20px;
padding-bottom: 15px;
border-bottom: 1px solid #e4e7ed;
}
.preview-header h2 {
margin: 0 0 10px 0;
font-size: 20px;
font-weight: 600;
color: #303133;
}
.preview-meta {
display: flex;
gap: 15px;
font-size: 14px;
color: #606266;
}
.preview-content {
line-height: 1.8;
color: #303133;
}
.preview-content p {
margin: 0 0 15px 0;
text-indent: 2em;
}
.empty-chapters {
padding: 40px 0;
}
/* 响应式设计 */
@media (max-width: 768px) {
.page-header {
flex-direction: column;
gap: 15px;
}
.selector-content {
flex-direction: column;
gap: 15px;
align-items: flex-start;
}
.chapter-item {
flex-direction: column;
gap: 10px;
}
.chapter-actions {
width: 100%;
justify-content: flex-end;
}
}
</style>
+300
View File
@@ -0,0 +1,300 @@
<template>
<div class="dashboard-container">
<!-- 侧边栏 -->
<div class="sidebar" :class="{ 'collapsed': isCollapse }">
<div class="logo">
<h2>📚 91写作</h2>
</div>
<el-menu
:default-active="activeMenu"
class="sidebar-menu"
@select="handleMenuSelect"
:collapse="isCollapse"
:collapse-transition="false"
>
<el-menu-item index="/">
<el-icon><House /></el-icon>
<template #title>首页</template>
</el-menu-item>
<el-menu-item index="/novels">
<el-icon><Document /></el-icon>
<template #title>小说列表</template>
</el-menu-item>
<el-menu-item index="/prompts">
<el-icon><ChatLineSquare /></el-icon>
<template #title>提示词库</template>
</el-menu-item>
<el-menu-item index="/genres">
<el-icon><Collection /></el-icon>
<template #title>小说类型管理</template>
</el-menu-item>
<el-menu-item index="/chapters">
<el-icon><Notebook /></el-icon>
<template #title>章节管理</template>
</el-menu-item>
<el-menu-item index="/goals">
<el-icon><Aim /></el-icon>
<template #title>写作目标</template>
</el-menu-item>
<el-menu-item index="/billing">
<el-icon><CreditCard /></el-icon>
<template #title>Token计费</template>
</el-menu-item>
<el-menu-item index="/settings">
<el-icon><Setting /></el-icon>
<template #title>系统设置</template>
</el-menu-item>
</el-menu>
</div>
<!-- 主要内容区域 -->
<div class="main-container">
<!-- 顶部导航栏 -->
<div class="header">
<div class="header-left">
<el-button
type="text"
@click="toggleSidebar"
class="collapse-btn"
>
<el-icon><Expand v-if="isCollapse" /><Fold v-else /></el-icon>
</el-button>
<span class="page-title">{{ pageTitle }}</span>
</div>
<div class="header-right">
<!-- API配置状态 -->
<el-button
@click="showApiConfig = true"
:type="isApiConfigured ? 'success' : 'warning'"
size="small"
>
<el-icon><Key /></el-icon>
{{ isApiConfigured ? 'API已配置' : 'API配置' }}
</el-button>
</div>
</div>
<!-- 页面内容 -->
<div class="content">
<router-view />
</div>
</div>
<!-- API配置对话框 -->
<el-dialog v-model="showApiConfig" title="API配置" width="700px">
<ApiConfig @close="showApiConfig = false" />
</el-dialog>
</div>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useNovelStore } from '@/stores/novel'
import {
House, Document, ChatLineSquare, Collection, Notebook, Aim,
CreditCard, Setting, Key,
Expand, Fold
} from '@element-plus/icons-vue'
import ApiConfig from '@/components/ApiConfig.vue'
const router = useRouter()
const route = useRoute()
const novelStore = useNovelStore()
// 响应式数据
const isCollapse = ref(false)
const showApiConfig = ref(false)
const activeMenu = ref('/')
// 计算属性
const isApiConfigured = computed(() => novelStore.isApiConfigured)
const pageTitle = computed(() => {
const titleMap = {
'/': '首页',
'/novels': '小说列表',
'/prompts': '提示词库',
'/genres': '小说类型管理',
'/chapters': '章节管理',
'/goals': '写作目标',
'/billing': 'Token计费',
'/settings': '系统设置'
}
return titleMap[route.path] || '首页'
})
// 监听路由变化
watch(() => route.path, (newPath) => {
activeMenu.value = newPath
}, { immediate: true })
// 方法
const toggleSidebar = () => {
isCollapse.value = !isCollapse.value
}
const handleMenuSelect = (index) => {
router.push(index)
}
</script>
<style scoped>
.dashboard-container {
display: flex;
height: 100vh;
background-color: #f5f5f5;
}
.sidebar {
width: 250px;
background-color: #304156;
color: white;
display: flex;
flex-direction: column;
transition: width 0.3s;
overflow: hidden;
}
.sidebar.collapsed {
width: 64px;
}
.sidebar.collapsed .logo h2 {
display: none;
}
.logo {
height: 60px;
display: flex;
align-items: center;
justify-content: center;
background-color: #2b3a4b;
color: white;
margin: 0;
}
.logo h2 {
margin: 0;
font-size: 18px;
white-space: nowrap;
}
.sidebar-menu {
border: none;
background-color: #304156;
height: calc(100vh - 60px);
}
.sidebar-menu .el-menu-item,
.sidebar-menu .el-sub-menu__title {
color: #bfcbd9;
border-bottom: none;
}
.sidebar-menu .el-menu-item:hover,
.sidebar-menu .el-sub-menu__title:hover {
background-color: #263445;
color: #409eff;
}
.sidebar-menu .el-menu-item.is-active {
background-color: #409eff;
color: white;
}
.main-container {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.header {
height: 60px;
background-color: white;
border-bottom: 1px solid #e4e7ed;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 20px;
box-shadow: 0 1px 4px rgba(0,21,41,.08);
}
.header-left {
display: flex;
align-items: center;
}
.collapse-btn {
margin-right: 15px;
font-size: 18px;
}
.page-title {
font-size: 18px;
font-weight: 500;
color: #303133;
}
.header-right {
display: flex;
align-items: center;
gap: 15px;
}
.user-info {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
padding: 5px 10px;
border-radius: 4px;
transition: background-color 0.3s;
}
.user-info:hover {
background-color: #f5f7fa;
}
.username {
color: #606266;
font-size: 14px;
}
.content {
flex: 1;
padding: 20px;
overflow-y: auto;
background-color: #f5f5f5;
}
/* 响应式设计 */
@media (max-width: 768px) {
.sidebar {
position: fixed;
z-index: 1000;
height: 100vh;
}
.main-container {
margin-left: 0;
}
.content {
padding: 15px;
}
}
</style>
+613
View File
@@ -0,0 +1,613 @@
<template>
<div class="genre-management">
<!-- 页面头部 -->
<div class="page-header">
<div class="header-content">
<h1>📖 小说类型管理</h1>
<p>管理小说类型及其相关的提示词和标签</p>
</div>
<div class="header-actions">
<el-button type="primary" @click="showCreateDialog = true">
<el-icon><Plus /></el-icon>
添加新类型
</el-button>
</div>
</div>
<!-- 类型列表 -->
<div class="genres-grid">
<div
v-for="genre in genres"
:key="genre.code"
class="genre-card"
>
<el-card shadow="hover" class="genre-item">
<div class="genre-header">
<div class="genre-info">
<h3 class="genre-name">{{ genre.name }}</h3>
<el-tag size="small" type="info">{{ genre.code }}</el-tag>
</div>
<div class="genre-actions">
<el-button size="small" type="primary" @click="editGenre(genre)">
<el-icon><Edit /></el-icon>
编辑
</el-button>
<el-dropdown @command="(cmd) => handleGenreAction(cmd, genre)">
<el-button size="small" type="text">
<el-icon><MoreFilled /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="duplicate">
<el-icon><CopyDocument /></el-icon>
复制
</el-dropdown-item>
<el-dropdown-item divided command="delete" :disabled="genre.isDefault">
<el-icon><Delete /></el-icon>
删除
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
<div class="genre-description">
<p>{{ genre.prompt }}</p>
</div>
<div class="genre-tags">
<el-tag
v-for="tag in genre.tags.slice(0, 5)"
:key="tag"
size="small"
style="margin: 2px 4px 2px 0;"
>
{{ tag }}
</el-tag>
<el-tag v-if="genre.tags.length > 5" size="small" type="info">
+{{ genre.tags.length - 5 }}
</el-tag>
</div>
<div class="genre-meta">
<div class="meta-item">
<el-icon><Calendar /></el-icon>
<span>{{ formatDate(genre.createdAt) }}</span>
</div>
<div class="meta-item" v-if="genre.usageCount">
<el-icon><Document /></el-icon>
<span>已使用 {{ genre.usageCount }} </span>
</div>
<div class="meta-item" v-if="genre.isDefault">
<el-tag type="warning" size="small">系统预设</el-tag>
</div>
</div>
</el-card>
</div>
</div>
<!-- 空状态 -->
<div v-if="genres.length === 0" class="empty-state">
<el-empty description="暂无类型,创建第一个小说类型吧!">
<el-button type="primary" @click="showCreateDialog = true">创建类型</el-button>
</el-empty>
</div>
<!-- 创建/编辑类型对话框 -->
<el-dialog
v-model="showCreateDialog"
:title="editingGenre ? '编辑类型' : '创建新类型'"
width="600px"
@close="resetForm"
>
<el-form
ref="formRef"
:model="genreForm"
:rules="genreRules"
label-width="100px"
>
<el-form-item label="类型代码" prop="code">
<el-input
v-model="genreForm.code"
placeholder="例如:urban, fantasy"
:disabled="editingGenre?.isDefault"
/>
<div class="form-tip">用于程序识别的英文代码不可重复</div>
</el-form-item>
<el-form-item label="类型名称" prop="name">
<el-input v-model="genreForm.name" placeholder="例如:都市、玄幻" />
</el-form-item>
<el-form-item label="创作提示" prop="prompt">
<el-input
v-model="genreForm.prompt"
type="textarea"
:rows="4"
placeholder="描述这个类型的创作特点和要求..."
/>
</el-form-item>
<el-form-item label="关键标签" prop="tags">
<div class="tags-input-section">
<el-input
v-model="tagInput"
placeholder="输入标签后按回车添加"
@keyup.enter="addTag"
>
<template #append>
<el-button @click="addTag">添加</el-button>
</template>
</el-input>
<div class="tags-display" v-if="genreForm.tags.length > 0">
<el-tag
v-for="(tag, index) in genreForm.tags"
:key="index"
closable
@close="removeTag(index)"
style="margin: 4px 4px 0 0;"
>
{{ tag }}
</el-tag>
</div>
</div>
</el-form-item>
<el-form-item label="示例作品" prop="examples">
<el-input
v-model="genreForm.examples"
type="textarea"
:rows="2"
placeholder="列举一些典型的作品或作者(可选)"
/>
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button @click="showCreateDialog = false">取消</el-button>
<el-button type="primary" @click="saveGenre" :loading="isSaving">
{{ editingGenre ? '保存修改' : '创建类型' }}
</el-button>
</div>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
Plus, Edit, Delete, Calendar, Document, MoreFilled, CopyDocument
} from '@element-plus/icons-vue'
// 响应式数据
const genres = ref([])
const showCreateDialog = ref(false)
const editingGenre = ref(null)
const formRef = ref()
const tagInput = ref('')
const isSaving = ref(false)
// 表单数据
const genreForm = ref({
code: '',
name: '',
prompt: '',
tags: [],
examples: ''
})
// 表单验证规则
const genreRules = {
code: [
{ required: true, message: '请输入类型代码', trigger: 'blur' },
{ pattern: /^[a-zA-Z0-9_-]+$/, message: '代码只能包含字母、数字、下划线和横线', trigger: 'blur' },
{ validator: validateCodeUnique, trigger: 'blur' }
],
name: [
{ required: true, message: '请输入类型名称', trigger: 'blur' }
],
prompt: [
{ required: true, message: '请输入创作提示', trigger: 'blur' }
]
}
// 验证代码唯一性
function validateCodeUnique(rule, value, callback) {
if (!value) {
callback()
return
}
const existingGenre = genres.value.find(g =>
g.code === value && (!editingGenre.value || g.code !== editingGenre.value.code)
)
if (existingGenre) {
callback(new Error('类型代码已存在'))
} else {
callback()
}
}
// 加载默认类型
const loadDefaultGenres = () => {
const defaultGenres = [
{
code: 'fantasy',
name: '玄幻',
tags: ['修仙', '异世界', '法宝', '灵气', '境界'],
prompt: '创作一部玄幻小说,包含修仙体系、异世界冒险等元素,注重世界观构建和修炼体系描写。',
examples: '《斗破苍穹》、《完美世界》',
isDefault: true,
createdAt: new Date(),
usageCount: 0
},
{
code: 'urban',
name: '都市',
tags: ['都市', '现代', '职场', '生活'],
prompt: '创作一部都市小说,以现代都市为背景,贴近现实生活,注重人物情感和社会现象描写。',
examples: '《何以笙箫默》、《杜拉拉升职记》',
isDefault: true,
createdAt: new Date(),
usageCount: 0
},
{
code: 'history',
name: '历史',
tags: ['历史', '古代', '朝廷', '战争'],
prompt: '创作一部历史小说,以真实历史为背景,注重历史考证和时代特色描写。',
examples: '《明朝那些事儿》、《康熙大帝》',
isDefault: true,
createdAt: new Date(),
usageCount: 0
},
{
code: 'scifi',
name: '科幻',
tags: ['科幻', '未来', '科技', '太空'],
prompt: '创作一部科幻小说,包含未来科技、太空探索等元素,注重科学性和想象力的平衡。',
examples: '《三体》、《银河系漫游指南》',
isDefault: true,
createdAt: new Date(),
usageCount: 0
},
{
code: 'wuxia',
name: '武侠',
tags: ['武侠', '江湖', '武功', '侠义'],
prompt: '创作一部武侠小说,以江湖为背景,注重武功描写和侠义精神体现。',
examples: '《射雕英雄传》、《天龙八部》',
isDefault: true,
createdAt: new Date(),
usageCount: 0
},
{
code: 'romance',
name: '言情',
tags: ['言情', '爱情', '情感', '浪漫'],
prompt: '创作一部言情小说,以爱情为主线,注重情感描写和人物关系发展。',
examples: '《简爱》、《傲慢与偏见》',
isDefault: true,
createdAt: new Date(),
usageCount: 0
}
]
return defaultGenres
}
// 加载类型数据
const loadGenres = () => {
try {
const saved = localStorage.getItem('novelGenres')
if (saved) {
const parsed = JSON.parse(saved)
// 确保包含默认类型
const defaultGenres = loadDefaultGenres()
const savedCodes = parsed.map(g => g.code)
const missingDefaults = defaultGenres.filter(g => !savedCodes.includes(g.code))
genres.value = [...parsed, ...missingDefaults]
} else {
// 首次加载,使用默认类型
genres.value = loadDefaultGenres()
saveGenres()
}
} catch (error) {
console.error('加载类型数据失败:', error)
genres.value = loadDefaultGenres()
}
}
// 保存类型数据
const saveGenres = () => {
try {
localStorage.setItem('novelGenres', JSON.stringify(genres.value))
console.log('类型数据已保存:', genres.value)
} catch (error) {
console.error('保存类型数据失败:', error)
ElMessage.error('保存数据失败')
}
}
// 编辑类型
const editGenre = (genre) => {
editingGenre.value = genre
genreForm.value = {
code: genre.code,
name: genre.name,
prompt: genre.prompt,
tags: [...genre.tags],
examples: genre.examples || ''
}
showCreateDialog.value = true
}
// 处理类型操作
const handleGenreAction = async (command, genre) => {
switch (command) {
case 'duplicate':
duplicateGenre(genre)
break
case 'delete':
await deleteGenre(genre)
break
}
}
// 复制类型
const duplicateGenre = (genre) => {
genreForm.value = {
code: `${genre.code}_copy`,
name: `${genre.name}(副本)`,
prompt: genre.prompt,
tags: [...genre.tags],
examples: genre.examples || ''
}
editingGenre.value = null
showCreateDialog.value = true
}
// 删除类型
const deleteGenre = async (genre) => {
if (genre.isDefault) {
ElMessage.warning('系统预设类型不能删除')
return
}
try {
await ElMessageBox.confirm(
`确定要删除类型"${genre.name}"吗?此操作不可恢复。`,
'确认删除',
{ type: 'warning' }
)
const index = genres.value.findIndex(g => g.code === genre.code)
if (index > -1) {
genres.value.splice(index, 1)
saveGenres()
ElMessage.success('类型删除成功')
}
} catch (error) {
// 用户取消删除
}
}
// 添加标签
const addTag = () => {
const tag = tagInput.value.trim()
if (tag && !genreForm.value.tags.includes(tag)) {
genreForm.value.tags.push(tag)
tagInput.value = ''
}
}
// 移除标签
const removeTag = (index) => {
genreForm.value.tags.splice(index, 1)
}
// 保存类型
const saveGenre = async () => {
try {
await formRef.value.validate()
isSaving.value = true
const genreData = {
...genreForm.value,
tags: genreForm.value.tags.filter(tag => tag.trim()),
createdAt: editingGenre.value?.createdAt || new Date(),
updatedAt: new Date(),
usageCount: editingGenre.value?.usageCount || 0,
isDefault: editingGenre.value?.isDefault || false
}
if (editingGenre.value) {
// 编辑现有类型
const index = genres.value.findIndex(g => g.code === editingGenre.value.code)
if (index > -1) {
genres.value[index] = genreData
}
ElMessage.success('类型更新成功')
} else {
// 创建新类型
genres.value.push(genreData)
ElMessage.success('类型创建成功')
}
saveGenres()
showCreateDialog.value = false
resetForm()
} catch (error) {
console.error('保存类型失败:', error)
} finally {
isSaving.value = false
}
}
// 重置表单
const resetForm = () => {
genreForm.value = {
code: '',
name: '',
prompt: '',
tags: [],
examples: ''
}
tagInput.value = ''
editingGenre.value = null
formRef.value?.clearValidate()
}
// 格式化日期
const formatDate = (date) => {
return new Date(date).toLocaleDateString('zh-CN')
}
// 生命周期
onMounted(() => {
loadGenres()
})
</script>
<style scoped>
.genre-management {
padding: 0;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 24px;
padding: 24px;
background: white;
border-radius: 8px;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
}
.header-content h1 {
margin: 0 0 8px 0;
font-size: 24px;
color: #303133;
}
.header-content p {
margin: 0;
color: #606266;
font-size: 14px;
}
.genres-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(400px, 1fr));
gap: 20px;
padding: 0 24px;
}
.genre-item {
border: none;
transition: all 0.3s;
}
.genre-item:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.15);
}
.genre-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 16px;
}
.genre-info {
flex: 1;
}
.genre-name {
margin: 0 0 8px 0;
font-size: 18px;
font-weight: 600;
color: #303133;
}
.genre-actions {
display: flex;
gap: 8px;
}
.genre-description {
margin-bottom: 16px;
padding: 12px;
background: #f8f9fa;
border-radius: 6px;
border-left: 3px solid #409eff;
}
.genre-description p {
margin: 0;
color: #606266;
line-height: 1.6;
}
.genre-tags {
margin-bottom: 16px;
min-height: 32px;
}
.genre-meta {
display: flex;
gap: 16px;
font-size: 12px;
color: #909399;
}
.meta-item {
display: flex;
align-items: center;
gap: 4px;
}
.tags-input-section {
width: 100%;
}
.tags-display {
margin-top: 8px;
}
.form-tip {
font-size: 12px;
color: #909399;
margin-top: 4px;
}
.empty-state {
padding: 60px 24px;
text-align: center;
}
.dialog-footer {
text-align: right;
}
/* 响应式设计 */
@media (max-width: 768px) {
.genres-grid {
grid-template-columns: 1fr;
padding: 0 16px;
}
.page-header {
flex-direction: column;
gap: 16px;
align-items: stretch;
padding: 16px;
}
}
</style>
+35 -1
View File
@@ -22,6 +22,18 @@
<el-icon class="mr-2"><Document /></el-icon>
文章摘要
</el-button>
<el-button @click="showChapterManagerDialog = true">
<el-icon class="mr-2"><Notebook /></el-icon>
章节管理
</el-button>
<el-button @click="showWritingGoalsDialog = true">
<el-icon class="mr-2"><Aim /></el-icon>
写作目标
</el-button>
<el-button @click="showBackupDialog = true">
<el-icon class="mr-2"><FolderOpened /></el-icon>
备份管理
</el-button>
<el-button @click="showApiConfigDialog = true" :type="isApiConfigured ? 'success' : 'warning'">
<el-icon class="mr-2"><Key /></el-icon>
{{ isApiConfigured ? 'API已配置' : 'API配置' }}
@@ -60,6 +72,7 @@
v-model="selectedTemplate"
placeholder="请选择写作模板"
style="width: 100%"
value-key="id"
@change="handleTemplateChange"
>
<el-option
@@ -349,6 +362,21 @@
<el-dialog v-model="showSummaryDialog" title="文章摘要" width="800px">
<SummaryGenerator />
</el-dialog>
<!-- 章节管理对话框 -->
<el-dialog v-model="showChapterManagerDialog" title="章节管理" width="1200px" top="5vh">
<ChapterManager />
</el-dialog>
<!-- 写作目标对话框 -->
<el-dialog v-model="showWritingGoalsDialog" title="写作目标" width="1000px">
<WritingGoals />
</el-dialog>
<!-- 备份管理对话框 -->
<el-dialog v-model="showBackupDialog" title="备份管理" width="1100px">
<BackupManager />
</el-dialog>
</div>
</template>
@@ -356,7 +384,7 @@
import { ref, computed, onMounted, shallowRef, onBeforeUnmount, watch } from 'vue'
import { useNovelStore } from '@/stores/novel'
import { ElMessage } from 'element-plus'
import { DataAnalysis, Collection, Setting, Key, Document, Loading } from '@element-plus/icons-vue'
import { DataAnalysis, Collection, Setting, Key, Document, Loading, Notebook, Aim, FolderOpened } from '@element-plus/icons-vue'
import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
import { createEditor, createToolbar } from '@wangeditor/editor'
import '@wangeditor/editor/dist/css/style.css'
@@ -366,6 +394,9 @@ import CorpusManager from '@/components/CorpusManager.vue'
import TemplateManager from '@/components/TemplateManager.vue'
import ApiConfig from '@/components/ApiConfig.vue'
import SummaryGenerator from '@/components/SummaryGenerator.vue'
import ChapterManager from '@/components/ChapterManager.vue'
import WritingGoals from '@/components/WritingGoals.vue'
import BackupManager from '@/components/BackupManager.vue'
const novelStore = useNovelStore()
@@ -398,6 +429,9 @@ const showCorpusDialog = ref(false)
const showTemplateDialog = ref(false)
const showApiConfigDialog = ref(false)
const showSummaryDialog = ref(false)
const showChapterManagerDialog = ref(false)
const showWritingGoalsDialog = ref(false)
const showBackupDialog = ref(false)
// 新增章节和AI对话相关状态
const chapters = computed(() => novelStore.chapters)
+832
View File
@@ -0,0 +1,832 @@
<template>
<div class="home-page">
<!-- 欢迎区域 -->
<div class="welcome-section">
<el-card class="welcome-card" shadow="never">
<div class="welcome-content">
<div class="welcome-text">
<h1>欢迎回来</h1>
<p>开始您的创作之旅让AI助力您的小说创作</p>
</div>
<div class="welcome-actions">
<el-button type="primary" size="large" @click="createNovel">
<el-icon><Plus /></el-icon>
创建新小说
</el-button>
</div>
</div>
</el-card>
</div>
<!-- 统计概览 -->
<div class="stats-section">
<el-row :gutter="20">
<el-col :span="6">
<el-card class="stat-card" shadow="hover">
<div class="stat-item">
<div class="stat-icon novels">
<el-icon><Document /></el-icon>
</div>
<div class="stat-content">
<div class="stat-number">{{ stats.totalNovels }}</div>
<div class="stat-label">总小说数</div>
</div>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card class="stat-card" shadow="hover">
<div class="stat-item">
<div class="stat-icon words">
<el-icon><EditPen /></el-icon>
</div>
<div class="stat-content">
<div class="stat-number">{{ formatNumber(stats.totalWords) }}</div>
<div class="stat-label">总字数</div>
</div>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card class="stat-card" shadow="hover">
<div class="stat-item">
<div class="stat-icon chapters">
<el-icon><Notebook /></el-icon>
</div>
<div class="stat-content">
<div class="stat-number">{{ stats.totalChapters }}</div>
<div class="stat-label">总章节数</div>
</div>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card class="stat-card" shadow="hover">
<div class="stat-item">
<div class="stat-icon tokens">
<el-icon><CreditCard /></el-icon>
</div>
<div class="stat-content">
<div class="stat-number">{{ formatNumber(stats.totalTokens) }}</div>
<div class="stat-label">已用Token</div>
</div>
</div>
</el-card>
</el-col>
</el-row>
</div>
<!-- 主要内容区域 -->
<el-row :gutter="20" class="main-content">
<!-- 左侧写作目标 -->
<el-col :span="12">
<el-card class="goals-card" shadow="hover">
<template #header>
<div class="card-header">
<span>📝 今日写作目标</span>
<el-button type="text" @click="showGoalsDialog = true">
管理目标
</el-button>
</div>
</template>
<div class="goals-content">
<!-- 动态显示目标 -->
<div
v-for="goal in displayedGoals"
:key="goal.id"
class="goal-item"
>
<div class="goal-info">
<span class="goal-label">{{ goal.title }}</span>
<span class="goal-value">{{ goal.targetValue }}{{ goal.unit }}</span>
</div>
<div class="goal-progress">
<el-progress
:percentage="getGoalProgress(goal)"
:color="getProgressColor(getGoalProgress(goal))"
:stroke-width="8"
:show-text="false"
/>
<span class="progress-text">{{ goal.currentValue }}{{ goal.unit }} / {{ goal.targetValue }}{{ goal.unit }}</span>
</div>
</div>
<!-- 如果没有目标时显示默认内容 -->
<div v-if="displayedGoals.length === 0" class="no-goals">
<el-empty description="暂无活跃目标" size="small">
<el-button type="primary" size="small" @click="showGoalsDialog = true">
创建目标
</el-button>
</el-empty>
</div>
<!-- 查看全部目标按钮 -->
<div v-if="totalActiveGoals > maxDisplayGoals" class="view-all-goals">
<el-button type="text" size="small" @click="showGoalsDialog = true">
查看全部 {{ totalActiveGoals }} 个目标
</el-button>
</div>
<div class="streak-info" v-if="displayedGoals.length > 0">
<el-icon class="streak-icon"><Trophy /></el-icon>
<span>连续写作 {{ calculateStreak() }} </span>
</div>
</div>
</el-card>
</el-col>
<!-- 右侧快速操作 -->
<el-col :span="12">
<el-card class="quick-actions-card" shadow="hover">
<template #header>
<span>🚀 快速操作</span>
</template>
<div class="quick-actions">
<div class="action-grid">
<div class="action-item" @click="openPrompts">
<div class="action-icon">
<el-icon><ChatLineSquare /></el-icon>
</div>
<span>提示词库</span>
</div>
<div class="action-item" @click="openChapters">
<div class="action-icon">
<el-icon><Notebook /></el-icon>
</div>
<span>章节管理</span>
</div>
<div class="action-item" @click="openBilling">
<div class="action-icon">
<el-icon><CreditCard /></el-icon>
</div>
<span>Token计费</span>
</div>
</div>
</div>
</el-card>
</el-col>
</el-row>
<!-- 最近小说 -->
<div class="recent-novels-section">
<el-card class="recent-novels-card" shadow="hover">
<template #header>
<div class="card-header">
<span>📚 最近编辑的小说</span>
<el-button type="text" @click="viewAllNovels">
查看全部
</el-button>
</div>
</template>
<div class="novels-list">
<div
v-for="novel in recentNovels"
:key="novel.id"
class="novel-item"
@click="openNovel(novel)"
>
<div class="novel-cover">
<img v-if="novel.cover" :src="novel.cover" :alt="novel.title" />
<div v-else class="default-cover">
<el-icon><Document /></el-icon>
</div>
</div>
<div class="novel-info">
<h4 class="novel-title">{{ novel.title }}</h4>
<p class="novel-desc">{{ novel.description }}</p>
<div class="novel-meta">
<span class="word-count">{{ formatNumber(novel.wordCount) }} </span>
<span class="update-time">{{ formatTime(novel.updatedAt) }}</span>
</div>
</div>
<div class="novel-actions">
<el-button type="text" size="small">
继续写作
</el-button>
</div>
</div>
<div v-if="recentNovels.length === 0" class="empty-novels">
<el-empty description="暂无小说,开始创作您的第一部作品吧!">
<el-button type="primary" @click="createNovel">创建小说</el-button>
</el-empty>
</div>
</div>
</el-card>
</div>
<!-- 写作目标管理对话框 -->
<el-dialog v-model="showGoalsDialog" title="写作目标管理" width="800px">
<WritingGoals @close="showGoalsDialog = false" />
</el-dialog>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useNovelStore } from '@/stores/novel'
import {
Plus, Edit, Document, EditPen, Notebook, CreditCard,
ChatLineSquare, Trophy
} from '@element-plus/icons-vue'
import WritingGoals from '@/components/WritingGoals.vue'
import billingService from '@/services/billing.js'
const router = useRouter()
const novelStore = useNovelStore()
// 响应式数据
const showGoalsDialog = ref(false)
const stats = computed(() => {
// 从本地存储获取真实的小说数据
const novelsData = JSON.parse(localStorage.getItem('novels') || '[]')
// 使用计费服务获取真实的token使用统计
const usageStats = billingService.getUsageStats()
// 计算真实统计数据
const totalNovels = novelsData.length
const totalWords = novelsData.reduce((sum, novel) => sum + (novel.wordCount || 0), 0)
const totalChapters = novelsData.reduce((sum, novel) => sum + ((novel.chapterList || []).length), 0)
const totalTokens = usageStats.totalInputTokens + usageStats.totalOutputTokens
return {
totalNovels,
totalWords,
totalChapters,
totalTokens
}
})
// 添加响应式的目标数据状态
const goalsRefreshTrigger = ref(0)
const maxDisplayGoals = ref(3) // 首页最多显示的目标数量
// 获取所有活跃目标
const activeGoals = computed(() => {
// 触发重新计算(通过依赖goalsRefreshTrigger
goalsRefreshTrigger.value
// 从本地存储获取真实的写作目标数据
const goalsData = JSON.parse(localStorage.getItem('writingGoals') || '[]')
const active = goalsData.filter(goal => goal.status === 'active')
// 按优先级排序(priority字段,数字越小优先级越高),如果没有priority则按创建时间排序
return active.sort((a, b) => {
if (a.priority !== undefined && b.priority !== undefined) {
return a.priority - b.priority
}
if (a.priority !== undefined) return -1
if (b.priority !== undefined) return 1
return new Date(a.createdAt || 0) - new Date(b.createdAt || 0)
})
})
// 首页显示的目标(限制数量)
const displayedGoals = computed(() => {
return activeGoals.value.slice(0, maxDisplayGoals.value)
})
// 总的活跃目标数量
const totalActiveGoals = computed(() => {
return activeGoals.value.length
})
// 兼容旧的currentGoal计算属性(保持向后兼容)
const currentGoal = computed(() => {
const daily = activeGoals.value.find(goal => goal.type === 'daily')
const weekly = activeGoals.value.find(goal => goal.type === 'weekly')
return {
dailyTarget: daily?.targetValue || 2000,
dailyWritten: daily?.currentValue || 0,
weeklyTarget: weekly?.targetValue || 14000,
weeklyWritten: weekly?.currentValue || 0,
streak: 0
}
})
const recentNovels = computed(() => {
// 从本地存储获取真实的小说数据
const novelsData = JSON.parse(localStorage.getItem('novels') || '[]')
// 按更新时间排序,取前3个
return novelsData
.sort((a, b) => new Date(b.updatedAt || 0) - new Date(a.updatedAt || 0))
.slice(0, 3)
.map(novel => ({
id: novel.id,
title: novel.title,
description: novel.description,
wordCount: novel.wordCount || 0,
updatedAt: new Date(novel.updatedAt || Date.now()),
cover: novel.cover
}))
})
// 计算属性
const dailyProgress = computed(() => {
return Math.min(100, Math.round((currentGoal.value.dailyWritten / currentGoal.value.dailyTarget) * 100))
})
const weeklyProgress = computed(() => {
return Math.min(100, Math.round((currentGoal.value.weeklyWritten / currentGoal.value.weeklyTarget) * 100))
})
// 新增辅助函数
const getGoalProgress = (goal) => {
if (!goal.targetValue || goal.targetValue === 0) return 0
return Math.min(100, Math.round((goal.currentValue / goal.targetValue) * 100))
}
const calculateStreak = () => {
// 简化的连续天数计算逻辑
// 可以根据实际需求实现更复杂的逻辑
return 0
}
const getGoalTypeText = (type) => {
const typeMap = {
daily: '每日',
weekly: '每周',
monthly: '每月',
custom: '自定义'
}
return typeMap[type] || '目标'
}
// 方法
const formatNumber = (num) => {
if (num >= 10000) {
return (num / 10000).toFixed(1) + '万'
}
return num.toLocaleString()
}
const formatTime = (date) => {
const now = new Date()
const diff = now - date
const hours = Math.floor(diff / (1000 * 60 * 60))
const days = Math.floor(hours / 24)
if (days > 0) {
return `${days}天前`
} else if (hours > 0) {
return `${hours}小时前`
} else {
return '刚刚'
}
}
const getProgressColor = (percentage) => {
if (percentage >= 100) return '#67c23a'
if (percentage >= 80) return '#e6a23c'
if (percentage >= 60) return '#409eff'
return '#f56c6c'
}
const createNovel = () => {
router.push('/novels')
}
const openNovel = (novel) => {
// 跳转到小说编辑页面
router.push(`/writer?novelId=${novel.id}`)
}
const viewAllNovels = () => {
router.push('/novels')
}
const openPrompts = () => {
router.push('/prompts')
}
const openChapters = () => {
router.push('/chapters')
}
const openBilling = () => {
router.push('/billing')
}
// 页面获得焦点时重新计算数据,确保数据同步
const refreshData = () => {
goalsRefreshTrigger.value++
console.log('首页刷新目标数据')
}
// 暴露刷新函数给全局,以便其他页面调用
window.refreshHomeData = refreshData
// 生命周期
onMounted(() => {
// 监听localStorage变化,以便实时更新目标数据
window.addEventListener('storage', (e) => {
if (e.key === 'writingGoals') {
refreshData()
}
})
// 监听页面可见性变化
document.addEventListener('visibilitychange', () => {
if (!document.hidden) {
refreshData()
}
})
})
</script>
<style scoped>
.home-page {
padding: 0;
}
.welcome-section {
margin-bottom: 20px;
}
.welcome-card {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border: none;
}
.welcome-card :deep(.el-card__body) {
padding: 40px;
}
.welcome-content {
display: flex;
justify-content: space-between;
align-items: center;
color: white;
}
.welcome-text h1 {
margin: 0 0 10px 0;
font-size: 32px;
font-weight: 600;
}
.welcome-text p {
margin: 0;
font-size: 16px;
opacity: 0.9;
}
.welcome-actions {
display: flex;
gap: 15px;
}
.stats-section {
margin-bottom: 20px;
}
.stat-card {
border: none;
transition: transform 0.3s;
}
.stat-card:hover {
transform: translateY(-2px);
}
.stat-item {
display: flex;
align-items: center;
gap: 15px;
}
.stat-icon {
width: 50px;
height: 50px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
color: white;
}
.stat-icon.novels {
background: linear-gradient(135deg, #667eea, #764ba2);
}
.stat-icon.words {
background: linear-gradient(135deg, #f093fb, #f5576c);
}
.stat-icon.chapters {
background: linear-gradient(135deg, #4facfe, #00f2fe);
}
.stat-icon.tokens {
background: linear-gradient(135deg, #43e97b, #38f9d7);
}
.stat-content {
flex: 1;
}
.stat-number {
font-size: 24px;
font-weight: 600;
color: #303133;
line-height: 1;
}
.stat-label {
font-size: 14px;
color: #909399;
margin-top: 5px;
}
.main-content {
margin-bottom: 20px;
}
/* 卡片高度对齐 */
.goals-card,
.quick-actions-card {
height: 100%;
min-height: 380px;
}
.goals-card :deep(.el-card__body),
.quick-actions-card :deep(.el-card__body) {
height: 100%;
display: flex;
flex-direction: column;
}
.goals-content {
flex: 1;
display: flex;
flex-direction: column;
justify-content: flex-start;
min-height: 300px;
}
.quick-actions {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
font-weight: 600;
}
.goals-content {
padding: 10px 0;
}
.goal-item {
margin-bottom: 20px;
padding: 15px;
background: #fafafa;
border-radius: 8px;
border: 1px solid #f0f0f0;
}
.goal-item:last-child {
margin-bottom: 15px;
}
.goal-info {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
.goal-label {
font-size: 14px;
color: #606266;
}
.goal-value {
font-size: 14px;
font-weight: 600;
color: #303133;
}
.goal-progress {
position: relative;
}
.progress-text {
display: block;
text-align: right;
font-size: 12px;
color: #909399;
margin-top: 5px;
line-height: 1;
}
.streak-info {
display: flex;
align-items: center;
gap: 8px;
padding: 15px;
background: #f8f9fa;
border-radius: 8px;
margin-top: auto;
margin-bottom: 0;
}
.streak-icon {
color: #f39c12;
font-size: 18px;
}
.no-goals {
padding: 20px;
text-align: center;
}
.view-all-goals {
text-align: center;
padding: 10px;
border-top: 1px solid #f0f0f0;
margin-top: 15px;
}
.view-all-goals .el-button {
color: #409eff;
font-size: 12px;
}
.quick-actions {
padding: 10px 0;
}
.action-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 15px;
height: 100%;
align-content: start;
}
.action-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 25px 20px;
border: 1px solid #e4e7ed;
border-radius: 8px;
cursor: pointer;
transition: all 0.3s;
min-height: 100px;
justify-content: center;
}
.action-item:hover {
border-color: #409eff;
background-color: #f0f9ff;
transform: translateY(-2px);
}
.action-icon {
width: 45px;
height: 45px;
border-radius: 50%;
background: #409eff;
color: white;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
}
.recent-novels-section {
margin-bottom: 20px;
}
.novels-list {
display: flex;
flex-direction: column;
gap: 15px;
}
.novel-item {
display: flex;
align-items: center;
gap: 15px;
padding: 15px;
border: 1px solid #e4e7ed;
border-radius: 8px;
cursor: pointer;
transition: all 0.3s;
}
.novel-item:hover {
border-color: #409eff;
background-color: #f0f9ff;
}
.novel-cover {
width: 60px;
height: 80px;
border-radius: 4px;
overflow: hidden;
flex-shrink: 0;
}
.novel-cover img {
width: 100%;
height: 100%;
object-fit: cover;
}
.default-cover {
width: 100%;
height: 100%;
background: #f5f7fa;
display: flex;
align-items: center;
justify-content: center;
color: #c0c4cc;
font-size: 24px;
}
.novel-info {
flex: 1;
}
.novel-title {
margin: 0 0 5px 0;
font-size: 16px;
font-weight: 600;
color: #303133;
}
.novel-desc {
margin: 0 0 8px 0;
font-size: 14px;
color: #606266;
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.novel-meta {
display: flex;
gap: 15px;
font-size: 12px;
color: #909399;
}
.novel-actions {
flex-shrink: 0;
}
.empty-novels {
padding: 40px 0;
}
/* 响应式设计 */
@media (max-width: 768px) {
.welcome-content {
flex-direction: column;
text-align: center;
gap: 20px;
}
.goals-card,
.quick-actions-card {
min-height: auto;
}
.action-grid {
grid-template-columns: 1fr;
}
.novel-item {
flex-direction: column;
text-align: center;
}
.goals-content {
min-height: auto;
}
}
</style>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+745
View File
@@ -0,0 +1,745 @@
<template>
<div class="settings-page">
<!-- 页面头部 -->
<div class="page-header">
<div class="header-content">
<h1> 系统设置</h1>
<p>管理您的91写作应用配置和偏好设置</p>
</div>
</div>
<!-- 设置内容 -->
<div class="settings-content">
<el-tabs v-model="activeTab" class="settings-tabs">
<!-- API配置 -->
<el-tab-pane label="API配置" name="api">
<el-card shadow="never">
<template #header>
<div class="card-header">
<span>🔑 AI模型API配置</span>
<el-button type="primary" @click="testAllConnections">测试所有连接</el-button>
</div>
</template>
<ApiConfig />
</el-card>
</el-tab-pane>
<!-- 数据管理 -->
<el-tab-pane label="数据管理" name="data">
<el-card shadow="never">
<template #header>
<span>💾 数据备份与恢复</span>
</template>
<div class="data-management">
<!-- 数据概览 -->
<div class="data-overview">
<h3>📊 数据概览</h3>
<div class="data-stats">
<div class="stat-item">
<div class="stat-label">小说作品</div>
<div class="stat-value">{{ dataStats.novels }}</div>
</div>
<div class="stat-item">
<div class="stat-label">提示词库</div>
<div class="stat-value">{{ dataStats.prompts }}</div>
</div>
<div class="stat-item">
<div class="stat-label">小说类型</div>
<div class="stat-value">{{ dataStats.genres }}</div>
</div>
<div class="stat-item">
<div class="stat-label">写作目标</div>
<div class="stat-value">{{ dataStats.goals }}</div>
</div>
<div class="stat-item">
<div class="stat-label">数据大小</div>
<div class="stat-value">{{ dataStats.size }}</div>
</div>
</div>
</div>
<el-divider />
<div class="data-section">
<h3>📤 数据导出</h3>
<p>导出您的小说数据设置和提示词库支持完整备份或分类导出</p>
<div class="data-actions">
<el-button type="primary" @click="exportAllData">
<el-icon><Download /></el-icon>
导出所有数据
</el-button>
<el-button @click="exportNovels">
<el-icon><Document /></el-icon>
小说数据
</el-button>
<el-button @click="exportPrompts">
<el-icon><ChatLineSquare /></el-icon>
提示词库
</el-button>
<el-button @click="exportGenres">
<el-icon><Collection /></el-icon>
小说类型
</el-button>
<el-button @click="exportSettings">
<el-icon><Setting /></el-icon>
API配置
</el-button>
</div>
</div>
<el-divider />
<div class="data-section">
<h3>📥 数据导入</h3>
<p>从备份文件恢复您的数据支持完整恢复或选择性导入</p>
<div class="data-actions">
<el-upload
:before-upload="beforeImport"
:show-file-list="false"
accept=".json"
>
<el-button type="success">
<el-icon><Upload /></el-icon>
选择备份文件
</el-button>
</el-upload>
<el-button @click="showImportDialog = true">
<el-icon><Setting /></el-icon>
导入选项
</el-button>
</div>
</div>
<el-divider />
<div class="data-section">
<h3>🗑 数据清除</h3>
<p class="warning-text"> 危险操作将清除本地数据请谨慎操作</p>
<div class="data-actions">
<el-button type="danger" @click="clearAllData">
<el-icon><Delete /></el-icon>
清除所有数据
</el-button>
<el-button type="warning" @click="clearNovels">
<el-icon><Document /></el-icon>
仅清除小说
</el-button>
<el-button type="warning" @click="clearSettings">
<el-icon><Setting /></el-icon>
重置API配置
</el-button>
</div>
</div>
</div>
</el-card>
</el-tab-pane>
<!-- 关于 -->
<el-tab-pane label="关于" name="about">
<el-card shadow="never">
<template #header>
<span> 关于应用</span>
</template>
<div class="about-content">
<div class="app-info">
<div class="app-logo">
<h1>📚 91写作</h1>
</div>
<div class="app-details">
<p><strong>版本</strong>v0.2.0</p>
<p><strong>更新时间</strong>2024年2月</p>
<p><strong>开发者</strong>91写作团队</p>
<p><strong>描述</strong>基于AI技术的智能小说创作辅助工具</p>
</div>
</div>
<el-divider />
<div class="features-list">
<h3>主要功能</h3>
<ul>
<li>🤖 AI辅助写作</li>
<li>📖 小说管理</li>
<li>📝 章节管理</li>
<li>💡 提示词库</li>
<li>🎯 写作目标</li>
<li>💰 Token计费</li>
<li> 个性化设置</li>
</ul>
</div>
<el-divider />
<div class="update-log">
<h3>更新日志</h3>
<div class="log-item">
<h4>v0.2.0 (2024-02-15)</h4>
<ul>
<li>重构为模块化架构</li>
<li>新增系统设置页面</li>
<li>优化用户界面</li>
<li>增强数据管理功能</li>
</ul>
</div>
</div>
</div>
</el-card>
</el-tab-pane>
</el-tabs>
</div>
<!-- 导入选项对话框 -->
<el-dialog v-model="showImportDialog" title="导入选项" width="500px">
<div class="import-options">
<p>选择要导入的数据类型</p>
<el-checkbox-group v-model="importOptions">
<el-checkbox label="novels">小说数据</el-checkbox>
<el-checkbox label="prompts">提示词库</el-checkbox>
<el-checkbox label="novelGenres">小说类型</el-checkbox>
<el-checkbox label="writingGoals">写作目标</el-checkbox>
<el-checkbox label="settings">API配置</el-checkbox>
</el-checkbox-group>
</div>
<template #footer>
<el-button @click="showImportDialog = false">取消</el-button>
<el-button type="primary" @click="confirmImportOptions">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, watch, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Download, Upload, Document, Setting, Delete, ChatLineSquare, Collection } from '@element-plus/icons-vue'
import ApiConfig from '@/components/ApiConfig.vue'
// 响应式数据
const activeTab = ref('api')
const showImportDialog = ref(false)
const importOptions = ref(['novels', 'prompts', 'novelGenres', 'writingGoals'])
// 数据统计
const dataStats = ref({
novels: 0,
prompts: 0,
genres: 0,
goals: 0,
size: '0KB'
})
// 方法
const testAllConnections = () => {
ElMessage.info('正在测试所有API连接...')
// 这里调用API配置组件的测试方法
}
// 计算数据统计
const calculateDataStats = () => {
try {
const novels = JSON.parse(localStorage.getItem('novels') || '[]')
const prompts = JSON.parse(localStorage.getItem('prompts') || '[]')
const genres = JSON.parse(localStorage.getItem('novelGenres') || '[]')
const goals = JSON.parse(localStorage.getItem('writingGoals') || '[]')
// 计算数据大小
const allData = JSON.stringify({
novels,
prompts,
genres,
goals
})
const sizeInBytes = new Blob([allData]).size
const sizeInKB = (sizeInBytes / 1024).toFixed(1)
const sizeInMB = (sizeInBytes / (1024 * 1024)).toFixed(2)
dataStats.value = {
novels: novels.length,
prompts: prompts.length,
genres: genres.length,
goals: goals.length,
size: sizeInBytes > 1024 * 1024 ? `${sizeInMB}MB` : `${sizeInKB}KB`
}
} catch (error) {
console.error('计算数据统计失败:', error)
}
}
const exportAllData = () => {
const data = {
novels: JSON.parse(localStorage.getItem('novels') || '[]'),
prompts: JSON.parse(localStorage.getItem('prompts') || '[]'),
novelGenres: JSON.parse(localStorage.getItem('novelGenres') || '[]'),
writingGoals: JSON.parse(localStorage.getItem('writingGoals') || '[]'),
settings: {
apiConfig: JSON.parse(localStorage.getItem('api-config') || '{}'),
tokenUsage: JSON.parse(localStorage.getItem('token-usage') || '{}')
},
exportTime: new Date().toISOString(),
version: 'v0.2.0'
}
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `91写作-完整备份-${new Date().toISOString().split('T')[0]}.json`
a.click()
URL.revokeObjectURL(url)
ElMessage.success('完整数据导出成功')
}
const exportNovels = () => {
const novels = JSON.parse(localStorage.getItem('novels') || '[]')
const data = {
novels,
exportTime: new Date().toISOString(),
type: 'novels'
}
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `91写作-小说数据-${new Date().toISOString().split('T')[0]}.json`
a.click()
URL.revokeObjectURL(url)
ElMessage.success('小说数据导出成功')
}
const exportPrompts = () => {
const prompts = JSON.parse(localStorage.getItem('prompts') || '[]')
const data = {
prompts,
exportTime: new Date().toISOString(),
type: 'prompts'
}
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `91写作-提示词库-${new Date().toISOString().split('T')[0]}.json`
a.click()
URL.revokeObjectURL(url)
ElMessage.success('提示词库导出成功')
}
const exportGenres = () => {
const genres = JSON.parse(localStorage.getItem('novelGenres') || '[]')
const data = {
novelGenres: genres,
exportTime: new Date().toISOString(),
type: 'genres'
}
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `91写作-小说类型-${new Date().toISOString().split('T')[0]}.json`
a.click()
URL.revokeObjectURL(url)
ElMessage.success('小说类型数据导出成功')
}
const exportSettings = () => {
const settings = {
apiConfig: JSON.parse(localStorage.getItem('api-config') || '{}'),
tokenUsage: JSON.parse(localStorage.getItem('token-usage') || '{}'),
exportTime: new Date().toISOString(),
type: 'settings'
}
const blob = new Blob([JSON.stringify(settings, null, 2)], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `91写作-系统设置-${new Date().toISOString().split('T')[0]}.json`
a.click()
URL.revokeObjectURL(url)
ElMessage.success('系统设置导出成功')
}
const confirmImportOptions = () => {
if (importOptions.value.length === 0) {
ElMessage.warning('请至少选择一种数据类型进行导入')
return
}
const input = document.createElement('input')
input.type = 'file'
input.accept = '.json'
input.onchange = (e) => {
const file = e.target.files[0]
if (file) {
beforeImport(file)
}
}
input.click()
showImportDialog.value = false
}
const beforeImport = (file) => {
const reader = new FileReader()
reader.onload = (e) => {
try {
const data = JSON.parse(e.target.result)
ElMessageBox.confirm(
`即将导入以下数据类型:${importOptions.value.join('、')}。这将覆盖现有数据,是否继续?`,
'确认导入',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}
).then(() => {
let importCount = 0
// 根据选择导入数据
if (importOptions.value.includes('novels') && data.novels) {
localStorage.setItem('novels', JSON.stringify(data.novels))
importCount++
}
if (importOptions.value.includes('prompts') && data.prompts) {
localStorage.setItem('prompts', JSON.stringify(data.prompts))
importCount++
}
if (importOptions.value.includes('novelGenres') && data.novelGenres) {
localStorage.setItem('novelGenres', JSON.stringify(data.novelGenres))
importCount++
}
if (importOptions.value.includes('writingGoals')) {
if (data.writingGoals) {
localStorage.setItem('writingGoals', JSON.stringify(data.writingGoals))
importCount++
} else if (data.goals) {
localStorage.setItem('writingGoals', JSON.stringify(data.goals))
importCount++
}
}
if (importOptions.value.includes('settings') && data.settings) {
if (data.settings.apiConfig) {
localStorage.setItem('api-config', JSON.stringify(data.settings.apiConfig))
importCount++
}
if (data.settings.tokenUsage) {
localStorage.setItem('token-usage', JSON.stringify(data.settings.tokenUsage))
importCount++
}
}
// 重新计算数据统计
calculateDataStats()
if (importCount > 0) {
ElMessage.success(`成功导入 ${importCount} 项数据`)
} else {
ElMessage.warning('未找到匹配的数据进行导入')
}
})
} catch (error) {
ElMessage.error('文件格式错误,请选择有效的备份文件')
}
}
reader.readAsText(file)
return false // 阻止自动上传
}
const clearAllData = () => {
ElMessageBox.confirm(
'这将清除所有本地数据,包括小说、设置、提示词等。此操作不可恢复,确定继续吗?',
'确认清除',
{
confirmButtonText: '确定清除',
cancelButtonText: '取消',
type: 'warning'
}
).then(() => {
localStorage.clear()
ElMessage.success('所有数据已清除')
setTimeout(() => {
location.reload()
}, 1000)
})
}
const clearNovels = () => {
ElMessageBox.confirm(
'这将清除所有小说数据,此操作不可恢复,确定继续吗?',
'确认清除小说',
{
confirmButtonText: '确定清除',
cancelButtonText: '取消',
type: 'warning'
}
).then(() => {
localStorage.removeItem('novels')
calculateDataStats()
ElMessage.success('小说数据已清除')
})
}
const clearSettings = () => {
ElMessageBox.confirm(
'这将清除API配置等系统设置,确定继续吗?',
'确认重置设置',
{
confirmButtonText: '确定重置',
cancelButtonText: '取消',
type: 'warning'
}
).then(() => {
// 清除API配置等设置
const settingsKeys = ['api-config', 'token-usage']
settingsKeys.forEach(key => localStorage.removeItem(key))
ElMessage.success('系统设置已重置')
setTimeout(() => {
location.reload()
}, 1000)
})
}
// 生命周期
onMounted(() => {
calculateDataStats()
})
</script>
<style scoped>
.settings-page {
padding: 0;
}
.page-header {
margin-bottom: 20px;
}
.header-content h1 {
margin: 0 0 5px 0;
font-size: 24px;
font-weight: 600;
color: #303133;
}
.header-content p {
margin: 0;
color: #606266;
font-size: 14px;
}
.settings-content {
background: white;
border-radius: 8px;
}
.settings-tabs {
min-height: 600px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
font-weight: 600;
}
.data-management {
padding: 10px 0;
}
.data-overview {
margin-bottom: 20px;
}
.data-overview h3 {
margin: 0 0 15px 0;
font-size: 16px;
font-weight: 600;
color: #303133;
}
.data-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 15px;
margin-bottom: 10px;
}
.stat-item {
text-align: center;
padding: 15px;
background: #f8f9fa;
border-radius: 8px;
border: 1px solid #e9ecef;
}
.stat-label {
font-size: 12px;
color: #6c757d;
margin-bottom: 5px;
}
.stat-value {
font-size: 18px;
font-weight: 600;
color: #495057;
}
.data-section {
margin-bottom: 20px;
}
.data-section h3 {
margin: 0 0 10px 0;
font-size: 16px;
font-weight: 600;
color: #303133;
}
.data-section p {
margin: 0 0 15px 0;
color: #606266;
font-size: 14px;
}
.warning-text {
color: #f56c6c !important;
}
.data-actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.about-content {
padding: 10px 0;
}
.app-info {
text-align: center;
margin-bottom: 20px;
}
.app-logo h1 {
margin: 0 0 20px 0;
font-size: 32px;
color: #409eff;
}
.app-details p {
margin: 8px 0;
color: #606266;
}
.features-list h3,
.update-log h3 {
margin: 0 0 15px 0;
font-size: 16px;
font-weight: 600;
color: #303133;
}
.features-list ul {
margin: 0;
padding-left: 20px;
}
.features-list li {
margin: 8px 0;
color: #606266;
}
.log-item {
margin-bottom: 20px;
}
.log-item h4 {
margin: 0 0 10px 0;
font-size: 14px;
font-weight: 600;
color: #409eff;
}
.import-options {
padding: 10px 0;
}
.import-options p {
margin: 0 0 15px 0;
color: #606266;
}
.import-options .el-checkbox-group {
display: flex;
flex-direction: column;
gap: 10px;
}
.log-item ul {
margin: 0;
padding-left: 20px;
}
.log-item li {
margin: 5px 0;
color: #606266;
font-size: 14px;
}
/* 响应式设计 */
@media (max-width: 768px) {
.data-actions {
flex-direction: column;
}
.data-actions .el-button {
width: 100%;
}
}
/* 主题样式 */
:root[data-theme="light"] {
--bg-color: #ffffff;
--text-color: #303133;
--border-color: #e4e7ed;
}
:root[data-theme="dark"] {
--bg-color: #1d1d1d;
--text-color: #ffffff;
--border-color: #434343;
}
:root[data-theme="dark"] .settings-page {
background-color: var(--bg-color);
color: var(--text-color);
}
:root[data-theme="dark"] .el-card {
background-color: #2d2d2d;
border-color: var(--border-color);
}
/* 禁用动画 */
.no-animations * {
animation-duration: 0ms !important;
animation-delay: 0ms !important;
transition-duration: 0ms !important;
transition-delay: 0ms !important;
}
</style>
+745
View File
@@ -0,0 +1,745 @@
<template>
<div class="token-billing">
<!-- 页面头部 -->
<div class="page-header">
<div class="header-content">
<h1>📊 Token使用统计</h1>
<p>查看API Token使用情况和统计数据</p>
</div>
<div class="header-actions">
<el-button @click="exportBilling">
<el-icon><Download /></el-icon>
导出统计
</el-button>
</div>
</div>
<!-- Token使用概览 -->
<div class="account-overview">
<el-row :gutter="20">
<el-col :span="6">
<el-card class="overview-card usage">
<div class="overview-item">
<div class="overview-icon">
<el-icon><DataAnalysis /></el-icon>
</div>
<div class="overview-content">
<div class="overview-value">{{ formatNumber(todayTokens) }}</div>
<div class="overview-label">今日Token</div>
</div>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card class="overview-card input">
<div class="overview-item">
<div class="overview-icon">
<el-icon><Upload /></el-icon>
</div>
<div class="overview-content">
<div class="overview-value">{{ formatNumber(totalInputTokens) }}</div>
<div class="overview-label">输入Token</div>
</div>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card class="overview-card output">
<div class="overview-item">
<div class="overview-icon">
<el-icon><Download /></el-icon>
</div>
<div class="overview-content">
<div class="overview-value">{{ formatNumber(totalOutputTokens) }}</div>
<div class="overview-label">输出Token</div>
</div>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card class="overview-card total">
<div class="overview-item">
<div class="overview-icon">
<el-icon><TrendCharts /></el-icon>
</div>
<div class="overview-content">
<div class="overview-value">{{ formatNumber(totalTokens) }}</div>
<div class="overview-label">总Token数</div>
</div>
</div>
</el-card>
</el-col>
</el-row>
</div>
<!-- 使用统计图表 -->
<div class="statistics-section">
<el-card>
<template #header>
<div class="card-header">
<h3>📊 使用趋势</h3>
<div class="time-filter">
<el-radio-group v-model="statisticsTimeRange" size="small">
<el-radio-button label="7d">最近7天</el-radio-button>
<el-radio-button label="30d">最近30天</el-radio-button>
<el-radio-button label="90d">最近90天</el-radio-button>
</el-radio-group>
</div>
</div>
</template>
<div class="statistics-content">
<el-row :gutter="20">
<el-col :span="12">
<div class="chart-container">
<h4>Token使用趋势</h4>
<div class="chart-placeholder">
<p>Token使用趋势图可集成 ECharts</p>
</div>
</div>
</el-col>
<el-col :span="12">
<div class="chart-container">
<h4>输入/输出Token分布</h4>
<div class="chart-placeholder">
<p>输入/输出Token分布图可集成 ECharts</p>
</div>
</div>
</el-col>
</el-row>
</div>
</el-card>
</div>
<!-- 筛选和搜索 -->
<div class="filter-section">
<el-card shadow="never">
<div class="filter-content">
<div class="filter-left">
<el-select v-model="typeFilter" placeholder="类型筛选" style="width: 120px;">
<el-option label="全部" value="all" />
<el-option label="文本生成" value="generation" />
<el-option label="文本润色" value="polish" />
<el-option label="大纲生成" value="outline" />
<el-option label="对话聊天" value="chat" />
</el-select>
<el-select v-model="modelFilter" placeholder="模型筛选" style="width: 140px;">
<el-option label="全部模型" value="all" />
<el-option label="GPT-4" value="gpt-4" />
<el-option label="GPT-3.5" value="gpt-3.5" />
<el-option label="Claude" value="claude" />
<el-option label="文心一言" value="wenxin" />
</el-select>
<el-date-picker
v-model="dateRange"
type="daterange"
range-separator=""
start-placeholder="开始日期"
end-placeholder="结束日期"
style="width: 240px;"
/>
</div>
<div class="filter-right">
<el-input
v-model="searchKeyword"
placeholder="搜索请求内容..."
clearable
style="width: 250px;"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
</div>
</div>
</el-card>
</div>
<!-- 使用记录列表 -->
<div class="billing-records">
<el-card>
<template #header>
<div class="card-header">
<h3>📋 使用记录</h3>
<div class="record-stats">
<span> {{ filteredRecords.length }} 条记录</span>
<span>总Token: {{ formatNumber(totalFilteredTokens) }}</span>
</div>
</div>
</template>
<el-table
:data="paginatedRecords"
stripe
style="width: 100%"
@row-click="viewRecordDetails"
>
<el-table-column prop="timestamp" label="时间" width="160">
<template #default="{ row }">
{{ formatDateTime(row.timestamp) }}
</template>
</el-table-column>
<el-table-column prop="type" label="类型" width="100">
<template #default="{ row }">
<el-tag :type="getTypeColor(row.type)" size="small">
{{ getTypeText(row.type) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="model" label="模型" width="120">
<template #default="{ row }">
<span class="model-name">{{ row.model }}</span>
</template>
</el-table-column>
<el-table-column prop="content" label="请求内容" min-width="200">
<template #default="{ row }">
<div class="content-preview">
{{ row.content.substring(0, 50) }}{{ row.content.length > 50 ? '...' : '' }}
</div>
</template>
</el-table-column>
<el-table-column prop="inputTokens" label="输入Token" width="100" align="right">
<template #default="{ row }">
{{ formatNumber(row.inputTokens) }}
</template>
</el-table-column>
<el-table-column prop="outputTokens" label="输出Token" width="100" align="right">
<template #default="{ row }">
{{ formatNumber(row.outputTokens) }}
</template>
</el-table-column>
<el-table-column prop="totalTokens" label="总Token" width="100" align="right">
<template #default="{ row }">
{{ formatNumber(row.totalTokens) }}
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="80">
<template #default="{ row }">
<el-tag :type="getStatusColor(row.status)" size="small">
{{ getStatusText(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="100" fixed="right">
<template #default="{ row }">
<el-button
type="text"
size="small"
@click.stop="viewRecordDetails(row)"
>
详情
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination-container">
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:page-sizes="[10, 20, 50, 100]"
:total="filteredRecords.length"
layout="total, sizes, prev, pager, next, jumper"
/>
</div>
</el-card>
</div>
<!-- 记录详情对话框 -->
<el-dialog
v-model="showDetailsDialog"
title="使用记录详情"
width="700px"
>
<div v-if="selectedRecord" class="record-details">
<div class="details-grid">
<div class="detail-item">
<label>请求时间</label>
<span>{{ formatDateTime(selectedRecord.timestamp) }}</span>
</div>
<div class="detail-item">
<label>请求类型</label>
<el-tag :type="getTypeColor(selectedRecord.type)">
{{ getTypeText(selectedRecord.type) }}
</el-tag>
</div>
<div class="detail-item">
<label>使用模型</label>
<span>{{ selectedRecord.model }}</span>
</div>
<div class="detail-item">
<label>请求状态</label>
<el-tag :type="getStatusColor(selectedRecord.status)">
{{ getStatusText(selectedRecord.status) }}
</el-tag>
</div>
<div class="detail-item">
<label>输入Token</label>
<span>{{ formatNumber(selectedRecord.inputTokens) }}</span>
</div>
<div class="detail-item">
<label>输出Token</label>
<span>{{ formatNumber(selectedRecord.outputTokens) }}</span>
</div>
<div class="detail-item">
<label>总Token</label>
<span>{{ formatNumber(selectedRecord.totalTokens) }}</span>
</div>
</div>
<div class="content-section">
<h4>请求内容</h4>
<div class="content-box">
{{ selectedRecord.content }}
</div>
</div>
<div class="response-section" v-if="selectedRecord.response">
<h4>响应内容</h4>
<div class="content-box">
{{ selectedRecord.response }}
</div>
</div>
</div>
</el-dialog>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import {
Download, Upload, DataAnalysis,
TrendCharts, Search
} from '@element-plus/icons-vue'
import billingService from '../services/billing.js'
// 响应式数据
const statisticsTimeRange = ref('7d')
const typeFilter = ref('all')
const modelFilter = ref('all')
const dateRange = ref([])
const searchKeyword = ref('')
const currentPage = ref(1)
const pageSize = ref(20)
const showDetailsDialog = ref(false)
const selectedRecord = ref(null)
// Token统计数据
const todayStats = computed(() => {
return billingService.getTodayStats()
})
const todayTokens = computed(() => {
return todayStats.value.tokenCount
})
const usageStats = computed(() => {
return billingService.getUsageStats()
})
const totalInputTokens = computed(() => {
return usageStats.value.totalInputTokens
})
const totalOutputTokens = computed(() => {
return usageStats.value.totalOutputTokens
})
const totalTokens = computed(() => {
return totalInputTokens.value + totalOutputTokens.value
})
// 使用记录数据
const billingRecords = ref([])
// 加载计费记录
const loadBillingRecords = () => {
try {
billingRecords.value = billingService.getBillingRecords()
// 如果没有数据,可选择是否添加示例数据
if (billingRecords.value.length === 0) {
console.log('暂无使用记录')
}
} catch (error) {
console.error('加载使用记录失败:', error)
billingRecords.value = []
}
}
// 计算属性
const filteredRecords = computed(() => {
let result = billingRecords.value
// 类型筛选
if (typeFilter.value !== 'all') {
result = result.filter(record => record.type === typeFilter.value)
}
// 模型筛选
if (modelFilter.value !== 'all') {
result = result.filter(record => record.model.toLowerCase().includes(modelFilter.value))
}
// 日期筛选
if (dateRange.value && dateRange.value.length === 2) {
const [start, end] = dateRange.value
result = result.filter(record => {
const recordDate = new Date(record.timestamp)
return recordDate >= start && recordDate <= end
})
}
// 关键词搜索
if (searchKeyword.value) {
const keyword = searchKeyword.value.toLowerCase()
result = result.filter(record =>
record.content.toLowerCase().includes(keyword) ||
(record.response && record.response.toLowerCase().includes(keyword))
)
}
return result.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp))
})
const paginatedRecords = computed(() => {
const start = (currentPage.value - 1) * pageSize.value
const end = start + pageSize.value
return filteredRecords.value.slice(start, end)
})
const totalFilteredTokens = computed(() => {
return filteredRecords.value.reduce((sum, record) => sum + record.totalTokens, 0)
})
// 方法
const formatNumber = (num) => {
return num.toLocaleString()
}
const formatDateTime = (date) => {
return new Date(date).toLocaleString('zh-CN')
}
const getTypeColor = (type) => {
const colors = {
generation: 'primary',
polish: 'success',
outline: 'warning',
chat: 'info'
}
return colors[type] || 'default'
}
const getTypeText = (type) => {
const texts = {
generation: '文本生成',
polish: '文本润色',
outline: '大纲生成',
chat: '对话聊天'
}
return texts[type] || '未知'
}
const getStatusColor = (status) => {
const colors = {
success: 'success',
failed: 'danger',
pending: 'warning'
}
return colors[status] || 'default'
}
const getStatusText = (status) => {
const texts = {
success: '成功',
failed: '失败',
pending: '处理中'
}
return texts[status] || '未知'
}
const exportBilling = () => {
try {
const data = billingService.exportBillingData('csv')
const blob = new Blob([data], { type: 'text/csv;charset=utf-8;' })
const link = document.createElement('a')
const url = URL.createObjectURL(blob)
link.setAttribute('href', url)
link.setAttribute('download', `token_usage_${new Date().toISOString().slice(0, 10)}.csv`)
link.style.visibility = 'hidden'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
ElMessage.success('使用统计导出成功!')
} catch (error) {
console.error('导出失败:', error)
ElMessage.error('导出失败,请重试')
}
}
const viewRecordDetails = (record) => {
selectedRecord.value = record
showDetailsDialog.value = true
}
// 生命周期
onMounted(() => {
// 加载使用记录
loadBillingRecords()
})
</script>
<style scoped>
.token-billing {
padding: 0;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding: 20px;
background: white;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.header-content h1 {
margin: 0 0 5px 0;
font-size: 24px;
color: #303133;
}
.header-content p {
margin: 0;
color: #606266;
font-size: 14px;
}
.header-actions {
display: flex;
gap: 10px;
}
.account-overview {
margin-bottom: 20px;
}
.overview-card {
height: 100%;
}
.overview-card.usage :deep(.el-card__body) {
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
color: white;
}
.overview-card.input :deep(.el-card__body) {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.overview-card.output :deep(.el-card__body) {
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
color: white;
}
.overview-card.total :deep(.el-card__body) {
background: linear-gradient(135deg, #43e97b 0%, #38f9d7 100%);
color: white;
}
.overview-item {
display: flex;
align-items: center;
gap: 15px;
}
.overview-icon {
width: 50px;
height: 50px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.2);
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
}
.overview-content {
flex: 1;
}
.overview-value {
font-size: 24px;
font-weight: 600;
margin-bottom: 5px;
}
.overview-label {
font-size: 14px;
opacity: 0.9;
}
.statistics-section {
margin-bottom: 20px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.card-header h3 {
margin: 0;
font-size: 18px;
color: #303133;
}
.time-filter {
margin-left: auto;
}
.statistics-content {
padding: 20px 0;
}
.chart-container {
text-align: center;
}
.chart-container h4 {
margin: 0 0 15px 0;
font-size: 16px;
color: #606266;
}
.chart-placeholder {
height: 200px;
background: #f8f9fa;
border: 2px dashed #e9ecef;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
color: #6c757d;
font-size: 14px;
}
.filter-section {
margin-bottom: 20px;
}
.filter-content {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 0;
}
.filter-left {
display: flex;
gap: 15px;
align-items: center;
}
.billing-records {
margin-bottom: 20px;
}
.record-stats {
display: flex;
gap: 20px;
font-size: 14px;
color: #606266;
}
.content-preview {
color: #606266;
font-size: 13px;
}
.model-name {
font-weight: 500;
color: #409eff;
}
.cost-amount {
font-weight: 600;
color: #e6a23c;
}
.pagination-container {
margin-top: 20px;
display: flex;
justify-content: center;
}
.record-details {
max-height: 500px;
overflow-y: auto;
}
.details-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 15px;
margin-bottom: 20px;
}
.detail-item {
display: flex;
align-items: center;
gap: 10px;
}
.detail-item label {
font-weight: 600;
color: #606266;
min-width: 80px;
}
.content-section,
.response-section {
margin-bottom: 20px;
}
.content-section h4,
.response-section h4 {
margin: 0 0 10px 0;
font-size: 14px;
color: #303133;
}
.content-box {
background: #f8f9fa;
border: 1px solid #e9ecef;
border-radius: 4px;
padding: 15px;
font-size: 13px;
line-height: 1.5;
max-height: 200px;
overflow-y: auto;
white-space: pre-wrap;
word-break: break-all;
}
</style>
+7882
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff