style:1.组织管理页面支持组织列表滚动 2.优化一些页面的标题和图标显示

This commit is contained in:
xiamuceer
2025-12-29 12:08:01 +08:00
parent 907a6550ee
commit 7714a22479
19 changed files with 404 additions and 214 deletions
+12 -14
View File
@@ -8,7 +8,7 @@
# 应用配置
# ==========================================
APP_NAME=MuMuAINovel
APP_VERSION=1.1.4
APP_VERSION=1.2.2
APP_HOST=0.0.0.0
APP_PORT=8000
DEBUG=false
@@ -25,7 +25,7 @@ POSTGRES_PASSWORD=123456
POSTGRES_PORT=5432
# 数据库连接 URL(Docker 部署时自动生成)
DATABASE_URL=postgresql+asyncpg://mumuai:123456@localhost:5432/mumuai_novel
# DATABASE_URL=postgresql+asyncpg://mumuai:123456@localhost:5432/mumuai_novel
# ==========================================
# SQLite 数据库配置
@@ -33,13 +33,6 @@ DATABASE_URL=postgresql+asyncpg://mumuai:123456@localhost:5432/mumuai_novel
# DATABASE_URL=sqlite+aiosqlite:///data/ai_story.db
# ==========================================
# 代理配置(可选)
# ==========================================
# HTTP_PROXY=http://your-proxy:port
# HTTPS_PROXY=http://your-proxy:port
# NO_PROXY=localhost,127.0.0.1
# ==========================================
# 日志配置
# ==========================================
@@ -54,6 +47,13 @@ LOG_BACKUP_COUNT=30
# ==========================================
CORS_ORIGINS=["http://localhost:8000","http://127.0.0.1:8000"]
# ==========================================
# 代理配置(可选)
# ==========================================
# HTTP_PROXY=http://your-proxy:port
# HTTPS_PROXY=http://your-proxy:port
# NO_PROXY=localhost,127.0.0.1
# ==========================================
# AI 服务配置(至少配置一个)
# ==========================================
@@ -71,11 +71,9 @@ DEFAULT_MAX_TOKENS=32000
# ==========================================
# LinuxDO OAuth 配置(可选)
# ==========================================
# LINUXDO_CLIENT_ID=your_client_id_here
# LINUXDO_CLIENT_SECRET=your_client_secret_here
# LINUXDO_REDIRECT_URI=http://localhost:8000/api/auth/callback
# 前端 URL(OAuth 回调后重定向)
LINUXDO_CLIENT_ID=11111
LINUXDO_CLIENT_SECRET=11111
LINUXDO_REDIRECT_URI=http://localhost:8000/api/auth/callback
FRONTEND_URL=http://localhost:8000
# 初始管理员(LinuxDO user_id
+5 -5
View File
@@ -497,7 +497,7 @@ async def generate_organization_stream(
- 其他要求:{gen_request.requirements or ''}
"""
yield await SSEResponse.send_progress("构建AI提示词...", 20)
yield await SSEResponse.send_progress("构建AI提示词...", 5)
# 获取自定义提示词模板
template = await PromptService.get_template("SINGLE_ORGANIZATION_GENERATION", user_id, db)
@@ -508,7 +508,7 @@ async def generate_organization_stream(
user_input=user_input
)
yield await SSEResponse.send_progress("调用AI服务生成组织...", 30)
yield await SSEResponse.send_progress("调用AI服务生成组织...", 10)
logger.info(f"🎯 开始为项目 {gen_request.project_id} 生成组织(SSE流式)")
try:
@@ -525,7 +525,7 @@ async def generate_organization_stream(
# 定期更新字数(5-95%,AI生成占90%)
if chunk_count % 5 == 0:
progress = min(5 + (chunk_count // 5), 95)
progress = min(10 + (chunk_count // 5), 95)
yield await SSEResponse.send_progress(
f"AI生成组织中... ({len(ai_content)}字符)",
progress
@@ -544,7 +544,7 @@ async def generate_organization_stream(
yield await SSEResponse.send_error("AI服务返回空响应")
return
yield await SSEResponse.send_progress("解析AI响应...", 96)
yield await SSEResponse.send_progress("解析AI响应...", 90)
# ✅ 使用统一的 JSON 清洗方法
try:
@@ -557,7 +557,7 @@ async def generate_organization_stream(
yield await SSEResponse.send_error(f"AI返回的内容无法解析为JSON{str(e)}")
return
yield await SSEResponse.send_progress("创建组织记录...", 97)
yield await SSEResponse.send_progress("创建组织记录...", 95)
# 创建角色记录(组织也是角色的一种)
character = Character(
+13 -13
View File
@@ -134,10 +134,10 @@ async def world_building_generator(
请结合上述资料,生成符合历史/现实的世界观设定。"""
final_prompt = enhanced_prompt
yield await SSEResponse.send_progress("💡 已整合参考资料,开始生成世界观...", 30)
yield await SSEResponse.send_progress("💡 已整合参考资料,开始生成世界观...", 10)
else:
final_prompt = base_prompt
yield await SSEResponse.send_progress("正在调用AI生成...", 30)
yield await SSEResponse.send_progress("正在调用AI生成...", 10)
# ===== 流式生成世界观(带重试机制) =====
MAX_WORLD_RETRIES = 3 # 最多重试3次
@@ -148,7 +148,7 @@ async def world_building_generator(
while world_retry_count < MAX_WORLD_RETRIES and not world_generation_success:
try:
retry_suffix = f" (重试{world_retry_count}/{MAX_WORLD_RETRIES})" if world_retry_count > 0 else ""
yield await SSEResponse.send_progress(f"生成世界观{retry_suffix}...", 30 + world_retry_count * 5)
yield await SSEResponse.send_progress(f"生成世界观{retry_suffix}...", 10 + world_retry_count * 5)
# 流式生成世界观
accumulated_text = ""
@@ -181,7 +181,7 @@ async def world_building_generator(
if world_retry_count < MAX_WORLD_RETRIES:
yield await SSEResponse.send_progress(
f"⚠️ AI返回为空,准备重试...",
30 + world_retry_count * 5,
10 + world_retry_count * 5,
"warning"
)
continue
@@ -221,7 +221,7 @@ async def world_building_generator(
if world_retry_count < MAX_WORLD_RETRIES:
yield await SSEResponse.send_progress(
f"⚠️ JSON解析失败,准备重试...",
30 + world_retry_count * 5,
10 + world_retry_count * 5,
"warning"
)
continue
@@ -241,7 +241,7 @@ async def world_building_generator(
if world_retry_count < MAX_WORLD_RETRIES:
yield await SSEResponse.send_progress(
f"⚠️ 生成异常,准备重试...",
30 + world_retry_count * 5,
10 + world_retry_count * 5,
"warning"
)
continue
@@ -1599,10 +1599,10 @@ async def world_building_regenerate_generator(
请结合上述资料,生成符合历史/现实的世界观设定。"""
final_prompt = enhanced_prompt
yield await SSEResponse.send_progress("💡 已整合参考资料,开始生成世界观...", 30)
yield await SSEResponse.send_progress("💡 已整合参考资料,开始生成世界观...", 10)
else:
final_prompt = base_prompt
yield await SSEResponse.send_progress("正在调用AI生成...", 30)
yield await SSEResponse.send_progress("正在调用AI生成...", 10)
# ===== 流式生成世界观(带重试机制) =====
MAX_WORLD_RETRIES = 3 # 最多重试3次
@@ -1613,7 +1613,7 @@ async def world_building_regenerate_generator(
while world_retry_count < MAX_WORLD_RETRIES and not world_generation_success:
try:
retry_suffix = f" (重试{world_retry_count}/{MAX_WORLD_RETRIES})" if world_retry_count > 0 else ""
yield await SSEResponse.send_progress(f"重新生成世界观{retry_suffix}...", 30 + world_retry_count * 5)
yield await SSEResponse.send_progress(f"重新生成世界观{retry_suffix}...", 10 + world_retry_count * 5)
# 流式生成世界观
accumulated_text = ""
@@ -1630,7 +1630,7 @@ async def world_building_regenerate_generator(
yield await SSEResponse.send_chunk(chunk)
if chunk_count % 5 == 0:
progress = min(30 + (chunk_count // 5), 85)
progress = min(10 + (chunk_count // 5), 85)
yield await SSEResponse.send_progress(f"生成中... ({len(accumulated_text)}字符)", progress)
if chunk_count % 20 == 0:
@@ -1643,7 +1643,7 @@ async def world_building_regenerate_generator(
if world_retry_count < MAX_WORLD_RETRIES:
yield await SSEResponse.send_progress(
f"⚠️ AI返回为空,准备重试...",
30 + world_retry_count * 5,
10 + world_retry_count * 5,
"warning"
)
continue
@@ -1679,7 +1679,7 @@ async def world_building_regenerate_generator(
if world_retry_count < MAX_WORLD_RETRIES:
yield await SSEResponse.send_progress(
f"⚠️ JSON解析失败,准备重试...",
30 + world_retry_count * 5,
10 + world_retry_count * 5,
"warning"
)
continue
@@ -1699,7 +1699,7 @@ async def world_building_regenerate_generator(
if world_retry_count < MAX_WORLD_RETRIES:
yield await SSEResponse.send_progress(
f"⚠️ 生成异常,准备重试...",
30 + world_retry_count * 5,
10 + world_retry_count * 5,
"warning"
)
continue
+6
View File
@@ -4,8 +4,14 @@
set -e # 遇到错误立即退出
# 获取版本信息(从config.py中提取)
APP_VERSION=$(grep -oP "app_version:\s*str\s*=\s*\"\K[^\"]*" /app/app/config.py || echo "unknown")
BUILD_TIME=$(date '+%Y-%m-%d %H:%M:%S')
echo "================================================"
echo "🚀 MuMuAINovel 启动中..."
echo "📦 版本: v${APP_VERSION}"
echo "🕐 启动时间: ${BUILD_TIME}"
echo "================================================"
# 数据库配置(从环境变量读取)
+1 -1
View File
@@ -102,7 +102,7 @@ services:
- 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:-2000}
- DEFAULT_MAX_TOKENS=${DEFAULT_MAX_TOKENS:-32000}
# LinuxDO OAuth 配置
- LINUXDO_CLIENT_ID=${LINUXDO_CLIENT_ID:-11111}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
"version": "1.2.1",
"version": "1.2.2",
"type": "module",
"scripts": {
"dev": "vite",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 243 KiB

After

Width:  |  Height:  |  Size: 275 KiB

+14
View File
@@ -3,3 +3,17 @@
margin: 0;
padding: 0;
}
/* 移动端按钮文本优化 */
@media (max-width: 576px) {
.button-text-mobile {
font-size: 14px;
}
/* 小屏幕下按钮文字可以隐藏,只保留图标 */
@media (max-width: 480px) {
.button-text-mobile {
display: none;
}
}
}
@@ -12,6 +12,7 @@ import {
GithubOutlined,
ReloadOutlined,
ClockCircleOutlined,
SyncOutlined,
} from '@ant-design/icons';
import {
fetchChangelog,
@@ -29,6 +30,7 @@ interface ChangelogModalProps {
// 提交类型图标和颜色配置
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: '样式' },
+4 -1
View File
@@ -283,7 +283,10 @@ export default function Careers() {
flexWrap: 'wrap',
gap: '12px'
}}>
<Title level={3} style={{ margin: 0 }}></Title>
<Title level={3} style={{ margin: 0 }}>
<TrophyOutlined style={{ marginRight: 8 }} />
</Title>
<Space wrap>
<Button
type="dashed"
+30 -12
View File
@@ -7,6 +7,7 @@ import {
LeftOutlined,
RightOutlined,
UnorderedListOutlined,
FundOutlined,
} from '@ant-design/icons';
import { useParams } from 'react-router-dom';
import api from '../services/api';
@@ -189,14 +190,30 @@ const ChapterAnalysis: React.FC = () => {
}
return (
<div style={{
display: 'flex',
height: '100%',
gap: isMobile ? 0 : 16,
flexDirection: isMobile ? 'column' : 'row'
}}>
{/* 左侧章节列表 - 桌面端 */}
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
{/* 页面标题 - 仅桌面端显示 */}
{!isMobile && (
<div style={{
padding: '16px 0',
marginBottom: 16,
borderBottom: '1px solid #f0f0f0'
}}>
<h2 style={{ margin: 0, fontSize: 24 }}>
<FundOutlined style={{ marginRight: 8 }} />
</h2>
</div>
)}
<div style={{
flex: 1,
display: 'flex',
gap: isMobile ? 0 : 16,
flexDirection: isMobile ? 'column' : 'row',
overflow: 'hidden'
}}>
{/* 左侧章节列表 - 桌面端 */}
{!isMobile && (
<Card
title="章节列表"
style={{ width: 280, height: '100%', overflow: 'hidden' }}
@@ -237,9 +254,9 @@ const ChapterAnalysis: React.FC = () => {
/>
)}
</Card>
)}
)}
{/* 移动端章节列表抽屉 */}
{/* 移动端章节列表抽屉 */}
{isMobile && (
<Drawer
title="章节列表"
@@ -284,10 +301,10 @@ const ChapterAnalysis: React.FC = () => {
/>
)}
</Drawer>
)}
)}
{/* 右侧内容区域 */}
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
{/* 右侧内容区域 */}
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
{!selectedChapter ? (
<Card style={{ height: '100%' }}>
<Empty description="请从左侧选择一个章节查看" style={{ marginTop: 100 }} />
@@ -530,6 +547,7 @@ const ChapterAnalysis: React.FC = () => {
)}
</>
)}
</div>
</div>
</div>
);
+4 -1
View File
@@ -1491,7 +1491,10 @@ export default function Chapters() {
justifyContent: 'space-between',
alignItems: isMobile ? 'stretch' : 'center'
}}>
<h2 style={{ margin: 0, fontSize: isMobile ? 18 : 24 }}></h2>
<h2 style={{ margin: 0, fontSize: isMobile ? 18 : 24 }}>
<BookOutlined style={{ marginRight: 8 }} />
</h2>
<Space direction={isMobile ? 'vertical' : 'horizontal'} style={{ width: isMobile ? '100%' : 'auto' }}>
{currentProject.outline_mode === 'one-to-many' && (
<Button
+4 -1
View File
@@ -382,7 +382,10 @@ export default function Characters() {
justifyContent: 'space-between',
alignItems: isMobile ? 'stretch' : 'center'
}}>
<h2 style={{ margin: 0, fontSize: isMobile ? 18 : 24 }}></h2>
<h2 style={{ margin: 0, fontSize: isMobile ? 18 : 24 }}>
<TeamOutlined style={{ marginRight: 8 }} />
</h2>
<Space wrap>
<Button
type="primary"
+168 -63
View File
@@ -1,7 +1,7 @@
import { useState, useEffect, useCallback } from 'react';
import { useParams } from 'react-router-dom';
import { Card, Table, Tag, Button, Space, message, Modal, Form, Select, InputNumber, Input, Descriptions } from 'antd';
import { PlusOutlined, TeamOutlined, UserOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons';
import { Card, Table, Tag, Button, Space, message, Modal, Form, Select, InputNumber, Input, Descriptions, Drawer } from 'antd';
import { PlusOutlined, UserOutlined, EditOutlined, DeleteOutlined, UnorderedListOutlined, BankOutlined } from '@ant-design/icons';
import { useStore } from '../store';
import { useCharacterSync } from '../store/hooks';
import axios from 'axios';
@@ -57,6 +57,7 @@ export default function Organizations() {
const [editOrgForm] = Form.useForm();
const [isMobile, setIsMobile] = useState(window.innerWidth <= 768);
const [modal, contextHolder] = Modal.useModal();
const [orgListVisible, setOrgListVisible] = useState(false);
useEffect(() => {
const handleResize = () => {
@@ -82,7 +83,7 @@ export default function Organizations() {
} finally {
setLoading(false);
}
}, [projectId, selectedOrg]);
}, [projectId]);
const loadCharacters = useCallback(async () => {
try {
@@ -300,61 +301,169 @@ export default function Organizations() {
);
return (
<div>
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
{contextHolder}
<Card
title={
<Space wrap>
<TeamOutlined />
<span style={{ fontSize: isMobile ? 14 : 16 }}></span>
{!isMobile && <Tag color="blue">{currentProject?.title}</Tag>}
</Space>
}
>
{/* 页面标题 - 仅桌面端显示 */}
{!isMobile && (
<div style={{
display: isMobile ? 'flex' : 'grid',
flexDirection: isMobile ? 'column' : undefined,
gridTemplateColumns: isMobile ? undefined : '300px 1fr',
gap: isMobile ? '16px' : '24px',
maxHeight: isMobile ? 'calc(100vh - 200px)' : undefined,
overflowY: isMobile ? 'auto' : undefined
padding: '16px 0',
marginBottom: 16,
borderBottom: '1px solid #f0f0f0'
}}>
{/* 左侧:组织列表 */}
<div>
<Card
size="small"
title={`组织列表 (${organizations.length})`}
loading={loading}
>
<Space direction="vertical" style={{ width: '100%' }}>
{organizations.map(org => (
<Card
key={org.id}
size="small"
hoverable
style={{
cursor: 'pointer',
border: selectedOrg?.id === org.id ? '2px solid var(--color-primary)' : '1px solid var(--color-border-secondary)'
}}
onClick={() => handleSelectOrganization(org)}
>
<Space direction="vertical" size="small" style={{ width: '100%' }}>
<strong>{org.name}</strong>
<Tag>{org.type}</Tag>
<div style={{ fontSize: '12px', color: '#666' }}>
: {org.member_count} | : {org.power_level}
</div>
</Space>
</Card>
))}
</Space>
</Card>
</div>
<h2 style={{ margin: 0, fontSize: 24 }}>
<BankOutlined style={{ marginRight: 8 }} />
</h2>
</div>
)}
<div style={{
flex: 1,
display: 'flex',
gap: isMobile ? 0 : 16,
flexDirection: isMobile ? 'column' : 'row',
overflow: 'hidden'
}}>
{/* 左侧组织列表 - 桌面端 */}
{!isMobile && (
<Card
title={`组织列表 (${organizations.length})`}
style={{ width: 300, height: '100%', overflow: 'hidden' }}
bodyStyle={{ padding: 0, height: 'calc(100% - 57px)', overflow: 'auto' }}
loading={loading}
>
{organizations.length === 0 ? (
<div style={{ textAlign: 'center', padding: '40px 20px', color: '#999' }}>
</div>
) : (
<Space direction="vertical" style={{ width: '100%', padding: '12px' }}>
{organizations.map(org => (
<Card
key={org.id}
size="small"
hoverable
style={{
cursor: 'pointer',
border: selectedOrg?.id === org.id ? '2px solid #1890ff' : '1px solid #d9d9d9',
background: selectedOrg?.id === org.id ? '#e6f7ff' : 'transparent'
}}
onClick={() => handleSelectOrganization(org)}
>
<Space direction="vertical" size="small" style={{ width: '100%' }}>
<strong style={{ fontSize: 14 }}>{org.name}</strong>
<Tag color="blue">{org.type}</Tag>
<div style={{ fontSize: '12px', color: '#666' }}>
: {org.member_count} | : {org.power_level}
</div>
</Space>
</Card>
))}
</Space>
)}
</Card>
)}
{/* 右侧:组织详情和成员 */}
<div style={{ minHeight: isMobile ? 'auto' : undefined }}>
{selectedOrg ? (
<Space direction="vertical" style={{ width: '100%' }} size="large">
{/* 移动端组织列表抽屉 */}
{isMobile && (
<Drawer
title="组织列表"
placement="left"
onClose={() => setOrgListVisible(false)}
open={orgListVisible}
width="85%"
styles={{ body: { padding: 0 } }}
>
{organizations.length === 0 ? (
<div style={{ textAlign: 'center', padding: '40px 20px', color: '#999' }}>
</div>
) : (
<Space direction="vertical" style={{ width: '100%', padding: '12px' }}>
{organizations.map(org => (
<Card
key={org.id}
size="small"
hoverable
style={{
cursor: 'pointer',
border: selectedOrg?.id === org.id ? '2px solid #1890ff' : '1px solid #d9d9d9',
background: selectedOrg?.id === org.id ? '#e6f7ff' : 'transparent'
}}
onClick={() => {
handleSelectOrganization(org);
setOrgListVisible(false);
}}
>
<Space direction="vertical" size="small" style={{ width: '100%' }}>
<strong style={{ fontSize: 14 }}>{org.name}</strong>
<Tag color="blue">{org.type}</Tag>
<div style={{ fontSize: '12px', color: '#666' }}>
: {org.member_count} | : {org.power_level}
</div>
</Space>
</Card>
))}
</Space>
)}
</Drawer>
)}
{/* 右侧内容区域 */}
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
{!selectedOrg ? (
<Card style={{ height: '100%' }}>
<div style={{ textAlign: 'center', padding: '100px 20px', color: '#999' }}>
{isMobile && organizations.length > 0 && (
<Button
type="primary"
icon={<UnorderedListOutlined />}
onClick={() => setOrgListVisible(true)}
style={{ marginBottom: 20 }}
>
</Button>
)}
<div></div>
</div>
</Card>
) : (
<>
{/* 工具栏 - 移动端显示项目标题和组织列表按钮 */}
{isMobile && (
<Card size="small" style={{ marginBottom: 8 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Space>
<BankOutlined />
<span style={{ fontSize: 14, fontWeight: 600 }}>
</span>
<Tag color="blue">{currentProject?.title}</Tag>
</Space>
<Button
icon={<UnorderedListOutlined />}
onClick={() => setOrgListVisible(true)}
size="small"
>
</Button>
</div>
</Card>
)}
{/* 内容区域 */}
<div style={{
flex: 1,
display: 'flex',
gap: isMobile ? 0 : 16,
overflow: 'hidden'
}}>
<Card
style={{ flex: 1, overflow: 'auto' }}
bodyStyle={{ padding: isMobile ? '12px' : '24px' }}
>
<Space direction="vertical" style={{ width: '100%' }} size={isMobile ? 'middle' : 'large'}>
<Card
title="组织详情"
size="small"
@@ -445,17 +554,13 @@ export default function Organizations() {
}}
/>
</Card>
</Space>
) : (
<Card>
<div style={{ textAlign: 'center', padding: '40px', color: '#999' }}>
</div>
</Space>
</Card>
)}
</div>
</div>
</>
)}
</div>
</Card>
</div>
{/* 添加成员模态框 */}
<Modal
+5 -2
View File
@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react';
import { Button, List, Modal, Form, Input, message, Empty, Space, Popconfirm, Card, Select, Radio, Tag, InputNumber, Tooltip, Tabs } from 'antd';
import { EditOutlined, DeleteOutlined, ThunderboltOutlined, BranchesOutlined, AppstoreAddOutlined, CheckCircleOutlined, ExclamationCircleOutlined, PlusOutlined } from '@ant-design/icons';
import { EditOutlined, DeleteOutlined, ThunderboltOutlined, BranchesOutlined, AppstoreAddOutlined, CheckCircleOutlined, ExclamationCircleOutlined, PlusOutlined, FileTextOutlined } from '@ant-design/icons';
import { useStore } from '../store';
import { useOutlineSync } from '../store/hooks';
import { cardStyles } from '../components/CardStyles';
@@ -1904,7 +1904,10 @@ export default function Outline() {
alignItems: isMobile ? 'stretch' : 'center'
}}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<h2 style={{ margin: 0, fontSize: isMobile ? 18 : 24 }}></h2>
<h2 style={{ margin: 0, fontSize: isMobile ? 18 : 24 }}>
<FileTextOutlined style={{ marginRight: 8 }} />
</h2>
{currentProject?.outline_mode && (
<Tag color={currentProject.outline_mode === 'one-to-one' ? 'blue' : 'green'} style={{ width: 'fit-content' }}>
{currentProject.outline_mode === 'one-to-one' ? '传统模式 (1→1)' : '细化模式 (1→N)'}
+2 -2
View File
@@ -1,7 +1,7 @@
import { useState, useEffect } from 'react';
import { useParams } from 'react-router-dom';
import { Card, Table, Tag, Button, Space, message, Modal, Form, Select, Slider, Input, Tabs, AutoComplete } from 'antd';
import { PlusOutlined, TeamOutlined, UserOutlined, EditOutlined } from '@ant-design/icons';
import { PlusOutlined, ApartmentOutlined, UserOutlined, EditOutlined } from '@ant-design/icons';
import { useStore } from '../store';
import axios from 'axios';
@@ -308,7 +308,7 @@ export default function Relationships() {
<Card
title={
<Space wrap>
<TeamOutlined />
<ApartmentOutlined />
<span style={{ fontSize: isMobile ? 14 : 16 }}></span>
{!isMobile && <Tag color="blue">{currentProject?.title}</Tag>}
</Space>
+45 -33
View File
@@ -1,4 +1,4 @@
import { Card, Descriptions, Empty, Typography, Button, Modal, Form, Input, message, Space } from 'antd';
import { Card, Descriptions, Empty, Typography, Button, Modal, Form, Input, message, Flex } from 'antd';
import { GlobalOutlined, EditOutlined, SyncOutlined } from '@ant-design/icons';
import { useState } from 'react';
import { useStore } from '../store';
@@ -174,39 +174,51 @@ export default function WorldSetting() {
backgroundColor: '#fff',
padding: '16px 0',
marginBottom: 24,
borderBottom: '1px solid #f0f0f0',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between'
borderBottom: '1px solid #f0f0f0'
}}>
<div style={{ display: 'flex', alignItems: 'center' }}>
<GlobalOutlined style={{ fontSize: 24, marginRight: 12, color: 'var(--color-primary)' }} />
<h2 style={{ margin: 0 }}></h2>
</div>
<Space>
<Button
icon={<SyncOutlined />}
onClick={handleRegenerate}
disabled={isRegenerating}
>
AI重新生成
</Button>
<Button
type="primary"
icon={<EditOutlined />}
onClick={() => {
editForm.setFieldsValue({
world_time_period: currentProject.world_time_period || '',
world_location: currentProject.world_location || '',
world_atmosphere: currentProject.world_atmosphere || '',
world_rules: currentProject.world_rules || '',
});
setIsEditModalVisible(true);
}}
>
</Button>
</Space>
<Flex
justify="space-between"
align="flex-start"
gap={12}
wrap="wrap"
>
<div style={{ display: 'flex', alignItems: 'center', minWidth: 'fit-content' }}>
<GlobalOutlined style={{ fontSize: 24, marginRight: 12, color: 'var(--color-primary)' }} />
<h2 style={{ margin: 0, whiteSpace: 'nowrap' }}></h2>
</div>
<Flex gap={8} wrap="wrap" style={{ flex: '0 1 auto' }}>
<Button
icon={<SyncOutlined />}
onClick={handleRegenerate}
disabled={isRegenerating}
style={{
minWidth: 'fit-content',
flex: '1 1 auto'
}}
>
<span className="button-text-mobile">AI重新生成</span>
</Button>
<Button
type="primary"
icon={<EditOutlined />}
onClick={() => {
editForm.setFieldsValue({
world_time_period: currentProject.world_time_period || '',
world_location: currentProject.world_location || '',
world_atmosphere: currentProject.world_atmosphere || '',
world_rules: currentProject.world_rules || '',
});
setIsEditModalVisible(true);
}}
style={{
minWidth: 'fit-content',
flex: '1 1 auto'
}}
>
<span className="button-text-mobile"></span>
</Button>
</Flex>
</Flex>
</div>
{/* 可滚动内容区域 */}
+4 -1
View File
@@ -180,7 +180,10 @@ export default function WritingStyles() {
justifyContent: 'space-between',
alignItems: isMobile ? 'stretch' : 'center'
}}>
<h2 style={{ margin: 0, fontSize: isMobile ? 18 : 24 }}></h2>
<h2 style={{ margin: 0, fontSize: isMobile ? 18 : 24 }}>
<EditOutlined style={{ marginRight: 8 }} />
</h2>
<Button
type="primary"
icon={<PlusOutlined />}
+84 -64
View File
@@ -31,7 +31,7 @@ export interface ChangelogEntry {
};
message: string;
commitUrl: string;
type: 'feature' | 'fix' | 'docs' | 'style' | 'refactor' | 'perf' | 'test' | 'chore' | 'other';
type: 'feature' | 'fix' | 'docs' | 'style' | 'refactor' | 'perf' | 'test' | 'chore' | 'update' | 'other';
scope?: string;
}
@@ -39,91 +39,111 @@ const GITHUB_API_BASE = 'https://api.github.com';
const REPO_OWNER = 'xiamuceer-j';
const REPO_NAME = 'MuMuAINovel';
/**
* 提交类型映射表
* 统一不同别名到标准类型
*/
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',
};
/**
* 从提交信息中解析类型和作用域
* 支持常见的提交信息格式:
* - type: message
* - type(scope): message
* - [type] message
*
* 匹配优先级(从高到低):
* 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();
const lowerMessage = message.toLowerCase().trim();
// 第一优先级:精确匹配 update: 开头(在正则之前检查)
if (lowerMessage.startsWith('update:')) {
const cleanMsg = message.replace(/^update:\s*/i, '');
return { type: 'feature', cleanMessage: cleanMsg };
}
// 第二优先级:匹配标准 conventional commits 格式 type: message 或 type(scope): message
const conventionalMatch = message.match(/^(feat|feature|fix|docs|style|refactor|perf|test|chore)(?:\(([^)]+)\))?\s*:\s*(.+)/i);
// 优先级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 = typeStr === 'feature' ? 'feature' : typeStr as ChangelogEntry['type'];
const mappedType = TYPE_MAPPING[typeStr] || 'other';
return {
type: mappedType,
scope: conventionalMatch[2],
cleanMessage: conventionalMatch[3],
cleanMessage: conventionalMatch[3].trim(),
};
}
// 第三优先级:匹配 [type] message 格式
const bracketMatch = message.match(/^\[(feat|feature|fix|docs|style|refactor|perf|test|chore|update)\]\s*(.+)/i);
// 优先级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 = (typeStr === 'update' || typeStr === 'feature') ? 'feature' : typeStr as ChangelogEntry['type'];
const mappedType = TYPE_MAPPING[typeStr] || 'other';
return {
type: mappedType,
cleanMessage: bracketMatch[2],
cleanMessage: bracketMatch[2].trim(),
};
}
// 第四优先级:通过前缀精确匹配(避免误判
if (lowerMessage.startsWith('fix:')|| lowerMessage.startsWith('fix')) {
const cleanMsg = message.replace(/^fix:\s*/i, '');
return { type: 'fix', cleanMessage: cleanMsg };
}
if (lowerMessage.startsWith('perf:')) {
const cleanMsg = message.replace(/^perf:\s*/i, '');
return { type: 'perf', cleanMessage: cleanMsg };
}
if (lowerMessage.startsWith('docs:')) {
const cleanMsg = message.replace(/^docs:\s*/i, '');
return { type: 'docs', cleanMessage: cleanMsg };
}
if (lowerMessage.startsWith('feat:') || lowerMessage.startsWith('feature:')) {
const cleanMsg = message.replace(/^(feat|feature):\s*/i, '');
return { type: 'feature', cleanMessage: cleanMsg };
}
// 第五优先级:关键词模糊匹配(仅当前面都不匹配时)
if (lowerMessage.includes('修复') || lowerMessage.includes('fix')) {
return { type: 'fix', cleanMessage: message };
}
if (lowerMessage.includes('优化') || lowerMessage.includes('perf')) {
return { type: 'perf', cleanMessage: message };
}
if (lowerMessage.includes('文档') || lowerMessage.includes('doc')) {
return { type: 'docs', cleanMessage: message };
}
if (lowerMessage.includes('新增') || lowerMessage.includes('添加') || lowerMessage.includes('增加')) {
return { type: 'feature', cleanMessage: message };
}
if (lowerMessage.includes('样式') || lowerMessage.includes('style')) {
return { type: 'style', cleanMessage: message };
}
if (lowerMessage.includes('重构') || lowerMessage.includes('refactor')) {
return { type: 'refactor', cleanMessage: message };
// 优先级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 };
}