feat: 忘记密码/修改密码 + 设置页结构重组 + barrel cleanup + LoginPage 版本统一
新增: - ForgotPasswordModal — 手机号+短信验证码重置密码 (AuthAPI.resetPassword) - ChangePasswordModal — 当前密码验证后修改密码 (AuthAPI.changePassword) - useSmsCooldown Hook — 通用短信发送冷却逻辑,供注册/忘记密码复用 - PasswordSetting — 设置页账号安全区块,双选项:修改密码 / 短信验证码重置 修改: - LoginForm \"忘记密码?\" 链接接入 ForgotPasswordModal(替换 TODO) - LoginPage 移除 edition 业务版本 UI 区分,登录/注册 Tab 全局统一 - 设置页 Section 组件移入 blocks/ 子目录(git mv 保留历史) - auth.ts sendSms URL 修正: /sms/send → /send-sms 清理: - settings/index.ts 移除 4 个未使用 re-export,精简为仅 SettingsPage - navbar/index.ts 移除未使用 re-export,精简为仅 NavBar 文档: - WORKLOG.md 新增 2026-06-08 工作日志 - ARCHITECTURE.md / PROJECT.md 更新设置页目录结构与新组件 - TODO.md 忘记密码流程标记已完成 + 短信人机验证列入后期优化
This commit is contained in:
@@ -3,202 +3,201 @@
|
||||
// 数据来源:GET /api/v1/announcements
|
||||
// ============================================================
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { Drawer, Card, Typography, Empty, Tag, Spin, Modal, Tooltip } from 'antd';
|
||||
import { NotificationOutlined } from '@ant-design/icons';
|
||||
import {useEffect, useState, useCallback} from 'react';
|
||||
import {Drawer, Card, Typography, Empty, Tag, Spin, Modal, Tooltip} from 'antd';
|
||||
import {NotificationOutlined} from '@ant-design/icons';
|
||||
|
||||
import { fetchAnnouncements, type Announcement } from '@/services/modules';
|
||||
import {fetchAnnouncements, type Announcement, AnnouncementListResponse} from '@/services/modules';
|
||||
|
||||
const { Text, Paragraph } = Typography;
|
||||
const {Text, Paragraph} = Typography;
|
||||
|
||||
// ---------- 辅助 ----------
|
||||
|
||||
/** 公告类型 → Tag 颜色映射 */
|
||||
const typeColorMap: Record<string, string> = {
|
||||
system: 'blue',
|
||||
feature: 'purple',
|
||||
activity: 'orange',
|
||||
maintenance: 'red',
|
||||
system: 'blue',
|
||||
feature: 'purple',
|
||||
activity: 'orange',
|
||||
maintenance: 'red',
|
||||
};
|
||||
|
||||
/** 公告类型 → 中文标签 */
|
||||
const typeLabelMap: Record<string, string> = {
|
||||
system: '系统',
|
||||
feature: '功能',
|
||||
activity: '活动',
|
||||
maintenance: '维护',
|
||||
system: '系统',
|
||||
feature: '功能',
|
||||
activity: '活动',
|
||||
maintenance: '维护',
|
||||
};
|
||||
|
||||
/** 格式化日期(YYYY-MM-DD HH:mm) */
|
||||
function formatDate(iso: string): string {
|
||||
try {
|
||||
return new Date(iso).toLocaleDateString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
try {
|
||||
return new Date(iso).toLocaleDateString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 组件 ----------
|
||||
|
||||
interface AnnouncementDrawerProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function AnnouncementDrawer({ open, onClose }: AnnouncementDrawerProps) {
|
||||
const [list, setList] = useState<Announcement[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
export function AnnouncementDrawer({open, onClose}: AnnouncementDrawerProps) {
|
||||
const [list, setList] = useState<Announcement[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// 详情 Modal
|
||||
const [detail, setDetail] = useState<Announcement | null>(null);
|
||||
// 详情 Modal
|
||||
const [detail, setDetail] = useState<Announcement | null>(null);
|
||||
|
||||
// 打开抽屉时请求公告列表
|
||||
const loadAnnouncements = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await fetchAnnouncements();
|
||||
// 仅展示有效公告
|
||||
setList(data.filter((item) => item.is_active));
|
||||
} catch (err) {
|
||||
setError((err as Error).message || '加载公告失败');
|
||||
setList([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
// 打开抽屉时请求公告列表
|
||||
const loadAnnouncements = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data: AnnouncementListResponse = await fetchAnnouncements();
|
||||
setList(data.filter((item: Announcement) => item.is_active));
|
||||
} catch (err) {
|
||||
setError((err as Error).message || '加载公告失败');
|
||||
setList([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
loadAnnouncements();
|
||||
}
|
||||
}, [open, loadAnnouncements]);
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
loadAnnouncements();
|
||||
}
|
||||
}, [open, loadAnnouncements]);
|
||||
|
||||
// ---- 渲染 ----
|
||||
// ---- 渲染 ----
|
||||
|
||||
return (
|
||||
<>
|
||||
<Drawer
|
||||
title={
|
||||
<span className="flex items-center gap-2">
|
||||
return (
|
||||
<>
|
||||
<Drawer
|
||||
title={
|
||||
<span className="flex items-center gap-2">
|
||||
<NotificationOutlined />
|
||||
公告
|
||||
</span>
|
||||
}
|
||||
placement="right"
|
||||
size={380}
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
styles={{ body: { padding: 0 } }}
|
||||
>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Spin description="加载中..." />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Empty description={error} />
|
||||
</div>
|
||||
) : list.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Empty description="暂无公告" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3 p-3">
|
||||
{list.map((item) => (
|
||||
<Tooltip
|
||||
key={item.id}
|
||||
title={
|
||||
item.content.length > 100
|
||||
? item.content.slice(0, 200) + '…'
|
||||
: undefined
|
||||
}
|
||||
placement="left"
|
||||
>
|
||||
<Card
|
||||
size="small"
|
||||
className="rounded-md! border-0! shadow-sm! cursor-pointer hover:shadow-md! transition-shadow"
|
||||
title={
|
||||
<Text strong className="text-sm" ellipsis>
|
||||
{item.pinned && '📌 '}
|
||||
{item.title}
|
||||
</Text>
|
||||
}
|
||||
extra={
|
||||
<Tag
|
||||
color={typeColorMap[item.type] || 'default'}
|
||||
className="m-0! text-xs"
|
||||
>
|
||||
{typeLabelMap[item.type] || item.type}
|
||||
</Tag>
|
||||
}
|
||||
onClick={() => setDetail(item)}
|
||||
>
|
||||
<Paragraph
|
||||
className="mb-2! text-xs"
|
||||
type="secondary"
|
||||
ellipsis={{ rows: 2 }}
|
||||
>
|
||||
{item.content}
|
||||
</Paragraph>
|
||||
<Text type="secondary" className="text-xs">
|
||||
{formatDate(item.created_at)}
|
||||
</Text>
|
||||
</Card>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Drawer>
|
||||
}
|
||||
placement="right"
|
||||
size={380}
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
styles={{body: {padding: 0}}}
|
||||
>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Spin description="加载中..." />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Empty description={error} />
|
||||
</div>
|
||||
) : list.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Empty description="暂无公告" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3 p-3">
|
||||
{list.map((item:Announcement) => (
|
||||
<Tooltip
|
||||
key={item.id}
|
||||
title={
|
||||
item.content.length > 100
|
||||
? item.content.slice(0, 200) + '…'
|
||||
: undefined
|
||||
}
|
||||
placement="left"
|
||||
>
|
||||
<Card
|
||||
size="small"
|
||||
className="rounded-md! border-0! shadow-sm! cursor-pointer hover:shadow-md! transition-shadow"
|
||||
title={
|
||||
<Text strong className="text-sm" ellipsis>
|
||||
{item.pinned && '📌 '}
|
||||
{item.title}
|
||||
</Text>
|
||||
}
|
||||
extra={
|
||||
<Tag
|
||||
color={typeColorMap[item.type] || 'default'}
|
||||
className="m-0! text-xs"
|
||||
>
|
||||
{typeLabelMap[item.type] || item.type}
|
||||
</Tag>
|
||||
}
|
||||
onClick={() => setDetail(item)}
|
||||
>
|
||||
<Paragraph
|
||||
className="mb-2! text-xs"
|
||||
type="secondary"
|
||||
ellipsis={{rows: 2}}
|
||||
>
|
||||
{item.content}
|
||||
</Paragraph>
|
||||
<Text type="secondary" className="text-xs">
|
||||
{formatDate(item.created_at)}
|
||||
</Text>
|
||||
</Card>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Drawer>
|
||||
|
||||
{/* ======== 公告详情 Modal ======== */}
|
||||
<Modal
|
||||
open={!!detail}
|
||||
onCancel={() => setDetail(null)}
|
||||
footer={null}
|
||||
width={480}
|
||||
centered
|
||||
title={
|
||||
detail && (
|
||||
<span className="flex items-center gap-2">
|
||||
{/* ======== 公告详情 Modal ======== */}
|
||||
<Modal
|
||||
open={!!detail}
|
||||
onCancel={() => setDetail(null)}
|
||||
footer={null}
|
||||
width={480}
|
||||
centered
|
||||
title={
|
||||
detail && (
|
||||
<span className="flex items-center gap-2">
|
||||
{detail.pinned && '📌 '}
|
||||
{detail.title}
|
||||
{detail.title}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
>
|
||||
{detail && (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Tag
|
||||
color={typeColorMap[detail.type] || 'default'}
|
||||
className="m-0! text-xs"
|
||||
>
|
||||
{typeLabelMap[detail.type] || detail.type}
|
||||
</Tag>
|
||||
<Text type="secondary" className="text-xs">
|
||||
{formatDate(detail.created_at)}
|
||||
</Text>
|
||||
</div>
|
||||
<div className="px-1 py-2 rounded-md"
|
||||
style={{
|
||||
background: 'var(--color-fill-tertiary, rgba(0,0,0,0.04))',
|
||||
}}
|
||||
>
|
||||
<Paragraph className="mb-0! text-sm leading-relaxed whitespace-pre-wrap">
|
||||
{detail.content}
|
||||
</Paragraph>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
)
|
||||
}
|
||||
>
|
||||
{detail && (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Tag
|
||||
color={typeColorMap[detail.type] || 'default'}
|
||||
className="m-0! text-xs"
|
||||
>
|
||||
{typeLabelMap[detail.type] || detail.type}
|
||||
</Tag>
|
||||
<Text type="secondary" className="text-xs">
|
||||
{formatDate(detail.created_at)}
|
||||
</Text>
|
||||
</div>
|
||||
<div className="px-1 py-2 rounded-md"
|
||||
style={{
|
||||
background: 'var(--color-fill-tertiary, rgba(0,0,0,0.04))',
|
||||
}}
|
||||
>
|
||||
<Paragraph className="mb-0! text-sm leading-relaxed whitespace-pre-wrap">
|
||||
{detail.content}
|
||||
</Paragraph>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,133 +7,133 @@
|
||||
// - 登录/注册 与 退出登录 仍按登录状态互斥显示
|
||||
// ============================================================
|
||||
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { App } from 'antd';
|
||||
import {useState, useCallback, useMemo} from 'react';
|
||||
import {useNavigate} from 'react-router-dom';
|
||||
import {App} from 'antd';
|
||||
|
||||
import { useAppContext } from '@/contexts/app-context';
|
||||
import { useTheme } from '@/hooks/use-theme';
|
||||
import { emit, EVENTS } from '@/utils/event-bus.ts';
|
||||
import { NavItem } from './NavItem';
|
||||
import { AnnouncementDrawer } from './AnnouncementDrawer';
|
||||
import { personalLeft, personalRight, enterpriseLeft, enterpriseRight } from './nav-config';
|
||||
import type { NavItemConfig } from './types';
|
||||
import {useAppContext} from '@/contexts/app-context';
|
||||
import {useTheme} from '@/hooks/use-theme';
|
||||
import {emit, EVENTS} from '@/utils/event-bus.ts';
|
||||
import {NavItem} from './NavItem';
|
||||
import {AnnouncementDrawer} from './AnnouncementDrawer';
|
||||
import {personalLeft, personalRight, enterpriseLeft, enterpriseRight} from './nav-config';
|
||||
import type {NavItemConfig} from './types';
|
||||
|
||||
const NAVBAR_HEIGHT = 56;
|
||||
|
||||
export function NavBar() {
|
||||
const { platform, edition, isLoggedIn, logout } = useAppContext();
|
||||
const { isDark } = useTheme();
|
||||
const { message } = App.useApp();
|
||||
const navigate = useNavigate();
|
||||
const {platform, edition, isLoggedIn, logout} = useAppContext();
|
||||
const {isDark} = useTheme();
|
||||
const {message} = App.useApp();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
|
||||
// 根据版本选择导航项
|
||||
const { leftItems, rightItems } = useMemo(() => {
|
||||
const isEnterprise = edition === 'enterprise';
|
||||
return {
|
||||
leftItems: isEnterprise ? enterpriseLeft : personalLeft,
|
||||
rightItems: isEnterprise ? enterpriseRight : personalRight,
|
||||
};
|
||||
}, [edition]);
|
||||
// 根据版本选择导航项
|
||||
const {leftItems, rightItems} = useMemo(() => {
|
||||
const isEnterprise = edition === 'enterprise';
|
||||
return {
|
||||
leftItems: isEnterprise ? enterpriseLeft : personalLeft,
|
||||
rightItems: isEnterprise ? enterpriseRight : personalRight,
|
||||
};
|
||||
}, [edition]);
|
||||
|
||||
// 过滤规则:
|
||||
// requireGuest → 仅未登录时可见(登录/注册)
|
||||
// "退出登录" → 仅已登录时可见
|
||||
// 其他所有项 → 始终可见,未登录点击时弹"请先登录"
|
||||
const visibleLeft = useMemo(
|
||||
() =>
|
||||
leftItems.filter((item) => {
|
||||
if (item.requireGuest && isLoggedIn) return false;
|
||||
return !(item.key === 'logout' && !isLoggedIn);
|
||||
}),
|
||||
[leftItems, isLoggedIn],
|
||||
);
|
||||
const visibleRight = useMemo(
|
||||
() =>
|
||||
rightItems.filter((item) => {
|
||||
if (item.requireGuest && isLoggedIn) return false;
|
||||
return !(item.key === 'logout' && !isLoggedIn);
|
||||
}),
|
||||
[rightItems, isLoggedIn],
|
||||
);
|
||||
// 过滤规则:
|
||||
// requireGuest → 仅未登录时可见(登录/注册)
|
||||
// "退出登录" → 仅已登录时可见
|
||||
// 其他所有项 → 始终可见,未登录点击时弹"请先登录"
|
||||
const visibleLeft = useMemo(
|
||||
() =>
|
||||
leftItems.filter((item) => {
|
||||
if (item.requireGuest && isLoggedIn) return false;
|
||||
return !(item.key === 'logout' && !isLoggedIn);
|
||||
}),
|
||||
[leftItems, isLoggedIn],
|
||||
);
|
||||
const visibleRight: NavItemConfig[] = useMemo(
|
||||
() =>
|
||||
rightItems.filter((item) => {
|
||||
if (item.requireGuest && isLoggedIn) return false;
|
||||
return !(item.key === 'logout' && !isLoggedIn);
|
||||
}),
|
||||
[rightItems, isLoggedIn],
|
||||
);
|
||||
|
||||
// 点击导航项
|
||||
const handleNavClick = useCallback(
|
||||
(item: NavItemConfig) => {
|
||||
// 需要登录但未登录 → 提示并弹出登录 Modal
|
||||
if (item.requireLogin && !isLoggedIn) {
|
||||
message.warning('请先登录');
|
||||
emit(EVENTS.SHOW_LOGIN);
|
||||
return;
|
||||
}
|
||||
// 点击导航项
|
||||
const handleNavClick = useCallback(
|
||||
(item: NavItemConfig) => {
|
||||
// 需要登录但未登录 → 提示并弹出登录 Modal
|
||||
if (item.requireLogin && !isLoggedIn) {
|
||||
message.warning('请先登录');
|
||||
emit(EVENTS.SHOW_LOGIN);
|
||||
return;
|
||||
}
|
||||
|
||||
switch (item.action) {
|
||||
case 'drawer':
|
||||
if (item.key === 'announcements') {
|
||||
setDrawerOpen(true);
|
||||
}
|
||||
break;
|
||||
switch (item.action) {
|
||||
case 'drawer':
|
||||
if (item.key === 'announcements') {
|
||||
setDrawerOpen(true);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'button':
|
||||
if (item.key === 'logout') {
|
||||
logout();
|
||||
} else if (item.key === 'settings') {
|
||||
emit(EVENTS.OPEN_SETTINGS);
|
||||
}
|
||||
break;
|
||||
case 'button':
|
||||
if (item.key === 'logout') {
|
||||
logout();
|
||||
} else if (item.key === 'settings') {
|
||||
emit(EVENTS.OPEN_SETTINGS);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'external':
|
||||
if (item.target) {
|
||||
window.open(item.target, '_blank');
|
||||
}
|
||||
break;
|
||||
case 'external':
|
||||
if (item.target) {
|
||||
window.open(item.target, '_blank');
|
||||
}
|
||||
break;
|
||||
|
||||
case 'navigate':
|
||||
case 'spa':
|
||||
if (item.key === 'auth') {
|
||||
emit(EVENTS.SHOW_LOGIN);
|
||||
} else if (item.target) {
|
||||
navigate(item.target);
|
||||
}
|
||||
break;
|
||||
}
|
||||
},
|
||||
[isLoggedIn, logout, message],
|
||||
);
|
||||
case 'navigate':
|
||||
case 'spa':
|
||||
if (item.key === 'auth') {
|
||||
emit(EVENTS.SHOW_LOGIN);
|
||||
} else if (item.target) {
|
||||
navigate(item.target);
|
||||
}
|
||||
break;
|
||||
}
|
||||
},
|
||||
[isLoggedIn, logout, message],
|
||||
);
|
||||
|
||||
const macPaddingLeft = platform === 'darwin' ? 80 : 12;
|
||||
const macPaddingLeft = platform === 'darwin' ? 80 : 12;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="navbar-drag-region flex items-center justify-between select-none"
|
||||
style={{
|
||||
height: NAVBAR_HEIGHT,
|
||||
paddingLeft: macPaddingLeft,
|
||||
paddingRight: 12,
|
||||
background: isDark
|
||||
? 'linear-gradient(180deg, #1E1B4B 0%, #1A1730 100%)'
|
||||
: 'linear-gradient(180deg, #FFFFFF 0%, #F5F3FF 100%)',
|
||||
borderBottom: `1px solid ${isDark ? '#2E2A4A' : '#E5E7EB'}`,
|
||||
boxShadow: isDark ? '0 1px 4px rgba(0,0,0,0.3)' : '0 1px 4px rgba(99,102,241,0.06)',
|
||||
}}
|
||||
>
|
||||
<div className="navbar-no-drag flex items-center gap-0.5">
|
||||
{visibleLeft.map((item) => (
|
||||
<NavItem key={item.key} item={item} onClick={handleNavClick} />
|
||||
))}
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="navbar-drag-region flex items-center justify-between select-none"
|
||||
style={{
|
||||
height: NAVBAR_HEIGHT,
|
||||
paddingLeft: macPaddingLeft,
|
||||
paddingRight: 12,
|
||||
background: isDark
|
||||
? 'linear-gradient(180deg, #1E1B4B 0%, #1A1730 100%)'
|
||||
: 'linear-gradient(180deg, #FFFFFF 0%, #F5F3FF 100%)',
|
||||
borderBottom: `1px solid ${isDark ? '#2E2A4A' : '#E5E7EB'}`,
|
||||
boxShadow: isDark ? '0 1px 4px rgba(0,0,0,0.3)' : '0 1px 4px rgba(99,102,241,0.06)',
|
||||
}}
|
||||
>
|
||||
<div className="navbar-no-drag flex items-center gap-0.5">
|
||||
{visibleLeft.map((item) => (
|
||||
<NavItem key={item.key} item={item} onClick={handleNavClick} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="navbar-no-drag flex items-center gap-0.5">
|
||||
{visibleRight.map((item) => (
|
||||
<NavItem key={item.key} item={item} onClick={handleNavClick} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="navbar-no-drag flex items-center gap-0.5">
|
||||
{visibleRight.map((item) => (
|
||||
<NavItem key={item.key} item={item} onClick={handleNavClick} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AnnouncementDrawer open={drawerOpen} onClose={() => setDrawerOpen(false)} />
|
||||
</>
|
||||
);
|
||||
<AnnouncementDrawer open={drawerOpen} onClose={() => setDrawerOpen(false)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1 @@
|
||||
export { NavBar } from './NavBar';
|
||||
export { AnnouncementDrawer } from './AnnouncementDrawer';
|
||||
export type { NavItemConfig, NavBarConfig, NavAction } from './types';
|
||||
|
||||
199
src/pages/login/components/ChangePasswordModal.tsx
Normal file
199
src/pages/login/components/ChangePasswordModal.tsx
Normal file
@@ -0,0 +1,199 @@
|
||||
// ============================================================
|
||||
// ChangePasswordModal — 修改密码弹窗(已登录用户)
|
||||
//
|
||||
// 场景:已登录用户通过旧密码验证后设置新密码
|
||||
// 复用:可在设置页"修改密码"入口使用,也可在其他位置独立调用
|
||||
//
|
||||
// API:
|
||||
// - AuthAPI.changePassword({ current_password, new_password })
|
||||
// ============================================================
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { Modal, Input, Button, Typography, App } from 'antd';
|
||||
import { LockOutlined, KeyOutlined, SafetyOutlined } from '@ant-design/icons';
|
||||
|
||||
import { useTheme } from '@/hooks/use-theme.ts';
|
||||
import { AuthAPI } from '@/services/modules';
|
||||
import { validatePassword, validateConfirmPassword } from '../config/validators';
|
||||
|
||||
const { Text, Title } = Typography;
|
||||
|
||||
interface ChangePasswordModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ChangePasswordModal({ open, onClose }: ChangePasswordModalProps) {
|
||||
const { isDark } = useTheme();
|
||||
const { message } = App.useApp();
|
||||
|
||||
// ---- 表单字段 ----
|
||||
const [currentPassword, setCurrentPassword] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
|
||||
// ---- 错误信息 ----
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
|
||||
// ---- 提交状态 ----
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
// ---- 清除单个字段错误 ----
|
||||
const clearError = useCallback((name: string) => {
|
||||
setErrors((p) => {
|
||||
if (!(name in p)) return p;
|
||||
const n = { ...p };
|
||||
delete n[name];
|
||||
return n;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// ---- 提交 ----
|
||||
const handleSubmit = useCallback(async () => {
|
||||
const newErrors: Record<string, string> = {};
|
||||
|
||||
if (!currentPassword) {
|
||||
newErrors.currentPassword = '请输入当前密码';
|
||||
}
|
||||
const pwdErr = validatePassword(newPassword);
|
||||
if (pwdErr) newErrors.newPassword = pwdErr;
|
||||
const confirmErr = validateConfirmPassword(confirmPassword, newPassword);
|
||||
if (confirmErr) newErrors.confirmPassword = confirmErr;
|
||||
|
||||
if (Object.keys(newErrors).length > 0) {
|
||||
setErrors(newErrors);
|
||||
return;
|
||||
}
|
||||
|
||||
setErrors({});
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await AuthAPI.changePassword({
|
||||
current_password: currentPassword,
|
||||
new_password: newPassword,
|
||||
});
|
||||
message.success('密码修改成功');
|
||||
// 重置表单
|
||||
setCurrentPassword('');
|
||||
setNewPassword('');
|
||||
setConfirmPassword('');
|
||||
onClose();
|
||||
} catch (err) {
|
||||
message.error((err as Error).message || '密码修改失败');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}, [currentPassword, newPassword, confirmPassword, onClose, message]);
|
||||
|
||||
// ---- 关闭时重置错误 ----
|
||||
const handleClose = useCallback(() => {
|
||||
setErrors({});
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onCancel={handleClose}
|
||||
footer={null}
|
||||
width={440}
|
||||
centered
|
||||
destroyOnHidden={true}
|
||||
mask={{ closable: false }}
|
||||
styles={{
|
||||
body: {
|
||||
padding: '24px 32px 16px',
|
||||
background: isDark ? '#1A1730' : '#FFFFFF',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{/* 标题 */}
|
||||
<div className="text-center mb-5">
|
||||
<Title level={5} className="!mb-1 gradient-primary-text">
|
||||
修改密码
|
||||
</Title>
|
||||
<Text type="secondary" className="text-xs">
|
||||
请输入当前密码并设置新密码
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{/* 表单 */}
|
||||
<div className="space-y-4">
|
||||
{/* 当前密码 */}
|
||||
<div>
|
||||
<Input.Password
|
||||
size="large"
|
||||
prefix={<SafetyOutlined className="opacity-40" />}
|
||||
placeholder="当前密码"
|
||||
value={currentPassword}
|
||||
onChange={(e) => {
|
||||
setCurrentPassword(e.target.value);
|
||||
clearError('currentPassword');
|
||||
}}
|
||||
status={errors.currentPassword ? 'error' : undefined}
|
||||
allowClear
|
||||
/>
|
||||
{errors.currentPassword && (
|
||||
<Text type="danger" className="text-xs ml-1">
|
||||
{errors.currentPassword}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 新密码 */}
|
||||
<div>
|
||||
<Input.Password
|
||||
size="large"
|
||||
prefix={<LockOutlined className="opacity-40" />}
|
||||
placeholder="新密码(6~128位)"
|
||||
value={newPassword}
|
||||
onChange={(e) => {
|
||||
setNewPassword(e.target.value);
|
||||
clearError('newPassword');
|
||||
}}
|
||||
status={errors.newPassword ? 'error' : undefined}
|
||||
allowClear
|
||||
/>
|
||||
{errors.newPassword && (
|
||||
<Text type="danger" className="text-xs ml-1">
|
||||
{errors.newPassword}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 确认新密码 */}
|
||||
<div>
|
||||
<Input.Password
|
||||
size="large"
|
||||
prefix={<KeyOutlined className="opacity-40" />}
|
||||
placeholder="请再次输入新密码"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => {
|
||||
setConfirmPassword(e.target.value);
|
||||
clearError('confirmPassword');
|
||||
}}
|
||||
status={errors.confirmPassword ? 'error' : undefined}
|
||||
allowClear
|
||||
/>
|
||||
{errors.confirmPassword && (
|
||||
<Text type="danger" className="text-xs ml-1">
|
||||
{errors.confirmPassword}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 提交按钮 */}
|
||||
<Button
|
||||
type="primary"
|
||||
block
|
||||
size="large"
|
||||
loading={submitting}
|
||||
onClick={handleSubmit}
|
||||
className="h-11! text-base! font-medium! mt-2!"
|
||||
>
|
||||
确认修改
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
265
src/pages/login/components/ForgotPasswordModal.tsx
Normal file
265
src/pages/login/components/ForgotPasswordModal.tsx
Normal file
@@ -0,0 +1,265 @@
|
||||
// ============================================================
|
||||
// ForgotPasswordModal — 忘记密码弹窗
|
||||
//
|
||||
// 场景:未登录用户通过手机号 + 短信验证码重置密码
|
||||
// 复用:可在登录页"忘记密码?"入口使用,也可在其他位置独立调用
|
||||
//
|
||||
// API:
|
||||
// - AuthAPI.sendSms({ phone }) 发送短信验证码
|
||||
// - AuthAPI.resetPassword({ phone, sms_code, new_password }) 重置密码
|
||||
// ============================================================
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { Modal, Input, Button, Typography, App } from 'antd';
|
||||
import {
|
||||
PhoneOutlined,
|
||||
SafetyCertificateOutlined,
|
||||
LockOutlined,
|
||||
KeyOutlined,
|
||||
} from '@ant-design/icons';
|
||||
|
||||
import { useTheme } from '@/hooks/use-theme.ts';
|
||||
import { AuthAPI } from '@/services/modules';
|
||||
import {
|
||||
validatePhone,
|
||||
validateSmsCode,
|
||||
validatePassword,
|
||||
validateConfirmPassword,
|
||||
} from '../config/validators';
|
||||
import { useSmsCooldown } from '../hooks/use-sms-cooldown';
|
||||
|
||||
const { Text, Title } = Typography;
|
||||
|
||||
interface ForgotPasswordModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ForgotPasswordModal({ open, onClose }: ForgotPasswordModalProps) {
|
||||
const { isDark } = useTheme();
|
||||
const { message } = App.useApp();
|
||||
|
||||
// ---- 表单字段 ----
|
||||
const [phone, setPhone] = useState('');
|
||||
const [smsCode, setSmsCode] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
|
||||
// ---- 错误信息 ----
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
|
||||
// ---- 提交状态 ----
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
// ---- 短信冷却 ----
|
||||
const { cooldown, secondsLeft, startCooldown } = useSmsCooldown(60);
|
||||
|
||||
// ---- 清除单个字段错误 ----
|
||||
const clearError = useCallback((name: string) => {
|
||||
setErrors((p) => {
|
||||
if (!(name in p)) return p;
|
||||
const n = { ...p };
|
||||
delete n[name];
|
||||
return n;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// ---- 发送验证码 ----
|
||||
const handleSendSms = useCallback(() => {
|
||||
const err = validatePhone(phone);
|
||||
if (err) {
|
||||
setErrors((p) => ({ ...p, phone: err }));
|
||||
return;
|
||||
}
|
||||
clearError('phone');
|
||||
startCooldown();
|
||||
AuthAPI.sendSms({ phone })
|
||||
.then(() => message.success('验证码已发送'))
|
||||
.catch((e) => message.error((e as Error).message || '验证码发送失败'));
|
||||
}, [phone, startCooldown, clearError, message]);
|
||||
|
||||
// ---- 提交重置 ----
|
||||
const handleSubmit = useCallback(async () => {
|
||||
// 逐字段校验
|
||||
const newErrors: Record<string, string> = {};
|
||||
const phoneErr = validatePhone(phone);
|
||||
if (phoneErr) newErrors.phone = phoneErr;
|
||||
const smsErr = validateSmsCode(smsCode);
|
||||
if (smsErr) newErrors.smsCode = smsErr;
|
||||
const pwdErr = validatePassword(newPassword);
|
||||
if (pwdErr) newErrors.newPassword = pwdErr;
|
||||
const confirmErr = validateConfirmPassword(confirmPassword, newPassword);
|
||||
if (confirmErr) newErrors.confirmPassword = confirmErr;
|
||||
|
||||
if (Object.keys(newErrors).length > 0) {
|
||||
setErrors(newErrors);
|
||||
return;
|
||||
}
|
||||
|
||||
setErrors({});
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await AuthAPI.resetPassword({
|
||||
phone,
|
||||
sms_code: smsCode,
|
||||
new_password: newPassword,
|
||||
});
|
||||
message.success('密码重置成功,请使用新密码登录');
|
||||
// 重置表单状态
|
||||
setPhone('');
|
||||
setSmsCode('');
|
||||
setNewPassword('');
|
||||
setConfirmPassword('');
|
||||
onClose();
|
||||
} catch (err) {
|
||||
message.error((err as Error).message || '密码重置失败');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}, [phone, smsCode, newPassword, confirmPassword, onClose, message]);
|
||||
|
||||
// ---- 关闭时重置 ----
|
||||
const handleClose = useCallback(() => {
|
||||
setErrors({});
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onCancel={handleClose}
|
||||
footer={null}
|
||||
width={440}
|
||||
centered
|
||||
destroyOnHidden={true}
|
||||
mask={{ closable: false }}
|
||||
styles={{
|
||||
body: {
|
||||
padding: '24px 32px 16px',
|
||||
background: isDark ? '#1A1730' : '#FFFFFF',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{/* 标题 */}
|
||||
<div className="text-center mb-5">
|
||||
<Title level={5} className="!mb-1 gradient-primary-text">
|
||||
重置密码
|
||||
</Title>
|
||||
<Text type="secondary" className="text-xs">
|
||||
通过手机短信验证码重置您的密码
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{/* 表单 */}
|
||||
<div className="space-y-4">
|
||||
{/* 手机号 */}
|
||||
<div>
|
||||
<Input
|
||||
size="large"
|
||||
prefix={<PhoneOutlined className="opacity-40" />}
|
||||
placeholder="请输入手机号"
|
||||
value={phone}
|
||||
onChange={(e) => {
|
||||
setPhone(e.target.value);
|
||||
clearError('phone');
|
||||
}}
|
||||
status={errors.phone ? 'error' : undefined}
|
||||
allowClear
|
||||
/>
|
||||
{errors.phone && (
|
||||
<Text type="danger" className="text-xs ml-1">
|
||||
{errors.phone}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 短信验证码 */}
|
||||
<div>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
size="large"
|
||||
prefix={<SafetyCertificateOutlined className="opacity-40" />}
|
||||
placeholder="请输入验证码"
|
||||
value={smsCode}
|
||||
onChange={(e) => {
|
||||
setSmsCode(e.target.value);
|
||||
clearError('smsCode');
|
||||
}}
|
||||
status={errors.smsCode ? 'error' : undefined}
|
||||
className="flex-1"
|
||||
allowClear
|
||||
/>
|
||||
<Button
|
||||
size="large"
|
||||
type="primary"
|
||||
disabled={cooldown}
|
||||
onClick={handleSendSms}
|
||||
className="text-xs! shrink-0 px-3!"
|
||||
>
|
||||
{cooldown ? `${secondsLeft}s` : '发送验证码'}
|
||||
</Button>
|
||||
</div>
|
||||
{errors.smsCode && (
|
||||
<Text type="danger" className="text-xs ml-1">
|
||||
{errors.smsCode}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 新密码 */}
|
||||
<div>
|
||||
<Input.Password
|
||||
size="large"
|
||||
prefix={<LockOutlined className="opacity-40" />}
|
||||
placeholder="新密码(6~128位)"
|
||||
value={newPassword}
|
||||
onChange={(e) => {
|
||||
setNewPassword(e.target.value);
|
||||
clearError('newPassword');
|
||||
}}
|
||||
status={errors.newPassword ? 'error' : undefined}
|
||||
allowClear
|
||||
/>
|
||||
{errors.newPassword && (
|
||||
<Text type="danger" className="text-xs ml-1">
|
||||
{errors.newPassword}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 确认新密码 */}
|
||||
<div>
|
||||
<Input.Password
|
||||
size="large"
|
||||
prefix={<KeyOutlined className="opacity-40" />}
|
||||
placeholder="请再次输入新密码"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => {
|
||||
setConfirmPassword(e.target.value);
|
||||
clearError('confirmPassword');
|
||||
}}
|
||||
status={errors.confirmPassword ? 'error' : undefined}
|
||||
allowClear
|
||||
/>
|
||||
{errors.confirmPassword && (
|
||||
<Text type="danger" className="text-xs ml-1">
|
||||
{errors.confirmPassword}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 提交按钮 */}
|
||||
<Button
|
||||
type="primary"
|
||||
block
|
||||
size="large"
|
||||
loading={submitting}
|
||||
onClick={handleSubmit}
|
||||
className="h-11! text-base! font-medium! mt-2!"
|
||||
>
|
||||
重置密码
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -22,6 +22,7 @@ interface LoginFormProps {
|
||||
onSubmit: (values: LoginFormValues) => void;
|
||||
onRememberChange: (checked: boolean) => void;
|
||||
onAutoLoginChange: (checked: boolean) => void;
|
||||
onForgotPassword?: () => void;
|
||||
}
|
||||
|
||||
export function LoginForm({
|
||||
@@ -33,6 +34,7 @@ export function LoginForm({
|
||||
onSubmit,
|
||||
onRememberChange,
|
||||
onAutoLoginChange,
|
||||
onForgotPassword,
|
||||
}: LoginFormProps) {
|
||||
const [username, setUsername] = useState(initialUsername || '');
|
||||
const [password, setPassword] = useState(initialPassword || '');
|
||||
@@ -147,12 +149,7 @@ export function LoginForm({
|
||||
自动登录
|
||||
</Checkbox>
|
||||
</div>
|
||||
<a
|
||||
className="text-xs"
|
||||
onClick={() => {
|
||||
/* TODO: 忘记密码流程 */
|
||||
}}
|
||||
>
|
||||
<a className="text-xs" onClick={() => onForgotPassword?.()}>
|
||||
忘记密码?
|
||||
</a>
|
||||
</div>
|
||||
|
||||
57
src/pages/login/hooks/use-sms-cooldown.ts
Normal file
57
src/pages/login/hooks/use-sms-cooldown.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
// ============================================================
|
||||
// useSmsCooldown — 短信验证码发送冷却 Hook
|
||||
//
|
||||
// 从 RegisterForm 提取,供所有需要短信验证码的场景复用:
|
||||
// - 注册(RegisterForm)
|
||||
// - 忘记密码(ForgotPasswordModal)
|
||||
// ============================================================
|
||||
|
||||
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||
|
||||
export interface SmsCooldownState {
|
||||
/** 是否在冷却中 */
|
||||
cooldown: boolean;
|
||||
/** 冷却剩余秒数 */
|
||||
secondsLeft: number;
|
||||
/** 开始冷却 */
|
||||
startCooldown: () => void;
|
||||
}
|
||||
|
||||
export function useSmsCooldown(cooldownSeconds: number = 60): SmsCooldownState {
|
||||
const [cooldown, setCooldown] = useState(false);
|
||||
const [secondsLeft, setSecondsLeft] = useState(0);
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
const startCooldown = useCallback(() => {
|
||||
// 清理之前的计时器(防止重复点击导致多个计时器叠加)
|
||||
if (timerRef.current) {
|
||||
clearInterval(timerRef.current);
|
||||
}
|
||||
setCooldown(true);
|
||||
setSecondsLeft(cooldownSeconds);
|
||||
timerRef.current = setInterval(() => {
|
||||
setSecondsLeft((prev) => {
|
||||
if (prev <= 1) {
|
||||
if (timerRef.current) {
|
||||
clearInterval(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
setCooldown(false);
|
||||
return 0;
|
||||
}
|
||||
return prev - 1;
|
||||
});
|
||||
}, 1000);
|
||||
}, [cooldownSeconds]);
|
||||
|
||||
// 组件卸载时清理计时器
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timerRef.current) {
|
||||
clearInterval(timerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { cooldown, secondsLeft, startCooldown };
|
||||
}
|
||||
@@ -14,8 +14,9 @@ import { useAppContext } from '@/contexts/app-context.ts';
|
||||
import { useTheme } from '@/hooks/use-theme.ts';
|
||||
import { LoginForm } from './components/LoginForm';
|
||||
import { RegisterForm } from './components/RegisterForm';
|
||||
import { ForgotPasswordModal } from './components/ForgotPasswordModal';
|
||||
import { useAuthState } from './hooks/use-auth-state';
|
||||
import {AuthAPI, LoginResponseBody} from '@/services/modules';
|
||||
import { AuthAPI, LoginResponseBody } from '@/services/modules';
|
||||
import { getDeviceId } from '@/utils/device';
|
||||
import type { LoginFormValues, RegisterFormValues } from './types';
|
||||
|
||||
@@ -27,13 +28,13 @@ interface LoginPageProps {
|
||||
}
|
||||
|
||||
export function LoginPage({ open, onClose }: LoginPageProps) {
|
||||
const { edition, login } = useAppContext();
|
||||
const { login } = useAppContext();
|
||||
const { isDark } = useTheme();
|
||||
const { message } = App.useApp();
|
||||
|
||||
const isEnterprise = edition === 'enterprise';
|
||||
const [activeTab, setActiveTab] = useState<string>('login');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [forgotOpen, setForgotOpen] = useState(false);
|
||||
|
||||
const authState = useAuthState();
|
||||
|
||||
@@ -43,7 +44,7 @@ export function LoginPage({ open, onClose }: LoginPageProps) {
|
||||
async (values: LoginFormValues) => {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const auth:LoginResponseBody = await AuthAPI.login({
|
||||
const auth: LoginResponseBody = await AuthAPI.login({
|
||||
username: values.username,
|
||||
password: values.password,
|
||||
device_id: getDeviceId(),
|
||||
@@ -117,6 +118,7 @@ export function LoginPage({ open, onClose }: LoginPageProps) {
|
||||
onSubmit={handleLogin}
|
||||
onRememberChange={authState.handleRememberChange}
|
||||
onAutoLoginChange={authState.handleAutoLoginChange}
|
||||
onForgotPassword={() => setForgotOpen(true)}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -125,37 +127,34 @@ export function LoginPage({ open, onClose }: LoginPageProps) {
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
footer={null}
|
||||
width={440}
|
||||
centered
|
||||
destroyOnHidden={false}
|
||||
mask={{
|
||||
closable: false,
|
||||
}}
|
||||
styles={{
|
||||
body: {
|
||||
padding: '24px 32px 16px',
|
||||
background: isDark ? '#1A1730' : '#FFFFFF',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{/* 标题 */}
|
||||
<div className="text-center mb-5">
|
||||
<Text strong className="text-lg gradient-primary-text block">
|
||||
船长 · HeiXiu
|
||||
</Text>
|
||||
<Text type="secondary" className="text-xs">
|
||||
{isEnterprise ? '企业版 — 员工账号登录' : '个人版'}
|
||||
</Text>
|
||||
</div>
|
||||
<>
|
||||
<Modal
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
footer={null}
|
||||
width={440}
|
||||
centered
|
||||
destroyOnHidden={false}
|
||||
mask={{
|
||||
closable: false,
|
||||
}}
|
||||
styles={{
|
||||
body: {
|
||||
padding: '24px 32px 16px',
|
||||
background: isDark ? '#1A1730' : '#FFFFFF',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{/* 标题 */}
|
||||
<div className="text-center mb-5">
|
||||
<Text strong className="text-lg gradient-primary-text block">
|
||||
船长 · HeiXiu
|
||||
</Text>
|
||||
<Text type="secondary" className="text-xs">
|
||||
欢迎使用船长·HeiXiu
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{/* 个人版 Tab | 企业版仅登录 */}
|
||||
{isEnterprise ? (
|
||||
loginTab
|
||||
) : (
|
||||
<Tabs
|
||||
activeKey={activeTab}
|
||||
onChange={setActiveTab}
|
||||
@@ -166,7 +165,10 @@ export function LoginPage({ open, onClose }: LoginPageProps) {
|
||||
{ key: 'register', label: '注册', children: registerTab },
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
</Modal>
|
||||
|
||||
{/* 忘记密码弹窗 */}
|
||||
<ForgotPasswordModal open={forgotOpen} onClose={() => setForgotOpen(false)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,10 +13,11 @@
|
||||
import { Modal } from 'antd';
|
||||
import { SettingOutlined } from '@ant-design/icons';
|
||||
import { useAppContext } from '@/contexts/app-context';
|
||||
import { ThemeSetting } from './ThemeSetting';
|
||||
import { StoragePathSetting } from './StoragePathSetting';
|
||||
import { SystemInfoSetting } from './SystemInfoSetting';
|
||||
import { UpdateSetting } from './UpdateSetting';
|
||||
import { ThemeSetting } from './blocks/ThemeSetting';
|
||||
import { StoragePathSetting } from './blocks/StoragePathSetting';
|
||||
import { SystemInfoSetting } from './blocks/SystemInfoSetting';
|
||||
import { UpdateSetting } from './blocks/UpdateSetting';
|
||||
import { PasswordSetting } from './blocks/PasswordSetting';
|
||||
|
||||
interface SettingsPageProps {
|
||||
open: boolean;
|
||||
@@ -24,31 +25,32 @@ interface SettingsPageProps {
|
||||
}
|
||||
|
||||
export function SettingsPage({ open, onClose }: SettingsPageProps) {
|
||||
const { edition } = useAppContext();
|
||||
const { edition } = useAppContext();
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
width={480}
|
||||
onCancel={onClose}
|
||||
destroyOnHidden={true}
|
||||
mask={{ closable: false }}
|
||||
keyboard={false}
|
||||
footer={null}
|
||||
title={
|
||||
<span className="flex items-center gap-2">
|
||||
<SettingOutlined />
|
||||
设置
|
||||
</span>
|
||||
}
|
||||
styles={{ body: { padding: 0, maxHeight: '70vh', overflow: 'auto' } }}
|
||||
>
|
||||
<div className="flex flex-col gap-4 p-4">
|
||||
<ThemeSetting />
|
||||
<StoragePathSetting edition={edition} />
|
||||
<SystemInfoSetting />
|
||||
<UpdateSetting />
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
width={480}
|
||||
onCancel={onClose}
|
||||
destroyOnHidden={true}
|
||||
mask={{ closable: false }}
|
||||
keyboard={false}
|
||||
footer={null}
|
||||
title={
|
||||
<span className="flex items-center gap-2">
|
||||
<SettingOutlined />
|
||||
设置
|
||||
</span>
|
||||
}
|
||||
styles={{ body: { padding: 0, maxHeight: '70vh', overflow: 'auto' } }}
|
||||
>
|
||||
<div className="flex flex-col gap-4 p-4">
|
||||
<ThemeSetting />
|
||||
<StoragePathSetting edition={edition} />
|
||||
<SystemInfoSetting />
|
||||
<PasswordSetting />
|
||||
<UpdateSetting />
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
52
src/pages/settings/blocks/PasswordSetting.tsx
Normal file
52
src/pages/settings/blocks/PasswordSetting.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
// ============================================================
|
||||
// PasswordSetting — 账号安全区块(密码操作入口)
|
||||
//
|
||||
// 提供两种密码操作,用户自选:
|
||||
// 1. 修改密码 — 通过当前密码验证后设置新密码
|
||||
// 2. 忘记密码 — 通过手机短信验证码重置密码
|
||||
// ============================================================
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Card, Button, Typography } from 'antd';
|
||||
import { LockOutlined, PhoneOutlined } from '@ant-design/icons';
|
||||
|
||||
import { ChangePasswordModal } from '@/pages/login/components/ChangePasswordModal';
|
||||
import { ForgotPasswordModal } from '@/pages/login/components/ForgotPasswordModal';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
export function PasswordSetting() {
|
||||
const [changePwdOpen, setChangePwdOpen] = useState(false);
|
||||
const [forgotPwdOpen, setForgotPwdOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card size="small" title="账号安全" className="rounded-md!">
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* 修改密码:通过旧密码 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Text type="secondary" className="text-sm">
|
||||
通过当前密码修改
|
||||
</Text>
|
||||
<Button icon={<LockOutlined />} onClick={() => setChangePwdOpen(true)}>
|
||||
修改密码
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 忘记密码:通过手机验证码重置 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Text type="secondary" className="text-sm">
|
||||
忘记密码?通过手机验证码重置
|
||||
</Text>
|
||||
<Button icon={<PhoneOutlined />} onClick={() => setForgotPwdOpen(true)}>
|
||||
短信验证码重置
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<ChangePasswordModal open={changePwdOpen} onClose={() => setChangePwdOpen(false)} />
|
||||
<ForgotPasswordModal open={forgotPwdOpen} onClose={() => setForgotPwdOpen(false)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -159,8 +159,14 @@ function PathRow({
|
||||
}
|
||||
|
||||
export function StoragePathSetting({ edition }: StoragePathSettingProps) {
|
||||
const { outputPath, teamRepoPath, setOutputPath, setTeamRepoPath, clearOutputPath, clearTeamRepoPath } =
|
||||
useSettings();
|
||||
const {
|
||||
outputPath,
|
||||
teamRepoPath,
|
||||
setOutputPath,
|
||||
setTeamRepoPath,
|
||||
clearOutputPath,
|
||||
clearTeamRepoPath,
|
||||
} = useSettings();
|
||||
|
||||
const isEnterprise = edition === 'enterprise';
|
||||
|
||||
@@ -65,15 +65,8 @@ function renderChangelog(markdown: string) {
|
||||
}
|
||||
|
||||
export function UpdateSetting() {
|
||||
const {
|
||||
status,
|
||||
info,
|
||||
progress,
|
||||
error,
|
||||
checkForUpdates,
|
||||
downloadUpdate,
|
||||
installUpdate,
|
||||
} = useUpdater();
|
||||
const { status, info, progress, error, checkForUpdates, downloadUpdate, installUpdate } =
|
||||
useUpdater();
|
||||
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
|
||||
@@ -210,15 +203,15 @@ export function UpdateSetting() {
|
||||
title={`更新详情 — v${info?.version || ''}`}
|
||||
open={detailOpen}
|
||||
onCancel={() => setDetailOpen(false)}
|
||||
footer={
|
||||
<Button onClick={() => setDetailOpen(false)}>关闭</Button>
|
||||
}
|
||||
footer={<Button onClick={() => setDetailOpen(false)}>关闭</Button>}
|
||||
width={520}
|
||||
>
|
||||
<div className="max-h-96 overflow-y-auto py-2">
|
||||
{info?.releaseNotes
|
||||
? renderChangelog(info.releaseNotes)
|
||||
: <Text type="secondary">暂无更新详情</Text>}
|
||||
{info?.releaseNotes ? (
|
||||
renderChangelog(info.releaseNotes)
|
||||
) : (
|
||||
<Text type="secondary">暂无更新详情</Text>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
</Card>
|
||||
@@ -3,7 +3,3 @@
|
||||
// ============================================================
|
||||
|
||||
export { SettingsPage } from './SettingsPage';
|
||||
export { ThemeSetting } from './ThemeSetting';
|
||||
export { StoragePathSetting } from './StoragePathSetting';
|
||||
export { SystemInfoSetting } from './SystemInfoSetting';
|
||||
export { UpdateSetting } from './UpdateSetting';
|
||||
|
||||
@@ -8,7 +8,7 @@ import {post, get} from '../request';
|
||||
const AuthUrlObj = {
|
||||
login: '/api/v1/auth/login',
|
||||
register: '/api/v1/auth/register',
|
||||
sendSms: '/api/v1/auth/sms/send',
|
||||
sendSms: '/api/v1/auth/send-sms',
|
||||
refreshToken: '/api/v1/auth/refresh',
|
||||
userInfo: '/api/v1/auth/me',
|
||||
changePassword: '/api/v1/auth/change-password',
|
||||
|
||||
@@ -11,14 +11,14 @@ const ModelsUrlObj = {
|
||||
// ---------- 类型 ----------
|
||||
|
||||
/** 模型类别 */
|
||||
export type ModelCategory = 'img2img' | 'txt2img' | 'img2video' | 'txt2audio';
|
||||
export type ModelCategory = 'image_to_image' | 'text_to_image' | 'image_to_video' | 'text_to_audio';
|
||||
|
||||
/** 模型类别中文映射 */
|
||||
export const MODEL_CATEGORY_LABELS: Record<ModelCategory, string> = {
|
||||
img2img: '图生图',
|
||||
txt2img: '文生图',
|
||||
img2video: '图生视频',
|
||||
txt2audio: '文生音频',
|
||||
image_to_image: '图生图',
|
||||
text_to_image: '文生图',
|
||||
image_to_video: '图生视频',
|
||||
text_to_audio: '文生音频',
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user