mirror of
https://github.com/ponysb/91Writing.git
synced 2026-09-01 15:35:57 +08:00
Add files via upload
This commit is contained in:
@@ -1,203 +1,299 @@
|
||||
# 📚 91AI小说生成器
|
||||
# 📚 91写作 - AI智能小说创作工具
|
||||
|
||||
> 一个基于 Vue 3 + Element Plus 的智能小说创作工具,集成多种 AI 模型,助力作者高效创作
|
||||
> 基于 Vue 3 + Element Plus 的专业AI小说创作平台,集成先进AI模型,提供完整的创作工具链
|
||||
|
||||
[](https://vuejs.org/)
|
||||
[](https://element-plus.org/)
|
||||
[](https://vitejs.dev/)
|
||||
[](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支持一下!**
|
||||
|
||||
[](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
@@ -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>
|
||||
|
||||
Generated
+3819
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
@@ -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
@@ -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 = [
|
||||
|
||||
@@ -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
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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>
|
||||
@@ -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>
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user