feat(rvw): complete tenant portal polish and ops assignment fixes

Finalize RVW tenant portal UX and reliability updates by aligning login/profile interactions, stabilizing SMS code sends in weak-network scenarios, and fixing multi-tenant assignment payload handling to prevent runtime errors. Refresh RVW status and deployment checklist docs with SAE routing, frontend image build, and post-release validation guidance.

Made-with: Cursor
This commit is contained in:
2026-03-15 18:22:01 +08:00
parent 83e395824b
commit 707f783229
20 changed files with 498 additions and 153 deletions

View File

@@ -43,6 +43,7 @@ import JournalConfigListPage from './pages/admin/journal-configs/JournalConfigLi
import JournalConfigDetailPage from './pages/admin/journal-configs/JournalConfigDetailPage'
// 个人中心页面
import ProfilePage from './pages/user/ProfilePage'
import TenantProfilePage from './pages/tenant/TenantProfilePage'
/**
* 应用根组件
@@ -199,6 +200,15 @@ function App() {
<Route path="/:tenantSlug" element={<TenantPortalLayout />}>
{/* /:tenantSlug → 自动跳转到 /:tenantSlug/rvw */}
<Route index element={<Navigate to="rvw" replace />} />
{/* /:tenantSlug/profile → 期刊租户专属个人中心 */}
<Route
path="profile"
element={
<RouteGuard requiredModule="RVW" moduleName="个人中心">
<TenantProfilePage />
</RouteGuard>
}
/>
{/* /:tenantSlug/rvw/* → RouteGuard认证+模块权限)→ RVW 模块 */}
<Route
path="rvw/*"

View File

@@ -6,8 +6,10 @@
*/
import { useState, useEffect } from 'react';
import { useParams, Navigate, Outlet } from 'react-router-dom';
import { Spin } from 'antd';
import { useParams, Navigate, Outlet, useNavigate } from 'react-router-dom';
import { Spin, Dropdown, Avatar } from 'antd';
import type { MenuProps } from 'antd';
import { UserOutlined, LogoutOutlined } from '@ant-design/icons';
import { useAuth } from '../auth';
interface TenantBrand {
@@ -20,6 +22,7 @@ interface TenantBrand {
export default function TenantPortalLayout() {
const { tenantSlug } = useParams<{ tenantSlug: string }>();
const { logout, user } = useAuth();
const navigate = useNavigate();
const [brand, setBrand] = useState<TenantBrand | null>(null);
const [loading, setLoading] = useState(true);
@@ -38,6 +41,16 @@ export default function TenantPortalLayout() {
.finally(() => setLoading(false));
}, [tenantSlug]);
// 期刊门户标签页标题统一显示“AI智能审稿平台”
// 注意:必须放在所有 early return 之前,保持 Hooks 调用顺序稳定
useEffect(() => {
const prevTitle = document.title;
document.title = 'AI智能审稿平台';
return () => {
document.title = prevTitle;
};
}, []);
if (loading) {
return (
<div className="h-screen flex items-center justify-center bg-gray-50">
@@ -51,9 +64,15 @@ export default function TenantPortalLayout() {
// ── 品牌信息 ─────────────────────────────────────────────────
const journalName = brand?.name || tenantSlug || '';
// 期刊门户固定副标题为"智能审稿工作台",除非 API 返回了更具体的名称
const systemName = brand?.systemName && brand.systemName !== 'AI临床研究平台'
// 且避免与期刊名重复显示(如“中华脑血管病杂志 中华脑血管病杂志”)
const systemName = brand?.systemName
&& brand.systemName !== 'AI临床研究平台'
&& brand.systemName !== journalName
? brand.systemName
: '智能审稿工作台';
const headerTitle = journalName.includes(systemName)
? journalName
: `${journalName} ${systemName}`;
// 取期刊名首字母大写缩写最多2个字母用于 Header 方块 Logo
const abbr = journalName
.split(/\s+/)
@@ -66,6 +85,32 @@ export default function TenantPortalLayout() {
window.location.href = `/${tenantSlug}/login`;
};
const userMenuItems: MenuProps['items'] = [
{
key: 'profile',
icon: <UserOutlined />,
label: '个人中心',
},
{ type: 'divider' },
{
key: 'logout',
icon: <LogoutOutlined />,
label: '退出登录',
danger: true,
},
];
const handleUserMenuClick: MenuProps['onClick'] = ({ key }) => {
if (key === 'logout') {
void handleLogout();
return;
}
if (key === 'profile') {
navigate(`/${tenantSlug}/profile`);
return;
}
};
return (
<div className="h-screen w-full flex flex-col overflow-hidden bg-gray-50 font-sans antialiased">
@@ -82,43 +127,32 @@ export default function TenantPortalLayout() {
</div>
{/* 单行大标题:期刊名 + 固定后缀,完全复制原型图 h1 风格 */}
<h1 className="text-base font-bold text-gray-800 whitespace-nowrap">
{journalName}&nbsp;{systemName}
{headerTitle}
</h1>
</div>
{/* 右侧:原型图风格的边框容器(铃铛 + 用户) */}
<div className="h-10 flex items-center rounded-md border border-gray-200 bg-white px-2 shadow-[0_1px_2px_rgba(15,23,42,0.04)]">
<button
type="button"
className="w-8 h-8 inline-flex items-center justify-center text-gray-500 hover:text-gray-700 transition-colors rounded-md hover:bg-gray-50"
aria-label="通知"
>
<svg className="w-[18px] h-[18px]" fill="none" stroke="currentColor" strokeWidth={1.9} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round"
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
</svg>
</button>
<div className="w-px h-5 bg-gray-200 mx-2" />
<button
type="button"
onClick={handleLogout}
className="flex items-center gap-2 pr-1 hover:opacity-80 transition-opacity"
>
<div
className="w-[30px] h-[30px] rounded-full flex items-center justify-center text-xs font-semibold text-white select-none shrink-0 shadow-sm"
style={{ background: 'linear-gradient(135deg, #38bdf8 0%, #0369a1 100%)' }}
{/* 右侧:原型图风格的用户下拉 */}
<div className="h-full flex items-center">
<Dropdown menu={{ items: userMenuItems, onClick: handleUserMenuClick }} placement="bottomRight" trigger={['click']}>
<button
type="button"
className="flex items-center gap-2 px-2 py-1 hover:bg-gray-50 rounded-lg transition-colors border border-transparent hover:border-gray-200"
>
{user?.name?.[0] ?? 'U'}
</div>
<span className="text-sm font-medium text-gray-700 whitespace-nowrap leading-none">
{user?.name ?? '用户'}
</span>
<svg className="w-3 h-3 text-gray-400 shrink-0" fill="none" stroke="currentColor" strokeWidth={2.5} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</button>
<Avatar
src={user?.avatarUrl}
icon={!user?.avatarUrl && <UserOutlined />}
size={32}
className="shadow-sm"
style={{ backgroundColor: '#f3f4f6', color: '#6b7280' }}
/>
<span className="text-sm font-medium text-gray-700 whitespace-nowrap">
{user?.name ?? '责编'}
</span>
<svg className="w-3 h-3 text-gray-400 shrink-0" fill="none" stroke="currentColor" strokeWidth={2.5} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</button>
</Dropdown>
</div>
</header>

View File

@@ -74,10 +74,16 @@ const AssignTenantModal: React.FC<AssignTenantModalProps> = ({
const handleSubmit = async (values: any) => {
setSubmitting(true);
try {
const selectedModules = Array.isArray(values.allowedModules)
? values.allowedModules
: (typeof values.allowedModules === 'string' && values.allowedModules.trim()
? [values.allowedModules.trim()]
: []);
await userApi.assignTenantToUser(userId, {
tenantId: values.tenantId,
role: values.role,
allowedModules: values.allowedModules?.length > 0 ? values.allowedModules : undefined,
allowedModules: selectedModules.length > 0 ? selectedModules : undefined,
});
message.success('分配成功');
onSuccess();

View File

@@ -22,23 +22,6 @@ interface ForensicsReportProps {
data: ForensicsResult;
}
// 统计方法英文 -> 中文映射
const METHOD_NAMES: Record<string, string> = {
'chi-square': '卡方检验',
'mann-whitney': 'Mann-Whitney U 检验',
't-test': 'T 检验',
'anova': '方差分析',
'fisher': 'Fisher 精确检验',
'wilcoxon': 'Wilcoxon 检验',
'kruskal-wallis': 'Kruskal-Wallis 检验',
'mcnemar': 'McNemar 检验',
'correlation': '相关性分析',
'regression': '回归分析',
'logistic': 'Logistic 回归',
'cox': 'Cox 回归',
'kaplan-meier': 'Kaplan-Meier 生存分析',
};
// 问题类型代码 -> 中文描述映射
const ISSUE_TYPE_LABELS: Record<string, string> = {
// L1 算术验证
@@ -79,7 +62,6 @@ export default function ForensicsReport({ data }: ForensicsReportProps) {
// 防御性检查:确保所有数组和对象存在
const tables = data?.tables || [];
const issues = data?.issues || [];
const methods = data?.methods || [];
const summary = data?.summary || { totalTables: 0, totalIssues: 0, errorCount: 0, warningCount: 0 };
const llmTableReports = data?.llmTableReports || {};
@@ -95,11 +77,6 @@ export default function ForensicsReport({ data }: ForensicsReportProps) {
return tableIdToCaption[tableId] || tableId;
};
// 翻译统计方法名称为中文
const translateMethod = (method: string): string => {
return METHOD_NAMES[method.toLowerCase()] || method;
};
// 翻译问题类型代码为中文
const translateIssueType = (type: string): string => {
return ISSUE_TYPE_LABELS[type] || type;
@@ -144,7 +121,7 @@ export default function ForensicsReport({ data }: ForensicsReportProps) {
if (summary.warningCount > 0) {
return { label: '需关注', color: 'text-amber-600', bg: 'bg-amber-500', icon: AlertTriangle };
}
return { label: '数据正常', color: 'text-green-600', bg: 'bg-green-500', icon: CheckCircle };
return { label: '分析完成', color: 'text-blue-600', bg: 'bg-blue-500', icon: CheckCircle };
};
const status = getOverallStatus();
@@ -179,8 +156,7 @@ export default function ForensicsReport({ data }: ForensicsReportProps) {
<h3 className="font-bold text-lg text-slate-800"></h3>
</div>
<p className="text-slate-600 text-sm leading-relaxed mb-4">
{summary.totalTables} {summary.totalIssues}
{methods.length > 0 && `,识别到统计方法:${methods.map(translateMethod).join('、')}`}
{summary.totalTables} AI
</p>
{/* 统计指标 */}
@@ -201,12 +177,6 @@ export default function ForensicsReport({ data }: ForensicsReportProps) {
<span className="text-sm font-medium text-amber-700">{summary.warningCount} </span>
</div>
)}
{summary.errorCount === 0 && summary.warningCount === 0 && (
<div className="flex items-center gap-2 px-3 py-1.5 bg-green-50 rounded-lg border border-green-100">
<CheckCircle className="w-4 h-4 text-green-500" />
<span className="text-sm font-medium text-green-700"></span>
</div>
)}
</div>
</div>
</div>
@@ -351,8 +321,8 @@ function TableCard({ table, expanded, onToggle, highlightedCell, llmTableReport
</span>
)}
{!hasIssues && (
<span className="px-2 py-1 bg-green-100 text-green-700 text-xs rounded-md font-medium">
<span className="px-2 py-1 bg-blue-100 text-blue-700 text-xs rounded-md font-medium">
</span>
)}
{expanded ? (

View File

@@ -24,7 +24,6 @@ export default function ReportDetail({ report, onBack }: ReportDetailProps) {
// 检查文件格式:非 .docx 文件无法进行数据验证
const fileName = report.fileName || '';
const isDocx = fileName.toLowerCase().endsWith('.docx');
const isPdf = fileName.toLowerCase().endsWith('.pdf');
const isDoc = fileName.toLowerCase().endsWith('.doc');
const showNoForensicsTip = !hasForensics && (hasEditorial || hasMethodology) && (isPdf || isDoc);
@@ -115,7 +114,7 @@ export default function ReportDetail({ report, onBack }: ReportDetailProps) {
: 'font-medium text-slate-500 hover:text-slate-700'
}`}
>
({report.forensicsResult?.summary.totalIssues || 0})
</button>
)}
</div>

View File

@@ -22,6 +22,19 @@ interface TaskDetailProps {
onBack: () => void;
}
function markdownToPlainLines(markdown: string, maxLines = 80): string[] {
return (markdown || '')
.replace(/```[\s\S]*?```/g, ' ')
.replace(/^#{1,6}\s*/gm, '')
.replace(/\*\*(.*?)\*\*/g, '$1')
.replace(/\*(.*?)\*/g, '$1')
.replace(/\[(.*?)\]\((.*?)\)/g, '$1 ($2)')
.split('\n')
.map(line => line.replace(/^\s*[-*+]\s+/, '').trim())
.filter(Boolean)
.slice(0, maxLines);
}
// 状态信息映射
const STATUS_INFO: Record<TaskStatus, { label: string; color: string; icon: any }> = {
pending: { label: '等待开始', color: 'text-slate-500', icon: Clock },
@@ -415,6 +428,7 @@ export default function TaskDetail({ task: initialTask, jobId, onBack }: TaskDet
// 数据验证
if (report.forensicsResult) {
const forensics = report.forensicsResult;
children.push(
new Paragraph({
text: nextSectionTitle('数据验证'),
@@ -423,17 +437,22 @@ export default function TaskDetail({ task: initialTask, jobId, onBack }: TaskDet
})
);
const summary = report.forensicsResult.summary;
const summary = forensics.summary;
children.push(
new Paragraph({
text: `识别表格 ${summary.totalTables} 个;发现问题 ${summary.totalIssues} 个(错误 ${summary.errorCount},警告 ${summary.warningCount}`,
text: `检测到 ${summary.totalTables} 张表格,以下为逐表提取结果与 AI 分析`,
spacing: { after: 160 },
})
);
const llmFallbackSections = (forensics.llmReport || '')
.split(/(?=^##\s*表\d+[:])/m)
.map(section => section.trim())
.filter(Boolean);
// 导出表格明细(按 forensicsResult.tables 渲染)
if (report.forensicsResult.tables.length > 0) {
report.forensicsResult.tables.slice(0, 20).forEach((tableItem, tableIndex) => {
if (forensics.tables.length > 0) {
forensics.tables.slice(0, 20).forEach((tableItem, tableIndex) => {
children.push(
new Paragraph({
text: `${tableIndex + 1}${tableItem.caption || tableItem.id || '未命名表格'}`,
@@ -483,6 +502,27 @@ export default function TaskDetail({ task: initialTask, jobId, onBack }: TaskDet
);
}
const llmFromMap = forensics.llmTableReports?.[tableItem.id];
const llmFromFallback = llmFallbackSections[tableIndex];
const llmText = llmFromMap || llmFromFallback;
if (llmText) {
children.push(
new Paragraph({
children: [new TextRun({ text: 'AI 逐表分析:', bold: true })],
spacing: { before: 80, after: 40 },
})
);
markdownToPlainLines(llmText, 120).forEach((line) => {
children.push(
new Paragraph({
text: `${line}`,
indent: { left: 720 },
spacing: { after: 40 },
})
);
});
}
const tableIssues = (tableItem.issues || []).slice(0, 50);
if (tableIssues.length > 0) {
children.push(
@@ -504,17 +544,17 @@ export default function TaskDetail({ task: initialTask, jobId, onBack }: TaskDet
}
});
if (report.forensicsResult.tables.length > 20) {
if (forensics.tables.length > 20) {
children.push(
new Paragraph({
text: `(其余 ${report.forensicsResult.tables.length - 20} 张表格已省略)`,
text: `(其余 ${forensics.tables.length - 20} 张表格已省略)`,
spacing: { before: 80, after: 80 },
})
);
}
}
if (report.forensicsResult.issues.length > 0) {
if (forensics.issues.length > 0) {
children.push(
new Paragraph({
children: [new TextRun({ text: '问题清单:', bold: true })],
@@ -522,7 +562,7 @@ export default function TaskDetail({ task: initialTask, jobId, onBack }: TaskDet
})
);
report.forensicsResult.issues.slice(0, 120).forEach((issue) => {
forensics.issues.slice(0, 120).forEach((issue) => {
const level = issue.severity === 'ERROR' ? '错误' : issue.severity === 'WARNING' ? '警告' : '提示';
children.push(
new Paragraph({
@@ -533,23 +573,15 @@ export default function TaskDetail({ task: initialTask, jobId, onBack }: TaskDet
);
});
if (report.forensicsResult.issues.length > 120) {
if (forensics.issues.length > 120) {
children.push(
new Paragraph({
text: `(其余 ${report.forensicsResult.issues.length - 120} 条问题已省略)`,
text: `(其余 ${forensics.issues.length - 120} 条问题已省略)`,
indent: { left: 720 },
spacing: { after: 100 },
})
);
}
} else {
children.push(
new Paragraph({
children: [new TextRun({ text: '✓ 未发现数据一致性问题', color: '006600' })],
indent: { left: 720 },
spacing: { after: 100 },
})
);
}
}
@@ -825,7 +857,7 @@ export default function TaskDetail({ task: initialTask, jobId, onBack }: TaskDet
: 'font-medium text-slate-500 hover:text-slate-700'
}`}
>
({displayReport.forensicsResult.summary.totalIssues || 0})
</button>
)}
{displayReport.clinicalReview && (

View File

@@ -115,27 +115,6 @@ function StatusIcon({ status }: { status: DimStatus }) {
return <IconSpinner />;
}
// 综合分数圆形徽章
function ScoreBadge({ score }: { score?: number }) {
if (!score || score === 0) {
return (
<div className="w-9 h-9 rounded-full border-4 border-gray-100 bg-gray-50 flex items-center justify-center">
<span className="text-[10px] text-gray-400 font-bold">-</span>
</div>
);
}
const cls = score >= 80
? 'border-green-200 text-green-600 bg-green-50'
: score >= 60
? 'border-amber-200 text-amber-600 bg-amber-50'
: 'border-red-200 text-red-600 bg-red-50';
return (
<div className={`w-9 h-9 rounded-full border-4 flex items-center justify-center font-bold text-xs ${cls}`}>
{score}
</div>
);
}
// 状态 Badge胶囊标签
function StatusBadge({ status }: { status: ReviewTask['status'] }) {
const map: Record<ReviewTask['status'], { label: string; cls: string }> = {
@@ -402,7 +381,6 @@ export default function TenantDashboard() {
<tr>
<th className="px-6 py-3 font-medium"></th>
<th className="px-6 py-3 font-medium"></th>
<th className="px-6 py-3 font-medium"></th>
<th className="px-6 py-3 font-medium text-center">稿</th>
<th className="px-6 py-3 font-medium text-center"></th>
<th className="px-6 py-3 font-medium text-center"></th>
@@ -443,11 +421,6 @@ export default function TenantDashboard() {
})}
</td>
{/* 综合评分圆圈 */}
<td className="px-6 py-4">
<ScoreBadge score={task.overallScore} />
</td>
{/* 四维状态图标 */}
<td className="px-6 py-4 text-center">
<div className="flex justify-center">

View File

@@ -65,6 +65,7 @@ export default function LoginPage() {
const [form] = Form.useForm();
const [activeTab, setActiveTab] = useState<'password' | 'code'>('password');
const [countdown, setCountdown] = useState(0);
const [isSendingCode, setIsSendingCode] = useState(false);
const [tenantConfig, setTenantConfig] = useState<TenantConfig>(DEFAULT_CONFIG);
const [showPasswordModal, setShowPasswordModal] = useState(false);
const [passwordForm] = Form.useForm();
@@ -199,6 +200,7 @@ export default function LoginPage() {
// 发送验证码
const handleSendCode = async () => {
if (isSendingCode || countdown > 0) return;
try {
const phone = form.getFieldValue('phone');
if (!phone) {
@@ -210,11 +212,14 @@ export default function LoginPage() {
return;
}
setIsSendingCode(true);
await sendVerificationCode(phone, 'LOGIN');
message.success('验证码已发送');
setCountdown(60);
} catch (err) {
message.error(err instanceof Error ? err.message : '发送失败');
} finally {
setIsSendingCode(false);
}
};
@@ -381,10 +386,10 @@ export default function LoginPage() {
/>
<Button
onClick={handleSendCode}
disabled={countdown > 0}
disabled={countdown > 0 || isSendingCode}
style={{ width: 120 }}
>
{countdown > 0 ? `${countdown}s后重发` : '获取验证码'}
{isSendingCode ? '发送中...' : (countdown > 0 ? `${countdown}s后重发` : '获取验证码')}
</Button>
</Space.Compact>
</Form.Item>

View File

@@ -64,6 +64,7 @@ export default function TenantLoginPage() {
const [password, setPassword] = useState('');
const [code, setCode] = useState('');
const [countdown, setCountdown] = useState(0);
const [isSendingCode, setIsSendingCode] = useState(false);
const [errorMsg, setErrorMsg] = useState('');
const [showPwdModal, setShowPwdModal] = useState(false);
const [newPwd, setNewPwd] = useState('');
@@ -85,6 +86,15 @@ export default function TenantLoginPage() {
return () => clearTimeout(t);
}, [countdown]);
// 期刊登录页标签页标题统一显示“AI智能审稿平台”
useEffect(() => {
const prevTitle = document.title;
document.title = 'AI智能审稿平台';
return () => {
document.title = prevTitle;
};
}, []);
// ── 登录后跳转路径 ────────────────────────────────────────────
const getRedirect = useCallback(() => {
const params = new URLSearchParams(location.search);
@@ -101,12 +111,18 @@ export default function TenantLoginPage() {
// ── 发送验证码 ────────────────────────────────────────────────
const handleSendCode = async () => {
if (isSendingCode || countdown > 0) return;
if (!/^1[3-9]\d{9}$/.test(phone)) { setErrorMsg('请输入正确的手机号'); return; }
try {
setIsSendingCode(true);
await sendVerificationCode(phone, 'LOGIN');
setCountdown(60);
setErrorMsg('');
} catch (e: any) { setErrorMsg(e.message || '发送失败'); }
} catch (e: any) {
setErrorMsg(e.message || '发送失败');
} finally {
setIsSendingCode(false);
}
};
// ── 登录提交 ──────────────────────────────────────────────────
@@ -129,7 +145,11 @@ export default function TenantLoginPage() {
if (newPwd.length < 6) { setErrorMsg('密码至少 6 位'); return; }
if (newPwd !== confirmPwd) { setErrorMsg('两次输入的密码不一致'); return; }
try {
await changePassword({ oldPassword: password, newPassword: newPwd } as ChangePasswordRequest);
await changePassword({
oldPassword: password,
newPassword: newPwd,
confirmPassword: confirmPwd,
} as ChangePasswordRequest);
setShowPwdModal(false);
navigate(getRedirect(), { replace: true });
} catch (e: any) { setErrorMsg(e.message || '修改失败'); }
@@ -172,10 +192,10 @@ export default function TenantLoginPage() {
)}
</div>
<h1 className="text-3xl font-bold mb-3 leading-snug">{journalName}</h1>
<p className="text-lg text-sky-200 mb-8">{systemName} (Editor Portal)</p>
<h1 className="text-4xl font-bold mb-4 leading-snug">{journalName}</h1>
<p className="text-xl text-sky-100 mb-8">{systemName} (Editor Portal)</p>
<div className="inline-block px-6 py-2 border border-sky-400 rounded-full text-sm text-sky-200">
<div className="inline-block px-6 py-2 border border-sky-400 rounded-full text-sm text-sky-100 bg-sky-800/30 backdrop-blur-sm">
</div>
</div>
@@ -238,6 +258,7 @@ export default function TenantLoginPage() {
value={password}
onChange={e => setPassword(e.target.value)}
placeholder="请输入密码"
autoComplete="current-password"
required
className={inputCls}
/>
@@ -261,15 +282,15 @@ export default function TenantLoginPage() {
<button
type="button"
onClick={handleSendCode}
disabled={countdown > 0 || isLoading}
disabled={countdown > 0 || isLoading || isSendingCode}
className={[
'px-4 py-2.5 rounded-lg text-sm font-medium border transition whitespace-nowrap',
countdown > 0
countdown > 0 || isSendingCode
? 'border-gray-300 text-gray-400 cursor-not-allowed'
: 'border-[#0284c7] text-[#0284c7] hover:bg-sky-50',
].join(' ')}
>
{countdown > 0 ? `${countdown}s 后重发` : '发送验证码'}
{isSendingCode ? '发送中...' : (countdown > 0 ? `${countdown}s 后重发` : '发送验证码')}
</button>
</div>
</div>
@@ -296,7 +317,7 @@ export default function TenantLoginPage() {
'w-full flex items-center justify-center gap-2',
'bg-[#0284c7] text-white font-medium py-2.5 rounded-lg',
'hover:bg-[#0369a1] transition-colors',
'shadow-[0_4px_14px_rgba(2,132,199,0.35)]',
'shadow-lg shadow-sky-500/30',
'disabled:opacity-60 disabled:cursor-not-allowed',
].join(' ')}
>
@@ -324,7 +345,7 @@ export default function TenantLoginPage() {
<label className="block text-sm font-medium text-gray-700 mb-1"></label>
<input
type="password" value={newPwd} onChange={e => setNewPwd(e.target.value)}
required minLength={6} placeholder="至少 6 位"
required minLength={6} placeholder="至少 6 位" autoComplete="new-password"
className={inputCls}
/>
</div>
@@ -332,7 +353,7 @@ export default function TenantLoginPage() {
<label className="block text-sm font-medium text-gray-700 mb-1"></label>
<input
type="password" value={confirmPwd} onChange={e => setConfirmPwd(e.target.value)}
required placeholder="再次输入新密码"
required placeholder="再次输入新密码" autoComplete="new-password"
className={inputCls}
/>
</div>

View File

@@ -0,0 +1,139 @@
import { useState } from 'react';
import { useAuth } from '../../framework/auth';
import { useNavigate, useParams } from 'react-router-dom';
import { ArrowLeftOutlined, UserOutlined } from '@ant-design/icons';
import { Avatar, message } from 'antd';
function maskPhone(phone?: string) {
if (!phone || phone.length !== 11) return phone || '-';
return `${phone.slice(0, 3)}****${phone.slice(7)}`;
}
export default function TenantProfilePage() {
const { user, changePassword } = useAuth();
const navigate = useNavigate();
const { tenantSlug } = useParams<{ tenantSlug: string }>();
const [oldPassword, setOldPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [saving, setSaving] = useState(false);
const handleSubmit = async () => {
if (newPassword.length < 6) {
message.error('新密码至少 6 位');
return;
}
if (newPassword !== confirmPassword) {
message.error('两次输入的新密码不一致');
return;
}
try {
setSaving(true);
await changePassword({
oldPassword: oldPassword || undefined,
newPassword,
confirmPassword,
});
message.success('密码修改成功');
setOldPassword('');
setNewPassword('');
setConfirmPassword('');
} catch (error: any) {
message.error(error?.message || '密码修改失败,请重试');
} finally {
setSaving(false);
}
};
return (
<div className="h-full overflow-auto bg-gray-50 p-6">
<div className="max-w-3xl mx-auto">
{/* 顶部导航栏 (模拟原型图详情页头部风格) */}
<div className="flex items-center mb-6">
<button
onClick={() => navigate(`/${tenantSlug}/rvw`)}
className="mr-4 w-8 h-8 rounded-full hover:bg-gray-200 flex items-center justify-center transition text-gray-500 hover:text-gray-800"
>
<ArrowLeftOutlined />
</button>
<h1 className="text-xl font-bold text-gray-800"></h1>
</div>
{/* 卡片容器 */}
<div className="bg-white rounded-xl border border-gray-200 shadow-sm p-8">
{/* 用户信息区 */}
<div className="flex items-center mb-8 pb-8 border-b border-gray-100">
<Avatar
size={64}
src={user?.avatarUrl}
icon={<UserOutlined />}
className="bg-gray-100 text-gray-400"
/>
<div className="ml-4">
<h2 className="text-lg font-bold text-gray-900">{user?.name || '用户'}</h2>
<p className="text-gray-500 text-sm mt-1">{maskPhone(user?.phone)}</p>
</div>
<div className="ml-auto">
<span className="px-3 py-1 bg-green-50 text-green-700 text-xs font-medium rounded-full border border-green-200">
</span>
</div>
</div>
{/* 修改密码表单区 */}
<div>
<h3 className="text-base font-bold text-gray-800 mb-6"></h3>
<div className="max-w-md space-y-5">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1.5"></label>
<input
type="password"
className="w-full px-4 py-2.5 bg-white border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-sky-400 focus:border-sky-500 transition-all"
value={oldPassword}
onChange={(e) => setOldPassword(e.target.value)}
placeholder="请输入当前密码"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1.5"></label>
<input
type="password"
className="w-full px-4 py-2.5 bg-white border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-sky-400 focus:border-sky-500 transition-all"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder="至少 6 位"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1.5"></label>
<input
type="password"
className="w-full px-4 py-2.5 bg-white border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-sky-400 focus:border-sky-500 transition-all"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="请再次输入新密码"
/>
</div>
<div className="pt-2">
<button
onClick={handleSubmit}
disabled={saving}
className="px-6 py-2.5 bg-[#0284c7] hover:bg-[#0369a1] text-white text-sm font-medium rounded-lg shadow-sm disabled:opacity-60 disabled:cursor-not-allowed transition-all"
>
{saving ? '保存中...' : '确认修改'}
</button>
</div>
</div>
</div>
</div>
</div>
</div>
);
}