feat(admin): Complete Phase 3.5.1-3.5.4 Prompt Management System (83%)

Summary:
- Implement Prompt management infrastructure and core services
- Build admin portal frontend with light theme
- Integrate CodeMirror 6 editor for non-technical users

Phase 3.5.1: Infrastructure Setup
- Create capability_schema for Prompt storage
- Add prompt_templates and prompt_versions tables
- Add prompt:view/edit/debug/publish permissions
- Migrate RVW prompts to database (RVW_EDITORIAL, RVW_METHODOLOGY)

Phase 3.5.2: PromptService Core
- Implement gray preview logic (DRAFT for debuggers, ACTIVE for users)
- Module-level debug control (setDebugMode)
- Handlebars template rendering
- Variable extraction and validation (extractVariables, validateVariables)
- Three-level disaster recovery (database -> cache -> hardcoded fallback)

Phase 3.5.3: Management API
- 8 RESTful endpoints (/api/admin/prompts/*)
- Permission control (PROMPT_ENGINEER can edit, SUPER_ADMIN can publish)

Phase 3.5.4: Frontend Management UI
- Build admin portal architecture (AdminLayout, OrgLayout)
- Add route system (/admin/*, /org/*)
- Implement PromptListPage (filter, search, debug switch)
- Implement PromptEditor (CodeMirror 6 simplified for clinical users)
- Implement PromptEditorPage (edit, save, publish, test, version history)

Technical Details:
- Backend: 6 files, ~2044 lines (prompt.service.ts 596 lines)
- Frontend: 9 files, ~1735 lines (PromptEditorPage.tsx 399 lines)
- CodeMirror 6: Line numbers, auto-wrap, variable highlight, search, undo/redo
- Chinese-friendly: 15px font, 1.8 line-height, system fonts

Next Step: Phase 3.5.5 - Integrate RVW module with PromptService

Tested: Backend API tests passed (8/8), Frontend pending user testing
Status: Ready for Phase 3.5.5 RVW integration
This commit is contained in:
2026-01-11 21:25:16 +08:00
parent cdfbc9927a
commit 5523ef36ea
297 changed files with 15914 additions and 1266 deletions

View File

@@ -0,0 +1,367 @@
/**
* 登录页面
*
* 支持两种登录方式:
* 1. 手机号 + 密码
* 2. 手机号 + 验证码
*
* 路由:
* - /login - 通用登录(个人用户)
* - /t/{tenantCode}/login - 租户专属登录(机构用户)
*/
import { useState, useEffect } from 'react';
import { useNavigate, useParams, useLocation } from 'react-router-dom';
import { Form, Input, Button, Tabs, message, Card, Space, Typography, Alert, Modal } from 'antd';
import { PhoneOutlined, LockOutlined, SafetyOutlined, UserOutlined } from '@ant-design/icons';
import { useAuth } from '../framework/auth';
import type { ChangePasswordRequest } from '../framework/auth';
const { Title, Text, Paragraph } = Typography;
const { TabPane } = Tabs;
// 租户配置类型
interface TenantConfig {
name: string;
logo?: string;
primaryColor: string;
systemName: string;
}
// 默认配置
const DEFAULT_CONFIG: TenantConfig = {
name: 'AI临床研究平台',
primaryColor: '#1890ff',
systemName: 'AI临床研究平台',
};
export default function LoginPage() {
const navigate = useNavigate();
const location = useLocation();
const { tenantCode } = useParams<{ tenantCode?: string }>();
const {
loginWithPassword,
loginWithCode,
sendVerificationCode,
isLoading,
error,
user,
changePassword,
} = useAuth();
const [form] = Form.useForm();
const [activeTab, setActiveTab] = useState<'password' | 'code'>('password');
const [countdown, setCountdown] = useState(0);
const [tenantConfig, setTenantConfig] = useState<TenantConfig>(DEFAULT_CONFIG);
const [showPasswordModal, setShowPasswordModal] = useState(false);
const [passwordForm] = Form.useForm();
// 获取租户配置
useEffect(() => {
if (tenantCode) {
// TODO: 从API获取租户配置
fetch(`/api/v1/public/tenant-config/${tenantCode}`)
.then(res => res.json())
.then(data => {
if (data.success && data.data) {
setTenantConfig(data.data);
}
})
.catch(() => {
// 使用默认配置
});
}
}, [tenantCode]);
// 验证码倒计时
useEffect(() => {
if (countdown > 0) {
const timer = setTimeout(() => setCountdown(countdown - 1), 1000);
return () => clearTimeout(timer);
}
}, [countdown]);
// 登录成功后检查是否需要修改密码
useEffect(() => {
if (user && user.isDefaultPassword) {
setShowPasswordModal(true);
} else if (user) {
// 登录成功,跳转
const from = (location.state as any)?.from?.pathname || '/';
navigate(from, { replace: true });
}
}, [user, navigate, location]);
// 发送验证码
const handleSendCode = async () => {
try {
const phone = form.getFieldValue('phone');
if (!phone) {
message.error('请输入手机号');
return;
}
if (!/^1[3-9]\d{9}$/.test(phone)) {
message.error('请输入正确的手机号');
return;
}
await sendVerificationCode(phone, 'LOGIN');
message.success('验证码已发送');
setCountdown(60);
} catch (err) {
message.error(err instanceof Error ? err.message : '发送失败');
}
};
// 提交登录
const handleSubmit = async (values: any) => {
try {
if (activeTab === 'password') {
await loginWithPassword(values.phone, values.password);
} else {
await loginWithCode(values.phone, values.code);
}
message.success('登录成功');
} catch (err) {
message.error(err instanceof Error ? err.message : '登录失败');
}
};
// 修改密码
const handleChangePassword = async (values: ChangePasswordRequest) => {
try {
await changePassword(values);
message.success('密码修改成功');
setShowPasswordModal(false);
// 跳转到首页
const from = (location.state as any)?.from?.pathname || '/';
navigate(from, { replace: true });
} catch (err) {
message.error(err instanceof Error ? err.message : '修改密码失败');
}
};
// 跳过修改密码
const handleSkipPassword = () => {
setShowPasswordModal(false);
const from = (location.state as any)?.from?.pathname || '/';
navigate(from, { replace: true });
};
return (
<div style={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: `linear-gradient(135deg, ${tenantConfig.primaryColor}15 0%, ${tenantConfig.primaryColor}05 100%)`,
padding: '20px',
}}>
<Card
style={{
width: '100%',
maxWidth: 420,
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.08)',
borderRadius: 16,
}}
bodyStyle={{ padding: '40px 32px' }}
>
{/* Logo和标题 */}
<div style={{ textAlign: 'center', marginBottom: 32 }}>
{tenantConfig.logo ? (
<img
src={tenantConfig.logo}
alt={tenantConfig.name}
style={{ height: 48, marginBottom: 16 }}
/>
) : (
<div style={{
width: 64,
height: 64,
borderRadius: 16,
background: `linear-gradient(135deg, ${tenantConfig.primaryColor} 0%, ${tenantConfig.primaryColor}dd 100%)`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
margin: '0 auto 16px',
}}>
<UserOutlined style={{ fontSize: 32, color: '#fff' }} />
</div>
)}
<Title level={3} style={{ margin: 0, marginBottom: 8 }}>
{tenantConfig.systemName}
</Title>
{tenantCode && (
<Text type="secondary">{tenantConfig.name}</Text>
)}
</div>
{/* 错误提示 */}
{error && (
<Alert
message={error}
type="error"
showIcon
style={{ marginBottom: 24 }}
closable
/>
)}
{/* 登录表单 */}
<Form
form={form}
onFinish={handleSubmit}
layout="vertical"
size="large"
>
<Tabs
activeKey={activeTab}
onChange={(key) => setActiveTab(key as 'password' | 'code')}
centered
>
<TabPane tab="密码登录" key="password" />
<TabPane tab="验证码登录" key="code" />
</Tabs>
<Form.Item
name="phone"
rules={[
{ required: true, message: '请输入手机号' },
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号' },
]}
>
<Input
prefix={<PhoneOutlined />}
placeholder="手机号"
maxLength={11}
/>
</Form.Item>
{activeTab === 'password' ? (
<Form.Item
name="password"
rules={[
{ required: true, message: '请输入密码' },
{ min: 6, message: '密码至少6位' },
]}
>
<Input.Password
prefix={<LockOutlined />}
placeholder="密码"
/>
</Form.Item>
) : (
<Form.Item
name="code"
rules={[
{ required: true, message: '请输入验证码' },
{ len: 6, message: '验证码为6位数字' },
]}
>
<Space.Compact style={{ width: '100%' }}>
<Input
prefix={<SafetyOutlined />}
placeholder="验证码"
maxLength={6}
style={{ flex: 1 }}
/>
<Button
onClick={handleSendCode}
disabled={countdown > 0}
style={{ width: 120 }}
>
{countdown > 0 ? `${countdown}s后重发` : '获取验证码'}
</Button>
</Space.Compact>
</Form.Item>
)}
<Form.Item style={{ marginBottom: 16 }}>
<Button
type="primary"
htmlType="submit"
block
loading={isLoading}
style={{
height: 44,
borderRadius: 8,
background: tenantConfig.primaryColor,
}}
>
</Button>
</Form.Item>
</Form>
{/* 底部信息 */}
<div style={{ textAlign: 'center' }}>
<Text type="secondary" style={{ fontSize: 12 }}>
© 2026 · AI临床研究平台
</Text>
</div>
</Card>
{/* 修改默认密码弹窗 */}
<Modal
title="修改默认密码"
open={showPasswordModal}
footer={null}
closable={false}
maskClosable={false}
>
<Paragraph type="warning">
使
</Paragraph>
<Form
form={passwordForm}
onFinish={handleChangePassword}
layout="vertical"
>
<Form.Item
name="newPassword"
label="新密码"
rules={[
{ required: true, message: '请输入新密码' },
{ min: 6, message: '密码至少6位' },
]}
>
<Input.Password placeholder="请输入新密码" />
</Form.Item>
<Form.Item
name="confirmPassword"
label="确认密码"
dependencies={['newPassword']}
rules={[
{ required: true, message: '请确认新密码' },
({ getFieldValue }) => ({
validator(_, value) {
if (!value || getFieldValue('newPassword') === value) {
return Promise.resolve();
}
return Promise.reject(new Error('两次输入的密码不一致'));
},
}),
]}
>
<Input.Password placeholder="请再次输入新密码" />
</Form.Item>
<Form.Item style={{ marginBottom: 0 }}>
<Space style={{ width: '100%', justifyContent: 'flex-end' }}>
<Button onClick={handleSkipPassword}>
</Button>
<Button type="primary" htmlType="submit" loading={isLoading}>
</Button>
</Space>
</Form.Item>
</Form>
</Modal>
</div>
);
}