feat(plugin): P1-P4 审计修复 — 第一批 (Excel/CSV导出 + 市场API + 对账扫描)
1.1 Excel/CSV 导出:
- 后端 export 支持 format 参数 (json/csv/xlsx)
- rust_xlsxwriter 生成带样式 Excel
- 前端导出按钮改为 Dropdown 格式选择 (JSON/CSV/Excel)
- blob 下载支持 CSV/XLSX 二进制格式
1.2 市场后端 API + 前端对接:
- SeaORM Entity: market_entry, market_review
- API: 浏览/详情/一键安装/评论列表/提交评分
- 一键安装: upload → install → enable 一条龙 + 依赖检查
- 前端 PluginMarket 对接真实 API (搜索/分类/安装/评分)
1.3 对账扫描:
- reconcile_references() 扫描跨插件引用悬空 UUID
- POST /plugins/{plugin_id}/reconcile 端点
This commit is contained in:
@@ -217,7 +217,7 @@ export interface ExportOptions {
|
||||
search?: string;
|
||||
sort_by?: string;
|
||||
sort_order?: 'asc' | 'desc';
|
||||
format?: 'csv' | 'json';
|
||||
format?: 'json' | 'csv' | 'xlsx';
|
||||
}
|
||||
|
||||
export async function exportPluginData(
|
||||
@@ -238,6 +238,25 @@ export async function exportPluginData(
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function exportPluginDataAsBlob(
|
||||
pluginId: string,
|
||||
entity: string,
|
||||
format: 'csv' | 'xlsx',
|
||||
options?: Omit<ExportOptions, 'format'>,
|
||||
): Promise<Blob> {
|
||||
const params: Record<string, string> = { format };
|
||||
if (options?.filter) params.filter = JSON.stringify(options.filter);
|
||||
if (options?.search) params.search = options.search;
|
||||
if (options?.sort_by) params.sort_by = options.sort_by;
|
||||
if (options?.sort_order) params.sort_order = options.sort_order;
|
||||
|
||||
const response = await client.get(
|
||||
`/plugins/${pluginId}/${entity}/export`,
|
||||
{ params, responseType: 'blob' },
|
||||
);
|
||||
return response.data as Blob;
|
||||
}
|
||||
|
||||
export interface ImportRowError {
|
||||
row: number;
|
||||
errors: string[];
|
||||
|
||||
@@ -256,3 +256,81 @@ export interface PluginTriggerEvent {
|
||||
entity: string;
|
||||
on: 'create' | 'update' | 'delete' | 'create_or_update';
|
||||
}
|
||||
|
||||
// ── 插件市场 API ──
|
||||
|
||||
export interface MarketEntry {
|
||||
id: string;
|
||||
plugin_id: string;
|
||||
name: string;
|
||||
version: string;
|
||||
description?: string;
|
||||
author?: string;
|
||||
category?: string;
|
||||
tags?: string[];
|
||||
icon_url?: string;
|
||||
screenshots?: string[];
|
||||
min_platform_version?: string;
|
||||
status: string;
|
||||
download_count: number;
|
||||
rating_avg: number;
|
||||
rating_count: number;
|
||||
changelog?: string;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export interface MarketEntryDetail extends MarketEntry {
|
||||
dependency_warnings: string[];
|
||||
}
|
||||
|
||||
export interface MarketReview {
|
||||
id: string;
|
||||
user_id: string;
|
||||
market_entry_id: string;
|
||||
rating: number;
|
||||
review_text?: string;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
export async function listMarketEntries(params?: {
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
category?: string;
|
||||
search?: string;
|
||||
}) {
|
||||
const { data } = await client.get<{ success: boolean; data: PaginatedResponse<MarketEntry> }>(
|
||||
'/market/entries',
|
||||
{ params },
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function getMarketEntry(id: string) {
|
||||
const { data } = await client.get<{ success: boolean; data: MarketEntryDetail }>(
|
||||
`/market/entries/${id}`,
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function installFromMarket(id: string) {
|
||||
const { data } = await client.post<{ success: boolean; data: PluginInfo }>(
|
||||
`/market/entries/${id}/install`,
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function listMarketReviews(id: string) {
|
||||
const { data } = await client.get<{ success: boolean; data: MarketReview[] }>(
|
||||
`/market/entries/${id}/reviews`,
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function submitMarketReview(id: string, review: { rating: number; review_text?: string }) {
|
||||
const { data } = await client.post<{ success: boolean; data: MarketReview }>(
|
||||
`/market/entries/${id}/reviews`,
|
||||
review,
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
Timeline,
|
||||
Upload,
|
||||
Alert,
|
||||
Dropdown,
|
||||
} from 'antd';
|
||||
import {
|
||||
PlusOutlined,
|
||||
@@ -38,6 +39,7 @@ import {
|
||||
batchPluginData,
|
||||
resolveRefLabels,
|
||||
exportPluginData,
|
||||
exportPluginDataAsBlob,
|
||||
importPluginData,
|
||||
type PluginDataListOptions,
|
||||
type ImportResult,
|
||||
@@ -576,32 +578,54 @@ export default function PluginCRUDPage({
|
||||
刷新
|
||||
</Button>
|
||||
{entityDef?.exportable && (
|
||||
<Button
|
||||
icon={<DownloadOutlined />}
|
||||
loading={exporting}
|
||||
onClick={async () => {
|
||||
setExporting(true);
|
||||
try {
|
||||
const rows = await exportPluginData(pluginId, entityName, {
|
||||
sort_by: sortBy,
|
||||
sort_order: sortOrder,
|
||||
});
|
||||
const blob = new Blob([JSON.stringify(rows, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${entityName}_export_${Date.now()}.json`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
message.success(`导出 ${rows.length} 条记录`);
|
||||
} catch {
|
||||
message.error('导出失败');
|
||||
}
|
||||
setExporting(false);
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: [
|
||||
{ key: 'json', label: 'JSON' },
|
||||
{ key: 'csv', label: 'CSV' },
|
||||
{ key: 'xlsx', label: 'Excel (.xlsx)' },
|
||||
],
|
||||
onClick: async ({ key }) => {
|
||||
setExporting(true);
|
||||
try {
|
||||
const ts = Date.now();
|
||||
if (key === 'json') {
|
||||
const rows = await exportPluginData(pluginId, entityName, {
|
||||
sort_by: sortBy,
|
||||
sort_order: sortOrder,
|
||||
});
|
||||
const blob = new Blob([JSON.stringify(rows, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${entityName}_export_${ts}.json`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
message.success(`导出 ${rows.length} 条记录`);
|
||||
} else {
|
||||
const blob = await exportPluginDataAsBlob(
|
||||
pluginId, entityName, key as 'csv' | 'xlsx',
|
||||
{ sort_by: sortBy, sort_order: sortOrder },
|
||||
);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${entityName}_export_${ts}.${key === 'csv' ? 'csv' : 'xlsx'}`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
message.success('导出成功');
|
||||
}
|
||||
} catch {
|
||||
message.error('导出失败');
|
||||
}
|
||||
setExporting(false);
|
||||
},
|
||||
}}
|
||||
>
|
||||
导出
|
||||
</Button>
|
||||
<Button icon={<DownloadOutlined />} loading={exporting}>
|
||||
导出
|
||||
</Button>
|
||||
</Dropdown>
|
||||
)}
|
||||
{entityDef?.importable && (
|
||||
<Button
|
||||
|
||||
@@ -13,6 +13,10 @@ import {
|
||||
message,
|
||||
Empty,
|
||||
Tooltip,
|
||||
Form,
|
||||
Input as TextArea,
|
||||
Alert,
|
||||
Spin,
|
||||
} from 'antd';
|
||||
import {
|
||||
SearchOutlined,
|
||||
@@ -20,24 +24,18 @@ import {
|
||||
AppstoreOutlined,
|
||||
StarOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { listPlugins } from '../api/plugins';
|
||||
import {
|
||||
listMarketEntries,
|
||||
installFromMarket,
|
||||
listMarketReviews,
|
||||
submitMarketReview,
|
||||
listPlugins,
|
||||
type MarketEntry,
|
||||
type MarketReview,
|
||||
} from '../api/plugins';
|
||||
|
||||
const { Title, Text, Paragraph } = Typography;
|
||||
|
||||
interface MarketPlugin {
|
||||
id: string;
|
||||
name: string;
|
||||
version: string;
|
||||
description?: string;
|
||||
author?: string;
|
||||
category?: string;
|
||||
tags?: string[];
|
||||
rating_avg: number;
|
||||
rating_count: number;
|
||||
download_count: number;
|
||||
status: string;
|
||||
}
|
||||
|
||||
const CATEGORY_COLORS: Record<string, string> = {
|
||||
'财务': '#059669',
|
||||
'CRM': '#2563EB',
|
||||
@@ -48,16 +46,22 @@ const CATEGORY_COLORS: Record<string, string> = {
|
||||
};
|
||||
|
||||
export default function PluginMarket() {
|
||||
const [plugins, setPlugins] = useState<MarketPlugin[]>([]);
|
||||
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<MarketPlugin | null>(null);
|
||||
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);
|
||||
@@ -68,63 +72,70 @@ export default function PluginMarket() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchInstalled();
|
||||
// 市场插件目前从已安装列表模拟(后续对接远程市场 API)
|
||||
loadMarketPlugins();
|
||||
}, [fetchInstalled]);
|
||||
|
||||
const loadMarketPlugins = async () => {
|
||||
// 当前阶段:从已安装插件列表构建
|
||||
// TODO: 对接远程插件市场 API
|
||||
const loadMarketPlugins = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await listPlugins(1);
|
||||
const market: MarketPlugin[] = result.data.map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
version: p.version,
|
||||
description: p.description,
|
||||
author: p.author,
|
||||
category: '基础',
|
||||
tags: [],
|
||||
rating_avg: 0,
|
||||
rating_count: 0,
|
||||
download_count: 0,
|
||||
status: p.status,
|
||||
}));
|
||||
setPlugins(market);
|
||||
const result = await listMarketEntries({ search: searchText || undefined, category: selectedCategory || undefined });
|
||||
setPlugins(result.data);
|
||||
} catch {
|
||||
message.error('加载插件市场失败');
|
||||
}
|
||||
};
|
||||
setLoading(false);
|
||||
}, [searchText, selectedCategory]);
|
||||
|
||||
const filteredPlugins = plugins.filter((p) => {
|
||||
const matchSearch =
|
||||
!searchText ||
|
||||
p.name.toLowerCase().includes(searchText.toLowerCase()) ||
|
||||
(p.description ?? '').toLowerCase().includes(searchText.toLowerCase());
|
||||
const matchCategory = !selectedCategory || p.category === selectedCategory;
|
||||
return matchSearch && matchCategory;
|
||||
});
|
||||
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 = (plugin: MarketPlugin) => {
|
||||
const showDetail = async (plugin: MarketEntry) => {
|
||||
setSelectedPlugin(plugin);
|
||||
setDetailVisible(true);
|
||||
try {
|
||||
const result = await listMarketReviews(plugin.id);
|
||||
setReviews(result);
|
||||
} catch {
|
||||
setReviews([]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInstall = async (plugin: MarketPlugin) => {
|
||||
const handleInstall = async (plugin: MarketEntry) => {
|
||||
setInstalling(plugin.id);
|
||||
try {
|
||||
await installFromMarket(plugin.id);
|
||||
message.success(`${plugin.name} 安装成功`);
|
||||
fetchInstalled();
|
||||
} catch {
|
||||
message.error('安装失败');
|
||||
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 }}>
|
||||
@@ -164,65 +175,70 @@ export default function PluginMarket() {
|
||||
</div>
|
||||
|
||||
{/* 插件卡片网格 */}
|
||||
{filteredPlugins.length === 0 ? (
|
||||
<Empty description="暂无可用插件" />
|
||||
) : (
|
||||
<Row gutter={[16, 16]}>
|
||||
{filteredPlugins.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 ?? ''] ?? '#64748B'}
|
||||
style={{ marginLeft: 8 }}
|
||||
>
|
||||
{plugin.category}
|
||||
</Tag>
|
||||
</div>
|
||||
<Paragraph
|
||||
type="secondary"
|
||||
ellipsis={{ rows: 2 }}
|
||||
style={{ minHeight: 44, marginBottom: 8 }}
|
||||
<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%' }}
|
||||
>
|
||||
{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>
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<Text strong style={{ fontSize: 16 }}>{plugin.name}</Text>
|
||||
<Tag
|
||||
color={CATEGORY_COLORS[plugin.category ?? ''] ?? '#64748B'}
|
||||
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>
|
||||
</div>
|
||||
{installedIds.has(plugin.name) && (
|
||||
<Tag color="green" style={{ marginTop: 8 }}>已安装</Tag>
|
||||
)}
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
)}
|
||||
<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)}
|
||||
onCancel={() => {
|
||||
setDetailVisible(false);
|
||||
reviewForm.resetFields();
|
||||
}}
|
||||
footer={null}
|
||||
width={600}
|
||||
width={640}
|
||||
>
|
||||
{selectedPlugin && (
|
||||
<div>
|
||||
@@ -233,16 +249,21 @@ export default function PluginMarket() {
|
||||
</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>
|
||||
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Rate disabled value={Math.round(selectedPlugin.rating_avg)} />
|
||||
<Text type="secondary" style={{ marginLeft: 8 }}>
|
||||
{selectedPlugin.rating_count} 评分
|
||||
</Text>
|
||||
</div>
|
||||
{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 }}>
|
||||
@@ -252,16 +273,69 @@ export default function PluginMarket() {
|
||||
</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.name)}
|
||||
disabled={installedIds.has(selectedPlugin.plugin_id)}
|
||||
onClick={() => handleInstall(selectedPlugin)}
|
||||
block
|
||||
style={{ marginBottom: 24 }}
|
||||
>
|
||||
{installedIds.has(selectedPlugin.name) ? '已安装' : '安装'}
|
||||
{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>
|
||||
|
||||
Reference in New Issue
Block a user