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:
@@ -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/*"
|
||||
|
||||
@@ -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} {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>
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
139
frontend-v2/src/pages/tenant/TenantProfilePage.tsx
Normal file
139
frontend-v2/src/pages/tenant/TenantProfilePage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user