Update 2026-05-18 14:31:53
@@ -6,6 +6,7 @@ __pycache__/
|
||||
.Python
|
||||
env/
|
||||
venv/
|
||||
.venv/
|
||||
ENV/
|
||||
*.egg-info/
|
||||
dist/
|
||||
@@ -69,8 +70,8 @@ test/
|
||||
frontend/dist/
|
||||
frontend/build/
|
||||
|
||||
# 后端静态文件(会从前端构建阶段复制)
|
||||
backend/static/
|
||||
# 后端静态文件(将从宿主机复制)
|
||||
# backend/static/
|
||||
|
||||
# 提示词工坊实例标识(每个容器需要独立生成)
|
||||
backend/.instance_id
|
||||
|
||||
@@ -7,7 +7,7 @@ on:
|
||||
workflow_dispatch: # 允许手动触发
|
||||
|
||||
env:
|
||||
DOCKER_IMAGE: mumujie/mumuainovel
|
||||
DOCKER_IMAGE: mumulingsi-project/mumulingsi
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
jobs:
|
||||
|
||||
@@ -81,6 +81,10 @@ backend/data/users.json
|
||||
backend/data/admins.json
|
||||
backend/storage/
|
||||
|
||||
# Project analysis / intermediate docs (do not commit)
|
||||
.claude/
|
||||
.work/
|
||||
|
||||
# Temporary files
|
||||
*.bak
|
||||
*.swp
|
||||
@@ -108,7 +112,7 @@ dmypy.json
|
||||
BUILD_GUIDE.md
|
||||
launcher.py
|
||||
launcher.spec
|
||||
mumuainovel.md
|
||||
xinmi.md
|
||||
logo.ico
|
||||
.embed_cache
|
||||
dist_embed/
|
||||
|
||||
@@ -4,37 +4,7 @@
|
||||
# 构建参数
|
||||
ARG USE_CN_MIRROR=false
|
||||
|
||||
# 阶段1: 构建前端
|
||||
FROM node:22-alpine AS frontend-builder
|
||||
|
||||
ARG USE_CN_MIRROR
|
||||
|
||||
WORKDIR /frontend
|
||||
|
||||
# 复制前端依赖文件
|
||||
COPY frontend/package*.json ./
|
||||
|
||||
# 根据参数决定是否使用国内npm镜像
|
||||
RUN if [ "$USE_CN_MIRROR" = "true" ]; then \
|
||||
npm config set registry https://registry.npmmirror.com; \
|
||||
fi
|
||||
|
||||
# 删除 package-lock.json 以避免因镜像源不一致导致的 404 错误
|
||||
RUN rm -f package-lock.json
|
||||
|
||||
# 安装依赖
|
||||
RUN npm install
|
||||
|
||||
# 复制前端源代码
|
||||
COPY frontend/ ./
|
||||
|
||||
# 临时修改vite配置,使其输出到dist目录(而不是../backend/static)
|
||||
RUN sed -i "s|outDir: '../backend/static'|outDir: 'dist'|g" vite.config.ts
|
||||
|
||||
# 构建前端
|
||||
RUN npm run build
|
||||
|
||||
# 阶段2: 构建最终镜像
|
||||
# 阶段1: 最终镜像
|
||||
FROM python:3.11-slim
|
||||
|
||||
ARG USE_CN_MIRROR
|
||||
@@ -46,8 +16,8 @@ WORKDIR /app
|
||||
|
||||
# 根据参数决定是否使用国内镜像源
|
||||
RUN if [ "$USE_CN_MIRROR" = "true" ]; then \
|
||||
sed -i 's/deb.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list.d/debian.sources && \
|
||||
sed -i 's/security.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list.d/debian.sources; \
|
||||
sed -i 's/deb.debian.org/mirrors.tuna.tsinghua.edu.cn/g' /etc/apt/sources.list.d/debian.sources && \
|
||||
sed -i 's/security.debian.org/mirrors.tuna.tsinghua.edu.cn/g' /etc/apt/sources.list.d/debian.sources; \
|
||||
fi
|
||||
|
||||
# 安装系统依赖(添加数据库工具)
|
||||
@@ -61,8 +31,6 @@ RUN apt-get update && apt-get install -y \
|
||||
COPY backend/requirements.txt ./
|
||||
|
||||
# 安装 Python 依赖
|
||||
# 先安装 torch CPU版本(~200MB vs 完整版~2GB,节省90%下载时间)
|
||||
# 对于embedding场景,CPU版本完全够用
|
||||
RUN if [ "$USE_CN_MIRROR" = "true" ]; then \
|
||||
pip install --no-cache-dir torch==2.8.0 --index-url https://mirrors.aliyun.com/pypi/simple/ --extra-index-url https://download.pytorch.org/whl/cpu && \
|
||||
pip install --no-cache-dir -r requirements.txt -i https://mirrors.aliyun.com/pypi/simple/; \
|
||||
@@ -78,7 +46,6 @@ RUN mkdir -p /app/embedding
|
||||
ENV SENTENCE_TRANSFORMERS_HOME=/app/embedding
|
||||
|
||||
# 下载 embedding 模型(从 HuggingFace)
|
||||
# 使用 Python 脚本预下载模型,这样运行时不需要网络
|
||||
RUN python -c "\
|
||||
from sentence_transformers import SentenceTransformer; \
|
||||
import os; \
|
||||
@@ -88,11 +55,12 @@ model = SentenceTransformer('sentence-transformers/paraphrase-multilingual-MiniL
|
||||
print('Model downloaded successfully!'); \
|
||||
"
|
||||
|
||||
# 复制后端代码(不包含embedding,因为已经下载了)
|
||||
# 复制后端代码
|
||||
COPY backend/ ./
|
||||
|
||||
# 从前端构建阶段复制构建好的静态文件
|
||||
COPY --from=frontend-builder /frontend/dist ./static
|
||||
# 复制宿主机预构建的静态文件
|
||||
# 这样可以避免 Docker 内部构建前端时的各种环境问题
|
||||
COPY backend/static/ ./static
|
||||
|
||||
# 复制 Alembic 迁移配置和脚本(PostgreSQL)
|
||||
COPY backend/alembic-postgres.ini ./alembic.ini
|
||||
@@ -100,8 +68,8 @@ COPY backend/alembic/postgres ./alembic
|
||||
COPY backend/scripts/entrypoint.sh /app/entrypoint.sh
|
||||
COPY backend/scripts/migrate.py ./scripts/migrate.py
|
||||
|
||||
# 赋予执行权限
|
||||
RUN chmod +x /app/entrypoint.sh
|
||||
# 修复 Windows CRLF 换行导致的启动失败,并赋予执行权限
|
||||
RUN sed -i 's/\r$//' /app/entrypoint.sh && chmod +x /app/entrypoint.sh
|
||||
|
||||
# 创建必要的目录
|
||||
RUN mkdir -p /app/data /app/logs
|
||||
@@ -114,7 +82,7 @@ ENV PYTHONUNBUFFERED=1
|
||||
ENV APP_HOST=0.0.0.0
|
||||
ENV APP_PORT=8000
|
||||
|
||||
# 设置运行时为离线模式(模型已在构建时下载)
|
||||
# 设置运行时为离线模式
|
||||
ENV TRANSFORMERS_OFFLINE=1
|
||||
ENV HF_DATASETS_OFFLINE=1
|
||||
ENV HF_HUB_OFFLINE=1
|
||||
@@ -123,5 +91,5 @@ ENV HF_HUB_OFFLINE=1
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" || exit 1
|
||||
|
||||
# 使用 entrypoint 脚本启动(自动执行迁移)
|
||||
# 使用 entrypoint 脚本启动
|
||||
ENTRYPOINT ["/app/entrypoint.sh"]
|
||||
@@ -1,643 +1,317 @@
|
||||
# 墨木灵思 📚✨
|
||||
|
||||
<div align="center">
|
||||
# 墨木灵思
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
**基于 AI 的智能小说创作助手**
|
||||
## 1. 项目简介
|
||||
|
||||
[特性](#-特性) • [快速开始](#-快速开始) • [配置说明](#%EF%B8%8F-配置说明) • [项目结构](#-项目结构)
|
||||
### 项目概述
|
||||
|
||||
</div>
|
||||
**墨木灵思**是一款基于大语言模型的智能小说创作平台,帮助作者从大纲、角色到章节一气呵成地完成创作,让 AI 成为可靠的写作搭档。
|
||||
|
||||
### 项目起源
|
||||
|
||||
长篇网文与原创小说创作往往面临设定繁杂、人物关系难梳理、章节衔接不连贯等问题。墨木灵思将 AI 能力融入创作全流程,把「灵感 → 大纲 → 角色 → 章节」串联为可管理的结构化工作流,降低创作门槛并提升产出效率。
|
||||
|
||||
### 项目定位
|
||||
|
||||
| 维度 | 说明 |
|
||||
|------|------|
|
||||
| **目标用户** | 网文作者、业余写作者、内容创作者、文学爱好者 |
|
||||
| **适用场景** | 长篇连载、短篇创作、世界观搭建、同人续写、拆书仿写 |
|
||||
| **部署形态** | 支持 Docker 一键部署,也可本地开发运行 |
|
||||
|
||||
### 核心价值
|
||||
|
||||
- 用 AI 辅助完成大纲、角色、世界观等前期设定,缩短冷启动时间
|
||||
- 多模型灵活切换,适配不同文风与成本需求
|
||||
- 角色关系、伏笔、职业体系等结构化管理能力,保持长篇一致性
|
||||
- 多用户数据隔离,适合个人或小团队私有化部署
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
## 2. 整体架构与技术栈
|
||||
|
||||
## 💬 加入交流群
|
||||
### 系统架构
|
||||
|
||||
欢迎扫码加入 QQ 交流群,一起交流 AI 小说创作心得、反馈问题、获取最新动态!
|
||||
采用**前后端分离**架构:React 单页应用负责交互,FastAPI 提供 REST API,PostgreSQL 持久化业务数据;生产环境通过 Docker Compose 编排应用与数据库,前端构建产物由后端静态托管,统一对外暴露 HTTP 服务。
|
||||
|
||||
<img src="frontend/public/qq.jpg" alt="QQ交流群二维码" width="300" />
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph Client
|
||||
UI[React SPA]
|
||||
end
|
||||
subgraph Server
|
||||
API[FastAPI]
|
||||
AI[AI 服务层]
|
||||
DB[(PostgreSQL)]
|
||||
Vec[ChromaDB / Embedding]
|
||||
end
|
||||
UI -->|HTTP / API| API
|
||||
API --> AI
|
||||
API --> DB
|
||||
API --> Vec
|
||||
AI -->|OpenAI 兼容 API| Ext[外部 LLM]
|
||||
```
|
||||
|
||||
</div>
|
||||
### 技术栈
|
||||
|
||||
| 分类 | 技术 | 版本 / 说明 |
|
||||
|------|------|-------------|
|
||||
| **后端** | Python | 3.11 |
|
||||
| | FastAPI | 0.121.0 |
|
||||
| | SQLAlchemy + Alembic | 2.0.25 / 1.14.0 |
|
||||
| | Uvicorn | 0.38.0 |
|
||||
| **前端** | React | 18.3.1 |
|
||||
| | TypeScript + Vite | — |
|
||||
| | Ant Design | 5.x |
|
||||
| | Zustand | 5.x |
|
||||
| **数据库** | PostgreSQL | 18(推荐) |
|
||||
| | SQLite | 可选(开发 / 轻量场景) |
|
||||
| **AI 与向量** | OpenAI / Anthropic SDK | 多模型接入 |
|
||||
| | ChromaDB + Sentence Transformers | 长期记忆与语义检索 |
|
||||
| **部署** | Docker + Docker Compose | 一键编排 |
|
||||
| | GitHub Actions | 镜像构建与发布 |
|
||||
|
||||
### 技术选型说明
|
||||
|
||||
- **FastAPI**:异步性能好,自带 OpenAPI 文档,适合 AI 类长耗时接口。
|
||||
- **PostgreSQL**:支持多用户、复杂关系与并发,满足生产级数据隔离。
|
||||
- **React + Ant Design**:组件丰富,适合复杂表单与可视化(关系图、时间线等)。
|
||||
- **Docker Compose**:降低部署成本,数据库与应用一键拉起。
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
## 3. 项目目录概览
|
||||
|
||||
## 💖 支持项目
|
||||
```
|
||||
MuMuAINovel/
|
||||
├── backend/ # 后端服务
|
||||
│ ├── app/
|
||||
│ │ ├── api/ # REST API 路由
|
||||
│ │ ├── models/ # 数据模型
|
||||
│ │ ├── services/ # 业务逻辑(含 AI 调用)
|
||||
│ │ ├── middleware/ # 中间件
|
||||
│ │ ├── mcp/ # MCP 插件集成
|
||||
│ │ └── main.py # 应用入口
|
||||
│ ├── alembic/ # 数据库迁移
|
||||
│ ├── scripts/ # 初始化与运维脚本
|
||||
│ ├── static/ # 前端构建产物(生产)
|
||||
│ └── requirements.txt
|
||||
├── frontend/ # 前端应用
|
||||
│ ├── src/
|
||||
│ │ ├── pages/ # 页面
|
||||
│ │ ├── components/ # 通用组件
|
||||
│ │ ├── services/ # API 封装
|
||||
│ │ ├── store/ # 状态管理
|
||||
│ │ └── theme/ # 主题配置
|
||||
│ └── package.json
|
||||
├── images/ # 文档与截图资源
|
||||
├── storage/ # 用户生成资源(如封面)
|
||||
├── logs/ # 运行日志
|
||||
├── docker-compose.yml # 容器编排
|
||||
├── Dockerfile # 镜像构建
|
||||
└── README.md
|
||||
```
|
||||
|
||||
如果这个项目对你有帮助,欢迎通过以下方式支持开发:
|
||||
|
||||
**[☕ 请我喝杯咖啡](https://mumuverse.space:1588/)**
|
||||
|
||||
**[🌐 MuMuのAPI站点](https://api.mumuverse.space/register?aff=4NN8)**
|
||||
|
||||
> 在 MuMu の API 站点充值满 50 元及以上,也可以获得下方赞助专属权益。
|
||||
|
||||
### 🎁 赞助专属权益
|
||||
|
||||
| 权益 | 说明 |
|
||||
| 目录 | 作用 |
|
||||
|------|------|
|
||||
| 📋 **优先需求响应** | 您的功能需求和问题反馈将获得优先处理 |
|
||||
| 🚀 **Windows一键启动** | 获取免安装 EXE 程序,双击即可使用 |
|
||||
| 💬 **专属技术支持** | 加入赞助者内部群,获得远程协助和配置指导 |
|
||||
|
||||
### ☕ 赞助 / API 站点充值档位
|
||||
|
||||
| 金额 | 描述 |
|
||||
|------|------|
|
||||
| ¥5 | 🌶️ 一包辣条 |
|
||||
| ¥10 | 🍱 一顿拼好饭 |
|
||||
| ¥20 | 🧋 一杯咖啡 |
|
||||
| ¥50 | 🍖 一次烧烤 |
|
||||
| ¥99 | 🍲 一顿海底捞 |
|
||||
|
||||
您的支持是我持续开发的动力!🙏
|
||||
|
||||
</div>
|
||||
| `backend/app/api/` | 项目、章节、角色、提示词等业务接口 |
|
||||
| `backend/app/services/` | AI 生成、润色、向量记忆等核心逻辑 |
|
||||
| `frontend/src/pages/` | 书架、项目详情、设置、提示词模板等页面 |
|
||||
| `backend/alembic/` | PostgreSQL / SQLite schema 迁移 |
|
||||
| `backend/scripts/` | 数据库初始化、入口脚本 |
|
||||
|
||||
---
|
||||
|
||||
## ✨ 特性
|
||||
## 4. 核心业务功能
|
||||
|
||||
- 🤖 **多 AI 模型** - 支持 OpenAI、Gemini、Claude 等主流模型
|
||||
- 📝 **智能向导** - AI 自动生成大纲、角色和世界观
|
||||
- 👥 **角色管理** - 人物关系、组织架构可视化管理
|
||||
- 📖 **章节编辑** - 支持创建、编辑、重新生成和润色
|
||||
- 🌐 **世界观设定** - 构建完整的故事背景
|
||||
- 🔐 **多种登录** - LinuxDO OAuth 或本地账户登录
|
||||
- 💾 **PostgreSQL** - 生产级数据库,多用户数据隔离
|
||||
- 🐳 **Docker 部署** - 一键启动,开箱即用
|
||||
- **智能创作向导**:根据题材与设定,AI 自动生成大纲、角色档案与世界观框架。
|
||||
- **多 AI 模型支持**:接入 OpenAI、Claude、Gemini 等,支持自定义 Base URL(兼容中转 API)。
|
||||
- **项目管理与书架**:多项目并行,支持导入导出,便于备份与迁移。
|
||||
- **角色与组织管理**:人物卡片、关系图谱、组织架构可视化编辑。
|
||||
- **职业等级体系**:可自定义修仙境界、魔法等级等成长体系。
|
||||
- **章节创作与润色**:章节生成、续写、重写、字数控制及 diff 对比。
|
||||
- **世界观与大纲**:结构化维护故事背景与情节脉络。
|
||||
- **伏笔管理**:追踪未回收伏笔,可视化时间线提醒。
|
||||
- **灵感模式**:快速生成创作点子与情节灵感。
|
||||
- **提示词工坊**:浏览、导入社区 Prompt 模板,可视化编辑自有模板。
|
||||
- **拆书功能**:分析既有作品结构,辅助仿写与续写。
|
||||
- **封面生成**:基于项目信息 AI 生成封面图。
|
||||
- **长期记忆**:基于 Embedding 的语义记忆,保持跨章节一致性。
|
||||
- **用户与认证**:本地账户、邮箱验证、LinuxDO OAuth;多用户数据隔离。
|
||||
- **系统设置**:SMTP、AI 密钥、主题等可在线配置。
|
||||
|
||||
## 📸 项目预览
|
||||
---
|
||||
|
||||
<details>
|
||||
## 5. 实际应用场景示例
|
||||
|
||||
<summary>多图预警</summary>
|
||||
### 适用行业与领域
|
||||
|
||||
<div align="center">
|
||||
- 网络文学与自媒体连载
|
||||
- 游戏、动漫、影视衍生文案
|
||||
- 教育培训中的创意写作练习
|
||||
- 个人 IP 与世界观孵化
|
||||
|
||||
### 登录界面
|
||||

|
||||
### 典型场景
|
||||
|
||||

|
||||
**场景一:新人作者快速开书**
|
||||
输入题材与基调后,由向导生成大纲与主要角色,再逐章扩写;适合从零起步、需要结构指引的作者。
|
||||
|
||||
### 主界面
|
||||

|
||||
**场景二:长篇连载一致性维护**
|
||||
在数百章规模下,通过角色关系图、伏笔管理与向量记忆,减少人设崩坏与情节穿帮。
|
||||
|
||||

|
||||
**场景三:拆书仿写与风格学习**
|
||||
导入参考作品或章节,分析结构后结合自身设定续写,用于练笔或同人向创作。
|
||||
|
||||
### 项目管理
|
||||

|
||||
---
|
||||
|
||||

|
||||
## 6. 帮助解决的核心问题
|
||||
|
||||
### 赞助我 💖
|
||||

|
||||
- **创作冷启动难**:自动生成大纲、角色与世界观,减少空白页焦虑。
|
||||
- **设定易混乱**:关系图、职业体系、伏笔追踪让长篇设定可检索、可维护。
|
||||
- **AI 调用分散**:统一配置多模型与 Prompt,降低切换成本。
|
||||
- **协作与部署复杂**:Docker 私有化部署,数据留在自有环境。
|
||||
- **章节质量不稳定**:润色、重写、分析建议一键应用,提升成稿效率。
|
||||
- **提示词难以沉淀**:模板工坊与可视化编辑,复用优质 Prompt。
|
||||
|
||||

|
||||
---
|
||||
|
||||
</div>
|
||||
## 7. 快速开始
|
||||
|
||||
</details>
|
||||
### 环境要求
|
||||
|
||||
## 📋 TODO List
|
||||
|
||||
### ✅ 已完成功能
|
||||
|
||||
- [x] **灵感模式** - 创作灵感和点子生成
|
||||
- [x] **自定义写作风格** - 支持自定义 AI 写作风格
|
||||
- [x] **数据导入导出** - 项目数据的导入导出
|
||||
- [x] **Prompt 调整界面** - 可视化编辑 Prompt 模板
|
||||
- [x] **章节字数限制** - 用户可设置生成字数
|
||||
- [x] **思维链与章节关系图谱** - 可视化章节逻辑关系
|
||||
- [x] **根据分析一键重写** - 根据分析建议重新生成
|
||||
- [x] **Linux DO 自动创建账号** - OAuth 登录自动生成账号
|
||||
- [x] **职业等级体系** - 自定义职业和等级系统,支持修仙境界、魔法等级等多种体系
|
||||
- [x] **角色/组织卡片导入导出** - 单独导出角色和组织卡片,支持跨项目数据共享
|
||||
- [x] **伏笔管理** - 智能追踪剧情伏笔,提醒未回收线索,可视化伏笔时间线
|
||||
- [x] **提示词工坊** - 社区驱动的 Prompt 模板分享平台,一键导入优质提示词
|
||||
- [x] **拆书功能** - 目前呼声比较高的功能,一键拆书,给当年的ta一个圆满的结局
|
||||
|
||||
### 📝 规划中功能
|
||||
|
||||
......
|
||||
|
||||
> 💡 欢迎提交 Issue 或 Pull Request!
|
||||
|
||||
## 💻 硬件配置要求
|
||||
|
||||
### 最低配置(个人使用/开发环境)
|
||||
|
||||
| 组件 | 要求 |
|
||||
| 方式 | 要求 |
|
||||
|------|------|
|
||||
| **CPU** | 2 核 |
|
||||
| **内存** | 2 GB RAM |
|
||||
| **存储** | 10 GB 可用空间 |
|
||||
| **网络** | 稳定互联网连接(用于调用 AI API) |
|
||||
| **Docker 部署(推荐)** | Docker 20.10+、Docker Compose 2.0+;至少一个 LLM API Key |
|
||||
| **本地开发** | Python 3.11、Node.js 18+、PostgreSQL 18(或 SQLite);稳定网络(调用外部 AI API) |
|
||||
|
||||
### 推荐配置(小型团队/生产环境)
|
||||
**硬件建议(个人使用)**:2 核 CPU、2 GB 内存、10 GB 磁盘;生产或小团队建议 4 核 / 8 GB 内存。项目依赖外部 AI API,无需本地 GPU。
|
||||
|
||||
| 组件 | 要求 |
|
||||
|------|------|
|
||||
| **CPU** | 4 核 |
|
||||
| **内存** | 8 GB RAM |
|
||||
| **存储** | 20 GB SSD |
|
||||
| **网络** | 稳定互联网连接 |
|
||||
|
||||
### 高并发配置(80-150 用户)
|
||||
|
||||
| 组件 | 要求 |
|
||||
|------|------|
|
||||
| **CPU** | 8 核 |
|
||||
| **内存** | 16 GB RAM |
|
||||
| **存储** | 50 GB+ SSD |
|
||||
| **网络** | 高带宽连接 |
|
||||
|
||||
> **📌 说明**
|
||||
> - **Embedding 模型**:约 400 MB 磁盘空间,运行时加载到内存
|
||||
> - **PostgreSQL**:默认配置使用 256 MB shared_buffers,1 GB effective_cache_size
|
||||
> - **Docker 部署**:建议预留额外 1-2 GB 内存给容器运行时
|
||||
> - 本项目主要依赖外部 AI API(OpenAI/Claude/Gemini),不需要本地 GPU
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
### 前置要求
|
||||
|
||||
- Docker 和 Docker Compose
|
||||
- 至少一个 AI 服务的 API Key(OpenAI/Gemini/Claude)
|
||||
|
||||
### Docker Compose 部署(推荐)
|
||||
### 安装步骤(Docker)
|
||||
|
||||
```bash
|
||||
# 1. 克隆项目
|
||||
git clone https://github.com/xiamuceer-j/墨木灵思.git
|
||||
cd 墨木灵思
|
||||
# 1. 获取项目代码
|
||||
git clone <你的仓库地址>
|
||||
cd MuMuAINovel
|
||||
|
||||
# 2. 配置环境变量(必需)
|
||||
# 2. 配置环境变量
|
||||
cp backend/.env.example .env
|
||||
# 编辑 .env 文件,填入必要配置(API Key、数据库密码等)
|
||||
# 编辑 .env:至少配置 AI API Key、数据库密码等
|
||||
|
||||
# 3. 确保文件准备完整
|
||||
# ⚠️ 重要:确保以下文件存在
|
||||
# - .env(配置文件,必需挂载到容器)
|
||||
# - backend/scripts/init_postgres.sql(数据库初始化脚本)
|
||||
|
||||
# 4. 启动服务
|
||||
docker-compose up -d
|
||||
|
||||
# 5. 访问应用
|
||||
# 打开浏览器访问 http://localhost:8000
|
||||
# 3. 确认必要文件存在
|
||||
# - .env
|
||||
# - backend/scripts/init_postgres.sql
|
||||
```
|
||||
|
||||
> **📌 注意事项**
|
||||
>
|
||||
> 1. **`.env` 文件挂载**: `docker-compose.yml` 会自动将 `.env` 挂载到容器,确保文件存在
|
||||
> 2. **数据库初始化**: `init_postgres.sql` 会在首次启动时自动执行,安装必要的PostgreSQL扩展
|
||||
> 3. **自行构建**: 如需从源码构建,请先下载 embedding 模型文件([加群获取](frontend/public/qq.jpg))
|
||||
|
||||
### 使用 Docker Hub 镜像(推荐新手)
|
||||
### 运行步骤
|
||||
|
||||
```bash
|
||||
# 1. 拉取最新镜像(已包含模型文件)
|
||||
docker pull mumujie/mumuainovel:latest
|
||||
# 启动全部服务
|
||||
docker compose up -d
|
||||
|
||||
# 2. 创建 docker-compose.yml(点击下方展开查看完整配置)
|
||||
# 查看状态与日志
|
||||
docker compose ps
|
||||
docker compose logs -f
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary>📄 点击展开 docker-compose.yml 完整配置</summary>
|
||||
### 访问地址
|
||||
|
||||
```yaml
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:18-alpine
|
||||
container_name: mumuainovel-postgres
|
||||
environment:
|
||||
POSTGRES_DB: ${POSTGRES_DB:-mumuai_novel}
|
||||
POSTGRES_USER: ${POSTGRES_USER:-mumuai}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-123456}
|
||||
POSTGRES_INITDB_ARGS: "--encoding=UTF8 --locale=C"
|
||||
TZ: ${TZ:-Asia/Shanghai}
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql
|
||||
- ./backend/scripts/init_postgres.sql:/docker-entrypoint-initdb.d/init.sql:ro
|
||||
ports:
|
||||
- "${POSTGRES_PORT:-5432}:5432"
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-mumuai} -d ${POSTGRES_DB:-mumuai_novel}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 10s
|
||||
networks:
|
||||
- ai-story-network
|
||||
command:
|
||||
- postgres
|
||||
- -c
|
||||
- max_connections=${POSTGRES_MAX_CONNECTIONS:-200}
|
||||
- -c
|
||||
- shared_buffers=${POSTGRES_SHARED_BUFFERS:-256MB}
|
||||
- -c
|
||||
- effective_cache_size=${POSTGRES_EFFECTIVE_CACHE_SIZE:-1GB}
|
||||
- -c
|
||||
- maintenance_work_mem=${POSTGRES_MAINTENANCE_WORK_MEM:-64MB}
|
||||
- -c
|
||||
- checkpoint_completion_target=${POSTGRES_CHECKPOINT_COMPLETION_TARGET:-0.9}
|
||||
- -c
|
||||
- wal_buffers=${POSTGRES_WAL_BUFFERS:-16MB}
|
||||
- -c
|
||||
- default_statistics_target=${POSTGRES_DEFAULT_STATISTICS_TARGET:-100}
|
||||
- -c
|
||||
- random_page_cost=${POSTGRES_RANDOM_PAGE_COST:-1.1}
|
||||
- -c
|
||||
- effective_io_concurrency=${POSTGRES_EFFECTIVE_IO_CONCURRENCY:-200}
|
||||
- -c
|
||||
- work_mem=${POSTGRES_WORK_MEM:-4MB}
|
||||
- -c
|
||||
- min_wal_size=${POSTGRES_MIN_WAL_SIZE:-1GB}
|
||||
- -c
|
||||
- max_wal_size=${POSTGRES_MAX_WAL_SIZE:-4GB}
|
||||
| 服务 | 默认地址 |
|
||||
|------|----------|
|
||||
| **Web 应用** | http://localhost:8000 |
|
||||
| **API 文档(Swagger)** | http://localhost:8000/docs |
|
||||
| **API 文档(ReDoc)** | http://localhost:8000/redoc |
|
||||
| **PostgreSQL** | localhost:5432(容器内通过服务名 `postgres` 访问) |
|
||||
|
||||
mumuainovel:
|
||||
image: mumujie/mumuainovel:latest
|
||||
container_name: mumuainovel
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- "${APP_PORT:-8000}:8000"
|
||||
volumes:
|
||||
- ./logs:/app/logs
|
||||
- ./.env:/app/.env:ro
|
||||
- ./storage/generated_covers:/app/backend/storage/generated_covers
|
||||
environment:
|
||||
# 应用配置
|
||||
- APP_NAME=${APP_NAME:-墨木灵思}
|
||||
- APP_VERSION=${APP_VERSION:-1.0.0}
|
||||
- APP_HOST=${APP_HOST:-0.0.0.0}
|
||||
- APP_PORT=8000
|
||||
- DEBUG=${DEBUG:-false}
|
||||
# 数据库配置
|
||||
- DATABASE_URL=postgresql+asyncpg://${POSTGRES_USER:-mumuai}:${POSTGRES_PASSWORD:-123456}@postgres:5432/${POSTGRES_DB:-mumuai_novel}
|
||||
- DB_HOST=postgres
|
||||
- DB_PORT=5432
|
||||
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-123456}
|
||||
# PostgreSQL 连接池配置
|
||||
- DATABASE_POOL_SIZE=${DATABASE_POOL_SIZE:-30}
|
||||
- DATABASE_MAX_OVERFLOW=${DATABASE_MAX_OVERFLOW:-20}
|
||||
- DATABASE_POOL_TIMEOUT=${DATABASE_POOL_TIMEOUT:-60}
|
||||
- DATABASE_POOL_RECYCLE=${DATABASE_POOL_RECYCLE:-1800}
|
||||
- DATABASE_POOL_PRE_PING=${DATABASE_POOL_PRE_PING:-True}
|
||||
- DATABASE_POOL_USE_LIFO=${DATABASE_POOL_USE_LIFO:-True}
|
||||
# 代理配置(可选)
|
||||
- HTTP_PROXY=${HTTP_PROXY:-}
|
||||
- HTTPS_PROXY=${HTTPS_PROXY:-}
|
||||
- NO_PROXY=${NO_PROXY:-localhost,127.0.0.1}
|
||||
# AI 服务配置
|
||||
- OPENAI_API_KEY=${OPENAI_API_KEY:-}
|
||||
- OPENAI_BASE_URL=${OPENAI_BASE_URL:-https://api.openai.com/v1}
|
||||
- GEMINI_API_KEY=${GEMINI_API_KEY:-}
|
||||
- GEMINI_BASE_URL=${GEMINI_BASE_URL:-}
|
||||
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
|
||||
- ANTHROPIC_BASE_URL=${ANTHROPIC_BASE_URL:-}
|
||||
- DEFAULT_AI_PROVIDER=${DEFAULT_AI_PROVIDER:-openai}
|
||||
- DEFAULT_MODEL=${DEFAULT_MODEL:-gpt-4o-mini}
|
||||
- DEFAULT_TEMPERATURE=${DEFAULT_TEMPERATURE:-0.7}
|
||||
- DEFAULT_MAX_TOKENS=${DEFAULT_MAX_TOKENS:-32000}
|
||||
# LinuxDO OAuth 配置
|
||||
- LINUXDO_CLIENT_ID=${LINUXDO_CLIENT_ID:-11111}
|
||||
- LINUXDO_CLIENT_SECRET=${LINUXDO_CLIENT_SECRET:-11111}
|
||||
- LINUXDO_REDIRECT_URI=${LINUXDO_REDIRECT_URI:-http://localhost:8000/api/auth/linuxdo/callback}
|
||||
- FRONTEND_URL=${FRONTEND_URL:-http://localhost:8000}
|
||||
# 本地账户登录配置
|
||||
- LOCAL_AUTH_ENABLED=${LOCAL_AUTH_ENABLED:-true}
|
||||
- LOCAL_AUTH_USERNAME=${LOCAL_AUTH_USERNAME:-admin}
|
||||
- LOCAL_AUTH_PASSWORD=${LOCAL_AUTH_PASSWORD:-admin123}
|
||||
- LOCAL_AUTH_DISPLAY_NAME=${LOCAL_AUTH_DISPLAY_NAME:-本地管理员}
|
||||
# 会话配置
|
||||
- SESSION_EXPIRE_MINUTES=${SESSION_EXPIRE_MINUTES:-120}
|
||||
- SESSION_REFRESH_THRESHOLD_MINUTES=${SESSION_REFRESH_THRESHOLD_MINUTES:-30}
|
||||
- SESSION_COOKIE_SECURE=${SESSION_COOKIE_SECURE:-true}
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
networks:
|
||||
- ai-story-network
|
||||
> 若修改 `.env` 中 `APP_PORT`(例如 `10003`),访问地址为 `http://localhost:10003`。
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
driver: local
|
||||
### 默认账号
|
||||
|
||||
networks:
|
||||
ai-story-network:
|
||||
driver: bridge
|
||||
```
|
||||
在 `.env` 中启用本地登录时,默认配置示例:
|
||||
|
||||
</details>
|
||||
| 项 | 默认值 |
|
||||
|----|--------|
|
||||
| 用户名 | `admin` |
|
||||
| 密码 | `admin123` |
|
||||
|
||||
```bash
|
||||
# 3. 启动服务
|
||||
docker-compose up -d
|
||||
**请在生产环境中立即修改默认密码。**
|
||||
|
||||
# 4. 查看日志
|
||||
docker-compose logs -f
|
||||
### 本地开发(可选)
|
||||
|
||||
# 5. 更新到最新版本
|
||||
docker-compose pull
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
> **💡 提示**: Docker Hub 镜像已包含所有依赖和模型文件,无需额外下载
|
||||
|
||||
### 本地开发 / 从源码构建
|
||||
|
||||
#### 前置准备
|
||||
|
||||
```bash
|
||||
# ⚠️ 重要:如果从源码构建,需要先下载 embedding 模型文件
|
||||
# 模型文件较大(约 400MB),需放置到以下目录:
|
||||
# backend/embedding/models--sentence-transformers--paraphrase-multilingual-MiniLM-L12-v2/
|
||||
#
|
||||
# 📥 获取方式:
|
||||
# - 加入项目 QQ 群或 Linux DO 讨论区获取下载链接
|
||||
# - 群号:见项目主页
|
||||
# - Linux DO:https://linux.do/t/topic/1100112
|
||||
```
|
||||
|
||||
#### 后端
|
||||
**后端**
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
python -m venv .venv
|
||||
source .venv/bin/activate # Windows: .venv\Scripts\activate
|
||||
# Windows: .venv\Scripts\activate
|
||||
# Linux/macOS: source .venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
|
||||
# 配置 .env 文件
|
||||
cp .env.example .env
|
||||
# 编辑 .env 填入必要配置
|
||||
|
||||
# 启动 PostgreSQL(可使用 Docker)
|
||||
docker run -d --name postgres \
|
||||
-e POSTGRES_PASSWORD=your_password \
|
||||
-e POSTGRES_DB=mumuai_novel \
|
||||
-p 5432:5432 \
|
||||
postgres:18-alpine
|
||||
|
||||
# 启动后端
|
||||
python -m uvicorn app.main:app --host localhost --port 8000 --reload
|
||||
# 配置 DATABASE_URL 与 AI Key 后启动
|
||||
python -m uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
||||
```
|
||||
|
||||
#### 前端
|
||||
**前端**
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev # 开发模式
|
||||
npm run build # 生产构建
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## ⚙️ 配置说明
|
||||
生产构建:`npm run build`,产物由后端 `backend/static` 托管。
|
||||
|
||||
### 必需配置
|
||||
|
||||
创建 `.env` 文件:
|
||||
### 必需配置示例
|
||||
|
||||
```bash
|
||||
# PostgreSQL 数据库(必需)
|
||||
DATABASE_URL=postgresql+asyncpg://mumuai:your_password@postgres:5432/mumuai_novel
|
||||
# 数据库(Docker 场景由 compose 注入,本地需自行填写)
|
||||
POSTGRES_PASSWORD=your_secure_password
|
||||
|
||||
# AI 服务
|
||||
OPENAI_API_KEY=your_openai_key
|
||||
# AI 服务(至少配置一项)
|
||||
OPENAI_API_KEY=your_key
|
||||
OPENAI_BASE_URL=https://api.openai.com/v1
|
||||
DEFAULT_AI_PROVIDER=openai
|
||||
DEFAULT_MODEL=gpt-4o-mini
|
||||
|
||||
# 本地账户登录
|
||||
# 本地登录
|
||||
LOCAL_AUTH_ENABLED=true
|
||||
LOCAL_AUTH_USERNAME=admin
|
||||
LOCAL_AUTH_PASSWORD=your_password
|
||||
```
|
||||
|
||||
### 可选配置
|
||||
### 常见问题
|
||||
|
||||
```bash
|
||||
# LinuxDO OAuth
|
||||
LINUXDO_CLIENT_ID=your_client_id
|
||||
LINUXDO_CLIENT_SECRET=your_client_secret
|
||||
LINUXDO_REDIRECT_URI=http://localhost:8000/api/auth/callback
|
||||
|
||||
# PostgreSQL 连接池(高并发优化)
|
||||
DATABASE_POOL_SIZE=30
|
||||
DATABASE_MAX_OVERFLOW=20
|
||||
|
||||
# 会话 Cookie Secure 标记
|
||||
# 默认 true,适合 HTTPS 部署;如果使用 HTTP 访问并且浏览器不保存登录 Cookie,可设为 false
|
||||
SESSION_COOKIE_SECURE=true
|
||||
```
|
||||
|
||||
> **🔐 Cookie Secure 说明**
|
||||
>
|
||||
> - HTTPS 部署:建议保持 `SESSION_COOKIE_SECURE=true`,浏览器只会通过 HTTPS 发送登录 Cookie。
|
||||
> - HTTP 部署:如果登录后浏览器没有保存 Cookie,请在 `.env` 中设置 `SESSION_COOKIE_SECURE=false`,然后重启后端或 Docker 容器。
|
||||
> - Docker Compose 示例默认使用 `SESSION_COOKIE_SECURE=${SESSION_COOKIE_SECURE:-true}`,如需关闭必须在 `.env` 中显式配置。
|
||||
|
||||
### 中转 API 配置
|
||||
|
||||
支持所有 OpenAI 兼容格式的中转服务:
|
||||
|
||||
```bash
|
||||
# New API 示例
|
||||
OPENAI_API_KEY=sk-xxxxxxxx
|
||||
OPENAI_BASE_URL=https://api.new-api.com/v1
|
||||
|
||||
# 其他中转服务
|
||||
OPENAI_BASE_URL=https://your-proxy-service.com/v1
|
||||
```
|
||||
|
||||
## 🐳 Docker 部署详情
|
||||
|
||||
### 服务架构
|
||||
|
||||
- **postgres**: PostgreSQL 18 数据库
|
||||
- 端口: 5432
|
||||
- 数据持久化: `postgres_data` volume
|
||||
- 初始化脚本: `backend/scripts/init_postgres.sql`(自动挂载)
|
||||
- 优化配置: 支持 80-150 并发用户
|
||||
|
||||
- **mumuainovel**: 主应用服务
|
||||
- 端口: 8000
|
||||
- 日志目录: `./logs`
|
||||
- 配置挂载: `.env` 文件
|
||||
- 自动等待数据库就绪
|
||||
- 健康检查: 每 30 秒检测一次
|
||||
|
||||
### 重要文件说明
|
||||
|
||||
| 文件 | 说明 | 是否必需 |
|
||||
|------|------|---------|
|
||||
| `.env` | 环境配置(API Key、数据库密码等) | ✅ 必需 |
|
||||
| `docker-compose.yml` | 服务编排配置 | ✅ 必需 |
|
||||
| `backend/scripts/init_postgres.sql` | PostgreSQL 扩展安装脚本 | ✅ 自动挂载 |
|
||||
| `backend/embedding/models--*/` | Embedding 模型文件 | ⚠️ 自建需要 |
|
||||
|
||||
> **注意**: 使用 Docker Hub 镜像时,模型文件已包含在镜像中,无需额外下载
|
||||
|
||||
### 常用命令
|
||||
|
||||
```bash
|
||||
# 启动服务
|
||||
docker-compose up -d
|
||||
|
||||
# 查看状态
|
||||
docker-compose ps
|
||||
|
||||
# 查看日志
|
||||
docker-compose logs -f
|
||||
|
||||
# 停止服务
|
||||
docker-compose down
|
||||
|
||||
# 重启服务
|
||||
docker-compose restart
|
||||
|
||||
# 查看资源使用
|
||||
docker stats
|
||||
```
|
||||
|
||||
### 数据持久化
|
||||
|
||||
- `./postgres_data` - PostgreSQL 数据库文件
|
||||
- `./logs` - 应用日志文件
|
||||
|
||||
### 端口配置
|
||||
|
||||
修改 `docker-compose.yml` 中的端口映射:
|
||||
|
||||
```yaml
|
||||
ports:
|
||||
- "8800:8000" # 宿主机:容器
|
||||
```
|
||||
|
||||
## 📁 项目结构
|
||||
|
||||
```
|
||||
墨木灵思/
|
||||
├── backend/ # 后端服务
|
||||
│ ├── app/
|
||||
│ │ ├── api/ # API 路由
|
||||
│ │ ├── models/ # 数据模型
|
||||
│ │ ├── services/ # 业务逻辑
|
||||
│ │ ├── middleware/ # 中间件
|
||||
│ │ ├── database.py # 数据库连接
|
||||
│ │ └── main.py # 应用入口
|
||||
│ ├── scripts/ # 工具脚本
|
||||
│ └── requirements.txt # Python 依赖
|
||||
├── frontend/ # 前端应用
|
||||
│ ├── src/
|
||||
│ │ ├── pages/ # 页面组件
|
||||
│ │ ├── components/ # 通用组件
|
||||
│ │ ├── services/ # API 服务
|
||||
│ │ └── store/ # 状态管理
|
||||
│ └── package.json
|
||||
├── docker-compose.yml # Docker Compose 配置
|
||||
├── Dockerfile # Docker 镜像构建
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## 🛠️ 技术栈
|
||||
|
||||
**后端**: FastAPI • PostgreSQL • SQLAlchemy • OpenAI/Claude/Gemini SDK
|
||||
|
||||
**前端**: React 18 • TypeScript • Ant Design • Zustand • Vite
|
||||
|
||||
## 📖 使用指南
|
||||
|
||||
1. **登录系统** - 使用本地账户或 LinuxDO 账户
|
||||
2. **创建项目** - 选择"使用向导创建"
|
||||
3. **AI 生成** - 输入基本信息,AI 自动生成大纲和角色
|
||||
4. **编辑完善** - 管理角色关系,生成和编辑章节
|
||||
|
||||
### API 文档
|
||||
|
||||
- Swagger UI: `http://localhost:8000/docs`
|
||||
- ReDoc: `http://localhost:8000/redoc`
|
||||
|
||||
## 🤝 贡献
|
||||
|
||||
欢迎提交 Issue 和 Pull Request!
|
||||
|
||||
1. Fork 本项目
|
||||
2. 创建特性分支 (`git checkout -b feature/AmazingFeature`)
|
||||
3. 提交更改 (`git commit -m 'Add some AmazingFeature'`)
|
||||
4. 推送到分支 (`git push origin feature/AmazingFeature`)
|
||||
5. 提交 Pull Request
|
||||
|
||||
### 贡献者
|
||||
|
||||
感谢所有为本项目做出贡献的开发者!
|
||||
|
||||
<a href="https://github.com/xiamuceer-j/墨木灵思/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=xiamuceer-j/墨木灵思" />
|
||||
</a>
|
||||
|
||||
## 📝 许可证
|
||||
|
||||
本项目采用 [GNU General Public License v3.0](LICENSE)
|
||||
|
||||
**GPL v3 意味着:**
|
||||
- ✅ 可自由使用、修改和分发
|
||||
- ✅ 可用于商业目的
|
||||
- 📝 必须开源修改版本
|
||||
- 📝 必须保留原作者版权
|
||||
- 📝 衍生作品必须使用 GPL v3 协议
|
||||
|
||||
## 🙏 致谢
|
||||
|
||||
- [FastAPI](https://fastapi.tiangolo.com/) - Python Web 框架
|
||||
- [React](https://react.dev/) - 前端框架
|
||||
- [Ant Design](https://ant.design/) - UI 组件库
|
||||
- [PostgreSQL](https://www.postgresql.org/) - 数据库
|
||||
|
||||
## 📧 联系方式
|
||||
|
||||
- 提交 [Issue](https://github.com/xiamuceer-j/墨木灵思/issues)
|
||||
- Linux DO [讨论](https://linux.do/t/topic/1106333)
|
||||
- 加入QQ群 [QQ群](frontend/public/qq.jpg)
|
||||
- 加入WX群 [WX群](frontend/public/WX.png)
|
||||
| 问题 | 解决方案 |
|
||||
|------|----------|
|
||||
| 启动后无法访问 | 检查 `APP_PORT` 映射与防火墙;`docker compose ps` 确认容器健康 |
|
||||
| 登录后 Cookie 未保存 | HTTP 部署时将 `SESSION_COOKIE_SECURE=false` 写入 `.env` 并重启 |
|
||||
| AI 调用失败 | 核对 API Key、Base URL 及网络代理(`HTTP_PROXY` / `HTTPS_PROXY`) |
|
||||
| 数据库连接失败 | 确认 PostgreSQL 容器已 healthy;检查 `DATABASE_URL` 用户名密码 |
|
||||
| 自建镜像缺少 Embedding | 使用官方镜像或按文档将模型放入 `backend/embedding/` 目录 |
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
## 8. 运行示例截图
|
||||
|
||||
**如果这个项目对你有帮助,请给个 ⭐️ Star!**
|
||||

|
||||
|
||||
Made with ❤️
|
||||

|
||||
|
||||
</div>
|
||||

|
||||
|
||||
## Star History
|
||||

|
||||
|
||||
<a href="https://www.star-history.com/#xiamuceer-j/墨木灵思&type=date&legend=top-left">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=xiamuceer-j/墨木灵思&type=date&theme=dark&legend=top-left" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=xiamuceer-j/墨木灵思&type=date&legend=top-left" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=xiamuceer-j/墨木灵思&type=date&legend=top-left" />
|
||||
</picture>
|
||||
</a>
|
||||

|
||||
|
||||
## History
|
||||

|
||||
|
||||

|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
@@ -19,13 +19,13 @@ TZ=Asia/Shanghai
|
||||
# ==========================================
|
||||
|
||||
# PostgreSQL 连接信息
|
||||
POSTGRES_DB=mumuai_novel
|
||||
POSTGRES_USER=mumuai
|
||||
POSTGRES_DB=mumulingsi_novel
|
||||
POSTGRES_USER=mumulingsi
|
||||
POSTGRES_PASSWORD=123456
|
||||
POSTGRES_PORT=5432
|
||||
|
||||
# 数据库连接 URL(Docker 部署时自动生成)
|
||||
# DATABASE_URL=postgresql+asyncpg://mumuai:123456@localhost:5432/mumuai_novel
|
||||
# DATABASE_URL=postgresql+asyncpg://mumulingsi:123456@localhost:5432/mumulingsi_novel
|
||||
|
||||
# ==========================================
|
||||
# SQLite 数据库配置
|
||||
@@ -45,7 +45,7 @@ LOG_BACKUP_COUNT=30
|
||||
# ==========================================
|
||||
# CORS 配置
|
||||
# ==========================================
|
||||
CORS_ORIGINS=["http://localhost:8000","http://127.0.0.1:8000"]
|
||||
CORS_ORIGINS=["http://localhost:3000","http://127.0.0.1:8000"]
|
||||
|
||||
# ==========================================
|
||||
# 代理配置(可选)
|
||||
@@ -116,11 +116,11 @@ EMAIL_VERIFICATION_RESEND_INTERVAL_SECONDS=60
|
||||
# 提示词工坊配置
|
||||
# ==========================================
|
||||
# 运行模式:client(本地部署)或 server(云端服务器)
|
||||
# 只有 mumuverse.space:1566 需要设置为 server
|
||||
# 云端服务配置示例
|
||||
WORKSHOP_MODE=client
|
||||
|
||||
# 云端服务地址(client 模式使用)
|
||||
WORKSHOP_CLOUD_URL=https://mumuverse.space:1566
|
||||
WORKSHOP_CLOUD_URL=
|
||||
|
||||
# 云端 API 请求超时时间(秒)
|
||||
WORKSHOP_API_TIMEOUT=30
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Alembic Database Migration Profile - PostgreSQL
|
||||
# Database version management for the 墨木灵思 project
|
||||
# Database version management for the mumulingsi project
|
||||
|
||||
[alembic]
|
||||
# Migration Script storage directory (PostgreSQL)
|
||||
@@ -10,7 +10,7 @@ file_template = %%(year)d%%(month).2d%%(day).2d_%%(hour).2d%%(minute).2d_%%(rev)
|
||||
|
||||
# Database connection string
|
||||
# Note: The actual connection string is read from the environment variable in env.py
|
||||
# sqlalchemy.url = postgresql+asyncpg://mumuai:password@localhost:5432/mumuai_novel
|
||||
# sqlalchemy.url = postgresql+asyncpg://mumulingsi:password@localhost:5432/mumulingsi_novel
|
||||
|
||||
# Log Configuration
|
||||
[loggers]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Alembic Database Migration Profile - SQLite
|
||||
# Database version management for the 墨木灵思 project
|
||||
# Database version management for the mumulingsi project
|
||||
|
||||
[alembic]
|
||||
# Migration Script storage directory (SQLite)
|
||||
|
||||
@@ -59,7 +59,7 @@ alembic -c alembic-postgres.ini current
|
||||
#### 配置环境变量
|
||||
```bash
|
||||
# .env 文件
|
||||
DATABASE_URL=sqlite+aiosqlite:///./data/mumuai.db
|
||||
DATABASE_URL=sqlite+aiosqlite:///./data/mumulingsi.db
|
||||
```
|
||||
|
||||
#### 生成迁移脚本
|
||||
@@ -115,7 +115,7 @@ alembic -c alembic-sqlite.ini current
|
||||
|
||||
```bash
|
||||
# 切换到 SQLite
|
||||
DATABASE_URL=sqlite+aiosqlite:///./data/mumuai.db
|
||||
DATABASE_URL=sqlite+aiosqlite:///./data/mumulingsi.db
|
||||
alembic -c alembic-sqlite.ini upgrade head
|
||||
|
||||
# 切换到 PostgreSQL
|
||||
|
||||
@@ -21,8 +21,8 @@ def require_login(request: Request):
|
||||
|
||||
# GitHub API配置
|
||||
GITHUB_API_BASE = "https://api.github.com"
|
||||
REPO_OWNER = "xiamuceer-j"
|
||||
REPO_NAME = "墨木灵思"
|
||||
REPO_OWNER = "mumulingsi-project"
|
||||
REPO_NAME = "mumulingsi"
|
||||
|
||||
# 缓存配置
|
||||
_cache = {
|
||||
@@ -88,7 +88,7 @@ async def fetch_github_commits(page: int = 1, per_page: int = 30) -> List[dict]:
|
||||
|
||||
headers = {
|
||||
"Accept": "application/vnd.github.v3+json",
|
||||
"User-Agent": "墨木灵思-App"
|
||||
"User-Agent": "mumulingsi-App"
|
||||
}
|
||||
|
||||
try:
|
||||
|
||||
@@ -16,7 +16,7 @@ config_logger = logging.getLogger(__name__)
|
||||
|
||||
# 数据库配置:PostgreSQL
|
||||
# 从环境变量获取数据库URL
|
||||
DATABASE_URL = os.getenv("DATABASE_URL", "postgresql+asyncpg://mumuai:password@localhost:5432/mumuai_novel")
|
||||
DATABASE_URL = os.getenv("DATABASE_URL", "postgresql+asyncpg://mumulingsi:password@localhost:5432/mumulingsi_novel")
|
||||
|
||||
config_logger.debug(f"数据库类型: PostgreSQL")
|
||||
config_logger.debug(f"数据库URL: {DATABASE_URL}")
|
||||
@@ -39,7 +39,7 @@ class Settings(BaseSettings):
|
||||
log_backup_count: int = 30 # 保留30个备份文件
|
||||
|
||||
# CORS配置
|
||||
cors_origins: list[str] = ["http://localhost:8000", "http://127.0.0.1:8000"]
|
||||
cors_origins: list[str] = ["http://localhost:3000", "http://127.0.0.1:8000"]
|
||||
|
||||
# 数据库配置 - PostgreSQL
|
||||
database_url: str = DATABASE_URL
|
||||
@@ -126,7 +126,7 @@ class Settings(BaseSettings):
|
||||
|
||||
# 提示词工坊配置
|
||||
WORKSHOP_MODE: str = "client" # client: 本地部署实例, server: 云端中央服务器
|
||||
WORKSHOP_CLOUD_URL: str = "https://mumuverse.space:1566" # 云端服务地址
|
||||
WORKSHOP_CLOUD_URL: str = "" # 云端服务地址
|
||||
WORKSHOP_API_TIMEOUT: int = 30 # 云端API请求超时时间(秒)
|
||||
|
||||
class Config:
|
||||
|
||||
@@ -141,6 +141,12 @@ async def db_session_stats(request: Request):
|
||||
}
|
||||
|
||||
|
||||
@app.get("/nanobot/sessions")
|
||||
async def nanobot_sessions():
|
||||
"""兼容层:为环境监控工具提供空的会话列表"""
|
||||
return []
|
||||
|
||||
|
||||
from app.api import (
|
||||
projects, outlines, characters, chapters,
|
||||
wizard_stream, relationships, organizations,
|
||||
|
||||
@@ -24,7 +24,7 @@ def _session_secret() -> bytes:
|
||||
or settings.openai_api_key
|
||||
)
|
||||
if not secret:
|
||||
secret = "mumuainovel-development-session-secret"
|
||||
secret = "mumulingsi-development-session-secret"
|
||||
return str(secret).encode("utf-8")
|
||||
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ logger = get_logger(__name__)
|
||||
|
||||
def normalize_provider(provider: Optional[str]) -> Optional[str]:
|
||||
"""标准化 provider 名称,兼容渠道别名。"""
|
||||
if provider == "mumu":
|
||||
if provider == "xinmi":
|
||||
return "openai"
|
||||
return provider
|
||||
|
||||
|
||||
@@ -225,11 +225,11 @@ class CoverGenerationService:
|
||||
return GeminiCoverProvider(api_key=api_key, base_url=normalized_base_url)
|
||||
if provider_value == "grok":
|
||||
return GrokCoverProvider(api_key=api_key, base_url=normalized_base_url)
|
||||
if provider_value == "mumu":
|
||||
if provider_value == "xinmi":
|
||||
if normalized_base_url.endswith("/v1beta"):
|
||||
return GeminiCoverProvider(api_key=api_key, base_url=normalized_base_url)
|
||||
return GrokCoverProvider(api_key=api_key, base_url=normalized_base_url or "https://api.mumuverse.space/v1")
|
||||
raise HTTPException(status_code=400, detail="当前版本仅支持 Gemini、Grok 或 MuMuのAPI 作为封面图片 Provider")
|
||||
return GrokCoverProvider(api_key=api_key, base_url=normalized_base_url or "v1")
|
||||
raise HTTPException(status_code=400, detail="当前版本仅支持 Gemini、Grok 或 墨木灵思 API 作为封面图片 Provider")
|
||||
|
||||
def _save_cover_file(
|
||||
self,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/bin/bash
|
||||
#!/bin/sh
|
||||
# Docker 容器启动入口脚本
|
||||
# 功能:等待数据库就绪,执行迁移,启动应用
|
||||
|
||||
@@ -31,8 +31,8 @@ echo "================================================"
|
||||
# 数据库配置(从环境变量读取)
|
||||
DB_HOST="${DB_HOST:-postgres}"
|
||||
DB_PORT="${DB_PORT:-5432}"
|
||||
DB_USER="${POSTGRES_USER:-mumuai}"
|
||||
DB_NAME="${POSTGRES_DB:-mumuai_novel}"
|
||||
DB_USER="${POSTGRES_USER:-mumulingsi}"
|
||||
DB_NAME="${POSTGRES_DB:-mumulingsi_novel}"
|
||||
|
||||
# 等待数据库就绪
|
||||
echo "⏳ 等待数据库启动..."
|
||||
|
||||
@@ -8,7 +8,7 @@ CREATE EXTENSION IF NOT EXISTS "pg_trgm"; -- 模糊搜索和全文检索支
|
||||
DO $$
|
||||
BEGIN
|
||||
RAISE NOTICE '==================================================';
|
||||
RAISE NOTICE '墨木灵思 PostgreSQL 扩展安装完成';
|
||||
RAISE NOTICE 'mumulingsi PostgreSQL 扩展安装完成';
|
||||
RAISE NOTICE '已安装扩展:';
|
||||
RAISE NOTICE ' - uuid-ossp: UUID生成支持';
|
||||
RAISE NOTICE ' - pg_trgm: 模糊搜索和全文检索支持';
|
||||
|
||||
@@ -56,8 +56,8 @@ class PostgreSQLSetup:
|
||||
port: int = 5432,
|
||||
admin_user: str = "postgres",
|
||||
admin_password: str = None,
|
||||
db_name: str = "mumuai_novel",
|
||||
db_user: str = "mumuai",
|
||||
db_name: str = "xinmi_novel",
|
||||
db_user: str = "mumulingsi",
|
||||
db_password: str = "123456"
|
||||
):
|
||||
"""
|
||||
@@ -374,9 +374,9 @@ async def main():
|
||||
admin_password = getpass(f"管理员密码: ")
|
||||
|
||||
print("\n请输入要创建的数据库信息:\n")
|
||||
db_name = input("数据库名 [mumuai_novel]: ").strip() or "mumuai_novel"
|
||||
db_user = input("数据库用户名 [mumuai]: ").strip() or "mumuai"
|
||||
db_password = getpass("数据库用户密码 [mumuai123]: ") or "mumuai123"
|
||||
db_name = input("数据库名 [xinmi_novel]: ").strip() or "xinmi_novel"
|
||||
db_user = input("数据库用户名 [mumulingsi]: ").strip() or "mumulingsi"
|
||||
db_password = getpass("数据库用户密码 [xinmi123]: ") or "xinmi123"
|
||||
|
||||
print(f"\n{'='*60}")
|
||||
print(f"配置摘要:")
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:18-alpine
|
||||
container_name: mumuainovel-postgres
|
||||
container_name: mumulingsi-postgres
|
||||
environment:
|
||||
POSTGRES_DB: ${POSTGRES_DB:-mumuai_novel}
|
||||
POSTGRES_USER: ${POSTGRES_USER:-mumuai}
|
||||
POSTGRES_DB: ${POSTGRES_DB:-mumulingsi_novel}
|
||||
POSTGRES_USER: ${POSTGRES_USER:-mumulingsi}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-123456}
|
||||
POSTGRES_INITDB_ARGS: "--encoding=UTF8 --locale=C"
|
||||
TZ: ${TZ:-Asia/Shanghai}
|
||||
@@ -15,7 +15,7 @@ services:
|
||||
- "${POSTGRES_PORT:-5432}:5432"
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-mumuai} -d ${POSTGRES_DB:-mumuai_novel}"]
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-mumulingsi} -d ${POSTGRES_DB:-mumulingsi_novel}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
@@ -49,12 +49,14 @@ services:
|
||||
- -c
|
||||
- max_wal_size=${POSTGRES_MAX_WAL_SIZE:-4GB}
|
||||
|
||||
mumuainovel:
|
||||
mumulingsi:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
image: mumujie/mumuainovel:latest
|
||||
container_name: mumuainovel
|
||||
args:
|
||||
- USE_CN_MIRROR=${USE_CN_MIRROR:-false}
|
||||
image: mumulingsi-project/mumulingsi:latest
|
||||
container_name: mumulingsi
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
@@ -76,11 +78,13 @@ services:
|
||||
- DEBUG=${DEBUG:-false}
|
||||
|
||||
# 数据库配置
|
||||
- DATABASE_URL=postgresql+asyncpg://${POSTGRES_USER:-mumuai}:${POSTGRES_PASSWORD:-123456}@postgres:5432/${POSTGRES_DB:-mumuai_novel}
|
||||
- DATABASE_URL=postgresql+asyncpg://${POSTGRES_USER:-mumulingsi}:${POSTGRES_PASSWORD:-123456}@postgres:5432/${POSTGRES_DB:-mumulingsi_novel}
|
||||
|
||||
# 数据库连接信息(用于 entrypoint.sh)
|
||||
# 数据库连接信息(用于 entrypoint.sh,须与 postgres 服务一致)
|
||||
- DB_HOST=postgres
|
||||
- DB_PORT=5432
|
||||
- POSTGRES_USER=${POSTGRES_USER:-mumulingsi}
|
||||
- POSTGRES_DB=${POSTGRES_DB:-mumulingsi_novel}
|
||||
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-123456}
|
||||
|
||||
# PostgreSQL 连接池配置
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
# XinMi API 文档地址(默认 https://api.xinmi.cloud,一般无需修改)
|
||||
# VITE_OFFICIAL_API_DOC_URL=https://api.xinmi.cloud
|
||||
@@ -4,6 +4,9 @@
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Noto+Serif+SC:wght@500;600;700&family=Source+Sans+3:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500&display=swap" rel="stylesheet" />
|
||||
<title>墨木灵思</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -27,14 +27,11 @@ import Login from './pages/Login';
|
||||
import AuthCallback from './pages/AuthCallback';
|
||||
import ProtectedRoute from './components/ProtectedRoute';
|
||||
import AppFooter from './components/AppFooter';
|
||||
import SpringFestival from './components/SpringFestival';
|
||||
import './App.css';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<>
|
||||
{/* 🧧 春节喜庆装饰 */}
|
||||
<SpringFestival />
|
||||
<BrowserRouter
|
||||
future={{
|
||||
v7_startTransition: true,
|
||||
|
||||
@@ -1,246 +0,0 @@
|
||||
import { Modal, Button, Space, theme } from 'antd';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
interface AnnouncementModalProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
onDoNotShowToday: () => void;
|
||||
onNeverShow: () => void;
|
||||
}
|
||||
|
||||
export default function AnnouncementModal({ visible, onClose, onDoNotShowToday, onNeverShow }: AnnouncementModalProps) {
|
||||
const [qqImageError, setQqImageError] = useState(false);
|
||||
const [wxImageError, setWxImageError] = useState(false);
|
||||
const { token } = theme.useToken();
|
||||
const alphaColor = (color: string, alpha: number) => `color-mix(in srgb, ${color} ${(alpha * 100).toFixed(0)}%, transparent)`;
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
setQqImageError(false);
|
||||
setWxImageError(false);
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
const handleDoNotShowToday = () => {
|
||||
onDoNotShowToday();
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleNeverShow = () => {
|
||||
onNeverShow();
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={
|
||||
<div style={{
|
||||
fontSize: '20px',
|
||||
fontWeight: 600,
|
||||
color: token.colorPrimary,
|
||||
textAlign: 'center',
|
||||
}}>
|
||||
🎉 欢迎使用 AI小说创作助手
|
||||
</div>
|
||||
}
|
||||
open={visible}
|
||||
onCancel={onClose}
|
||||
footer={
|
||||
<Space style={{ width: '100%', justifyContent: 'center' }}>
|
||||
<Button
|
||||
onClick={handleDoNotShowToday}
|
||||
size="large"
|
||||
style={{
|
||||
borderRadius: '8px',
|
||||
height: '40px',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
今日内不再展示
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={handleNeverShow}
|
||||
size="large"
|
||||
style={{
|
||||
borderRadius: '8px',
|
||||
height: '40px',
|
||||
fontSize: '14px',
|
||||
background: token.colorPrimary,
|
||||
borderColor: token.colorPrimary,
|
||||
boxShadow: `0 8px 20px ${alphaColor(token.colorPrimary, 0.32)}`,
|
||||
}}
|
||||
>
|
||||
永不再展示
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
width={700}
|
||||
centered
|
||||
styles={{
|
||||
body: {
|
||||
padding: '20px',
|
||||
background: token.colorBgContainer,
|
||||
},
|
||||
header: {
|
||||
background: `linear-gradient(135deg, ${alphaColor(token.colorPrimary, 0.1)} 0%, ${alphaColor(token.colorBgContainer, 0.98)} 100%)`,
|
||||
borderBottom: `1px solid ${token.colorBorderSecondary}`,
|
||||
padding: '16px 24px',
|
||||
},
|
||||
footer: {
|
||||
background: token.colorBgContainer,
|
||||
borderTop: `1px solid ${token.colorBorderSecondary}`,
|
||||
padding: '16px 24px',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{
|
||||
marginBottom: '12px',
|
||||
fontSize: '15px',
|
||||
color: token.colorTextSecondary,
|
||||
lineHeight: '1.5',
|
||||
}}>
|
||||
<p style={{ marginBottom: '8px' }}>👋 欢迎加入我们的交流群!在这里你可以:</p>
|
||||
<ul style={{
|
||||
textAlign: 'left',
|
||||
marginLeft: '40px',
|
||||
marginTop: '0',
|
||||
marginBottom: '12px',
|
||||
}}>
|
||||
<li>💬 与其他创作者交流心得</li>
|
||||
<li>💡 获取最新功能更新和使用技巧</li>
|
||||
<li>🐛 反馈问题和建议</li>
|
||||
<li>📚 分享创作经验和灵感</li>
|
||||
</ul>
|
||||
<p style={{ fontWeight: 600, color: token.colorText, marginBottom: '12px' }}>
|
||||
扫描下方二维码加入交流群:
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'flex-start',
|
||||
gap: '24px',
|
||||
padding: '16px',
|
||||
background: token.colorBgLayout,
|
||||
borderRadius: '8px',
|
||||
flexWrap: 'wrap',
|
||||
}}>
|
||||
{/* QQ 二维码 */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
minWidth: '200px',
|
||||
}}>
|
||||
<p style={{ fontWeight: 600, color: token.colorText, marginBottom: '8px', fontSize: '14px' }}>
|
||||
QQ交流群
|
||||
</p>
|
||||
{!qqImageError ? (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
background: token.colorBgContainer,
|
||||
borderRadius: '8px',
|
||||
padding: '6px',
|
||||
boxShadow: `0 2px 8px ${alphaColor(token.colorText, 0.12)}`,
|
||||
}}>
|
||||
<img
|
||||
src="/qq.jpg"
|
||||
alt="QQ交流群二维码"
|
||||
style={{
|
||||
maxWidth: '180px',
|
||||
maxHeight: '180px',
|
||||
width: 'auto',
|
||||
height: 'auto',
|
||||
display: 'block',
|
||||
objectFit: 'contain',
|
||||
}}
|
||||
onError={() => setQqImageError(true)}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{
|
||||
width: '180px',
|
||||
height: '180px',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
background: token.colorBgContainer,
|
||||
borderRadius: '8px',
|
||||
color: token.colorTextTertiary,
|
||||
}}>
|
||||
<p>二维码加载失败</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 微信二维码 */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
minWidth: '200px',
|
||||
}}>
|
||||
<p style={{ fontWeight: 600, color: token.colorText, marginBottom: '8px', fontSize: '14px' }}>
|
||||
微信交流群
|
||||
</p>
|
||||
{!wxImageError ? (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
background: token.colorBgContainer,
|
||||
borderRadius: '8px',
|
||||
padding: '6px',
|
||||
boxShadow: `0 2px 8px ${alphaColor(token.colorText, 0.12)}`,
|
||||
}}>
|
||||
<img
|
||||
src="/WX.png"
|
||||
alt="微信交流群二维码"
|
||||
style={{
|
||||
maxWidth: '180px',
|
||||
maxHeight: '180px',
|
||||
width: 'auto',
|
||||
height: 'auto',
|
||||
display: 'block',
|
||||
objectFit: 'contain',
|
||||
}}
|
||||
onError={() => setWxImageError(true)}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{
|
||||
width: '180px',
|
||||
height: '180px',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
background: token.colorBgContainer,
|
||||
borderRadius: '8px',
|
||||
color: token.colorTextTertiary,
|
||||
}}>
|
||||
<p>二维码加载失败</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
marginTop: '16px',
|
||||
padding: '10px',
|
||||
background: token.colorWarningBg,
|
||||
borderRadius: '8px',
|
||||
border: `1px solid ${token.colorWarningBorder}`,
|
||||
fontSize: '13px',
|
||||
color: token.colorWarning,
|
||||
}}>
|
||||
💡 提示:选择"今日内不再展示"当天不再显示,选择"永不再展示"将永久隐藏此公告
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Typography, Space, Divider, Badge, Button, Grid, theme } from 'antd';
|
||||
import { GithubOutlined, CopyrightOutlined, HeartFilled, ClockCircleOutlined, GiftOutlined } from '@ant-design/icons';
|
||||
import { Typography, Divider, Badge, Grid, theme } from 'antd';
|
||||
import { ClockCircleOutlined, CopyrightOutlined } from '@ant-design/icons';
|
||||
import { VERSION_INFO, getVersionString } from '../config/version';
|
||||
import { checkLatestVersion } from '../services/versionService';
|
||||
|
||||
@@ -21,7 +21,6 @@ export default function AppFooter({ sidebarWidth = 0 }: AppFooterProps) {
|
||||
const alphaColor = (color: string, alpha: number) => `color-mix(in srgb, ${color} ${(alpha * 100).toFixed(0)}%, transparent)`;
|
||||
|
||||
useEffect(() => {
|
||||
// 检查版本更新(每次都重新检查)
|
||||
const checkVersion = async () => {
|
||||
try {
|
||||
const result = await checkLatestVersion();
|
||||
@@ -33,19 +32,16 @@ export default function AppFooter({ sidebarWidth = 0 }: AppFooterProps) {
|
||||
}
|
||||
};
|
||||
|
||||
// 延迟3秒后检查,避免影响首次加载
|
||||
const timer = setTimeout(checkVersion, 3000);
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
// 点击版本号查看更新
|
||||
const handleVersionClick = () => {
|
||||
if (hasUpdate && releaseUrl) {
|
||||
window.open(releaseUrl, '_blank');
|
||||
}
|
||||
};
|
||||
|
||||
// 计算左边距:桌面端有侧边栏时需要偏移
|
||||
const leftOffset = isMobile ? 0 : sidebarWidth;
|
||||
|
||||
return (
|
||||
@@ -61,8 +57,8 @@ export default function AppFooter({ sidebarWidth = 0 }: AppFooterProps) {
|
||||
padding: isMobile ? '8px 12px' : '10px 16px',
|
||||
zIndex: 100,
|
||||
boxShadow: `0 -2px 16px ${alphaColor(token.colorText, 0.08)}`,
|
||||
backgroundColor: alphaColor(token.colorBgContainer, 0.82), // 半透明背景以支持 backdrop-filter
|
||||
transition: 'left 0.3s ease', // 平滑过渡
|
||||
backgroundColor: alphaColor(token.colorBgContainer, 0.82),
|
||||
transition: 'left 0.3s ease',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
@@ -70,87 +66,11 @@ export default function AppFooter({ sidebarWidth = 0 }: AppFooterProps) {
|
||||
maxWidth: 1400,
|
||||
margin: '0 auto',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
{isMobile ? (
|
||||
// 移动端:紧凑单行布局
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
gap: isMobile ? 8 : 16,
|
||||
flexWrap: 'wrap'
|
||||
}}>
|
||||
<Badge dot={hasUpdate} offset={[-8, 2]}>
|
||||
<Text
|
||||
onClick={handleVersionClick}
|
||||
style={{
|
||||
fontSize: 11,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
color: token.colorPrimary,
|
||||
cursor: hasUpdate ? 'pointer' : 'default',
|
||||
}}
|
||||
title={hasUpdate ? `发现新版本 v${latestVersion},点击查看` : '当前版本'}
|
||||
>
|
||||
<strong style={{ color: token.colorText }}>{VERSION_INFO.projectName}</strong>
|
||||
<span>{getVersionString()}</span>
|
||||
</Text>
|
||||
</Badge>
|
||||
<Divider type="vertical" style={{ margin: '0 4px', borderColor: token.colorBorder }} />
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<GiftOutlined />}
|
||||
onClick={() => window.open('https://mumuverse.space:1588/', '_blank')}
|
||||
style={{
|
||||
color: token.colorTextSecondary,
|
||||
fontSize: 11,
|
||||
height: 24,
|
||||
padding: '0 4px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
}}
|
||||
>
|
||||
赞助
|
||||
</Button>
|
||||
<Divider type="vertical" style={{ margin: '0 4px', borderColor: token.colorBorder }} />
|
||||
<Link
|
||||
href={VERSION_INFO.githubUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{
|
||||
fontSize: 11,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
color: token.colorTextSecondary,
|
||||
}}
|
||||
>
|
||||
<GithubOutlined style={{ fontSize: 12 }} />
|
||||
</Link>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 10,
|
||||
color: token.colorTextTertiary,
|
||||
}}
|
||||
>
|
||||
<ClockCircleOutlined style={{ fontSize: 10, marginRight: 4 }} />
|
||||
{VERSION_INFO.buildTime}
|
||||
</Text>
|
||||
</div>
|
||||
) : (
|
||||
// PC端:完整布局
|
||||
<Space
|
||||
direction="horizontal"
|
||||
size={12}
|
||||
split={<Divider type="vertical" style={{ borderColor: token.colorBorder }} />}
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center'
|
||||
}}
|
||||
>
|
||||
{/* 版本信息 */}
|
||||
@@ -158,24 +78,12 @@ export default function AppFooter({ sidebarWidth = 0 }: AppFooterProps) {
|
||||
<Text
|
||||
onClick={handleVersionClick}
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontSize: isMobile ? 11 : 12,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
color: token.colorTextSecondary,
|
||||
textShadow: 'none',
|
||||
cursor: hasUpdate ? 'pointer' : 'default',
|
||||
transition: 'all 0.3s',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (hasUpdate) {
|
||||
e.currentTarget.style.transform = 'scale(1.05)';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (hasUpdate) {
|
||||
e.currentTarget.style.transform = 'scale(1)';
|
||||
}
|
||||
}}
|
||||
title={hasUpdate ? `发现新版本 v${latestVersion},点击查看` : '当前版本'}
|
||||
>
|
||||
@@ -184,78 +92,7 @@ export default function AppFooter({ sidebarWidth = 0 }: AppFooterProps) {
|
||||
</Text>
|
||||
</Badge>
|
||||
|
||||
{/* GitHub 链接 */}
|
||||
<Link
|
||||
href={VERSION_INFO.githubUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{
|
||||
fontSize: 12,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
color: token.colorTextSecondary,
|
||||
}}
|
||||
>
|
||||
<GithubOutlined style={{ fontSize: 13 }} />
|
||||
<span>GitHub</span>
|
||||
</Link>
|
||||
|
||||
{/* 资源模块 */}
|
||||
<Link
|
||||
href="https://www.xinmi.cloud/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: token.colorTextSecondary,
|
||||
}}
|
||||
>
|
||||
源码库
|
||||
</Link>
|
||||
|
||||
{/* LinuxDO 社区 */}
|
||||
<Link
|
||||
href={VERSION_INFO.linuxDoUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: token.colorTextSecondary,
|
||||
}}
|
||||
>
|
||||
LinuxDO 社区
|
||||
</Link>
|
||||
|
||||
{/* 赞助按钮 */}
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<GiftOutlined style={{ fontSize: 14 }} />}
|
||||
onClick={() => window.open('https://mumuverse.space:1588/', '_blank')}
|
||||
style={{
|
||||
background: token.colorPrimary,
|
||||
border: 'none',
|
||||
boxShadow: `0 4px 12px ${alphaColor(token.colorPrimary, 0.35)}`,
|
||||
fontSize: 13,
|
||||
height: 32,
|
||||
padding: '0 20px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
fontWeight: 600,
|
||||
transition: 'all 0.3s',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(-2px)';
|
||||
e.currentTarget.style.boxShadow = `0 6px 16px ${alphaColor(token.colorPrimary, 0.5)}`;
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(0)';
|
||||
e.currentTarget.style.boxShadow = `0 4px 12px ${alphaColor(token.colorPrimary, 0.35)}`;
|
||||
}}
|
||||
>
|
||||
赞助支持
|
||||
</Button>
|
||||
<Divider type="vertical" style={{ borderColor: token.colorBorder }} />
|
||||
|
||||
{/* 许可证 */}
|
||||
<Link
|
||||
@@ -263,7 +100,7 @@ export default function AppFooter({ sidebarWidth = 0 }: AppFooterProps) {
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontSize: isMobile ? 11 : 12,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
@@ -274,39 +111,22 @@ export default function AppFooter({ sidebarWidth = 0 }: AppFooterProps) {
|
||||
<span>{VERSION_INFO.license}</span>
|
||||
</Link>
|
||||
|
||||
<Divider type="vertical" style={{ borderColor: token.colorBorder }} />
|
||||
|
||||
{/* 更新时间 */}
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontSize: isMobile ? 10 : 12,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
color: token.colorTextTertiary,
|
||||
}}
|
||||
>
|
||||
<ClockCircleOutlined style={{ fontSize: 12 }} />
|
||||
<ClockCircleOutlined style={{ fontSize: isMobile ? 10 : 12 }} />
|
||||
<span>{VERSION_INFO.buildTime}</span>
|
||||
</Text>
|
||||
|
||||
{/* 致谢信息 */}
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 12,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
color: token.colorTextSecondary,
|
||||
textShadow: `0 1px 3px ${alphaColor(token.colorText, 0.08)}`,
|
||||
}}
|
||||
>
|
||||
<span>Made with</span>
|
||||
<HeartFilled style={{ color: token.colorError, fontSize: 11 }} />
|
||||
<span>by {VERSION_INFO.author}</span>
|
||||
</Text>
|
||||
</Space>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -20,10 +20,7 @@ const bookshelfNewHoverShadow = `
|
||||
inset 0 1px 0 color-mix(in srgb, var(--ant-color-bg-container) 90%, transparent)
|
||||
`;
|
||||
|
||||
const promptTemplateBaseShadow = `
|
||||
0 6px 16px color-mix(in srgb, var(--ant-color-text) 11%, transparent),
|
||||
0 1px 0 color-mix(in srgb, var(--ant-color-white) 42%, transparent) inset
|
||||
`;
|
||||
const promptTemplateBaseShadow = '4px 4px 0 color-mix(in srgb, var(--ant-color-text) 10%, transparent)';
|
||||
|
||||
// BookshelfPage 样式(书架/书本卡片)
|
||||
export const bookshelfCardStyles = {
|
||||
@@ -107,28 +104,25 @@ export const bookshelfCardHoverHandlers = {
|
||||
export const promptTemplateCardStyles = {
|
||||
templateCard: {
|
||||
height: '100%',
|
||||
borderRadius: 14,
|
||||
borderRadius: 2,
|
||||
overflow: 'hidden',
|
||||
border: '1px solid color-mix(in srgb, var(--ant-color-text) 8%, transparent)',
|
||||
background: 'linear-gradient(180deg, color-mix(in srgb, var(--ant-color-bg-container) 97%, var(--ant-color-primary) 3%) 0%, var(--ant-color-bg-container) 100%)',
|
||||
border: '1px solid color-mix(in srgb, var(--ant-color-text) 14%, transparent)',
|
||||
background: 'var(--ant-color-bg-container)',
|
||||
boxShadow: promptTemplateBaseShadow,
|
||||
transition: 'transform 0.28s cubic-bezier(0.22, 1, 0.36, 1), box-shadow 0.28s cubic-bezier(0.22, 1, 0.36, 1), border-color 0.28s ease',
|
||||
transition: 'transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease',
|
||||
} as CSSProperties,
|
||||
};
|
||||
|
||||
export const promptTemplateCardHoverHandlers = {
|
||||
onMouseEnter: (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
const target = e.currentTarget;
|
||||
target.style.transform = 'translateY(-6px)';
|
||||
target.style.boxShadow = `
|
||||
0 14px 24px color-mix(in srgb, var(--ant-color-text) 16%, transparent),
|
||||
0 1px 0 color-mix(in srgb, var(--ant-color-white) 48%, transparent) inset
|
||||
`;
|
||||
target.style.transform = 'translate(-2px, -2px)';
|
||||
target.style.boxShadow = '6px 6px 0 color-mix(in srgb, var(--ant-color-primary) 25%, transparent)';
|
||||
target.style.borderColor = 'color-mix(in srgb, var(--ant-color-primary) 24%, transparent)';
|
||||
},
|
||||
onMouseLeave: (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
const target = e.currentTarget;
|
||||
target.style.transform = 'translateY(0)';
|
||||
target.style.transform = 'translate(0, 0)';
|
||||
target.style.boxShadow = promptTemplateBaseShadow;
|
||||
target.style.borderColor = 'color-mix(in srgb, var(--ant-color-text) 8%, transparent)';
|
||||
},
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { FloatButton, Grid } from 'antd';
|
||||
import { FileTextOutlined } from '@ant-design/icons';
|
||||
import ChangelogModal from './ChangelogModal';
|
||||
|
||||
const { useBreakpoint } = Grid;
|
||||
|
||||
export default function ChangelogFloatingButton() {
|
||||
const [showChangelog, setShowChangelog] = useState(false);
|
||||
const screens = useBreakpoint();
|
||||
const isMobile = !screens.md;
|
||||
|
||||
return (
|
||||
<>
|
||||
<FloatButton
|
||||
icon={<FileTextOutlined />}
|
||||
type="primary"
|
||||
tooltip="查看更新日志"
|
||||
style={{
|
||||
// 桌面端时,确保按钮在主内容区域内(侧边栏右侧)
|
||||
right: 24,
|
||||
bottom: 100,
|
||||
// 移动端无侧边栏,不需要额外处理
|
||||
...(isMobile ? {} : {
|
||||
// 确保 zIndex 低于侧边栏但高于内容
|
||||
zIndex: 999,
|
||||
}),
|
||||
}}
|
||||
onClick={() => setShowChangelog(true)}
|
||||
/>
|
||||
|
||||
<ChangelogModal
|
||||
visible={showChangelog}
|
||||
onClose={() => setShowChangelog(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,306 +0,0 @@
|
||||
import { Modal, Timeline, Tag, Avatar, Empty, Spin, Button, Space } from 'antd';
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
BugOutlined,
|
||||
StarOutlined,
|
||||
FileTextOutlined,
|
||||
BgColorsOutlined,
|
||||
ThunderboltOutlined,
|
||||
ExperimentOutlined,
|
||||
ToolOutlined,
|
||||
QuestionCircleOutlined,
|
||||
GithubOutlined,
|
||||
ReloadOutlined,
|
||||
ClockCircleOutlined,
|
||||
SyncOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import {
|
||||
fetchChangelog,
|
||||
groupChangelogByDate,
|
||||
cacheChangelog,
|
||||
clearChangelogCache,
|
||||
type ChangelogEntry,
|
||||
} from '../services/changelogService';
|
||||
|
||||
interface ChangelogModalProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
// 提交类型图标和颜色配置
|
||||
const typeConfig: Record<ChangelogEntry['type'], { icon: React.ReactNode; color: string; label: string }> = {
|
||||
feature: { icon: <StarOutlined />, color: 'green', label: '新功能' },
|
||||
update: { icon: <SyncOutlined />, color: 'geekblue', label: '更新' },
|
||||
fix: { icon: <BugOutlined />, color: 'red', label: '修复' },
|
||||
docs: { icon: <FileTextOutlined />, color: 'blue', label: '文档' },
|
||||
style: { icon: <BgColorsOutlined />, color: 'purple', label: '样式' },
|
||||
refactor: { icon: <ThunderboltOutlined />, color: 'orange', label: '重构' },
|
||||
perf: { icon: <ThunderboltOutlined />, color: 'gold', label: '性能' },
|
||||
test: { icon: <ExperimentOutlined />, color: 'cyan', label: '测试' },
|
||||
chore: { icon: <ToolOutlined />, color: 'default', label: '杂项' },
|
||||
other: { icon: <QuestionCircleOutlined />, color: 'default', label: '其他' },
|
||||
};
|
||||
|
||||
export default function ChangelogModal({ visible, onClose }: ChangelogModalProps) {
|
||||
const [changelog, setChangelog] = useState<ChangelogEntry[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [page, setPage] = useState(1);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
|
||||
// 加载更新日志
|
||||
// 每次用户打开窗口时才同步获取最新数据,不自动刷新
|
||||
const loadChangelog = async (pageNum: number = 1, append: boolean = false) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// 每次打开都从网络获取最新数据
|
||||
const entries = await fetchChangelog(pageNum, 30);
|
||||
|
||||
if (entries.length === 0) {
|
||||
setHasMore(false);
|
||||
} else {
|
||||
if (append) {
|
||||
setChangelog(prev => [...prev, ...entries]);
|
||||
} else {
|
||||
setChangelog(entries);
|
||||
// 缓存第一页数据(用于分页加载时的数据持久化)
|
||||
if (pageNum === 1) {
|
||||
cacheChangelog(entries);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '获取更新日志失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 初始加载
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
loadChangelog(1, false);
|
||||
setPage(1);
|
||||
setHasMore(true);
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
// 加载更多
|
||||
const handleLoadMore = () => {
|
||||
const nextPage = page + 1;
|
||||
setPage(nextPage);
|
||||
loadChangelog(nextPage, true);
|
||||
};
|
||||
|
||||
// 刷新(清除缓存并重新加载)
|
||||
const handleRefresh = () => {
|
||||
clearChangelogCache();
|
||||
setPage(1);
|
||||
setHasMore(true);
|
||||
loadChangelog(1, false);
|
||||
};
|
||||
|
||||
// 按日期分组
|
||||
const groupedChangelog = groupChangelogByDate(changelog);
|
||||
const sortedDates = Array.from(groupedChangelog.keys()).sort((a, b) => b.localeCompare(a));
|
||||
|
||||
// 格式化日期
|
||||
const formatDate = (dateStr: string) => {
|
||||
const date = new Date(dateStr);
|
||||
const now = new Date();
|
||||
const diffDays = Math.floor((now.getTime() - date.getTime()) / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffDays === 0) return '今天';
|
||||
if (diffDays === 1) return '昨天';
|
||||
if (diffDays < 7) return `${diffDays} 天前`;
|
||||
|
||||
return date.toLocaleDateString('zh-CN', { year: 'numeric', month: 'long', day: 'numeric' });
|
||||
};
|
||||
|
||||
// 格式化时间
|
||||
const formatTime = (dateStr: string) => {
|
||||
return new Date(dateStr).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' });
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={
|
||||
<Space>
|
||||
<GithubOutlined />
|
||||
<span>更新日志</span>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={handleRefresh}
|
||||
loading={loading}
|
||||
title="刷新"
|
||||
/>
|
||||
</Space>
|
||||
}
|
||||
open={visible}
|
||||
onCancel={onClose}
|
||||
footer={null}
|
||||
width={800}
|
||||
centered
|
||||
styles={{
|
||||
body: {
|
||||
maxHeight: '70vh',
|
||||
overflowY: 'auto',
|
||||
padding: '24px',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{error && (
|
||||
<div style={{
|
||||
padding: '16px',
|
||||
marginBottom: '16px',
|
||||
background: 'var(--color-error-bg)',
|
||||
border: '1px solid var(--color-error-border)',
|
||||
borderRadius: '4px',
|
||||
color: 'var(--color-error)',
|
||||
}}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && changelog.length === 0 ? (
|
||||
<div style={{ textAlign: 'center', padding: '40px 0' }}>
|
||||
<Spin size="large" tip="加载更新日志中..." />
|
||||
</div>
|
||||
) : changelog.length === 0 ? (
|
||||
<Empty description="暂无更新日志" />
|
||||
) : (
|
||||
<>
|
||||
{sortedDates.map(date => {
|
||||
const entries = groupedChangelog.get(date) || [];
|
||||
|
||||
return (
|
||||
<div key={date} style={{ marginBottom: '32px' }}>
|
||||
<div style={{
|
||||
fontSize: '16px',
|
||||
fontWeight: 600,
|
||||
color: 'var(--color-primary)',
|
||||
marginBottom: '16px',
|
||||
paddingBottom: '8px',
|
||||
borderBottom: '2px solid var(--color-border-secondary)',
|
||||
}}>
|
||||
<ClockCircleOutlined style={{ marginRight: '8px' }} />
|
||||
{formatDate(date)}
|
||||
</div>
|
||||
|
||||
<Timeline>
|
||||
{entries.map(entry => {
|
||||
const config = typeConfig[entry.type] || typeConfig.other;
|
||||
|
||||
return (
|
||||
<Timeline.Item
|
||||
key={entry.id}
|
||||
dot={
|
||||
<div style={{
|
||||
width: '24px',
|
||||
height: '24px',
|
||||
borderRadius: '50%',
|
||||
background: 'var(--color-bg-container)',
|
||||
border: `2px solid ${config.color === 'default' ? 'var(--color-border)' : config.color}`,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '12px',
|
||||
}}>
|
||||
{config.icon}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div style={{ marginLeft: '8px' }}>
|
||||
<Space size="small" wrap>
|
||||
<Tag color={config.color} icon={config.icon}>
|
||||
{config.label}
|
||||
</Tag>
|
||||
{entry.scope && (
|
||||
<Tag color="blue">{entry.scope}</Tag>
|
||||
)}
|
||||
<span style={{ color: 'var(--color-text-tertiary)', fontSize: '12px' }}>
|
||||
{formatTime(entry.date)}
|
||||
</span>
|
||||
</Space>
|
||||
|
||||
<div style={{
|
||||
marginTop: '8px',
|
||||
fontSize: '14px',
|
||||
lineHeight: '1.6',
|
||||
color: 'var(--color-text-primary)',
|
||||
}}>
|
||||
{entry.message}
|
||||
</div>
|
||||
|
||||
<Space size="small" style={{ marginTop: '8px' }}>
|
||||
{entry.author.avatar && (
|
||||
<Avatar size="small" src={entry.author.avatar} />
|
||||
)}
|
||||
<span style={{ color: 'var(--color-text-secondary)', fontSize: '13px' }}>
|
||||
{entry.author.username || entry.author.name}
|
||||
</span>
|
||||
<a
|
||||
href={entry.commitUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ fontSize: '12px' }}
|
||||
>
|
||||
查看提交
|
||||
</a>
|
||||
</Space>
|
||||
</div>
|
||||
</Timeline.Item>
|
||||
);
|
||||
})}
|
||||
</Timeline>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{
|
||||
hasMore && (
|
||||
<div style={{ textAlign: 'center', marginTop: '24px' }}>
|
||||
<Button
|
||||
type="default"
|
||||
onClick={handleLoadMore}
|
||||
loading={loading}
|
||||
>
|
||||
加载更多
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
{
|
||||
!hasMore && changelog.length > 0 && (
|
||||
<div style={{
|
||||
textAlign: 'center',
|
||||
color: 'var(--color-text-tertiary)',
|
||||
padding: '16px 0',
|
||||
fontSize: '14px',
|
||||
}}>
|
||||
已显示所有更新日志
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div style={{
|
||||
marginTop: '24px',
|
||||
padding: '12px',
|
||||
background: 'var(--color-info-bg)',
|
||||
borderRadius: '4px',
|
||||
border: '1px solid var(--color-info-border)',
|
||||
fontSize: '13px',
|
||||
color: 'var(--color-primary)',
|
||||
}}>
|
||||
💡 提示:每次打开窗口时自动获取最新更新日志,数据来源于 GitHub 提交历史
|
||||
</div>
|
||||
</Modal >
|
||||
);
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import axios from 'axios';
|
||||
const { TextArea } = Input;
|
||||
const { Text, Paragraph } = Typography;
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000';
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '';
|
||||
|
||||
interface CareerDetail {
|
||||
id: string;
|
||||
|
||||
@@ -16,9 +16,12 @@ export const VERSION_INFO = {
|
||||
projectName: '墨木灵思',
|
||||
projectFullName: '墨木灵思 - AI 智能小说创作助手',
|
||||
|
||||
// 链接信息
|
||||
githubUrl: 'https://www.xinmi.cloud/',
|
||||
linuxDoUrl: 'https://linux.do/t/topic/1106333',
|
||||
// 链接信息(不在代码里写死 GitHub;需要时在 .env 里配置 Vite 变量)
|
||||
linuxDoUrl: '',
|
||||
/** XinMi API 官方地址(侧栏入口、设置页文档链接) */
|
||||
xinmiApiBaseUrl: 'http://api.xinmi.cloud',
|
||||
officialApiDocUrl:
|
||||
String(import.meta.env.VITE_OFFICIAL_API_DOC_URL ?? '').trim() || 'http://api.xinmi.cloud',
|
||||
|
||||
// 许可证
|
||||
license: 'GPL v3.0',
|
||||
|
||||
@@ -5,7 +5,14 @@ body,
|
||||
}
|
||||
|
||||
:root {
|
||||
font-family: "PingFang SC", "Microsoft YaHei", "Heiti SC", Inter, system-ui, sans-serif;
|
||||
--app-font-serif: 'Noto Serif SC', 'Songti SC', 'SimSun', serif;
|
||||
--app-font-sans: 'Source Sans 3', 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||
--app-font-mono: 'IBM Plex Mono', 'Consolas', monospace;
|
||||
--app-ink: #292524;
|
||||
--app-parchment: #f5f0e6;
|
||||
--app-copper: #b45309;
|
||||
--app-gold: #f59e0b;
|
||||
font-family: var(--app-font-sans);
|
||||
line-height: 1.5715;
|
||||
font-weight: 400;
|
||||
font-synthesis: none;
|
||||
@@ -16,6 +23,41 @@ body,
|
||||
-ms-text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
[data-theme-resolved='dark'] {
|
||||
--app-ink: #e7e5e4;
|
||||
--app-parchment: #0c0a09;
|
||||
--app-copper: #f59e0b;
|
||||
--app-gold: #fbbf24;
|
||||
}
|
||||
|
||||
.app-serif-title {
|
||||
font-family: var(--app-font-serif);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.app-shell-sider .ant-menu-dark,
|
||||
.app-shell-sider .ant-menu {
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.app-shell-sider .ant-menu-item-selected {
|
||||
border-left: 3px solid var(--app-gold) !important;
|
||||
}
|
||||
|
||||
.app-login-tabs .ant-tabs-nav::before {
|
||||
border-bottom-color: color-mix(in srgb, var(--app-ink) 12%, transparent);
|
||||
}
|
||||
|
||||
.app-login-tabs .ant-tabs-tab-active .ant-tabs-tab-btn {
|
||||
color: var(--app-copper) !important;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.app-prompt-header {
|
||||
border-left: 4px solid var(--app-copper);
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
@@ -219,8 +261,8 @@ body {
|
||||
}
|
||||
|
||||
.ant-tooltip .ant-tooltip-inner {
|
||||
background: var(--app-tooltip-bg, #884d5c);
|
||||
border-radius: 8px;
|
||||
background: var(--app-tooltip-bg, #292524);
|
||||
border-radius: 2px;
|
||||
padding: 8px 16px;
|
||||
font-weight: 500;
|
||||
box-shadow: 0 4px 12px var(--app-tooltip-shadow, rgba(136, 77, 92, 0.3));
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
SmileOutlined
|
||||
} from '@ant-design/icons';
|
||||
|
||||
import { VERSION_INFO } from '../config/version';
|
||||
|
||||
const { Title, Paragraph, Text } = Typography;
|
||||
|
||||
export default function About() {
|
||||
@@ -52,7 +54,7 @@ export default function About() {
|
||||
<div style={{ textAlign: 'center', marginBottom: 48 }}>
|
||||
<Title level={1}>关于 墨木灵思</Title>
|
||||
<Paragraph style={{ fontSize: 18, color: token.colorTextSecondary }}>
|
||||
墨木灵思 (MoMu LingSi) 是一款基于人工智能的智能小说创作助手,旨在帮助创作者更高效、更具创意地完成文学作品。
|
||||
墨木灵思 是一款基于人工智能的智能小说创作助手,旨在帮助创作者更高效、更具创意地完成文学作品。
|
||||
</Paragraph>
|
||||
</div>
|
||||
|
||||
@@ -140,7 +142,16 @@ export default function About() {
|
||||
|
||||
<div style={{ textAlign: 'center', marginTop: 64, marginBottom: 32 }}>
|
||||
<Paragraph type="secondary">
|
||||
© 2026 墨木灵思团队 | <a href="https://www.xinmi.cloud/" target="_blank" rel="noopener noreferrer">了解更多</a>
|
||||
© 2026 墨木灵思团队
|
||||
{VERSION_INFO.officialApiDocUrl ? (
|
||||
<>
|
||||
{' '}
|
||||
|{' '}
|
||||
<a href={VERSION_INFO.officialApiDocUrl} target="_blank" rel="noopener noreferrer">
|
||||
了解更多
|
||||
</a>
|
||||
</>
|
||||
) : null}
|
||||
</Paragraph>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,16 +2,15 @@ import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Spin, Result, Button, Modal, Input, message, theme } from 'antd';
|
||||
import { authApi } from '../services/api';
|
||||
import AnnouncementModal from '../components/AnnouncementModal';
|
||||
|
||||
export default function AuthCallback() {
|
||||
const navigate = useNavigate();
|
||||
const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading');
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
const [showAnnouncement, setShowAnnouncement] = useState(false);
|
||||
const [showPasswordModal, setShowPasswordModal] = useState(false);
|
||||
const { token } = theme.useToken();
|
||||
const alphaColor = (color: string, alpha: number) => `color-mix(in srgb, ${color} ${(alpha * 100).toFixed(0)}%, transparent)`;
|
||||
|
||||
interface PasswordStatus {
|
||||
has_password: boolean;
|
||||
has_custom_password: boolean;
|
||||
@@ -26,64 +25,33 @@ export default function AuthCallback() {
|
||||
useEffect(() => {
|
||||
const handleCallback = async () => {
|
||||
try {
|
||||
// 后端会通过 Cookie 自动设置认证信息
|
||||
// 这里只需要验证登录状态
|
||||
const currentUser = await authApi.getCurrentUser();
|
||||
|
||||
// 检查是否是首次登录(通过 Cookie 标记)
|
||||
const isFirstLogin = document.cookie.includes('first_login=true');
|
||||
|
||||
setStatus('success');
|
||||
|
||||
if (isFirstLogin) {
|
||||
// 首次登录:生成默认密码并显示提示
|
||||
const defaultPassword = `${currentUser.username}@666`;
|
||||
const pwdStatus = {
|
||||
setPasswordStatus({
|
||||
has_password: false,
|
||||
has_custom_password: false,
|
||||
username: currentUser.username,
|
||||
default_password: defaultPassword
|
||||
};
|
||||
setPasswordStatus(pwdStatus);
|
||||
|
||||
// 清除首次登录标记 Cookie
|
||||
});
|
||||
document.cookie = 'first_login=; path=/; max-age=0';
|
||||
|
||||
// 显示密码初始化弹窗
|
||||
setTimeout(() => {
|
||||
setShowPasswordModal(true);
|
||||
}, 1000);
|
||||
setTimeout(() => setShowPasswordModal(true), 1000);
|
||||
return;
|
||||
}
|
||||
|
||||
// 非首次登录:正常流程
|
||||
// 从 sessionStorage 获取重定向地址
|
||||
const redirect = sessionStorage.getItem('login_redirect') || '/';
|
||||
sessionStorage.removeItem('login_redirect');
|
||||
|
||||
// 检查是否永久隐藏公告或今日已隐藏
|
||||
const hideForever = localStorage.getItem('announcement_hide_forever');
|
||||
const hideToday = localStorage.getItem('announcement_hide_today');
|
||||
const today = new Date().toDateString();
|
||||
|
||||
if (hideForever === 'true' || hideToday === today) {
|
||||
// 延迟一下再跳转,让用户看到成功提示
|
||||
setTimeout(() => {
|
||||
navigate(redirect);
|
||||
}, 1000);
|
||||
} else {
|
||||
// 延迟一下再显示公告,让用户看到成功提示
|
||||
setTimeout(() => {
|
||||
setShowAnnouncement(true);
|
||||
}, 1000);
|
||||
}
|
||||
setTimeout(() => navigate(redirect), 1000);
|
||||
} catch (error) {
|
||||
console.error('登录失败:', error);
|
||||
setStatus('error');
|
||||
setErrorMessage('登录失败,请重试');
|
||||
}
|
||||
};
|
||||
|
||||
handleCallback();
|
||||
}, [navigate]);
|
||||
|
||||
@@ -98,9 +66,7 @@ export default function AuthCallback() {
|
||||
}}>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<Spin size="large" />
|
||||
<div style={{ marginTop: 20, color: token.colorWhite, fontSize: 16 }}>
|
||||
正在处理登录...
|
||||
</div>
|
||||
<div style={{ marginTop: 20, color: token.colorWhite, fontSize: 16 }}>正在处理登录...</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -119,55 +85,21 @@ export default function AuthCallback() {
|
||||
status="error"
|
||||
title="登录失败"
|
||||
subTitle={errorMessage}
|
||||
extra={
|
||||
<Button type="primary" onClick={() => navigate('/login')}>
|
||||
返回登录
|
||||
</Button>
|
||||
}
|
||||
extra={<Button type="primary" onClick={() => navigate('/login')}>返回登录</Button>}
|
||||
style={{ background: token.colorBgContainer, padding: 40, borderRadius: 8 }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handleAnnouncementClose = () => {
|
||||
setShowAnnouncement(false);
|
||||
const redirect = sessionStorage.getItem('login_redirect') || '/';
|
||||
sessionStorage.removeItem('login_redirect');
|
||||
navigate(redirect);
|
||||
};
|
||||
|
||||
const handleDoNotShowToday = () => {
|
||||
// 设置今日不再显示
|
||||
const today = new Date().toDateString();
|
||||
localStorage.setItem('announcement_hide_today', today);
|
||||
};
|
||||
|
||||
const handleNeverShow = () => {
|
||||
// 设置永久不再显示
|
||||
localStorage.setItem('announcement_hide_forever', 'true');
|
||||
};
|
||||
|
||||
const handleSetPassword = async () => {
|
||||
// 如果没有输入新密码,使用默认密码
|
||||
const passwordToSet = newPassword || passwordStatus?.default_password;
|
||||
|
||||
if (!passwordToSet) {
|
||||
message.error('请输入新密码');
|
||||
return;
|
||||
}
|
||||
if (passwordToSet.length < 6) {
|
||||
message.error('密码长度至少为6个字符');
|
||||
return;
|
||||
}
|
||||
if (newPassword && newPassword !== confirmPassword) {
|
||||
message.error('两次输入的密码不一致');
|
||||
return;
|
||||
}
|
||||
if (!passwordToSet) { message.error('请输入新密码'); return; }
|
||||
if (passwordToSet.length < 6) { message.error('密码长度至少为6个字符'); return; }
|
||||
if (newPassword && newPassword !== confirmPassword) { message.error('两次输入的密码不一致'); return; }
|
||||
|
||||
setSettingPassword(true);
|
||||
try {
|
||||
// 首次登录使用初始化接口,后续使用修改接口
|
||||
const isFirstLogin = !passwordStatus?.has_password;
|
||||
if (isFirstLogin) {
|
||||
await authApi.initializePassword(passwordToSet);
|
||||
@@ -177,24 +109,9 @@ export default function AuthCallback() {
|
||||
message.success('密码设置成功');
|
||||
}
|
||||
setShowPasswordModal(false);
|
||||
|
||||
// 继续后续流程
|
||||
const redirect = sessionStorage.getItem('login_redirect') || '/';
|
||||
sessionStorage.removeItem('login_redirect');
|
||||
|
||||
const hideForever = localStorage.getItem('announcement_hide_forever');
|
||||
const hideToday = localStorage.getItem('announcement_hide_today');
|
||||
const today = new Date().toDateString();
|
||||
|
||||
if (hideForever === 'true' || hideToday === today) {
|
||||
setTimeout(() => {
|
||||
navigate(redirect);
|
||||
}, 500);
|
||||
} else {
|
||||
setTimeout(() => {
|
||||
setShowAnnouncement(true);
|
||||
}, 500);
|
||||
}
|
||||
setTimeout(() => navigate(redirect), 500);
|
||||
} catch {
|
||||
message.error('密码设置失败,请重试');
|
||||
} finally {
|
||||
@@ -203,46 +120,19 @@ export default function AuthCallback() {
|
||||
};
|
||||
|
||||
const handleSkipPasswordSetting = async () => {
|
||||
// 首次登录时,如果跳过设置,使用默认密码初始化
|
||||
const isFirstLogin = !passwordStatus?.has_password;
|
||||
if (isFirstLogin && passwordStatus?.default_password) {
|
||||
try {
|
||||
await authApi.initializePassword(passwordStatus.default_password);
|
||||
} catch (error) {
|
||||
console.error('初始化默认密码失败:', error);
|
||||
try { await authApi.initializePassword(passwordStatus.default_password); }
|
||||
catch (error) { console.error('初始化默认密码失败:', error); }
|
||||
}
|
||||
}
|
||||
|
||||
setShowPasswordModal(false);
|
||||
|
||||
// 继续后续流程
|
||||
const redirect = sessionStorage.getItem('login_redirect') || '/';
|
||||
sessionStorage.removeItem('login_redirect');
|
||||
|
||||
const hideForever = localStorage.getItem('announcement_hide_forever');
|
||||
const hideToday = localStorage.getItem('announcement_hide_today');
|
||||
const today = new Date().toDateString();
|
||||
|
||||
if (hideForever === 'true' || hideToday === today) {
|
||||
setTimeout(() => {
|
||||
navigate(redirect);
|
||||
}, 500);
|
||||
} else {
|
||||
setTimeout(() => {
|
||||
setShowAnnouncement(true);
|
||||
}, 500);
|
||||
}
|
||||
setTimeout(() => navigate(redirect), 500);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<AnnouncementModal
|
||||
visible={showAnnouncement}
|
||||
onClose={handleAnnouncementClose}
|
||||
onDoNotShowToday={handleDoNotShowToday}
|
||||
onNeverShow={handleNeverShow}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title="设置账号密码"
|
||||
open={showPasswordModal}
|
||||
@@ -255,45 +145,23 @@ export default function AuthCallback() {
|
||||
width={500}
|
||||
>
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<p>您已成功通过 Linux DO 授权登录!</p>
|
||||
<p>系统已为您自动生成默认密码,您可以选择设置自定义密码或继续使用默认密码。</p>
|
||||
<p>您已成功登录!</p>
|
||||
<p>您可以选择设置自定义密码或继续使用默认密码。</p>
|
||||
{passwordStatus?.default_password && (
|
||||
<div style={{
|
||||
background: token.colorFillTertiary,
|
||||
padding: 12,
|
||||
borderRadius: 4,
|
||||
marginTop: 12
|
||||
}}>
|
||||
<div style={{ background: token.colorFillTertiary, padding: 12, borderRadius: 4, marginTop: 12 }}>
|
||||
<strong>账号:</strong>{passwordStatus.username}<br />
|
||||
<strong>默认密码:</strong><code style={{
|
||||
background: token.colorBgContainer,
|
||||
padding: '2px 8px',
|
||||
borderRadius: 3,
|
||||
color: token.colorPrimary,
|
||||
fontSize: 14
|
||||
}}>{passwordStatus.default_password}</code>
|
||||
<strong>默认密码:</strong><code style={{ background: token.colorBgContainer, padding: '2px 8px', borderRadius: 3, color: token.colorPrimary, fontSize: 14 }}>{passwordStatus.default_password}</code>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 20 }}>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<label>新密码(至少6个字符):</label>
|
||||
<Input.Password
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
placeholder="请输入新密码"
|
||||
style={{ marginTop: 4 }}
|
||||
/>
|
||||
<Input.Password value={newPassword} onChange={(e) => setNewPassword(e.target.value)} placeholder="请输入新密码" style={{ marginTop: 4 }} />
|
||||
</div>
|
||||
<div>
|
||||
<label>确认密码:</label>
|
||||
<Input.Password
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
placeholder="请再次输入密码"
|
||||
style={{ marginTop: 4 }}
|
||||
/>
|
||||
<Input.Password value={confirmPassword} onChange={(e) => setConfirmPassword(e.target.value)} placeholder="请再次输入密码" style={{ marginTop: 4 }} />
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
@@ -308,7 +176,7 @@ export default function AuthCallback() {
|
||||
<Result
|
||||
status="success"
|
||||
title="登录成功"
|
||||
subTitle={showPasswordModal ? "请设置账号密码..." : (showAnnouncement ? "欢迎使用..." : "正在跳转...")}
|
||||
subTitle={showPasswordModal ? "请设置账号密码..." : "正在跳转..."}
|
||||
style={{ background: alphaColor(token.colorBgContainer, 0.96), padding: 40, borderRadius: 8 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Card, Button, Spin, Space, Tag, Typography, Alert, theme } from 'antd';
|
||||
import { BookOutlined, RocketOutlined, BulbOutlined, UploadOutlined, DownloadOutlined, LoadingOutlined, CalendarOutlined, DeleteOutlined, CheckCircleOutlined, EditOutlined, PauseCircleOutlined, PictureOutlined, SwapOutlined, ReloadOutlined } from '@ant-design/icons';
|
||||
import { Card, Button, Spin, Space, Tag, Typography, theme } from 'antd';
|
||||
import { BookOutlined, RocketOutlined, BulbOutlined, UploadOutlined, DownloadOutlined, LoadingOutlined, CalendarOutlined, DeleteOutlined, CheckCircleOutlined, EditOutlined, PauseCircleOutlined, PictureOutlined, SwapOutlined, ReloadOutlined, InfoCircleOutlined, CloseOutlined } from '@ant-design/icons';
|
||||
import { useState } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import type { Project } from '../types';
|
||||
import { bookshelfCardStyles, bookshelfCardHoverHandlers } from '../components/CardStyles';
|
||||
import { useThemeMode } from '../theme/useThemeMode';
|
||||
import { VERSION_INFO } from '../config/version';
|
||||
|
||||
const { Paragraph } = Typography;
|
||||
|
||||
@@ -215,42 +216,91 @@ export default function BookshelfPage({
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{showApiTip && projects.length === 0 && (
|
||||
<Alert
|
||||
message="欢迎使用 墨木灵思"
|
||||
description={
|
||||
<div style={{
|
||||
<div
|
||||
style={{
|
||||
marginTop: isMobile ? 12 : 14,
|
||||
paddingTop: isMobile ? 12 : 14,
|
||||
borderTop: `1px solid ${alphaColor(token.colorWhite, 0.28)}`,
|
||||
position: 'relative',
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: isMobile ? 'column' : 'row',
|
||||
alignItems: isMobile ? 'flex-start' : 'center',
|
||||
alignItems: isMobile ? 'stretch' : 'center',
|
||||
gap: isMobile ? 12 : 16,
|
||||
justifyContent: 'space-between'
|
||||
}}>
|
||||
<span style={{ fontSize: isMobile ? 12 : 14 }}>
|
||||
在开始创作之前,请先配置您的AI接口(支持 OpenAI / Anthropic)。
|
||||
</span>
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
onClick={onGoSettings}
|
||||
style={{ flexShrink: 0 }}
|
||||
justifyContent: 'space-between',
|
||||
}}
|
||||
>
|
||||
去配置
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
type="info"
|
||||
showIcon
|
||||
closable
|
||||
onClose={() => setShowApiTip(false)}
|
||||
<div
|
||||
style={{
|
||||
marginBottom: isMobile ? 16 : 24,
|
||||
borderRadius: 12
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
gap: 10,
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
<InfoCircleOutlined
|
||||
style={{
|
||||
fontSize: isMobile ? 18 : 20,
|
||||
color: token.colorWhite,
|
||||
marginTop: 2,
|
||||
flexShrink: 0,
|
||||
opacity: 0.95,
|
||||
}}
|
||||
/>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: isMobile ? 14 : 15,
|
||||
fontWeight: 600,
|
||||
color: token.colorWhite,
|
||||
marginBottom: 4,
|
||||
lineHeight: 1.35,
|
||||
}}
|
||||
>
|
||||
欢迎使用 {VERSION_INFO.projectName}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: isMobile ? 12 : 13,
|
||||
color: alphaColor(token.colorWhite, 0.9),
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
>
|
||||
在开始创作之前,请先配置您的 AI 接口(支持 OpenAI 兼容、Gemini 等)。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Space
|
||||
wrap
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
justifyContent: isMobile ? 'flex-end' : 'flex-end',
|
||||
width: isMobile ? '100%' : 'auto',
|
||||
}}
|
||||
>
|
||||
<Button size="small" type="primary" ghost onClick={onGoSettings}>
|
||||
去配置
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
type="text"
|
||||
icon={<CloseOutlined />}
|
||||
onClick={() => setShowApiTip(false)}
|
||||
style={{ color: alphaColor(token.colorWhite, 0.92) }}
|
||||
aria-label="关闭提示"
|
||||
/>
|
||||
</Space>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Spin spinning={loading}>
|
||||
<div style={{
|
||||
|
||||
@@ -29,7 +29,6 @@ import {
|
||||
} from '@ant-design/icons';
|
||||
import { authApi } from '../services/api';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import AnnouncementModal from '../components/AnnouncementModal';
|
||||
import ThemeSwitch from '../components/ThemeSwitch';
|
||||
|
||||
const { Title, Paragraph, Text } = Typography;
|
||||
@@ -83,19 +82,26 @@ export default function Login() {
|
||||
const [resetPasswordForm] = Form.useForm<ResetPasswordValues>();
|
||||
const { token } = theme.useToken();
|
||||
const alphaColor = (color: string, alpha: number) => `color-mix(in srgb, ${color} ${(alpha * 100).toFixed(0)}%, transparent)`;
|
||||
const primaryButtonShadow = `0 8px 20px ${alphaColor(token.colorPrimary, 0.28)}`;
|
||||
const hoverButtonShadow = `0 12px 28px ${alphaColor(token.colorPrimary, 0.36)}`;
|
||||
const [showAnnouncement, setShowAnnouncement] = useState(false);
|
||||
const [loginCodeSending, setLoginCodeSending] = useState(false);
|
||||
const [registerCodeSending, setRegisterCodeSending] = useState(false);
|
||||
const [resetCodeSending, setResetCodeSending] = useState(false);
|
||||
const primaryButtonStyle = {
|
||||
height: 46,
|
||||
fontSize: 15,
|
||||
fontWeight: 700,
|
||||
borderRadius: 2,
|
||||
letterSpacing: '0.06em',
|
||||
textTransform: 'uppercase' as const,
|
||||
};
|
||||
|
||||
const [loginCountdown, setLoginCountdown] = useState(0);
|
||||
const [registerCountdown, setRegisterCountdown] = useState(0);
|
||||
const [resetCountdown, setResetCountdown] = useState(0);
|
||||
|
||||
const [loginCodeSending, setLoginCodeSending] = useState(false);
|
||||
const [registerCodeSending, setRegisterCodeSending] = useState(false);
|
||||
const [resetCodeSending, setResetCodeSending] = useState(false);
|
||||
|
||||
const [showResetPassword, setShowResetPassword] = useState(false);
|
||||
|
||||
const localAuthEnabled = authConfig.local_auth_enabled;
|
||||
const linuxdoEnabled = authConfig.linuxdo_enabled;
|
||||
const emailAuthEnabled = authConfig.email_auth_enabled;
|
||||
const emailRegisterEnabled = authConfig.email_register_enabled;
|
||||
|
||||
@@ -105,27 +111,13 @@ export default function Login() {
|
||||
{ value: registerCountdown, setter: setRegisterCountdown },
|
||||
{ value: resetCountdown, setter: setResetCountdown },
|
||||
].map(({ value, setter }) => {
|
||||
if (value <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (value <= 0) return null;
|
||||
return window.setInterval(() => {
|
||||
setter((prev) => {
|
||||
if (prev <= 1) {
|
||||
return 0;
|
||||
}
|
||||
return prev - 1;
|
||||
});
|
||||
setter((prev) => (prev <= 1 ? 0 : prev - 1));
|
||||
}, 1000);
|
||||
});
|
||||
|
||||
return () => {
|
||||
timers.forEach((timer) => {
|
||||
if (timer) {
|
||||
window.clearInterval(timer);
|
||||
}
|
||||
});
|
||||
};
|
||||
return () => timers.forEach((timer) => timer && window.clearInterval(timer));
|
||||
}, [loginCountdown, registerCountdown, resetCountdown]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -141,8 +133,8 @@ export default function Login() {
|
||||
} catch (error) {
|
||||
console.error('获取认证配置失败:', error);
|
||||
setAuthConfig({
|
||||
local_auth_enabled: false,
|
||||
linuxdo_enabled: true,
|
||||
local_auth_enabled: true, // 默认开启本地,防止全关
|
||||
linuxdo_enabled: false,
|
||||
email_auth_enabled: false,
|
||||
email_register_enabled: false,
|
||||
});
|
||||
@@ -155,17 +147,8 @@ export default function Login() {
|
||||
|
||||
const handleLoginSuccess = () => {
|
||||
message.success('登录成功!');
|
||||
|
||||
const hideForever = localStorage.getItem('announcement_hide_forever');
|
||||
const hideToday = localStorage.getItem('announcement_hide_today');
|
||||
const today = new Date().toDateString();
|
||||
|
||||
if (hideForever === 'true' || hideToday === today) {
|
||||
const redirect = searchParams.get('redirect') || '/';
|
||||
navigate(redirect);
|
||||
} else {
|
||||
setShowAnnouncement(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLocalLogin = async (values: LocalLoginValues) => {
|
||||
@@ -282,52 +265,14 @@ export default function Login() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleLinuxDOLogin = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await authApi.getLinuxDOAuthUrl();
|
||||
|
||||
const redirect = searchParams.get('redirect');
|
||||
if (redirect) {
|
||||
sessionStorage.setItem('login_redirect', redirect);
|
||||
}
|
||||
|
||||
window.location.href = response.auth_url;
|
||||
} catch (error) {
|
||||
console.error('获取授权地址失败:', error);
|
||||
message.error('获取授权地址失败,请稍后重试');
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAnnouncementClose = () => {
|
||||
setShowAnnouncement(false);
|
||||
const redirect = searchParams.get('redirect') || '/';
|
||||
navigate(redirect);
|
||||
};
|
||||
|
||||
const handleDoNotShowToday = () => {
|
||||
const today = new Date().toDateString();
|
||||
localStorage.setItem('announcement_hide_today', today);
|
||||
};
|
||||
|
||||
const handleNeverShow = () => {
|
||||
localStorage.setItem('announcement_hide_forever', 'true');
|
||||
};
|
||||
|
||||
const loginTips = useMemo(() => {
|
||||
const tips = [
|
||||
'首次 LinuxDO 登录会自动创建账号。',
|
||||
];
|
||||
|
||||
const tips = [];
|
||||
if (localAuthEnabled) {
|
||||
tips.unshift('本地登录默认账号:admin / admin123');
|
||||
tips.push('本地登录默认账号:admin / admin123');
|
||||
}
|
||||
|
||||
if (emailAuthEnabled) {
|
||||
tips.push('邮箱注册用户支持通过邮箱验证码重置密码。');
|
||||
}
|
||||
|
||||
return tips;
|
||||
}, [emailAuthEnabled, localAuthEnabled]);
|
||||
|
||||
@@ -355,7 +300,6 @@ export default function Login() {
|
||||
];
|
||||
|
||||
const renderLocalLogin = () => (
|
||||
<>
|
||||
<Form
|
||||
form={localForm}
|
||||
layout="vertical"
|
||||
@@ -372,7 +316,7 @@ export default function Login() {
|
||||
prefix={<UserOutlined style={{ color: token.colorTextTertiary }} />}
|
||||
placeholder="请输入管理账号/邮箱"
|
||||
autoComplete="username"
|
||||
style={{ height: 46, borderRadius: 12 }}
|
||||
style={{ height: 46, borderRadius: 2 }}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
@@ -384,7 +328,7 @@ export default function Login() {
|
||||
prefix={<LockOutlined style={{ color: token.colorTextTertiary }} />}
|
||||
placeholder="请输入访问密钥"
|
||||
autoComplete="current-password"
|
||||
style={{ height: 46, borderRadius: 12 }}
|
||||
style={{ height: 46, borderRadius: 2 }}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item style={{ marginBottom: 0, marginTop: 8 }}>
|
||||
@@ -393,28 +337,12 @@ export default function Login() {
|
||||
htmlType="submit"
|
||||
loading={loading}
|
||||
block
|
||||
style={{
|
||||
height: 46,
|
||||
fontSize: 16,
|
||||
fontWeight: 600,
|
||||
background: `linear-gradient(90deg, ${token.colorPrimary} 0%, ${alphaColor(token.colorPrimary, 0.86)} 100%)`,
|
||||
border: 'none',
|
||||
borderRadius: '12px',
|
||||
boxShadow: primaryButtonShadow,
|
||||
}}
|
||||
style={primaryButtonStyle}
|
||||
>
|
||||
登录系统
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
{linuxdoEnabled ? (
|
||||
<>
|
||||
<Divider style={{ margin: '18px 0 16px' }}>第三方登录</Divider>
|
||||
{renderLinuxDOLogin()}
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
|
||||
const renderEmailLogin = () => {
|
||||
@@ -429,13 +357,8 @@ export default function Login() {
|
||||
</Button>
|
||||
</Space>
|
||||
|
||||
<Card size="small" bordered={false} style={{ borderRadius: 12, background: token.colorFillAlter }}>
|
||||
<Form
|
||||
form={resetPasswordForm}
|
||||
layout="vertical"
|
||||
onFinish={handleResetPassword}
|
||||
size="middle"
|
||||
>
|
||||
<Card size="small" bordered={false} style={{ borderRadius: 2, background: token.colorFillAlter, border: `1px solid ${token.colorBorder}` }}>
|
||||
<Form form={resetPasswordForm} layout="vertical" onFinish={handleResetPassword} size="middle">
|
||||
<Form.Item
|
||||
name="email"
|
||||
label="注册邮箱"
|
||||
@@ -458,11 +381,7 @@ export default function Login() {
|
||||
>
|
||||
<Input placeholder="请输入重置验证码" maxLength={6} />
|
||||
</Form.Item>
|
||||
<Button
|
||||
onClick={sendResetCode}
|
||||
loading={resetCodeSending}
|
||||
disabled={resetCountdown > 0}
|
||||
>
|
||||
<Button onClick={sendResetCode} loading={resetCodeSending} disabled={resetCountdown > 0}>
|
||||
{resetCountdown > 0 ? `${resetCountdown}s 后重发` : '发送验证码'}
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
@@ -470,10 +389,7 @@ export default function Login() {
|
||||
<Form.Item
|
||||
name="new_password"
|
||||
label="新密码"
|
||||
rules={[
|
||||
{ required: true, message: '请输入新密码' },
|
||||
{ min: 6, message: '密码长度至少为 6 个字符' },
|
||||
]}
|
||||
rules={[{ required: true, message: '请输入新密码' }, { min: 6, message: '密码长度至少为 6 个字符' }]}
|
||||
>
|
||||
<Input.Password prefix={<LockOutlined />} placeholder="请输入新密码" />
|
||||
</Form.Item>
|
||||
@@ -485,9 +401,7 @@ export default function Login() {
|
||||
{ required: true, message: '请再次输入新密码' },
|
||||
({ getFieldValue }) => ({
|
||||
validator(_, value) {
|
||||
if (!value || getFieldValue('new_password') === value) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
if (!value || getFieldValue('new_password') === value) return Promise.resolve();
|
||||
return Promise.reject(new Error('两次输入的新密码不一致'));
|
||||
},
|
||||
}),
|
||||
@@ -525,7 +439,7 @@ export default function Login() {
|
||||
prefix={<MailOutlined style={{ color: token.colorTextTertiary }} />}
|
||||
placeholder="请输入已注册邮箱"
|
||||
autoComplete="email"
|
||||
style={{ height: 46, borderRadius: 12 }}
|
||||
style={{ height: 46, borderRadius: 2 }}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
@@ -543,15 +457,10 @@ export default function Login() {
|
||||
prefix={<SafetyCertificateOutlined style={{ color: token.colorTextTertiary }} />}
|
||||
placeholder="请输入 6 位登录验证码"
|
||||
maxLength={6}
|
||||
style={{ height: 46, borderRadius: '12px 0 0 12px' }}
|
||||
style={{ height: 46, borderRadius: '2px 0 0 2px' }}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Button
|
||||
style={{ height: 46 }}
|
||||
onClick={sendLoginCode}
|
||||
loading={loginCodeSending}
|
||||
disabled={loginCountdown > 0}
|
||||
>
|
||||
<Button style={{ height: 46 }} onClick={sendLoginCode} loading={loginCodeSending} disabled={loginCountdown > 0}>
|
||||
{loginCountdown > 0 ? `${loginCountdown}s 后重发` : '发送验证码'}
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
@@ -563,15 +472,7 @@ export default function Login() {
|
||||
htmlType="submit"
|
||||
loading={loading}
|
||||
block
|
||||
style={{
|
||||
height: 46,
|
||||
fontSize: 16,
|
||||
fontWeight: 600,
|
||||
background: `linear-gradient(90deg, ${token.colorPrimary} 0%, ${alphaColor(token.colorPrimary, 0.86)} 100%)`,
|
||||
border: 'none',
|
||||
borderRadius: '12px',
|
||||
boxShadow: primaryButtonShadow,
|
||||
}}
|
||||
style={primaryButtonStyle}
|
||||
>
|
||||
验证码登录
|
||||
</Button>
|
||||
@@ -606,7 +507,7 @@ export default function Login() {
|
||||
prefix={<MailOutlined style={{ color: token.colorTextTertiary }} />}
|
||||
placeholder="请输入注册邮箱"
|
||||
autoComplete="email"
|
||||
style={{ height: 46, borderRadius: 12 }}
|
||||
style={{ height: 46, borderRadius: 2 }}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
@@ -624,46 +525,34 @@ export default function Login() {
|
||||
prefix={<SafetyCertificateOutlined style={{ color: token.colorTextTertiary }} />}
|
||||
placeholder="请输入 6 位验证码"
|
||||
maxLength={6}
|
||||
style={{ height: 46, borderRadius: '12px 0 0 12px' }}
|
||||
style={{ height: 46, borderRadius: '2px 0 0 2px' }}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Button
|
||||
style={{ height: 46 }}
|
||||
onClick={sendRegisterCode}
|
||||
loading={registerCodeSending}
|
||||
disabled={registerCountdown > 0}
|
||||
>
|
||||
<Button style={{ height: 46 }} onClick={sendRegisterCode} loading={registerCodeSending} disabled={registerCountdown > 0}>
|
||||
{registerCountdown > 0 ? `${registerCountdown}s 后重发` : '发送验证码'}
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="display_name"
|
||||
label="昵称"
|
||||
rules={[{ max: 50, message: '昵称长度不能超过 50 个字符' }]}
|
||||
>
|
||||
<Form.Item name="display_name" label="昵称" rules={[{ max: 50, message: '昵称长度不能超过 50 个字符' }]}>
|
||||
<Input
|
||||
prefix={<UserOutlined style={{ color: token.colorTextTertiary }} />}
|
||||
placeholder="选填,默认使用邮箱前缀"
|
||||
autoComplete="nickname"
|
||||
style={{ height: 46, borderRadius: 12 }}
|
||||
style={{ height: 46, borderRadius: 2 }}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="password"
|
||||
label="登录密码"
|
||||
rules={[
|
||||
{ required: true, message: '请输入登录密码' },
|
||||
{ min: 6, message: '密码长度至少为 6 个字符' },
|
||||
]}
|
||||
rules={[{ required: true, message: '请输入登录密码' }, { min: 6, message: '密码长度至少为 6 个字符' }]}
|
||||
>
|
||||
<Input.Password
|
||||
prefix={<LockOutlined style={{ color: token.colorTextTertiary }} />}
|
||||
placeholder="请输入登录密码"
|
||||
autoComplete="new-password"
|
||||
style={{ height: 46, borderRadius: 12 }}
|
||||
style={{ height: 46, borderRadius: 2 }}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
@@ -675,9 +564,7 @@ export default function Login() {
|
||||
{ required: true, message: '请再次输入登录密码' },
|
||||
({ getFieldValue }) => ({
|
||||
validator(_, value) {
|
||||
if (!value || getFieldValue('password') === value) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
if (!value || getFieldValue('password') === value) return Promise.resolve();
|
||||
return Promise.reject(new Error('两次输入的密码不一致'));
|
||||
},
|
||||
}),
|
||||
@@ -687,7 +574,7 @@ export default function Login() {
|
||||
prefix={<LockOutlined style={{ color: token.colorTextTertiary }} />}
|
||||
placeholder="请再次输入登录密码"
|
||||
autoComplete="new-password"
|
||||
style={{ height: 46, borderRadius: 12 }}
|
||||
style={{ height: 46, borderRadius: 2 }}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
@@ -697,361 +584,89 @@ export default function Login() {
|
||||
htmlType="submit"
|
||||
loading={loading}
|
||||
block
|
||||
style={{
|
||||
height: 46,
|
||||
fontSize: 16,
|
||||
fontWeight: 600,
|
||||
background: `linear-gradient(90deg, ${token.colorPrimary} 0%, ${alphaColor(token.colorPrimary, 0.86)} 100%)`,
|
||||
border: 'none',
|
||||
borderRadius: '12px',
|
||||
boxShadow: primaryButtonShadow,
|
||||
}}
|
||||
style={primaryButtonStyle}
|
||||
>
|
||||
注册并登录
|
||||
</Button>
|
||||
</Form.Item>
|
||||
|
||||
<Text type="secondary" style={{ marginTop: 12, display: 'block' }}>
|
||||
验证码将发送到你填写的邮箱,若未收到请检查垃圾箱或稍后重试。注册后可通过邮箱验证码登录,也支持邮箱重置密码。
|
||||
验证码将发送到你填写的邮箱,若未收到请检查垃圾箱或稍后重试。
|
||||
</Text>
|
||||
</Form>
|
||||
);
|
||||
|
||||
const renderLinuxDOLogin = () => (
|
||||
<div>
|
||||
<Button
|
||||
type="primary"
|
||||
size="large"
|
||||
icon={(
|
||||
<img
|
||||
src="/favicon.ico"
|
||||
alt="LinuxDO"
|
||||
style={{
|
||||
width: 20,
|
||||
height: 20,
|
||||
marginRight: 8,
|
||||
verticalAlign: 'middle',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
loading={loading}
|
||||
onClick={handleLinuxDOLogin}
|
||||
block
|
||||
style={{
|
||||
height: 46,
|
||||
fontSize: 16,
|
||||
fontWeight: 600,
|
||||
background: `linear-gradient(90deg, ${token.colorPrimary} 0%, ${alphaColor(token.colorPrimary, 0.86)} 100%)`,
|
||||
border: 'none',
|
||||
borderRadius: '12px',
|
||||
boxShadow: primaryButtonShadow,
|
||||
transition: 'all 0.3s ease',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(-2px)';
|
||||
e.currentTarget.style.boxShadow = hoverButtonShadow;
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(0)';
|
||||
e.currentTarget.style.boxShadow = primaryButtonShadow;
|
||||
}}
|
||||
>
|
||||
使用 LinuxDO OAuth 登录
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
const authTabs = [
|
||||
...(localAuthEnabled
|
||||
? [
|
||||
{
|
||||
key: 'local-login',
|
||||
label: '本地登录',
|
||||
children: renderLocalLogin(),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(emailAuthEnabled
|
||||
? [
|
||||
{
|
||||
key: 'email-login',
|
||||
label: '邮箱登录',
|
||||
children: renderEmailLogin(),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(emailAuthEnabled && emailRegisterEnabled
|
||||
? [
|
||||
{
|
||||
key: 'email-register',
|
||||
label: '邮箱注册',
|
||||
children: renderEmailRegister(),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(localAuthEnabled ? [{ key: 'local-login', label: '本地登录', children: renderLocalLogin() }] : []),
|
||||
...(emailAuthEnabled ? [{ key: 'email-login', label: '邮箱登录', children: renderEmailLogin() }] : []),
|
||||
...(emailAuthEnabled && emailRegisterEnabled ? [{ key: 'email-register', label: '邮箱注册', children: renderEmailRegister() }] : []),
|
||||
];
|
||||
|
||||
if (checking) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
minHeight: '100vh',
|
||||
background: token.colorBgLayout,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '100vh', background: token.colorBgLayout }}>
|
||||
<Spin size="large" style={{ color: token.colorPrimary }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const pageTexture = `repeating-linear-gradient(-12deg, transparent, transparent 31px, ${alphaColor(token.colorText, 0.035)} 31px, ${alphaColor(token.colorText, 0.035)} 32px)`;
|
||||
|
||||
return (
|
||||
<>
|
||||
<AnnouncementModal
|
||||
visible={showAnnouncement}
|
||||
onClose={handleAnnouncementClose}
|
||||
onDoNotShowToday={handleDoNotShowToday}
|
||||
onNeverShow={handleNeverShow}
|
||||
/>
|
||||
<Layout style={{ minHeight: '100vh', background: token.colorBgLayout }}>
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 20,
|
||||
right: 20,
|
||||
zIndex: 10,
|
||||
padding: '8px 10px',
|
||||
borderRadius: 12,
|
||||
background: alphaColor(token.colorBgContainer, 0.9),
|
||||
border: `1px solid ${token.colorBorderSecondary}`,
|
||||
backdropFilter: 'blur(6px)',
|
||||
}}
|
||||
>
|
||||
<Layout style={{ minHeight: '100vh', background: token.colorBgLayout, backgroundImage: pageTexture }}>
|
||||
<div style={{ position: 'fixed', top: 16, right: 16, zIndex: 10, padding: '6px 8px', borderRadius: 2, background: token.colorBgContainer, border: `1px solid ${token.colorBorder}` }}>
|
||||
<ThemeSwitch size="small" />
|
||||
</div>
|
||||
<Row style={{ minHeight: '100vh' }}>
|
||||
<Col xs={0} lg={11}>
|
||||
<section
|
||||
style={{
|
||||
height: '100%',
|
||||
padding: '44px 64px 88px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'space-between',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
backgroundColor: alphaColor(token.colorBgContainer, 0.78),
|
||||
backgroundImage: `linear-gradient(${alphaColor(token.colorTextSecondary, 0.06)} 1px, transparent 1px), linear-gradient(90deg, ${alphaColor(token.colorTextSecondary, 0.06)} 1px, transparent 1px)`,
|
||||
backgroundSize: '68px 68px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
background: `radial-gradient(circle at 25% 20%, ${alphaColor(token.colorPrimary, 0.12)} 0%, transparent 50%)`,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
zIndex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'space-between',
|
||||
gap: 34,
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<Space align="center" size={14}>
|
||||
<div
|
||||
style={{
|
||||
width: 46,
|
||||
height: 46,
|
||||
borderRadius: 14,
|
||||
background: `linear-gradient(135deg, ${token.colorPrimary} 0%, ${alphaColor(token.colorPrimary, 0.7)} 100%)`,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
boxShadow: primaryButtonShadow,
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src="/logo.svg"
|
||||
alt="墨木灵思"
|
||||
style={{ width: 26, height: 26, filter: 'brightness(0) invert(1)' }}
|
||||
/>
|
||||
<div style={{ minHeight: '100vh', display: 'flex', flexDirection: 'column', alignItems: 'center', padding: '40px 20px 64px', width: '100%' }}>
|
||||
<header style={{ width: '100%', maxWidth: 1080, marginBottom: 32, textAlign: 'center' }}>
|
||||
<Space align="center" size={12} style={{ justifyContent: 'center', marginBottom: 12 }}>
|
||||
<div style={{ width: 44, height: 44, border: `2px solid ${token.colorPrimary}`, display: 'flex', alignItems: 'center', justifyContent: 'center', background: token.colorBgContainer }}>
|
||||
<img src="/logo.svg" alt="墨木灵思" style={{ width: 24, height: 24 }} />
|
||||
</div>
|
||||
<Title level={3} style={{ margin: 0, color: token.colorText }}>
|
||||
墨木灵思
|
||||
</Title>
|
||||
<Title level={3} className="app-serif-title" style={{ margin: 0, fontWeight: 700 }}>墨木灵思</Title>
|
||||
</Space>
|
||||
|
||||
<Space direction="vertical" size={32} style={{ width: '100%' }}>
|
||||
<div style={{ maxWidth: 'min(860px, 100%)' }}>
|
||||
<Title
|
||||
level={1}
|
||||
style={{
|
||||
marginBottom: 22,
|
||||
color: token.colorText,
|
||||
lineHeight: 1.12,
|
||||
fontWeight: 800,
|
||||
fontSize: 'clamp(52px, 3vw, 78px)',
|
||||
}}
|
||||
>
|
||||
基于 AI 的
|
||||
<br />
|
||||
<span
|
||||
style={{
|
||||
backgroundImage: `linear-gradient(90deg, ${token.colorPrimary} 0%, #d946ef 100%)`,
|
||||
WebkitBackgroundClip: 'text',
|
||||
backgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
color: token.colorPrimary,
|
||||
}}
|
||||
>
|
||||
智能小说创作助手
|
||||
</span>
|
||||
</Title>
|
||||
<Paragraph
|
||||
style={{
|
||||
fontSize: 'clamp(18px, 1vw, 22px)',
|
||||
lineHeight: 1.85,
|
||||
color: token.colorTextSecondary,
|
||||
marginBottom: 0,
|
||||
maxWidth: 800,
|
||||
}}
|
||||
>
|
||||
从灵感到成稿,围绕「多模型协同、创作流程自动化、角色关系管理、章节精修」构建一体化创作工作台。
|
||||
</Paragraph>
|
||||
<Title level={1} className="app-serif-title" style={{ margin: '0 0 10px', fontSize: 'clamp(26px, 4vw, 40px)', fontWeight: 700, lineHeight: 1.25 }}>执笔于 AI,落墨成章</Title>
|
||||
<Paragraph style={{ margin: 0, color: token.colorTextSecondary, fontSize: 15, maxWidth: 520, marginInline: 'auto' }}>多模型协同 · 世界观构建 · 角色关系 · 章节精修</Paragraph>
|
||||
</header>
|
||||
<Row style={{ width: '100%', maxWidth: 1080, flex: 1 }} gutter={[28, 28]} align="stretch">
|
||||
<Col xs={24} lg={13}>
|
||||
<div>
|
||||
{featureItems.map((item, index) => (
|
||||
<div key={item.title} style={{ padding: '18px 0 18px 18px', borderLeft: `3px solid ${index % 2 === 0 ? token.colorPrimary : alphaColor(token.colorPrimary, 0.4)}`, borderBottom: `1px solid ${token.colorBorderSecondary}` }}>
|
||||
<Space align="start" size={12}>
|
||||
<span style={{ color: token.colorPrimary, fontSize: 18, marginTop: 2 }}>{item.icon}</span>
|
||||
<div>
|
||||
<Text strong style={{ display: 'block', marginBottom: 4, fontSize: 15 }}>{item.title}</Text>
|
||||
<Paragraph style={{ margin: 0, color: token.colorTextSecondary, fontSize: 14, lineHeight: 1.65 }}>{item.description}</Paragraph>
|
||||
</div>
|
||||
|
||||
<Row gutter={[20, 20]} style={{ width: '100%', maxWidth: 'min(920px, 100%)' }}>
|
||||
{featureItems.map((item) => (
|
||||
<Col span={12} key={item.title}>
|
||||
<Card
|
||||
size="small"
|
||||
bordered={false}
|
||||
style={{
|
||||
height: '100%',
|
||||
minHeight: 120,
|
||||
borderRadius: 16,
|
||||
background: alphaColor(token.colorBgContainer, 0.9),
|
||||
}}
|
||||
bodyStyle={{ padding: 16 }}
|
||||
>
|
||||
<Space direction="vertical" size={8}>
|
||||
<Space size={10} style={{ color: token.colorPrimary, fontWeight: 700, fontSize: 15 }}>
|
||||
{item.icon}
|
||||
<span>{item.title}</span>
|
||||
</Space>
|
||||
<Paragraph style={{ marginBottom: 0, color: token.colorTextSecondary, fontSize: 14, lineHeight: 1.65 }}>
|
||||
{item.description}
|
||||
</Paragraph>
|
||||
</div>
|
||||
))}
|
||||
<Space size={[8, 8]} wrap style={{ marginTop: 16 }}>
|
||||
{['OpenAI', 'Gemini', 'Claude', 'Docker', 'PostgreSQL'].map((label) => (
|
||||
<Tag key={label} style={{ margin: 0, borderRadius: 2, background: token.colorFillSecondary, border: `1px solid ${token.colorBorder}`, color: token.colorTextSecondary }}>{label}</Tag>
|
||||
))}
|
||||
</Space>
|
||||
</div>
|
||||
</Col>
|
||||
<Col xs={24} lg={11}>
|
||||
<Card bordered style={{ borderRadius: 2, border: `2px solid ${token.colorText}`, boxShadow: `8px 8px 0 ${alphaColor(token.colorText, 0.12)}`, background: token.colorBgContainer }} styles={{ body: { padding: '28px 28px 24px' } }}>
|
||||
<Space direction="vertical" size={4} style={{ width: '100%', marginBottom: 8 }}>
|
||||
<Title level={3} className="app-serif-title" style={{ margin: 0, fontWeight: 700 }}>进入创作台</Title>
|
||||
<Text type="secondary">登录后继续你的小说项目</Text>
|
||||
</Space>
|
||||
<div className="app-login-tabs" style={{ marginTop: 8 }}>
|
||||
{authTabs.length > 0 ? <Tabs defaultActiveKey={authTabs[0].key} items={authTabs} /> : null}
|
||||
{authTabs.length === 0 ? <Alert type="warning" showIcon message="当前未启用可用登录方式" description="请联系管理员在系统配置中启用本地登录或邮箱认证。" /> : null}
|
||||
{emailAuthEnabled && !emailRegisterEnabled ? <Alert type="info" showIcon style={{ marginTop: 12, borderRadius: 2 }} message="邮箱注册暂未开放" description="当前仅开放邮箱验证码登录与找回密码,如需注册请联系管理员。" /> : null}
|
||||
<Divider style={{ margin: '18px 0 12px', borderColor: token.colorBorderSecondary }} />
|
||||
<Alert type="info" showIcon icon={<SafetyCertificateOutlined />} style={{ background: token.colorFillTertiary, border: `1px solid ${token.colorBorder}`, borderRadius: 2 }} message="登录说明" description={<ul style={{ margin: 0, paddingLeft: 18 }}>{loginTips.map((tip) => <li key={tip} style={{ marginBottom: 4 }}>{tip}</li>)}</ul>} />
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
</Space>
|
||||
|
||||
<Space size={[10, 14]} wrap style={{ maxWidth: 'min(860px, 100%)' }}>
|
||||
<Tag color="blue">OpenAI</Tag>
|
||||
<Tag color="geekblue">Gemini</Tag>
|
||||
<Tag color="purple">Claude</Tag>
|
||||
<Tag color="cyan">LinuxDO OAuth</Tag>
|
||||
<Tag color="green">Docker Compose</Tag>
|
||||
<Tag color="gold">PostgreSQL</Tag>
|
||||
</Space>
|
||||
<Paragraph style={{ marginTop: 32, marginBottom: 0, fontSize: 12, color: token.colorTextTertiary, letterSpacing: '0.08em' }}>© 2026 墨木灵思 · GPLv3</Paragraph>
|
||||
</div>
|
||||
|
||||
<Paragraph
|
||||
style={{
|
||||
marginBottom: 0,
|
||||
fontSize: 12,
|
||||
color: token.colorTextTertiary,
|
||||
position: 'relative',
|
||||
zIndex: 1,
|
||||
letterSpacing: 0.4,
|
||||
}}
|
||||
>
|
||||
© 2026 墨木灵思 · GPLv3 License
|
||||
</Paragraph>
|
||||
</section>
|
||||
</Col>
|
||||
|
||||
<Col xs={24} lg={13}>
|
||||
<section
|
||||
style={{
|
||||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '48px min(7vw, 72px)',
|
||||
background: token.colorBgLayout,
|
||||
}}
|
||||
>
|
||||
<div style={{ width: '100%', maxWidth: 520 }}>
|
||||
<Space direction="vertical" size={4}>
|
||||
<Title level={2} style={{ marginBottom: 0, fontWeight: 700, color: token.colorText }}>
|
||||
欢迎回来
|
||||
</Title>
|
||||
<Paragraph style={{ marginBottom: 0, color: token.colorTextSecondary }}>
|
||||
登录 墨木灵思,继续你的小说创作项目。
|
||||
</Paragraph>
|
||||
</Space>
|
||||
|
||||
<div style={{ marginTop: 22 }}>
|
||||
{authTabs.length > 0 ? (
|
||||
<Tabs defaultActiveKey={authTabs[0].key} items={authTabs} />
|
||||
) : null}
|
||||
|
||||
{!localAuthEnabled && !linuxdoEnabled && !emailAuthEnabled ? (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message="当前未启用可用登录方式"
|
||||
description="请联系管理员在系统配置中启用本地登录、邮箱认证或 LinuxDO OAuth 登录。"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{emailAuthEnabled && !emailRegisterEnabled ? (
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginTop: 12, borderRadius: 12 }}
|
||||
message="邮箱注册暂未开放"
|
||||
description="当前仅开放邮箱验证码登录与找回密码,如需注册请联系管理员。"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Divider style={{ margin: '20px 0 14px' }} />
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
icon={<SafetyCertificateOutlined />}
|
||||
style={{ background: alphaColor(token.colorPrimary, 0.06), borderRadius: 12 }}
|
||||
message="登录说明"
|
||||
description={(
|
||||
<ul style={{ margin: 0, paddingLeft: 18 }}>
|
||||
{loginTips.map((tip) => (
|
||||
<li key={tip} style={{ marginBottom: 4 }}>
|
||||
{tip}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</Col>
|
||||
</Row>
|
||||
</Layout>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
import { useStore } from '../store';
|
||||
import { useCharacterSync, useOutlineSync, useChapterSync } from '../store/hooks';
|
||||
import { projectApi } from '../services/api';
|
||||
import { VERSION_INFO } from '../config/version';
|
||||
import ThemeSwitch from '../components/ThemeSwitch';
|
||||
import { useThemeMode } from '../theme/useThemeMode';
|
||||
import { getStoredSidebarCollapsed, setStoredSidebarCollapsed } from '../utils/sidebarState';
|
||||
@@ -198,7 +199,13 @@ export default function ProjectDetail() {
|
||||
{
|
||||
key: 'source-code',
|
||||
icon: <CloudOutlined />,
|
||||
label: <a href="https://www.xinmi.cloud/" target="_blank" rel="noopener noreferrer">源码库</a>,
|
||||
label: VERSION_INFO.officialApiDocUrl ? (
|
||||
<a href={VERSION_INFO.officialApiDocUrl} target="_blank" rel="noopener noreferrer">
|
||||
API 文档
|
||||
</a>
|
||||
) : (
|
||||
<span style={{ color: 'inherit' }}>API 文档</span>
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -9,7 +9,6 @@ import { eventBus, EventNames } from '../store/eventBus';
|
||||
import type { ReactNode } from 'react';
|
||||
import type { Project, User } from '../types';
|
||||
import UserMenu from '../components/UserMenu';
|
||||
import ChangelogFloatingButton from '../components/ChangelogFloatingButton';
|
||||
import ThemeSwitch from '../components/ThemeSwitch';
|
||||
import { useThemeMode } from '../theme/useThemeMode';
|
||||
import SettingsPage from './Settings';
|
||||
@@ -19,6 +18,8 @@ import PromptTemplates from './PromptTemplates';
|
||||
import BookImport from './BookImport';
|
||||
import BookshelfPage from './BookshelfPage';
|
||||
import { getStoredSidebarCollapsed, setStoredSidebarCollapsed } from '../utils/sidebarState';
|
||||
import { VERSION_INFO } from '../config/version';
|
||||
import { shellColors } from '../theme/themeConfig';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
@@ -386,10 +387,11 @@ export default function ProjectList() {
|
||||
};
|
||||
|
||||
const isMobile = window.innerWidth <= 768;
|
||||
const headerHeight = isMobile ? 56 : 70;
|
||||
const expandedSiderWidth = 220;
|
||||
const collapsedSiderWidth = 60;
|
||||
const headerHeight = isMobile ? 56 : 64;
|
||||
const expandedSiderWidth = 232;
|
||||
const collapsedSiderWidth = 64;
|
||||
const desktopSiderWidth = collapsed ? collapsedSiderWidth : expandedSiderWidth;
|
||||
const shell = shellColors[resolvedMode];
|
||||
|
||||
const currentViewTitle = activeView === 'projects'
|
||||
? '我的书架'
|
||||
@@ -447,9 +449,9 @@ export default function ProjectList() {
|
||||
label: '系统设置',
|
||||
}] : []),
|
||||
{
|
||||
key: 'mumu-api',
|
||||
key: 'xinmi-api',
|
||||
icon: <ApiOutlined />,
|
||||
label: 'MuMuのAPI',
|
||||
label: 'XinMi API',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -487,12 +489,16 @@ export default function ProjectList() {
|
||||
label: '系统设置',
|
||||
}] : []),
|
||||
{
|
||||
key: 'mumu-api',
|
||||
key: 'xinmi-api',
|
||||
icon: <ApiOutlined />,
|
||||
label: 'MuMuのAPI',
|
||||
label: 'XinMi API',
|
||||
},
|
||||
];
|
||||
|
||||
const openXinmiApi = () => {
|
||||
window.open(VERSION_INFO.officialApiDocUrl, '_blank', 'noopener,noreferrer');
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
height: '100vh',
|
||||
@@ -505,10 +511,11 @@ export default function ProjectList() {
|
||||
|
||||
{!isMobile && (
|
||||
<div
|
||||
className="app-shell-sider"
|
||||
style={{
|
||||
width: desktopSiderWidth,
|
||||
background: token.colorBgContainer,
|
||||
borderRight: `1px solid ${token.colorBorderSecondary}`,
|
||||
background: shell.siderBg,
|
||||
borderRight: `1px solid ${shell.siderBorder}`,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
position: 'fixed',
|
||||
@@ -518,15 +525,16 @@ export default function ProjectList() {
|
||||
height: '100vh',
|
||||
overflow: 'hidden',
|
||||
transition: 'width 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
boxShadow: `4px 0 16px ${alphaColor(token.colorText, 0.06)}`,
|
||||
boxShadow: 'none',
|
||||
zIndex: 1000
|
||||
}}>
|
||||
<div style={{
|
||||
height: 70,
|
||||
height: 64,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: collapsed ? 0 : '0 12px',
|
||||
background: token.colorPrimary,
|
||||
padding: collapsed ? 0 : '0 14px',
|
||||
background: shell.siderBg,
|
||||
borderBottom: `1px solid ${shell.siderBorder}`,
|
||||
flexShrink: 0,
|
||||
justifyContent: collapsed ? 'center' : 'space-between',
|
||||
gap: 8
|
||||
@@ -537,7 +545,7 @@ export default function ProjectList() {
|
||||
icon={<MenuUnfoldOutlined />}
|
||||
onClick={() => setCollapsed(false)}
|
||||
style={{
|
||||
color: token.colorWhite,
|
||||
color: shell.siderText,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
padding: 0,
|
||||
@@ -549,39 +557,53 @@ export default function ProjectList() {
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, minWidth: 0, overflow: 'hidden' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, minWidth: 0, overflow: 'hidden', flex: 1 }}>
|
||||
<div style={{
|
||||
width: 30,
|
||||
height: 30,
|
||||
background: alphaColor(token.colorWhite, 0.2),
|
||||
borderRadius: 8,
|
||||
width: 32,
|
||||
height: 32,
|
||||
border: `1px solid ${shell.siderAccent}`,
|
||||
borderRadius: 2,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: token.colorWhite,
|
||||
color: shell.siderAccent,
|
||||
fontSize: 16,
|
||||
backdropFilter: 'blur(4px)'
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
<BookOutlined />
|
||||
</div>
|
||||
<span style={{
|
||||
color: token.colorWhite,
|
||||
<div style={{ minWidth: 0, display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<span className="app-serif-title" style={{
|
||||
color: shell.siderText,
|
||||
fontWeight: 600,
|
||||
fontSize: 15,
|
||||
fontFamily: token.fontFamily,
|
||||
lineHeight: 1.2,
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis'
|
||||
}}>
|
||||
墨木灵思
|
||||
{VERSION_INFO.projectName}
|
||||
</span>
|
||||
<span style={{
|
||||
color: shell.siderMuted,
|
||||
fontWeight: 400,
|
||||
fontSize: 11,
|
||||
lineHeight: 1.2,
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
letterSpacing: '0.04em',
|
||||
}}>
|
||||
铜墨编辑部
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<MenuFoldOutlined />}
|
||||
onClick={() => setCollapsed(true)}
|
||||
style={{
|
||||
color: token.colorWhite,
|
||||
color: shell.siderMuted,
|
||||
width: 32,
|
||||
height: 32,
|
||||
padding: 0,
|
||||
@@ -594,13 +616,14 @@ export default function ProjectList() {
|
||||
|
||||
<div style={{ flex: 1, overflowY: 'auto', overflowX: 'hidden' }}>
|
||||
<Menu
|
||||
theme="dark"
|
||||
mode="inline"
|
||||
inlineCollapsed={collapsed}
|
||||
selectedKeys={[activeView]}
|
||||
style={{ borderRight: 0, paddingTop: 12, width: '100%' }}
|
||||
style={{ borderRight: 0, paddingTop: 12, width: '100%', background: 'transparent' }}
|
||||
onClick={({ key }) => {
|
||||
if (key === 'mumu-api') {
|
||||
window.open('https://api.mumuverse.space/register?aff=4NN8', '_blank', 'noopener,noreferrer');
|
||||
if (key === 'xinmi-api') {
|
||||
openXinmiApi();
|
||||
return;
|
||||
}
|
||||
changeView(key as ProjectListView);
|
||||
@@ -611,7 +634,7 @@ export default function ProjectList() {
|
||||
|
||||
<div style={{
|
||||
padding: collapsed ? '12px 8px' : 16,
|
||||
borderTop: `1px solid ${token.colorBorderSecondary}`,
|
||||
borderTop: `1px solid ${shell.siderBorder}`,
|
||||
flexShrink: 0
|
||||
}}>
|
||||
{collapsed ? (
|
||||
@@ -624,17 +647,17 @@ export default function ProjectList() {
|
||||
style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
background: alphaColor(token.colorBgContainer, 0.65),
|
||||
border: `1px solid ${token.colorBorder}`,
|
||||
color: token.colorTextSecondary,
|
||||
borderRadius: 2,
|
||||
background: alphaColor(shell.siderText, 0.08),
|
||||
border: `1px solid ${shell.siderBorder}`,
|
||||
color: shell.siderMuted,
|
||||
}}
|
||||
/>
|
||||
<UserMenu compact />
|
||||
</Space>
|
||||
) : (
|
||||
<Space direction="vertical" style={{ width: '100%' }} size={12}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', fontSize: 12, color: token.colorTextTertiary }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', fontSize: 12, color: shell.siderMuted }}>
|
||||
<span>主题模式</span>
|
||||
<span>{resolvedMode === 'dark' ? '深色' : '浅色'}</span>
|
||||
</div>
|
||||
@@ -647,7 +670,8 @@ export default function ProjectList() {
|
||||
)}
|
||||
|
||||
<div style={{
|
||||
background: token.colorPrimary,
|
||||
background: token.colorBgContainer,
|
||||
borderBottom: `2px solid ${shell.headerBorder}`,
|
||||
padding: isMobile ? '0 12px' : '0 24px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
@@ -657,7 +681,7 @@ export default function ProjectList() {
|
||||
left: isMobile ? 0 : desktopSiderWidth,
|
||||
right: 0,
|
||||
zIndex: 1000,
|
||||
boxShadow: `0 2px 10px ${alphaColor(token.colorText, 0.16)}`,
|
||||
boxShadow: 'none',
|
||||
height: headerHeight,
|
||||
flexShrink: 0,
|
||||
transition: 'left 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
@@ -672,19 +696,19 @@ export default function ProjectList() {
|
||||
onClick={() => setDrawerVisible(true)}
|
||||
style={{
|
||||
fontSize: 18,
|
||||
color: token.colorWhite,
|
||||
color: token.colorText,
|
||||
width: 36,
|
||||
height: 36
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h2 style={{
|
||||
<h2 className="app-serif-title" style={{
|
||||
margin: 0,
|
||||
color: token.colorWhite,
|
||||
color: token.colorText,
|
||||
fontSize: 16,
|
||||
fontWeight: 600,
|
||||
textShadow: `0 2px 4px ${alphaColor(token.colorText, 0.2)}`,
|
||||
textShadow: 'none',
|
||||
flex: 1,
|
||||
textAlign: 'center',
|
||||
whiteSpace: 'nowrap',
|
||||
@@ -701,12 +725,12 @@ export default function ProjectList() {
|
||||
<>
|
||||
<div style={{ width: 40, zIndex: 1 }} />
|
||||
|
||||
<h2 style={{
|
||||
<h2 className="app-serif-title" style={{
|
||||
margin: 0,
|
||||
color: token.colorWhite,
|
||||
fontSize: '24px',
|
||||
color: token.colorText,
|
||||
fontSize: '22px',
|
||||
fontWeight: 600,
|
||||
textShadow: `0 2px 4px ${alphaColor(token.colorText, 0.2)}`,
|
||||
textShadow: 'none',
|
||||
position: 'absolute',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
@@ -735,29 +759,29 @@ export default function ProjectList() {
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backdropFilter: 'blur(4px)',
|
||||
borderRadius: '28px',
|
||||
borderRadius: 2,
|
||||
minWidth: '56px',
|
||||
height: '56px',
|
||||
height: '52px',
|
||||
padding: '0 12px',
|
||||
boxShadow: `inset 0 0 15px ${alphaColor(token.colorWhite, 0.15)}, 0 4px 10px ${alphaColor(token.colorText, 0.1)}`,
|
||||
border: `1px solid ${token.colorBorder}`,
|
||||
background: token.colorFillTertiary,
|
||||
boxShadow: `3px 3px 0 ${alphaColor(token.colorText, 0.08)}`,
|
||||
cursor: 'default',
|
||||
transition: 'transform 0.3s ease, box-shadow 0.3s ease',
|
||||
transition: 'transform 0.2s ease, box-shadow 0.2s ease',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(-3px) scale(1.02)';
|
||||
e.currentTarget.style.boxShadow = `inset 0 0 20px ${alphaColor(token.colorWhite, 0.25)}, 0 8px 16px ${alphaColor(token.colorText, 0.15)}`;
|
||||
e.currentTarget.style.border = `1px solid ${alphaColor(token.colorWhite, 0.1)}`;
|
||||
e.currentTarget.style.transform = 'translate(-1px, -1px)';
|
||||
e.currentTarget.style.boxShadow = `4px 4px 0 ${alphaColor(token.colorPrimary, 0.2)}`;
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(0) scale(1)';
|
||||
e.currentTarget.style.boxShadow = `inset 0 0 15px ${alphaColor(token.colorWhite, 0.15)}, 0 4px 10px ${alphaColor(token.colorText, 0.1)}`;
|
||||
e.currentTarget.style.transform = 'translate(0, 0)';
|
||||
e.currentTarget.style.boxShadow = `3px 3px 0 ${alphaColor(token.colorText, 0.08)}`;
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: '11px', color: alphaColor(token.colorWhite, 0.9), marginBottom: '2px', lineHeight: 1 }}>
|
||||
<span style={{ fontSize: '11px', color: token.colorTextSecondary, marginBottom: '2px', lineHeight: 1 }}>
|
||||
{item.label}
|
||||
</span>
|
||||
<span style={{ fontSize: '15px', fontWeight: '600', color: token.colorWhite, lineHeight: 1, fontFamily: 'Monaco, monospace' }}>
|
||||
<span style={{ fontSize: '15px', fontWeight: '600', color: token.colorText, lineHeight: 1, fontFamily: 'var(--app-font-mono)' }}>
|
||||
{item.label === '总字数' ? formatWordCount(item.value) : item.value}
|
||||
{item.unit && <span style={{ fontSize: '10px', marginLeft: '2px', opacity: 0.8 }}>{item.unit}</span>}
|
||||
</span>
|
||||
@@ -804,8 +828,8 @@ export default function ProjectList() {
|
||||
selectedKeys={[activeView]}
|
||||
style={{ borderRight: 0, paddingTop: 8 }}
|
||||
onClick={({ key }) => {
|
||||
if (key === 'mumu-api') {
|
||||
window.open('https://api.mumuverse.space/register?aff=4NN8', '_blank', 'noopener,noreferrer');
|
||||
if (key === 'xinmi-api') {
|
||||
openXinmiApi();
|
||||
setDrawerVisible(false);
|
||||
return;
|
||||
}
|
||||
@@ -887,8 +911,6 @@ export default function ProjectList() {
|
||||
formatDate={formatDate}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ChangelogFloatingButton />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -248,8 +248,7 @@ export default function PromptTemplates() {
|
||||
};
|
||||
|
||||
const currentTemplates = getCurrentTemplates();
|
||||
const pageBackground = `linear-gradient(180deg, ${token.colorBgLayout} 0%, ${token.colorFillSecondary} 100%)`;
|
||||
const headerBackground = `linear-gradient(135deg, ${token.colorPrimary} 0%, ${token.colorPrimaryHover} 100%)`;
|
||||
const pageBackground = token.colorBgLayout;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -269,73 +268,37 @@ export default function PromptTemplates() {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}>
|
||||
{/* 顶部导航卡片 */}
|
||||
{/* 顶部标题区 */}
|
||||
<Card
|
||||
variant="borderless"
|
||||
className="app-prompt-header"
|
||||
style={{
|
||||
background: headerBackground,
|
||||
borderRadius: isMobile ? 16 : 24,
|
||||
boxShadow: token.boxShadowSecondary,
|
||||
background: token.colorBgContainer,
|
||||
borderRadius: 2,
|
||||
border: `1px solid ${token.colorBorder}`,
|
||||
marginBottom: isMobile ? 20 : 24,
|
||||
border: 'none',
|
||||
position: 'relative',
|
||||
overflow: 'hidden'
|
||||
boxShadow: `6px 6px 0 ${token.colorFillSecondary}`,
|
||||
}}
|
||||
>
|
||||
{/* 装饰性背景元素 */}
|
||||
<div style={{ position: 'absolute', top: -60, right: -60, width: 200, height: 200, borderRadius: '50%', background: token.colorWhite, opacity: 0.08, pointerEvents: 'none' }} />
|
||||
<div style={{ position: 'absolute', bottom: -40, left: '30%', width: 120, height: 120, borderRadius: '50%', background: token.colorWhite, opacity: 0.05, pointerEvents: 'none' }} />
|
||||
<div style={{ position: 'absolute', top: '50%', right: '15%', width: 80, height: 80, borderRadius: '50%', background: token.colorWhite, opacity: 0.06, pointerEvents: 'none' }} />
|
||||
|
||||
<Row align="middle" justify="space-between" gutter={[16, 16]} style={{ position: 'relative', zIndex: 1 }}>
|
||||
<Row align="middle" justify="space-between" gutter={[16, 16]}>
|
||||
<Col xs={24} sm={12} md={14}>
|
||||
<Space direction="vertical" size={4}>
|
||||
<Title level={isMobile ? 3 : 2} style={{ margin: 0, color: token.colorWhite, textShadow: `0 2px 4px ${token.colorBgMask}` }}>
|
||||
<FileSearchOutlined style={{ color: token.colorWhite, opacity: 0.9, marginRight: 8 }} />
|
||||
<Title level={isMobile ? 3 : 2} className="app-serif-title" style={{ margin: 0, color: token.colorText }}>
|
||||
<FileSearchOutlined style={{ color: token.colorPrimary, marginRight: 8 }} />
|
||||
提示词模板管理
|
||||
</Title>
|
||||
<Text style={{ fontSize: isMobile ? 12 : 14, color: token.colorTextLightSolid, opacity: 0.85, marginLeft: isMobile ? 40 : 48 }}>
|
||||
<Text style={{ fontSize: isMobile ? 12 : 14, color: token.colorTextSecondary }}>
|
||||
自定义 AI 生成提示词,打造个性化创作体验
|
||||
</Text>
|
||||
</Space>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={10}>
|
||||
<Space wrap style={{ justifyContent: isMobile ? 'flex-start' : 'flex-end', width: '100%' }}>
|
||||
<Button
|
||||
icon={<DownloadOutlined />}
|
||||
onClick={handleExport}
|
||||
size={isMobile ? 'small' : 'middle'}
|
||||
style={{
|
||||
borderRadius: 12,
|
||||
background: token.colorWhite,
|
||||
border: `1px solid ${token.colorWhite}`,
|
||||
boxShadow: token.boxShadow,
|
||||
color: token.colorPrimary,
|
||||
fontWeight: 600,
|
||||
backdropFilter: 'blur(10px)',
|
||||
transition: 'all 0.3s ease'
|
||||
}}
|
||||
>
|
||||
<Button icon={<DownloadOutlined />} onClick={handleExport} size={isMobile ? 'small' : 'middle'}>
|
||||
导出配置
|
||||
</Button>
|
||||
<Upload
|
||||
accept=".json"
|
||||
showUploadList={false}
|
||||
beforeUpload={handleImport}
|
||||
>
|
||||
<Button
|
||||
icon={<UploadOutlined />}
|
||||
size={isMobile ? 'small' : 'middle'}
|
||||
style={{
|
||||
borderRadius: 12,
|
||||
background: token.colorWhite,
|
||||
border: `1px solid ${token.colorWhite}`,
|
||||
boxShadow: token.boxShadow,
|
||||
color: token.colorPrimary,
|
||||
fontWeight: 600,
|
||||
backdropFilter: 'blur(10px)',
|
||||
}}
|
||||
>
|
||||
<Upload accept=".json" showUploadList={false} beforeUpload={handleImport}>
|
||||
<Button icon={<UploadOutlined />} size={isMobile ? 'small' : 'middle'}>
|
||||
导入配置
|
||||
</Button>
|
||||
</Upload>
|
||||
@@ -343,7 +306,6 @@ export default function PromptTemplates() {
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* 使用提示 */}
|
||||
<Alert
|
||||
message={
|
||||
<Space align="center">
|
||||
@@ -354,20 +316,20 @@ export default function PromptTemplates() {
|
||||
description={
|
||||
<div>
|
||||
<Text style={{ fontSize: isMobile ? 12 : 13, display: 'block', marginBottom: 8 }}>
|
||||
• <strong>系统默认模板</strong>(灰色头部):始终启用,无需手动开关。点击"编辑"后将创建您的自定义副本。
|
||||
• <strong>系统默认模板</strong>(灰底标题):始终启用,无需手动开关。点击「编辑」后将创建您的自定义副本。
|
||||
</Text>
|
||||
<Text style={{ fontSize: isMobile ? 12 : 13, display: 'block' }}>
|
||||
• <strong>已自定义模板</strong>(紫色头部):可通过开关控制启用/禁用,使用 <Text code>{'{variable_name}'}</Text> 格式表示变量占位符。点击"重置"可恢复为系统默认。
|
||||
• <strong>已自定义模板</strong>(赭石标题):可通过开关控制启用/禁用,使用 <Text code>{'{variable_name}'}</Text> 格式表示变量占位符。点击「重置」可恢复为系统默认。
|
||||
</Text>
|
||||
</div>
|
||||
}
|
||||
type="info"
|
||||
showIcon={false}
|
||||
style={{
|
||||
marginTop: isMobile ? 16 : 24,
|
||||
borderRadius: 12,
|
||||
background: token.colorInfoBg,
|
||||
border: `1px solid ${token.colorInfoBorder}`
|
||||
marginTop: isMobile ? 16 : 20,
|
||||
borderRadius: 2,
|
||||
background: token.colorFillTertiary,
|
||||
border: `1px solid ${token.colorBorder}`,
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
@@ -381,8 +343,9 @@ export default function PromptTemplates() {
|
||||
variant="borderless"
|
||||
style={{
|
||||
background: token.colorBgContainer,
|
||||
borderRadius: isMobile ? 12 : 16,
|
||||
boxShadow: token.boxShadowSecondary,
|
||||
borderRadius: 2,
|
||||
border: `1px solid ${token.colorBorder}`,
|
||||
boxShadow: 'none',
|
||||
marginBottom: isMobile ? 16 : 24
|
||||
}}
|
||||
styles={{ body: { padding: isMobile ? '12px' : '16px' } }}
|
||||
@@ -407,8 +370,9 @@ export default function PromptTemplates() {
|
||||
variant="borderless"
|
||||
style={{
|
||||
background: token.colorBgContainer,
|
||||
borderRadius: isMobile ? 12 : 16,
|
||||
boxShadow: token.boxShadowSecondary,
|
||||
borderRadius: 2,
|
||||
border: `1px solid ${token.colorBorder}`,
|
||||
boxShadow: 'none',
|
||||
}}
|
||||
>
|
||||
<Empty
|
||||
@@ -430,7 +394,7 @@ export default function PromptTemplates() {
|
||||
{/* 头部 */}
|
||||
<div style={{
|
||||
background: template.is_system_default
|
||||
? token.colorFillTertiary
|
||||
? token.colorFillSecondary
|
||||
: token.colorPrimary,
|
||||
padding: isMobile ? '16px' : '20px',
|
||||
position: 'relative'
|
||||
@@ -490,7 +454,7 @@ export default function PromptTemplates() {
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => handleEdit(template)}
|
||||
size={isMobile ? 'small' : 'middle'}
|
||||
style={{ borderRadius: 6 }}
|
||||
style={{ borderRadius: 2 }}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
@@ -498,7 +462,7 @@ export default function PromptTemplates() {
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={() => handleReset(template.template_key)}
|
||||
size={isMobile ? 'small' : 'middle'}
|
||||
style={{ borderRadius: 6 }}
|
||||
style={{ borderRadius: 2 }}
|
||||
>
|
||||
重置
|
||||
</Button>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { SaveOutlined, DeleteOutlined, ReloadOutlined, InfoCircleOutlined, Check
|
||||
import { settingsApi, mcpPluginApi } from '../services/api';
|
||||
import type { SettingsUpdate, APIKeyPreset, PresetCreateRequest, APIKeyPresetConfig } from '../types';
|
||||
import { eventBus, EventNames } from '../store/eventBus';
|
||||
import { VERSION_INFO } from '../config/version';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
const { Option } = Select;
|
||||
@@ -284,25 +285,29 @@ export default function SettingsPage() {
|
||||
});
|
||||
};
|
||||
|
||||
const mumuTextDefaultUrl = 'https://api.mumuverse.space/v1';
|
||||
const mumuRegisterUrl = 'https://api.mumuverse.space/register?aff=4NN8';
|
||||
const mumuCoverBaseUrlOptions = [
|
||||
{ value: 'https://api.mumuverse.space/v1beta', label: 'https://api.mumuverse.space/v1beta', defaultModel: 'gemini-3.1-flash-image-preview' },
|
||||
{ value: 'https://api.mumuverse.space/v1', label: 'https://api.mumuverse.space/v1', defaultModel: 'gpt-image-1.5' },
|
||||
const xinmiApiHost = VERSION_INFO.xinmiApiBaseUrl.replace(/\/$/, '');
|
||||
const xinmiTextDefaultUrl = `${xinmiApiHost}/v1`;
|
||||
const officialApiDocUrl = VERSION_INFO.officialApiDocUrl;
|
||||
const openOfficialApiDoc = () => {
|
||||
window.open(officialApiDocUrl, '_blank', 'noopener,noreferrer');
|
||||
};
|
||||
const xinmiCoverBaseUrlOptions = [
|
||||
{ value: `${xinmiApiHost}/v1beta`, label: 'v1beta', defaultModel: 'gemini-3.1-flash-image-preview' },
|
||||
{ value: `${xinmiApiHost}/v1`, label: 'v1', defaultModel: 'gpt-image-1.5' },
|
||||
];
|
||||
const defaultCoverSettings = {
|
||||
cover_enabled: false,
|
||||
cover_api_provider: 'mumu',
|
||||
cover_api_provider: 'xinmi',
|
||||
cover_api_key: '',
|
||||
cover_api_base_url: mumuCoverBaseUrlOptions[0].value,
|
||||
cover_image_model: mumuCoverBaseUrlOptions[0].defaultModel,
|
||||
cover_api_base_url: xinmiCoverBaseUrlOptions[0].value,
|
||||
cover_image_model: xinmiCoverBaseUrlOptions[0].defaultModel,
|
||||
};
|
||||
|
||||
const apiProviders = [
|
||||
{
|
||||
value: 'mumu',
|
||||
label: 'MuMuのAPI',
|
||||
defaultUrl: mumuTextDefaultUrl,
|
||||
value: 'xinmi',
|
||||
label: 'XinMi API',
|
||||
defaultUrl: xinmiTextDefaultUrl,
|
||||
defaultModel: 'gemini-3-flash-preview'
|
||||
},
|
||||
{ value: 'openai', label: 'OpenAI Compatible', defaultUrl: 'https://api.openai.com/v1' },
|
||||
@@ -321,7 +326,7 @@ export default function SettingsPage() {
|
||||
if (provider.defaultUrl) {
|
||||
nextValues.api_base_url = provider.defaultUrl;
|
||||
}
|
||||
if (provider.value === 'mumu') {
|
||||
if (provider.value === 'xinmi') {
|
||||
nextValues.api_key = '';
|
||||
nextValues.llm_model = provider.defaultModel || 'gemini-3-flash-preview';
|
||||
}
|
||||
@@ -334,10 +339,10 @@ export default function SettingsPage() {
|
||||
|
||||
const coverApiProviders = [
|
||||
{
|
||||
value: 'mumu',
|
||||
label: 'MuMuのAPI',
|
||||
defaultUrl: mumuCoverBaseUrlOptions[0].value,
|
||||
defaultModel: mumuCoverBaseUrlOptions[0].defaultModel,
|
||||
value: 'xinmi',
|
||||
label: 'XinMi API',
|
||||
defaultUrl: xinmiCoverBaseUrlOptions[0].value,
|
||||
defaultModel: xinmiCoverBaseUrlOptions[0].defaultModel,
|
||||
},
|
||||
{ value: 'gemini', label: 'Google Gemini', defaultUrl: 'https://generativelanguage.googleapis.com/v1beta' },
|
||||
{ value: 'grok', label: 'Grok', defaultUrl: 'https://api.x.ai/v1' },
|
||||
@@ -354,20 +359,20 @@ export default function SettingsPage() {
|
||||
if (provider.defaultUrl) {
|
||||
nextValues.cover_api_base_url = provider.defaultUrl;
|
||||
}
|
||||
if (provider.value === 'mumu') {
|
||||
if (provider.value === 'xinmi') {
|
||||
nextValues.cover_api_key = '';
|
||||
nextValues.cover_image_model = provider.defaultModel || mumuCoverBaseUrlOptions[0].defaultModel;
|
||||
nextValues.cover_image_model = provider.defaultModel || xinmiCoverBaseUrlOptions[0].defaultModel;
|
||||
}
|
||||
|
||||
form.setFieldsValue(nextValues);
|
||||
setCoverTestResult(null);
|
||||
};
|
||||
|
||||
const handleMumuCoverBaseUrlChange = (value: string) => {
|
||||
const option = mumuCoverBaseUrlOptions.find(item => item.value === value);
|
||||
const handleXinmiCoverBaseUrlChange = (value: string) => {
|
||||
const option = xinmiCoverBaseUrlOptions.find(item => item.value === value);
|
||||
form.setFieldsValue({
|
||||
cover_api_base_url: value,
|
||||
cover_image_model: option?.defaultModel || mumuCoverBaseUrlOptions[0].defaultModel,
|
||||
cover_image_model: option?.defaultModel || xinmiCoverBaseUrlOptions[0].defaultModel,
|
||||
});
|
||||
setCoverTestResult(null);
|
||||
};
|
||||
@@ -611,7 +616,7 @@ export default function SettingsPage() {
|
||||
if (provider.defaultUrl) {
|
||||
nextValues.api_base_url = provider.defaultUrl;
|
||||
}
|
||||
if (provider.value === 'mumu') {
|
||||
if (provider.value === 'xinmi') {
|
||||
nextValues.api_key = '';
|
||||
nextValues.llm_model = provider.defaultModel || 'gemini-3-flash-preview';
|
||||
}
|
||||
@@ -923,7 +928,7 @@ export default function SettingsPage() {
|
||||
// return 'purple';
|
||||
case 'gemini':
|
||||
return 'green';
|
||||
case 'mumu':
|
||||
case 'xinmi':
|
||||
return 'magenta';
|
||||
default:
|
||||
return 'default';
|
||||
@@ -1228,11 +1233,11 @@ export default function SettingsPage() {
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
{selectedProvider === 'mumu' && (
|
||||
{selectedProvider === 'xinmi' && (
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message="MuMuのAPI 专属供应商"
|
||||
message="墨木灵思 API 专属供应商"
|
||||
description={
|
||||
<Space direction="vertical" size={8} style={{ width: '100%' }}>
|
||||
<Text>
|
||||
@@ -1241,9 +1246,9 @@ export default function SettingsPage() {
|
||||
<div>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => window.open(mumuRegisterUrl, '_blank', 'noopener,noreferrer')}
|
||||
onClick={openOfficialApiDoc}
|
||||
>
|
||||
打开 MuMuのAPI 站点免费注册
|
||||
打开墨木灵思 API 文档
|
||||
</Button>
|
||||
</div>
|
||||
</Space>
|
||||
@@ -1740,22 +1745,22 @@ export default function SettingsPage() {
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
{selectedCoverProvider === 'mumu' && (
|
||||
{selectedCoverProvider === 'xinmi' && (
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message="MuMuのAPI 专属适配器"
|
||||
message="墨木灵思 API 专属适配器"
|
||||
description={
|
||||
<Space direction="vertical" size={8} style={{ width: '100%' }}>
|
||||
<Text>
|
||||
已固定提供 MuMuのAPI 图片接口地址选项,切换地址时会自动带出推荐模型。API Key 需前往 MuMuのAPI 站点注册获取。
|
||||
已固定提供墨木灵思 API 图片接口地址选项,切换地址时会自动带出推荐模型。API Key 请按文档在控制台获取。
|
||||
</Text>
|
||||
<div>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => window.open(mumuRegisterUrl, '_blank', 'noopener,noreferrer')}
|
||||
onClick={openOfficialApiDoc}
|
||||
>
|
||||
打开 MuMuのAPI 站点免费注册
|
||||
打开墨木灵思 API 文档
|
||||
</Button>
|
||||
</div>
|
||||
</Space>
|
||||
@@ -1765,15 +1770,15 @@ export default function SettingsPage() {
|
||||
)}
|
||||
|
||||
<Form.Item label="封面图片 API Key" name="cover_api_key" rules={[{ required: true, message: '请输入封面图片 API Key' }]}>
|
||||
<Input.Password size={isMobile ? 'middle' : 'large'} placeholder={selectedCoverProvider === 'mumu' ? '请输入 MuMuのAPI Key' : '输入封面图片 API Key'} autoComplete="new-password" />
|
||||
<Input.Password size={isMobile ? 'middle' : 'large'} placeholder={selectedCoverProvider === 'xinmi' ? '请输入墨木灵思 API Key' : '输入封面图片 API Key'} autoComplete="new-password" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="封面图片 API 地址" name="cover_api_base_url" rules={[{ type: 'url', message: '请输入有效的URL' }]}>
|
||||
{selectedCoverProvider === 'mumu' ? (
|
||||
{selectedCoverProvider === 'xinmi' ? (
|
||||
<Select
|
||||
size={isMobile ? 'middle' : 'large'}
|
||||
onChange={handleMumuCoverBaseUrlChange}
|
||||
options={mumuCoverBaseUrlOptions.map(option => ({
|
||||
onChange={handleXinmiCoverBaseUrlChange}
|
||||
options={xinmiCoverBaseUrlOptions.map(option => ({
|
||||
value: option.value,
|
||||
label: option.label,
|
||||
}))}
|
||||
@@ -1786,7 +1791,7 @@ export default function SettingsPage() {
|
||||
<Form.Item label="封面图片模型" name="cover_image_model" rules={[{ required: true, message: '请输入封面图片模型名称' }]}>
|
||||
<Input
|
||||
size={isMobile ? 'middle' : 'large'}
|
||||
placeholder={selectedCoverProvider === 'mumu'
|
||||
placeholder={selectedCoverProvider === 'xinmi'
|
||||
? '选择地址后自动填入推荐模型'
|
||||
: selectedCoverProvider === 'grok'
|
||||
? 'grok-2-image'
|
||||
@@ -1878,17 +1883,17 @@ export default function SettingsPage() {
|
||||
style={{ marginBottom: 16 }}
|
||||
>
|
||||
<Select placeholder="选择提供商" onChange={handlePresetProviderChange}>
|
||||
<Select.Option value="mumu">MuMuのAPI</Select.Option>
|
||||
<Select.Option value="xinmi">墨木灵思 API</Select.Option>
|
||||
<Select.Option value="openai">OpenAI</Select.Option>
|
||||
<Select.Option value="gemini">Google Gemini</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
{selectedPresetProvider === 'mumu' && (
|
||||
{selectedPresetProvider === 'xinmi' && (
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message="MuMuのAPI 专属供应商"
|
||||
message="墨木灵思 API 专属供应商"
|
||||
description={
|
||||
<Space direction="vertical" size={8} style={{ width: '100%' }}>
|
||||
<Text>
|
||||
@@ -1897,9 +1902,9 @@ export default function SettingsPage() {
|
||||
<div>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => window.open(mumuRegisterUrl, '_blank', 'noopener,noreferrer')}
|
||||
onClick={openOfficialApiDoc}
|
||||
>
|
||||
打开 MuMuのAPI 站点免费注册
|
||||
打开墨木灵思 API 文档
|
||||
</Button>
|
||||
</div>
|
||||
</Space>
|
||||
|
||||
@@ -1,276 +0,0 @@
|
||||
/**
|
||||
* GitHub 提交日志获取服务
|
||||
* 用于从 GitHub API 获取项目的提交历史并转换为更新日志
|
||||
*/
|
||||
|
||||
export interface GitHubCommit {
|
||||
sha: string;
|
||||
commit: {
|
||||
author: {
|
||||
name: string;
|
||||
email: string;
|
||||
date: string;
|
||||
};
|
||||
message: string;
|
||||
};
|
||||
html_url: string;
|
||||
author: {
|
||||
login: string;
|
||||
avatar_url: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface ChangelogEntry {
|
||||
id: string;
|
||||
date: string;
|
||||
version?: string;
|
||||
author: {
|
||||
name: string;
|
||||
avatar?: string;
|
||||
username?: string;
|
||||
};
|
||||
message: string;
|
||||
commitUrl: string;
|
||||
type: 'feature' | 'fix' | 'docs' | 'style' | 'refactor' | 'perf' | 'test' | 'chore' | 'update' | 'other';
|
||||
scope?: string;
|
||||
}
|
||||
|
||||
const GITHUB_API_BASE = 'https://api.github.com';
|
||||
const REPO_OWNER = 'xiamuceer-j';
|
||||
const REPO_NAME = '墨木灵思';
|
||||
|
||||
/**
|
||||
* 提交类型映射表
|
||||
* 统一不同别名到标准类型
|
||||
*/
|
||||
const TYPE_MAPPING: Record<string, ChangelogEntry['type']> = {
|
||||
// 功能类
|
||||
'feat': 'feature',
|
||||
'feature': 'feature',
|
||||
'update': 'update',
|
||||
|
||||
// 修复类
|
||||
'fix': 'fix',
|
||||
|
||||
// 文档类
|
||||
'docs': 'docs',
|
||||
'doc': 'docs',
|
||||
|
||||
// 样式类
|
||||
'style': 'style',
|
||||
|
||||
// 重构类
|
||||
'refactor': 'refactor',
|
||||
|
||||
// 性能类
|
||||
'perf': 'perf',
|
||||
|
||||
// 测试类
|
||||
'test': 'test',
|
||||
|
||||
// 杂项类
|
||||
'chore': 'chore',
|
||||
};
|
||||
|
||||
/**
|
||||
* 从提交信息中解析类型和作用域
|
||||
*
|
||||
* 匹配优先级(从高到低):
|
||||
* 1. 标准 Conventional Commits 格式: type(scope): message 或 type: message
|
||||
* 2. 方括号格式: [type] message
|
||||
* 3. 简单前缀格式: type: message(支持中文冒号)
|
||||
* 4. 关键词模糊匹配(中英文)
|
||||
*/
|
||||
function parseCommitType(message: string): { type: ChangelogEntry['type']; scope?: string; cleanMessage: string } {
|
||||
const lowerMessage = message.toLowerCase().trim();
|
||||
|
||||
// 优先级1:标准 Conventional Commits 格式 - type(scope): message 或 type: message
|
||||
// 匹配所有支持的类型
|
||||
const conventionalPattern = new RegExp(
|
||||
`^(${Object.keys(TYPE_MAPPING).join('|')})(?:\\(([^)]+)\\))?\\s*[:\\::]\\s*(.+)`,
|
||||
'i'
|
||||
);
|
||||
const conventionalMatch = message.match(conventionalPattern);
|
||||
if (conventionalMatch) {
|
||||
const typeStr = conventionalMatch[1].toLowerCase();
|
||||
const mappedType = TYPE_MAPPING[typeStr] || 'other';
|
||||
return {
|
||||
type: mappedType,
|
||||
scope: conventionalMatch[2],
|
||||
cleanMessage: conventionalMatch[3].trim(),
|
||||
};
|
||||
}
|
||||
|
||||
// 优先级2:方括号格式 - [type] message
|
||||
const bracketPattern = new RegExp(
|
||||
`^\\[(${Object.keys(TYPE_MAPPING).join('|')})\\]\\s*(.+)`,
|
||||
'i'
|
||||
);
|
||||
const bracketMatch = message.match(bracketPattern);
|
||||
if (bracketMatch) {
|
||||
const typeStr = bracketMatch[1].toLowerCase();
|
||||
const mappedType = TYPE_MAPPING[typeStr] || 'other';
|
||||
return {
|
||||
type: mappedType,
|
||||
cleanMessage: bracketMatch[2].trim(),
|
||||
};
|
||||
}
|
||||
|
||||
// 优先级3:简单前缀格式 - type: message(支持英文和中文冒号)
|
||||
for (const [key, value] of Object.entries(TYPE_MAPPING)) {
|
||||
const prefixPattern = new RegExp(`^${key}\\s*[:\\::]\\s*`, 'i');
|
||||
if (prefixPattern.test(lowerMessage)) {
|
||||
const cleanMsg = message.replace(prefixPattern, '').trim();
|
||||
return { type: value, cleanMessage: cleanMsg };
|
||||
}
|
||||
}
|
||||
|
||||
// 优先级4:关键词模糊匹配(仅当前面都不匹配时)
|
||||
const keywordMap: Array<{ keywords: string[]; type: ChangelogEntry['type'] }> = [
|
||||
{ keywords: ['修复', 'fix'], type: 'fix' },
|
||||
{ keywords: ['优化', 'perf'], type: 'perf' },
|
||||
{ keywords: ['文档', 'document'], type: 'docs' },
|
||||
{ keywords: ['新增', '添加', '增加', 'add'], type: 'feature' },
|
||||
{ keywords: ['更新', 'update'], type: 'update' },
|
||||
{ keywords: ['样式', 'style'], type: 'style' },
|
||||
{ keywords: ['重构', 'refactor'], type: 'refactor' },
|
||||
{ keywords: ['测试', 'test'], type: 'test' },
|
||||
];
|
||||
|
||||
for (const { keywords, type } of keywordMap) {
|
||||
if (keywords.some(keyword => lowerMessage.includes(keyword))) {
|
||||
return { type, cleanMessage: message };
|
||||
}
|
||||
}
|
||||
|
||||
// 默认类型
|
||||
return { type: 'other', cleanMessage: message };
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取GitHub提交历史
|
||||
*/
|
||||
export async function fetchGitHubCommits(page: number = 1, perPage: number = 30): Promise<GitHubCommit[]> {
|
||||
try {
|
||||
const url = `${GITHUB_API_BASE}/repos/${REPO_OWNER}/${REPO_NAME}/commits?author=${REPO_OWNER}&page=${page}&per_page=${perPage}`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/vnd.github.v3+json',
|
||||
},
|
||||
cache: 'no-cache',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`GitHub API 请求失败: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('获取 GitHub 提交历史失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将GitHub提交转换为更新日志条目
|
||||
*/
|
||||
export function convertCommitsToChangelog(commits: GitHubCommit[]): ChangelogEntry[] {
|
||||
return commits.map(commit => {
|
||||
const { type, scope, cleanMessage } = parseCommitType(commit.commit.message);
|
||||
|
||||
return {
|
||||
id: commit.sha,
|
||||
date: commit.commit.author.date,
|
||||
author: {
|
||||
name: commit.commit.author.name,
|
||||
avatar: commit.author?.avatar_url,
|
||||
username: commit.author?.login,
|
||||
},
|
||||
message: cleanMessage,
|
||||
commitUrl: commit.html_url,
|
||||
type,
|
||||
scope,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取格式化的更新日志
|
||||
*/
|
||||
export async function fetchChangelog(page: number = 1, perPage: number = 30): Promise<ChangelogEntry[]> {
|
||||
const commits = await fetchGitHubCommits(page, perPage);
|
||||
return convertCommitsToChangelog(commits);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按日期分组更新日志
|
||||
*/
|
||||
export function groupChangelogByDate(entries: ChangelogEntry[]): Map<string, ChangelogEntry[]> {
|
||||
const grouped = new Map<string, ChangelogEntry[]>();
|
||||
|
||||
entries.forEach(entry => {
|
||||
const date = new Date(entry.date).toISOString().split('T')[0];
|
||||
const existing = grouped.get(date) || [];
|
||||
existing.push(entry);
|
||||
grouped.set(date, existing);
|
||||
});
|
||||
|
||||
return grouped;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否应该获取更新日志(避免频繁请求)
|
||||
*/
|
||||
export function shouldFetchChangelog(): boolean {
|
||||
const lastFetch = localStorage.getItem('changelog_last_fetch');
|
||||
|
||||
if (!lastFetch) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const lastFetchTime = new Date(lastFetch).getTime();
|
||||
const now = Date.now();
|
||||
const oneHourMs = 60 * 60 * 1000; // 1小时
|
||||
|
||||
return now - lastFetchTime >= oneHourMs;
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录更新日志获取时间
|
||||
*/
|
||||
export function markChangelogFetched(): void {
|
||||
localStorage.setItem('changelog_last_fetch', new Date().toISOString());
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取缓存的更新日志
|
||||
*/
|
||||
export function getCachedChangelog(): ChangelogEntry[] | null {
|
||||
const cached = localStorage.getItem('changelog_cache');
|
||||
if (cached) {
|
||||
try {
|
||||
return JSON.parse(cached);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 缓存更新日志
|
||||
*/
|
||||
export function cacheChangelog(entries: ChangelogEntry[]): void {
|
||||
localStorage.setItem('changelog_cache', JSON.stringify(entries));
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除更新日志缓存
|
||||
* 用于强制刷新数据
|
||||
*/
|
||||
export function clearChangelogCache(): void {
|
||||
localStorage.removeItem('changelog_cache');
|
||||
localStorage.removeItem('changelog_last_fetch');
|
||||
}
|
||||
@@ -32,7 +32,7 @@ function compareVersion(v1: string, v2: string): number {
|
||||
export async function checkLatestVersion(): Promise<VersionCheckResult> {
|
||||
try {
|
||||
// 使用 shields.io 的 GitHub release badge API
|
||||
const badgeUrl = 'https://img.shields.io/github/v/release/xiamuceer-j/墨木灵思';
|
||||
const badgeUrl = 'https://img.shields.io/github/v/release/mumulingsi-project/mumulingsi';
|
||||
|
||||
const response = await fetch(badgeUrl, {
|
||||
method: 'GET',
|
||||
@@ -63,7 +63,7 @@ export async function checkLatestVersion(): Promise<VersionCheckResult> {
|
||||
return {
|
||||
hasUpdate,
|
||||
latestVersion,
|
||||
releaseUrl: `https://github.com/xiamuceer-j/墨木灵思/releases/tag/v${latestVersion}`,
|
||||
releaseUrl: '',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -74,7 +74,7 @@ export async function checkLatestVersion(): Promise<VersionCheckResult> {
|
||||
return {
|
||||
hasUpdate: false,
|
||||
latestVersion: VERSION_INFO.version,
|
||||
releaseUrl: VERSION_INFO.githubUrl,
|
||||
releaseUrl: '',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,23 +4,48 @@ import type { ThemeMode } from './themeStorage';
|
||||
|
||||
export type ResolvedThemeMode = Exclude<ThemeMode, 'system'>;
|
||||
|
||||
/** 铜墨编辑部 — 暖赭石 + 纸感底色,与原先蓝紫圆角风区分 */
|
||||
const sharedToken: ThemeConfig['token'] = {
|
||||
colorPrimary: '#4D8088',
|
||||
borderRadius: 8,
|
||||
colorPrimary: '#B45309',
|
||||
colorInfo: '#0D9488',
|
||||
colorSuccess: '#15803D',
|
||||
colorWarning: '#CA8A04',
|
||||
colorError: '#B91C1C',
|
||||
borderRadius: 2,
|
||||
wireframe: false,
|
||||
fontFamily: "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif",
|
||||
fontFamily: "'Source Sans 3', 'PingFang SC', 'Microsoft YaHei', sans-serif",
|
||||
fontFamilyCode: "'IBM Plex Mono', 'Consolas', monospace",
|
||||
};
|
||||
|
||||
const sharedComponents: ThemeConfig['components'] = {
|
||||
Button: {
|
||||
borderRadius: 8,
|
||||
controlHeight: 36,
|
||||
borderRadius: 2,
|
||||
controlHeight: 40,
|
||||
fontWeight: 600,
|
||||
primaryShadow: 'none',
|
||||
},
|
||||
Card: {
|
||||
borderRadiusLG: 12,
|
||||
borderRadiusLG: 2,
|
||||
boxShadowTertiary: 'none',
|
||||
},
|
||||
Menu: {
|
||||
itemBorderRadius: 0,
|
||||
itemHeight: 44,
|
||||
iconSize: 16,
|
||||
},
|
||||
Tabs: {
|
||||
inkBarColor: '#B45309',
|
||||
titleFontSize: 14,
|
||||
},
|
||||
Input: {
|
||||
borderRadius: 2,
|
||||
controlHeight: 42,
|
||||
},
|
||||
Layout: {
|
||||
headerHeight: 64,
|
||||
},
|
||||
Tooltip: {
|
||||
colorBgSpotlight: sharedToken.colorPrimary,
|
||||
colorBgSpotlight: '#292524',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -28,17 +53,34 @@ const lightThemeConfig: ThemeConfig = {
|
||||
algorithm: theme.defaultAlgorithm,
|
||||
token: {
|
||||
...sharedToken,
|
||||
colorBgBase: '#F8F6F1',
|
||||
colorTextBase: '#2B2B2B',
|
||||
colorBgLayout: '#F8F6F1',
|
||||
colorBgContainer: '#FFFFFF',
|
||||
colorBgBase: '#F5F0E6',
|
||||
colorTextBase: '#292524',
|
||||
colorBgLayout: '#EDE8DC',
|
||||
colorBgContainer: '#FFFCF7',
|
||||
colorBorder: '#C9BFB0',
|
||||
colorBorderSecondary: '#DDD5C8',
|
||||
colorFillSecondary: '#E8E2D6',
|
||||
colorFillTertiary: '#F0EBE1',
|
||||
colorPrimaryBg: '#FEF3C7',
|
||||
colorPrimaryBorder: '#D97706',
|
||||
colorPrimaryHover: '#92400E',
|
||||
colorLink: '#9A3412',
|
||||
colorLinkHover: '#7C2D12',
|
||||
},
|
||||
components: {
|
||||
...sharedComponents,
|
||||
Layout: {
|
||||
bodyBg: '#F8F6F1',
|
||||
headerBg: '#FFFFFF',
|
||||
siderBg: '#FFFFFF',
|
||||
bodyBg: '#EDE8DC',
|
||||
headerBg: '#FFFCF7',
|
||||
siderBg: '#1C1917',
|
||||
},
|
||||
Menu: {
|
||||
...sharedComponents.Menu,
|
||||
darkItemBg: 'transparent',
|
||||
darkItemColor: '#D6D3D1',
|
||||
darkItemSelectedBg: 'rgba(180, 83, 9, 0.22)',
|
||||
darkItemSelectedColor: '#FCD34D',
|
||||
darkItemHoverBg: 'rgba(255, 255, 255, 0.06)',
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -47,15 +89,34 @@ const darkThemeConfig: ThemeConfig = {
|
||||
algorithm: theme.darkAlgorithm,
|
||||
token: {
|
||||
...sharedToken,
|
||||
colorBgBase: '#141414',
|
||||
colorTextBase: '#f5f5f5',
|
||||
colorPrimary: '#F59E0B',
|
||||
colorBgBase: '#0C0A09',
|
||||
colorTextBase: '#E7E5E4',
|
||||
colorBgLayout: '#0C0A09',
|
||||
colorBgContainer: '#1C1917',
|
||||
colorBorder: '#44403C',
|
||||
colorBorderSecondary: '#292524',
|
||||
colorFillSecondary: '#292524',
|
||||
colorFillTertiary: '#1C1917',
|
||||
colorPrimaryBg: 'rgba(245, 158, 11, 0.12)',
|
||||
colorPrimaryBorder: '#B45309',
|
||||
colorLink: '#FBBF24',
|
||||
colorLinkHover: '#FCD34D',
|
||||
},
|
||||
components: {
|
||||
...sharedComponents,
|
||||
Layout: {
|
||||
bodyBg: '#0f1115',
|
||||
headerBg: '#141414',
|
||||
siderBg: '#141414',
|
||||
bodyBg: '#0C0A09',
|
||||
headerBg: '#1C1917',
|
||||
siderBg: '#0C0A09',
|
||||
},
|
||||
Menu: {
|
||||
...sharedComponents.Menu,
|
||||
darkItemBg: 'transparent',
|
||||
darkItemColor: '#A8A29E',
|
||||
darkItemSelectedBg: 'rgba(245, 158, 11, 0.18)',
|
||||
darkItemSelectedColor: '#FCD34D',
|
||||
darkItemHoverBg: 'rgba(255, 255, 255, 0.05)',
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -63,3 +124,25 @@ const darkThemeConfig: ThemeConfig = {
|
||||
export const getThemeConfig = (mode: ResolvedThemeMode): ThemeConfig => {
|
||||
return mode === 'dark' ? darkThemeConfig : lightThemeConfig;
|
||||
};
|
||||
|
||||
/** 侧栏等壳层用色(不依赖 ant token 的固定值) */
|
||||
export const shellColors = {
|
||||
light: {
|
||||
siderBg: '#1C1917',
|
||||
siderBorder: '#44403C',
|
||||
siderAccent: '#F59E0B',
|
||||
siderText: '#E7E5E4',
|
||||
siderMuted: '#A8A29E',
|
||||
headerBorder: '#C9BFB0',
|
||||
inkPattern: 'rgba(28, 25, 23, 0.04)',
|
||||
},
|
||||
dark: {
|
||||
siderBg: '#0C0A09',
|
||||
siderBorder: '#292524',
|
||||
siderAccent: '#FBBF24',
|
||||
siderText: '#E7E5E4',
|
||||
siderMuted: '#78716C',
|
||||
headerBorder: '#44403C',
|
||||
inkPattern: 'rgba(251, 191, 36, 0.06)',
|
||||
},
|
||||
} as const;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export type ThemeMode = 'light' | 'dark' | 'system';
|
||||
|
||||
const THEME_MODE_STORAGE_KEY = 'mumu_theme_mode';
|
||||
const THEME_MODE_STORAGE_KEY = 'mumulingsi_theme_mode';
|
||||
const LEGACY_THEME_MODE_STORAGE_KEY = 'xinmi_theme_mode';
|
||||
|
||||
const isThemeMode = (value: string | null): value is ThemeMode => {
|
||||
return value === 'light' || value === 'dark' || value === 'system';
|
||||
@@ -8,7 +9,18 @@ const isThemeMode = (value: string | null): value is ThemeMode => {
|
||||
|
||||
export const getStoredThemeMode = (): ThemeMode => {
|
||||
try {
|
||||
const value = localStorage.getItem(THEME_MODE_STORAGE_KEY);
|
||||
let value = localStorage.getItem(THEME_MODE_STORAGE_KEY);
|
||||
if (!value) {
|
||||
value = localStorage.getItem(LEGACY_THEME_MODE_STORAGE_KEY);
|
||||
if (isThemeMode(value)) {
|
||||
localStorage.setItem(THEME_MODE_STORAGE_KEY, value);
|
||||
try {
|
||||
localStorage.removeItem(LEGACY_THEME_MODE_STORAGE_KEY);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
if (isThemeMode(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
const SIDEBAR_COLLAPSED_STORAGE_KEY = 'mumu_sidebar_collapsed';
|
||||
const SIDEBAR_COLLAPSED_STORAGE_KEY = 'mumulingsi_sidebar_collapsed';
|
||||
const LEGACY_SIDEBAR_COLLAPSED_STORAGE_KEY = 'xinmi_sidebar_collapsed';
|
||||
|
||||
export const getStoredSidebarCollapsed = (): boolean => {
|
||||
if (typeof window === 'undefined') {
|
||||
@@ -6,7 +7,21 @@ export const getStoredSidebarCollapsed = (): boolean => {
|
||||
}
|
||||
|
||||
try {
|
||||
return localStorage.getItem(SIDEBAR_COLLAPSED_STORAGE_KEY) === '1';
|
||||
const v = localStorage.getItem(SIDEBAR_COLLAPSED_STORAGE_KEY);
|
||||
if (v === '1' || v === '0') {
|
||||
return v === '1';
|
||||
}
|
||||
const legacy = localStorage.getItem(LEGACY_SIDEBAR_COLLAPSED_STORAGE_KEY);
|
||||
if (legacy === '1' || legacy === '0') {
|
||||
localStorage.setItem(SIDEBAR_COLLAPSED_STORAGE_KEY, legacy);
|
||||
try {
|
||||
localStorage.removeItem(LEGACY_SIDEBAR_COLLAPSED_STORAGE_KEY);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return legacy === '1';
|
||||
}
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.warn('读取侧边栏状态失败:', error);
|
||||
return false;
|
||||
|
||||
|
After Width: | Height: | Size: 219 KiB |
|
After Width: | Height: | Size: 335 KiB |
|
After Width: | Height: | Size: 168 KiB |
|
After Width: | Height: | Size: 285 KiB |
|
After Width: | Height: | Size: 574 KiB |
|
After Width: | Height: | Size: 216 KiB |
|
After Width: | Height: | Size: 162 KiB |
|
After Width: | Height: | Size: 207 KiB |
|
After Width: | Height: | Size: 189 KiB |
|
After Width: | Height: | Size: 195 KiB |
@@ -7,10 +7,10 @@
|
||||
set -e
|
||||
|
||||
# ── 路径配置 ──────────────────────────────────────────────────────────────────
|
||||
INSTALL_DIR="$HOME/墨木灵思" # 项目安装目录
|
||||
DATA_DIR="$HOME/mumuainovel/data" # 数据库目录
|
||||
LOG_DIR="$HOME/mumuainovel/logs" # 日志目录
|
||||
REPO="https://ghfast.top/https://github.com/xiamuceer-j/墨木灵思.git" # GitHub 镜像
|
||||
INSTALL_DIR="$HOME/mumulingsi" # 项目安装目录
|
||||
DATA_DIR="$HOME/mumulingsi/data" # 数据库目录
|
||||
LOG_DIR="$HOME/mumulingsi/logs" # 日志目录
|
||||
REPO="https://ghfast.top/https://github.com/mumulingsi-project/mumulingsi.git" # GitHub 镜像
|
||||
|
||||
# ── 输出函数 ──────────────────────────────────────────────────────────────────
|
||||
GREEN='\033[0;32m'; YELLOW='\033[1;33m'; RED='\033[0;31m'; CYAN='\033[0;36m'; NC='\033[0m'
|
||||
@@ -118,7 +118,7 @@ LOG="$TMPDIR/patch.log"
|
||||
# ── 4a. 修补 memory_service.py ──────────────────────────────────────────────
|
||||
python3 << 'PYEOF'
|
||||
import os
|
||||
f = os.path.expanduser("~/墨木灵思/backend/app/services/memory_service.py")
|
||||
f = os.path.expanduser("~/mumulingsi/backend/app/services/memory_service.py")
|
||||
with open(f) as fh:
|
||||
c = fh.read()
|
||||
|
||||
@@ -150,11 +150,11 @@ python3 << 'PYEOF'
|
||||
import os
|
||||
home = os.path.expanduser("~")
|
||||
files = [
|
||||
f"{home}/墨木灵思/backend/app/api/chapters.py",
|
||||
f"{home}/墨木灵思/backend/app/api/memories.py",
|
||||
f"{home}/墨木灵思/backend/app/api/outlines.py",
|
||||
f"{home}/墨木灵思/backend/app/api/projects.py",
|
||||
f"{home}/墨木灵思/backend/app/services/foreshadow_service.py",
|
||||
f"{home}/mumulingsi/backend/app/api/chapters.py",
|
||||
f"{home}/mumulingsi/backend/app/api/memories.py",
|
||||
f"{home}/mumulingsi/backend/app/api/outlines.py",
|
||||
f"{home}/mumulingsi/backend/app/api/projects.py",
|
||||
f"{home}/mumulingsi/backend/app/services/foreshadow_service.py",
|
||||
]
|
||||
old = 'from app.services.memory_service import memory_service'
|
||||
new = 'try:\n from app.services.memory_service import memory_service\nexcept ImportError:\n memory_service = None'
|
||||
@@ -181,17 +181,17 @@ DEBUG=false
|
||||
TZ=Asia/Shanghai
|
||||
|
||||
# SQLite 数据库(替代 PostgreSQL)
|
||||
DATABASE_URL=sqlite+aiosqlite:///data/data/com.termux/files/home/mumuainovel/data/ai_story.db
|
||||
DATABASE_URL=sqlite+aiosqlite:///data/data/com.termux/files/home/mumulingsi/data/ai_story.db
|
||||
|
||||
# 日志
|
||||
LOG_LEVEL=INFO
|
||||
LOG_TO_FILE=true
|
||||
LOG_FILE_PATH=/data/data/com.termux/files/home/mumuainovel/logs/app.log
|
||||
LOG_FILE_PATH=/data/data/com.termux/files/home/mumulingsi/logs/app.log
|
||||
LOG_MAX_BYTES=10485760
|
||||
LOG_BACKUP_COUNT=5
|
||||
|
||||
# CORS
|
||||
CORS_ORIGINS=["http://localhost:8000","http://127.0.0.1:8000"]
|
||||
CORS_ORIGINS=["http://localhost:3000","http://127.0.0.1:8000"]
|
||||
|
||||
# ⚠️ 请填入你的 API Key
|
||||
OPENAI_API_KEY=***
|
||||
@@ -295,10 +295,10 @@ grep -E "built in" "$LOG" | sed 's/^/ /'
|
||||
|
||||
# =============================================================================
|
||||
# 步骤 9: 创建启动脚本
|
||||
# 说明: 生成 ~/mumuainovel-start.sh,支持前台/后台运行
|
||||
# 说明: 生成 ~/mumulingsi-start.sh,支持前台/后台运行
|
||||
# =============================================================================
|
||||
step 9 $TOTAL "创建启动脚本"
|
||||
cat > "$HOME/mumuainovel-start.sh" << STARTEOF
|
||||
cat > "$HOME/mumulingsi-start.sh" << STARTEOF
|
||||
#!/bin/bash
|
||||
# 墨木灵思 Termux 启动脚本
|
||||
set -e
|
||||
@@ -316,10 +316,10 @@ if [ "\$1" = "--bg" ]; then
|
||||
echo "🚀 后台启动 墨木灵思 (端口 8000)..."
|
||||
nohup "\$PYTHON" -m uvicorn app.main:app --host 0.0.0.0 --port 8000 \\
|
||||
> "\$LOG_DIR/app.log" 2>&1 &
|
||||
echo \$! > "$HOME/mumuainovel.pid"
|
||||
echo \$! > "$HOME/mumulingsi.pid"
|
||||
sleep 2
|
||||
if kill -0 \$(cat "$HOME/mumuainovel.pid") 2>/dev/null; then
|
||||
echo "✅ 已启动, PID: \$(cat $HOME/mumuainovel.pid)"
|
||||
if kill -0 \$(cat "$HOME/mumulingsi.pid") 2>/dev/null; then
|
||||
echo "✅ 已启动, PID: \$(cat $HOME/mumulingsi.pid)"
|
||||
else
|
||||
echo "❌ 启动失败,查看日志: \$LOG_DIR/app.log"
|
||||
exit 1
|
||||
@@ -329,8 +329,8 @@ else
|
||||
exec "\$PYTHON" -m uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||
fi
|
||||
STARTEOF
|
||||
chmod +x "$HOME/mumuainovel-start.sh"
|
||||
info "启动脚本已创建: ~/mumuainovel-start.sh"
|
||||
chmod +x "$HOME/mumulingsi-start.sh"
|
||||
info "启动脚本已创建: ~/mumulingsi-start.sh"
|
||||
|
||||
# =============================================================================
|
||||
# 安装完成
|
||||
@@ -341,16 +341,16 @@ echo -e "${GREEN}║ 🎉 墨木灵思 安装完成! ║${N
|
||||
echo -e "${GREEN}╠══════════════════════════════════════════════╣${NC}"
|
||||
echo -e "${GREEN}║ ║${NC}"
|
||||
echo -e "${GREEN}║ 前台运行(Ctrl+C 停止): ║${NC}"
|
||||
echo -e "${GREEN}║ bash ~/mumuainovel-start.sh ║${NC}"
|
||||
echo -e "${GREEN}║ bash ~/mumulingsi-start.sh ║${NC}"
|
||||
echo -e "${GREEN}║ ║${NC}"
|
||||
echo -e "${GREEN}║ 后台运行: ║${NC}"
|
||||
echo -e "${GREEN}║ bash ~/mumuainovel-start.sh --bg ║${NC}"
|
||||
echo -e "${GREEN}║ bash ~/mumulingsi-start.sh --bg ║${NC}"
|
||||
echo -e "${GREEN}║ ║${NC}"
|
||||
echo -e "${GREEN}║ 停止后台: ║${NC}"
|
||||
echo -e "${GREEN}║ kill \$(cat ~/mumuainovel.pid) ║${NC}"
|
||||
echo -e "${GREEN}║ kill \$(cat ~/mumulingsi.pid) ║${NC}"
|
||||
echo -e "${GREEN}║ ║${NC}"
|
||||
echo -e "${GREEN}║ 查看日志: ║${NC}"
|
||||
echo -e "${GREEN}║ tail -f ~/mumuainovel/logs/app.log ║${NC}"
|
||||
echo -e "${GREEN}║ tail -f ~/mumulingsi/logs/app.log ║${NC}"
|
||||
echo -e "${GREEN}║ ║${NC}"
|
||||
echo -e "${GREEN}║ 🌐 访问: http://127.0.0.1:8000 ║${NC}"
|
||||
echo -e "${GREEN}║ 🔑 账号: admin / admin123 ║${NC}"
|
||||
|
||||