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>
345 lines
11 KiB
TypeScript
345 lines
11 KiB
TypeScript
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>
|
||
);
|
||
}
|