Files
hms/apps/web/src/pages/PluginMarket.tsx
iven 83fe89cbcd
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
fix: 全系统审计问题修复 — 安全/数据完整性/功能缺陷/UX (Phase 1-5)
Phase 1 安全热修复:
- P0-1: /uploads 文件服务添加 JWT 认证中间件(支持 header + query param)
- P0-2: analytics/batch 路由从 public 移到 protected_routes
- P0-3: plugin engine SQL 注入修复(format! → 参数化查询)
- P0-new: stats_service compute_avg_field 字段白名单 + FLOAT8 类型转换

Phase 2 数据完整性:
- P0-4: 组织删除级联检查(添加部门存在性校验)
- P0-5: 部门删除级联检查(添加岗位 + 用户存在性校验)
- P0-8: workflow on_tenant_deleted 实现 5 实体批量删除
- P0-7: 并行网关 race condition 修复(consumed → completed 原子转换)

Phase 3 P1 后端 Bug:
- P1-12: plugin host 表名消毒(使用 sanitize_identifier)
- P1-10: workflow deprecated 状态转换(published → deprecated)
- P1-11: workflow 更新验证条件(nodes/edges 任一变化即验证)
- P0-9: 小程序 .gitignore 添加 .env/.env.*/日志
- P1-19: 小程序加密密钥替换为 64 字符强密钥

Phase 4 消息模块:
- P1-5: 通知偏好 GET 路由 + handler
- P1-4: 消息模板 update/delete CRUD + version
- P2-8: mark_all_read SQL 添加 version + 1
- P2-7: markAsRead 改为乐观更新 + 失败回滚

Phase 5 前端修复:
- P2-9: 通知面板点击导航到 /messages
- P2-1: 随访任务患者名批量 ID 解析(替代 UUID 显示)
- P2-5: AppointmentList 分离 patient_id/doctor_id 分别调用 API
- P2-17: PluginMarket installed 字段修正(name → id)
- P3-3: 路由标题 fallback 改为模式匹配(支持 :id 动态路径)
- P2-15: workflow updateDefinition 添加 version 字段
- P3-9: Kanban 版本使用记录实际 version
- P2-21: secure-storage 生产环境无密钥时阻止存储
- P3-11: destroyOnHidden → destroyOnClose
- P3-13: PendingTasks 深色模式 Tag 颜色适配

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-26 19:16:23 +08:00

345 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useState, useCallback } from 'react';
import {
Card,
Row,
Col,
Input,
Tag,
Button,
Space,
Typography,
Modal,
Rate,
message,
Empty,
Tooltip,
Form,
Input as TextArea,
Alert,
Spin,
} from 'antd';
import {
SearchOutlined,
DownloadOutlined,
AppstoreOutlined,
StarOutlined,
} from '@ant-design/icons';
import {
listMarketEntries,
installFromMarket,
listMarketReviews,
submitMarketReview,
listPlugins,
type MarketEntry,
type MarketReview,
} from '../api/plugins';
const { Title, Text, Paragraph } = Typography;
const CATEGORY_COLORS: Record<string, string> = {
'财务': '#059669',
'CRM': '#2563EB',
'进销存': '#9333EA',
'生产': '#dc2626',
'人力资源': '#d97706',
'基础': '#475569',
};
export default function PluginMarket() {
const [plugins, setPlugins] = useState<MarketEntry[]>([]);
const [loading, setLoading] = useState(true);
const [searchText, setSearchText] = useState('');
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
const [detailVisible, setDetailVisible] = useState(false);
const [selectedPlugin, setSelectedPlugin] = useState<MarketEntry | null>(null);
const [installing, setInstalling] = useState<string | null>(null);
// 当前已安装的插件列表(用于标识已安装状态)
const [installedIds, setInstalledIds] = useState<Set<string>>(new Set());
// 评论区
const [reviews, setReviews] = useState<MarketReview[]>([]);
const [reviewForm] = Form.useForm();
const [submittingReview, setSubmittingReview] = useState(false);
const fetchInstalled = useCallback(async () => {
try {
const result = await listPlugins(1);
const ids = new Set(result.data.map((p) => p.id));
setInstalledIds(ids);
} catch {
// 静默失败
}
}, []);
const loadMarketPlugins = useCallback(async () => {
setLoading(true);
try {
const result = await listMarketEntries({ search: searchText || undefined, category: selectedCategory || undefined });
setPlugins(result.data);
} catch {
message.error('加载插件市场失败');
}
setLoading(false);
}, [searchText, selectedCategory]);
useEffect(() => {
fetchInstalled();
}, [fetchInstalled]);
useEffect(() => {
loadMarketPlugins();
}, [loadMarketPlugins]);
const categories = Array.from(new Set(plugins.map((p) => p.category).filter((c): c is string => Boolean(c))));
const showDetail = async (plugin: MarketEntry) => {
setSelectedPlugin(plugin);
setDetailVisible(true);
try {
const result = await listMarketReviews(plugin.id);
setReviews(result);
} catch {
setReviews([]);
}
};
const handleInstall = async (plugin: MarketEntry) => {
setInstalling(plugin.id);
try {
await installFromMarket(plugin.id);
message.success(`${plugin.name} 安装成功`);
fetchInstalled();
loadMarketPlugins();
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : '安装失败';
message.error(msg);
}
setInstalling(null);
};
const handleSubmitReview = async () => {
if (!selectedPlugin) return;
try {
const values = await reviewForm.validateFields();
setSubmittingReview(true);
await submitMarketReview(selectedPlugin.id, values);
message.success('评分提交成功');
reviewForm.resetFields();
// 刷新评论和列表
const result = await listMarketReviews(selectedPlugin.id);
setReviews(result);
loadMarketPlugins();
} catch {
// 表单验证失败静默
}
setSubmittingReview(false);
};
return (
<div style={{ padding: 24 }}>
<div style={{ marginBottom: 24 }}>
<Title level={3} style={{ marginBottom: 8 }}>
<AppstoreOutlined />
</Title>
<Text type="secondary"> ERP </Text>
</div>
{/* 搜索和分类 */}
<div style={{ marginBottom: 24 }}>
<Space size="middle" wrap>
<Input
prefix={<SearchOutlined />}
placeholder="搜索插件..."
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
style={{ width: 300 }}
allowClear
/>
<Button
type={selectedCategory === null ? 'primary' : 'default'}
onClick={() => setSelectedCategory(null)}
>
</Button>
{categories.map((cat) => (
<Button
key={cat}
type={selectedCategory === cat ? 'primary' : 'default'}
onClick={() => setSelectedCategory(selectedCategory === cat ? null : cat)}
>
{cat}
</Button>
))}
</Space>
</div>
{/* 插件卡片网格 */}
<Spin spinning={loading}>
{plugins.length === 0 && !loading ? (
<Empty description="暂无可用插件" />
) : (
<Row gutter={[16, 16]}>
{plugins.map((plugin) => (
<Col xs={24} sm={12} md={8} lg={6} key={plugin.id}>
<Card
hoverable
onClick={() => showDetail(plugin)}
style={{ height: '100%' }}
>
<div style={{ marginBottom: 8 }}>
<Text strong style={{ fontSize: 16 }}>{plugin.name}</Text>
<Tag
color={CATEGORY_COLORS[plugin.category ?? ''] ?? '#475569'}
style={{ marginLeft: 8 }}
>
{plugin.category}
</Tag>
</div>
<Paragraph
type="secondary"
ellipsis={{ rows: 2 }}
style={{ minHeight: 44, marginBottom: 8 }}
>
{plugin.description ?? '暂无描述'}
</Paragraph>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Space size="small">
<Text type="secondary" style={{ fontSize: 12 }}>v{plugin.version}</Text>
{plugin.author && <Text type="secondary" style={{ fontSize: 12 }}>{plugin.author}</Text>}
</Space>
<Tooltip title="评分">
<Space size={2}>
<StarOutlined style={{ color: '#faad14', fontSize: 12 }} />
<Text style={{ fontSize: 12 }}>
{plugin.rating_count > 0
? plugin.rating_avg.toFixed(1)
: '-'}
</Text>
</Space>
</Tooltip>
</div>
{installedIds.has(plugin.plugin_id) && (
<Tag color="green" style={{ marginTop: 8 }}></Tag>
)}
</Card>
</Col>
))}
</Row>
)}
</Spin>
{/* 详情弹窗 */}
<Modal
title={selectedPlugin?.name}
open={detailVisible}
onCancel={() => {
setDetailVisible(false);
reviewForm.resetFields();
}}
footer={null}
width={640}
>
{selectedPlugin && (
<div>
<div style={{ marginBottom: 16 }}>
<Space>
<Tag color={CATEGORY_COLORS[selectedPlugin.category ?? ''] ?? '#475569'}>
{selectedPlugin.category}
</Tag>
<Text type="secondary">v{selectedPlugin.version}</Text>
<Text type="secondary">by {selectedPlugin.author ?? '未知'}</Text>
<Text type="secondary">
<DownloadOutlined /> {selectedPlugin.download_count}
</Text>
</Space>
</div>
<Paragraph>{selectedPlugin.description ?? '暂无描述'}</Paragraph>
{selectedPlugin.changelog && (
<div style={{ marginBottom: 16 }}>
<Text strong></Text>
<Paragraph type="secondary" style={{ whiteSpace: 'pre-wrap' }}>
{selectedPlugin.changelog}
</Paragraph>
</div>
)}
{selectedPlugin.tags && selectedPlugin.tags.length > 0 && (
<div style={{ marginBottom: 16 }}>
{selectedPlugin.tags.map((tag) => (
<Tag key={tag}>{tag}</Tag>
))}
</div>
)}
<div style={{ marginBottom: 16 }}>
<Rate disabled value={Math.round(selectedPlugin.rating_avg)} />
<Text type="secondary" style={{ marginLeft: 8 }}>
{selectedPlugin.rating_count}
</Text>
</div>
<Button
type="primary"
icon={<DownloadOutlined />}
loading={installing === selectedPlugin.id}
disabled={installedIds.has(selectedPlugin.plugin_id)}
onClick={() => handleInstall(selectedPlugin)}
block
style={{ marginBottom: 24 }}
>
{installedIds.has(selectedPlugin.plugin_id) ? '已安装' : '一键安装'}
</Button>
{/* 评论区 */}
<div style={{ borderTop: '1px solid #f0f0f0', paddingTop: 16 }}>
<Title level={5}> ({reviews.length})</Title>
{reviews.length > 0 && (
<div style={{ marginBottom: 16, maxHeight: 200, overflowY: 'auto' }}>
{reviews.map((review) => (
<div key={review.id} style={{ marginBottom: 12, paddingBottom: 12, borderBottom: '1px solid #f5f5f5' }}>
<Space>
<Rate disabled value={review.rating} style={{ fontSize: 14 }} />
<Text type="secondary" style={{ fontSize: 12 }}>
{review.created_at ? new Date(review.created_at).toLocaleDateString() : ''}
</Text>
</Space>
{review.review_text && (
<Paragraph style={{ marginTop: 4, marginBottom: 0 }} type="secondary">
{review.review_text}
</Paragraph>
)}
</div>
))}
</div>
)}
{reviews.length === 0 && (
<Alert type="info" message="暂无评价" style={{ marginBottom: 16 }} />
)}
{installedIds.has(selectedPlugin.plugin_id) && (
<Form form={reviewForm} layout="vertical">
<Form.Item name="rating" label="评分" rules={[{ required: true, message: '请选择评分' }]}>
<Rate />
</Form.Item>
<Form.Item name="review_text" label="评价内容">
<TextArea.TextArea rows={2} placeholder="写下你的使用体验..." />
</Form.Item>
<Form.Item>
<Button type="primary" loading={submittingReview} onClick={handleSubmitReview}>
</Button>
</Form.Item>
</Form>
)}
</div>
</div>
)}
</Modal>
</div>
);
}