Update 2026-05-18 14:31:53

This commit is contained in:
yi
2026-05-18 14:31:54 +08:00
parent df33ce2f18
commit b77e2d8a7a
54 changed files with 1003 additions and 2699 deletions
+2
View File
@@ -0,0 +1,2 @@
# XinMi API 文档地址(默认 https://api.xinmi.cloud,一般无需修改)
# VITE_OFFICIAL_API_DOC_URL=https://api.xinmi.cloud
+3
View File
@@ -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>
-3
View File
@@ -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>
);
}
+54 -234
View File
@@ -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,243 +66,67 @@ export default function AppFooter({ sidebarWidth = 0 }: AppFooterProps) {
maxWidth: 1400,
margin: '0 auto',
textAlign: 'center',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
gap: isMobile ? 8 : 16,
flexWrap: 'wrap'
}}
>
{isMobile ? (
// 移动端:紧凑单行布局
<div style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
gap: 8,
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 }} />}
{/* 版本信息 */}
<Badge dot={hasUpdate} offset={[-8, 2]}>
<Text
onClick={handleVersionClick}
style={{
fontSize: isMobile ? 11 : 12,
display: 'flex',
justifyContent: 'center',
alignItems: 'center'
alignItems: 'center',
gap: 6,
color: token.colorTextSecondary,
cursor: hasUpdate ? 'pointer' : 'default',
}}
title={hasUpdate ? `发现新版本 v${latestVersion},点击查看` : '当前版本'}
>
{/* 版本信息 */}
<Badge dot={hasUpdate} offset={[-8, 2]}>
<Text
onClick={handleVersionClick}
style={{
fontSize: 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},点击查看` : '当前版本'}
>
<strong style={{ color: token.colorText }}>{VERSION_INFO.projectName}</strong>
<span>{getVersionString()}</span>
</Text>
</Badge>
<strong style={{ color: token.colorText }}>{VERSION_INFO.projectName}</strong>
<span>{getVersionString()}</span>
</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>
<Divider type="vertical" style={{ borderColor: token.colorBorder }} />
{/* 资源模块 */}
<Link
href="https://www.xinmi.cloud/"
target="_blank"
rel="noopener noreferrer"
style={{
fontSize: 12,
color: token.colorTextSecondary,
}}
>
</Link>
{/* 许可证 */}
<Link
href={VERSION_INFO.licenseUrl}
target="_blank"
rel="noopener noreferrer"
style={{
fontSize: isMobile ? 11 : 12,
display: 'flex',
alignItems: 'center',
gap: 6,
color: token.colorTextSecondary,
}}
>
<CopyrightOutlined style={{ fontSize: 11 }} />
<span>{VERSION_INFO.license}</span>
</Link>
{/* LinuxDO 社区 */}
<Link
href={VERSION_INFO.linuxDoUrl}
target="_blank"
rel="noopener noreferrer"
style={{
fontSize: 12,
color: token.colorTextSecondary,
}}
>
LinuxDO
</Link>
<Divider type="vertical" style={{ borderColor: token.colorBorder }} />
{/* 赞助按钮 */}
<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>
{/* 许可证 */}
<Link
href={VERSION_INFO.licenseUrl}
target="_blank"
rel="noopener noreferrer"
style={{
fontSize: 12,
display: 'flex',
alignItems: 'center',
gap: 6,
color: token.colorTextSecondary,
}}
>
<CopyrightOutlined style={{ fontSize: 11 }} />
<span>{VERSION_INFO.license}</span>
</Link>
{/* 更新时间 */}
<Text
style={{
fontSize: 12,
display: 'flex',
alignItems: 'center',
gap: 4,
color: token.colorTextTertiary,
}}
>
<ClockCircleOutlined style={{ fontSize: 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>
)}
{/* 更新时间 */}
<Text
style={{
fontSize: isMobile ? 10 : 12,
display: 'flex',
alignItems: 'center',
gap: 4,
color: token.colorTextTertiary,
}}
>
<ClockCircleOutlined style={{ fontSize: isMobile ? 10 : 12 }} />
<span>{VERSION_INFO.buildTime}</span>
</Text>
</div>
</div>
);
}
+8 -14
View File
@@ -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)}
/>
</>
);
}
-306
View File
@@ -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;
+6 -3
View File
@@ -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',
+45 -3
View File
@@ -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));
+13 -2
View File
@@ -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>
+22 -154
View File
@@ -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,10 +176,10 @@ 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>
</>
);
}
}
+85 -35
View File
@@ -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={{
display: 'flex',
flexDirection: isMobile ? 'column' : 'row',
alignItems: isMobile ? 'flex-start' : '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 }}
{showApiTip && projects.length === 0 && (
<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 ? 'stretch' : 'center',
gap: isMobile ? 12 : 16,
justifyContent: 'space-between',
}}
>
<div
style={{
display: 'flex',
alignItems: 'flex-start',
gap: 10,
flex: 1,
minWidth: 0,
}}
>
</Button>
<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>
}
type="info"
showIcon
closable
onClose={() => setShowApiTip(false)}
style={{
marginBottom: isMobile ? 16 : 24,
borderRadius: 12
}}
/>
)}
</div>
)}
</Card>
<Spin spinning={loading}>
<div style={{
+140 -525
View File
@@ -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 redirect = searchParams.get('redirect') || '/';
navigate(redirect);
};
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,66 +300,49 @@ export default function Login() {
];
const renderLocalLogin = () => (
<>
<Form
form={localForm}
layout="vertical"
onFinish={handleLocalLogin}
size="large"
style={{ marginTop: 16 }}
<Form
form={localForm}
layout="vertical"
onFinish={handleLocalLogin}
size="large"
style={{ marginTop: 16 }}
>
<Form.Item
name="username"
label="管理账号"
rules={[{ required: true, message: '请输入管理账号/邮箱' }]}
>
<Form.Item
name="username"
label="管理账号"
rules={[{ required: true, message: '请输入管理账号/邮箱' }]}
<Input
prefix={<UserOutlined style={{ color: token.colorTextTertiary }} />}
placeholder="请输入管理账号/邮箱"
autoComplete="username"
style={{ height: 46, borderRadius: 2 }}
/>
</Form.Item>
<Form.Item
name="password"
label="访问密钥"
rules={[{ required: true, message: '请输入访问密钥' }]}
>
<Input.Password
prefix={<LockOutlined style={{ color: token.colorTextTertiary }} />}
placeholder="请输入访问密钥"
autoComplete="current-password"
style={{ height: 46, borderRadius: 2 }}
/>
</Form.Item>
<Form.Item style={{ marginBottom: 0, marginTop: 8 }}>
<Button
type="primary"
htmlType="submit"
loading={loading}
block
style={primaryButtonStyle}
>
<Input
prefix={<UserOutlined style={{ color: token.colorTextTertiary }} />}
placeholder="请输入管理账号/邮箱"
autoComplete="username"
style={{ height: 46, borderRadius: 12 }}
/>
</Form.Item>
<Form.Item
name="password"
label="访问密钥"
rules={[{ required: true, message: '请输入访问密钥' }]}
>
<Input.Password
prefix={<LockOutlined style={{ color: token.colorTextTertiary }} />}
placeholder="请输入访问密钥"
autoComplete="current-password"
style={{ height: 46, borderRadius: 12 }}
/>
</Form.Item>
<Form.Item style={{ marginBottom: 0, marginTop: 8 }}>
<Button
type="primary"
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,
}}
>
</Button>
</Form.Item>
</Form>
{linuxdoEnabled ? (
<>
<Divider style={{ margin: '18px 0 16px' }}></Divider>
{renderLinuxDOLogin()}
</>
) : null}
</>
</Button>
</Form.Item>
</Form>
);
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)',
}}
>
<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>
<Title level={3} style={{ margin: 0, color: token.colorText }}>
</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>
</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>
</Space>
</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>
</div>
<Paragraph
style={{
marginBottom: 0,
fontSize: 12,
color: token.colorTextTertiary,
position: 'relative',
zIndex: 1,
letterSpacing: 0.4,
}}
>
© 2026 · GPLv3 License
</Paragraph>
</section>
</Col>
<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>
<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} className="app-serif-title" style={{ margin: 0, fontWeight: 700 }}></Title>
</Space>
<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}>
<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>
{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>
</Space>
</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>
</section>
</Card>
</Col>
</Row>
</Layout>
</>
<Paragraph style={{ marginTop: 32, marginBottom: 0, fontSize: 12, color: token.colorTextTertiary, letterSpacing: '0.08em' }}>© 2026 · GPLv3</Paragraph>
</div>
</Layout>
);
}
+8 -1
View File
@@ -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>
),
},
],
},
+92 -70
View File
@@ -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,
fontWeight: 600,
fontSize: 15,
fontFamily: token.fontFamily,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis'
}}>
</span>
<div style={{ minWidth: 0, display: 'flex', flexDirection: 'column', gap: 2 }}>
<span className="app-serif-title" style={{
color: shell.siderText,
fontWeight: 600,
fontSize: 15,
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>
@@ -1061,4 +1083,4 @@ export default function ProjectList() {
</div>
);
}
}
+30 -66
View File
@@ -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 }}>
AI生成提示词
<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>
+47 -42
View File
@@ -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>
-276
View File
@@ -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');
}
+3 -3
View File
@@ -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: '',
};
}
}
+102 -19
View File
@@ -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;
+14 -2
View File
@@ -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;
}
+17 -2
View File
@@ -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;