create-fullstack-scaffold 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/index.js +1673 -696
- package/dist/cli/index.js.map +1 -1
- package/package.json +16 -10
- package/template/.husky/pre-commit +1 -1
- package/template/modules.config.ts +29 -2
- package/template/package.json +12 -12
- package/template/patches/{typescript+5.8.3.patch → typescript+5.9.3.patch} +2 -4
- package/template/playwright.config.ts +7 -1
- package/template/src/admin/App.tsx +2 -2
- package/template/src/admin/components/CaptchaModal.tsx +7 -9
- package/template/src/admin/layouts/Header.tsx +56 -22
- package/template/src/admin/layouts/Layout.tsx +14 -9
- package/template/src/admin/layouts/Sidebar.tsx +122 -105
- package/template/src/admin/pages/CategoryManagementPage.tsx +241 -0
- package/template/src/admin/pages/ContentPage.tsx +2 -0
- package/template/src/admin/pages/DashboardPage.tsx +2 -0
- package/template/src/admin/pages/DisputesPage.tsx +2 -0
- package/template/src/admin/pages/MediaTestPage.tsx +1 -1
- package/template/src/admin/pages/OrdersPage.tsx +2 -0
- package/template/src/admin/pages/PluginDashboardPage.tsx +297 -0
- package/template/src/admin/pages/PluginManagementPage.tsx +340 -0
- package/template/src/admin/pages/PluginReviewPage.tsx +255 -0
- package/template/src/admin/pages/SystemLogsPage.tsx +11 -2
- package/template/src/admin/pages/TicketsPage.tsx +2 -0
- package/template/src/admin/pages/UsersPage.tsx +3 -2
- package/template/src/admin/stores/adminStore.ts +5 -0
- package/template/src/cli/index.ts +17 -19
- package/template/src/cli/modules/auth/index.ts +65 -0
- package/template/src/cli/modules/config/index.ts +74 -77
- package/template/src/cli/modules/index.ts +28 -6
- package/template/src/cli/modules/notification/index.ts +95 -79
- package/template/src/cli/modules/plugin/index.ts +111 -0
- package/template/src/cli/modules/todo/index.ts +99 -51
- package/template/src/cli/utils/auto-command.ts +7 -25
- package/template/src/cli/utils/index.ts +3 -1
- package/template/src/client/App.tsx +36 -16
- package/template/src/client/Layout.tsx +67 -10
- package/template/src/client/components/AuthButton.tsx +25 -18
- package/template/src/client/components/BottomTabBar.tsx +107 -0
- package/template/src/client/components/Navigation.tsx +162 -52
- package/template/src/client/components/__tests__/App.test.tsx +48 -32
- package/template/src/client/components/__tests__/AuthButton.test.tsx +78 -77
- package/template/src/client/components/__tests__/Navigation.test.tsx +31 -20
- package/template/src/client/components/index.ts +1 -0
- package/template/src/client/contexts/PresetContext.tsx +10 -0
- package/template/src/client/main.tsx +63 -8
- package/template/src/client/pages/CartPage.tsx +244 -0
- package/template/src/client/pages/CategoriesPage.tsx +100 -0
- package/template/src/client/pages/ContentDetailPage.tsx +9 -14
- package/template/src/client/pages/ContentListPage.tsx +4 -11
- package/template/src/client/pages/DashboardPage.tsx +261 -0
- package/template/src/client/pages/DeveloperDashboardPage.tsx +211 -0
- package/template/src/client/pages/LoginPage.tsx +127 -0
- package/template/src/client/pages/OrdersPage.tsx +196 -0
- package/template/src/client/pages/PluginDetailPage.tsx +345 -0
- package/template/src/client/pages/PluginsPage.tsx +223 -0
- package/template/src/client/pages/ProfilePage.tsx +206 -0
- package/template/src/client/pages/PublishPage.tsx +336 -0
- package/template/src/client/pages/RegisterPage.tsx +136 -0
- package/template/src/client/pages/SearchPage.tsx +204 -0
- package/template/src/client/pages/SettingsPage.tsx +220 -0
- package/template/src/client/pages/TopicsPage.tsx +180 -0
- package/template/src/client/pages/__tests__/LoginPage.test.tsx +170 -0
- package/template/src/client/pages/__tests__/RegisterPage.test.tsx +168 -0
- package/template/src/client/preset-ui-config.ts +492 -0
- package/template/src/client/services/apiClient.ts +14 -4
- package/template/src/client/stores/__tests__/authStore.test.ts +293 -38
- package/template/src/client/stores/__tests__/todoStore.test.ts +7 -17
- package/template/src/client/stores/authStore.ts +58 -5
- package/template/src/client/stores/chatWSStore.ts +9 -0
- package/template/src/client/stores/notificationStore.ts +4 -0
- package/template/src/client/stores/pluginStore.ts +279 -0
- package/template/src/client/stores/todoStore.ts +2 -6
- package/template/src/server/core/__tests__/isr-cache.test.ts +117 -0
- package/template/src/server/core/__tests__/isr-invalidation.test.ts +72 -0
- package/template/src/server/core/__tests__/ssr-renderer.test.ts +89 -0
- package/template/src/server/core/isr-cache.ts +239 -0
- package/template/src/server/core/isr-invalidation.ts +45 -0
- package/template/src/server/core/module-loader.ts +14 -7
- package/template/src/server/core/ssr-renderer.ts +240 -0
- package/template/src/server/db/init.ts +257 -10
- package/template/src/server/db/schema/developers.ts +20 -0
- package/template/src/server/db/schema/index.ts +2 -0
- package/template/src/server/db/schema/plugins.ts +114 -0
- package/template/src/server/db/test-setup.ts +91 -0
- package/template/src/server/entries/cloudflare.ts +79 -7
- package/template/src/server/entries/node.ts +48 -5
- package/template/src/server/middleware/__tests__/captcha.test.ts +23 -13
- package/template/src/server/middleware/auth.ts +8 -1
- package/template/src/server/middleware/captcha.ts +14 -4
- package/template/src/server/middleware/rate-limit.ts +7 -2
- package/template/src/server/module-admin/module.ts +9 -5
- package/template/src/server/module-admin/routes/admin-notification-routes.ts +43 -14
- package/template/src/server/module-admin/routes/admin-routes.ts +0 -2
- package/template/src/server/module-admin/routes/client-auth-routes.ts +90 -0
- package/template/src/server/module-admin/routes/dashboard-routes.ts +79 -0
- package/template/src/server/module-admin/services/admin-service.ts +68 -11
- package/template/src/server/module-auth/__tests__/auth-service.test.ts +239 -0
- package/template/src/server/module-auth/index.ts +7 -0
- package/template/src/server/module-auth/module.ts +40 -0
- package/template/src/server/module-auth/routes/auth-routes.ts +94 -0
- package/template/src/server/module-auth/routes/profile-routes.ts +31 -0
- package/template/src/server/module-auth/services/auth-service.ts +100 -0
- package/template/src/server/module-content/module.ts +10 -4
- package/template/src/server/module-content/routes/public-content-routes.ts +2 -2
- package/template/src/server/module-content/routes/topics-routes.ts +205 -0
- package/template/src/server/module-content/services/content-service.ts +18 -2
- package/template/src/server/module-dispute/routes/dispute-routes.ts +2 -1
- package/template/src/server/module-dispute/services/dispute-service.ts +1 -1
- package/template/src/server/module-notifications/__tests__/sse-rpc.test.ts +14 -14
- package/template/src/server/module-notifications/routes/notification-routes.ts +34 -8
- package/template/src/server/module-order/__tests__/order-route.test.ts +22 -2
- package/template/src/server/module-order/module.ts +15 -0
- package/template/src/server/module-order/routes/cart-routes.ts +103 -0
- package/template/src/server/module-order/routes/orders-mock-routes.ts +67 -0
- package/template/src/server/module-order/services/order-service.ts +1 -1
- package/template/src/server/module-plugin/__tests__/plugin-query-service.test.ts +203 -0
- package/template/src/server/module-plugin/__tests__/plugin-service.test.ts +234 -0
- package/template/src/server/module-plugin/index.ts +2 -0
- package/template/src/server/module-plugin/module.ts +52 -0
- package/template/src/server/module-plugin/routes/plugin-admin-routes.ts +261 -0
- package/template/src/server/module-plugin/routes/plugin-routes.ts +354 -0
- package/template/src/server/module-plugin/services/admin-category-service.ts +99 -0
- package/template/src/server/module-plugin/services/admin-plugin-service.ts +170 -0
- package/template/src/server/module-plugin/services/admin-stats-service.ts +41 -0
- package/template/src/server/module-plugin/services/plugin-query-service.ts +360 -0
- package/template/src/server/module-plugin/services/plugin-review-service.ts +95 -0
- package/template/src/server/module-plugin/services/plugin-service.ts +163 -0
- package/template/src/server/module-ticket/services/ticket-service.ts +1 -1
- package/template/src/server/module-todos/routes/todos-routes.ts +0 -4
- package/template/src/server/route-registry.ts +16 -1
- package/template/src/server/test-utils/test-client.ts +1 -2
- package/template/src/server/utils/auth.ts +6 -0
- package/template/src/server/utils/json.ts +13 -0
- package/template/src/shared/core/module-manifest.ts +3 -6
- package/template/src/shared/modules/auth/index.ts +12 -0
- package/template/src/shared/modules/auth/schemas.ts +50 -0
- package/template/src/shared/modules/cart/index.ts +1 -0
- package/template/src/shared/modules/cart/schemas.ts +41 -0
- package/template/src/shared/modules/community/index.ts +1 -0
- package/template/src/shared/modules/community/schemas.ts +58 -0
- package/template/src/shared/modules/dashboard/index.ts +1 -0
- package/template/src/shared/modules/dashboard/schemas.ts +35 -0
- package/template/src/shared/modules/index.ts +41 -0
- package/template/src/shared/modules/order/schemas.ts +29 -0
- package/template/src/shared/modules/plugins/index.ts +48 -0
- package/template/src/shared/modules/plugins/schemas.ts +227 -0
- package/template/src/shared/schemas/index.ts +130 -0
- package/template/tests/e2e/todo.spec.ts +23 -18
- package/template/tests/e2e/visual-screenshots.spec.ts +1824 -0
- package/template/uploads/.gitkeep +0 -0
- package/template/vite.config.ts +2 -1
- package/template/vitest.setup.ts +11 -0
- package/template/wrangler.toml +4 -3
- package/template/package-lock.json +0 -14554
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
import { useState, useEffect, useCallback } from 'react'
|
|
2
|
+
import { Card, Table, Tag, Button, Space, message, Modal, Input, Popconfirm, Tooltip } from 'antd'
|
|
3
|
+
import {
|
|
4
|
+
Package,
|
|
5
|
+
Clock,
|
|
6
|
+
Download,
|
|
7
|
+
Users,
|
|
8
|
+
CheckCircle,
|
|
9
|
+
XCircle,
|
|
10
|
+
Star,
|
|
11
|
+
TrendingUp,
|
|
12
|
+
} from 'lucide-react'
|
|
13
|
+
import type { ColumnsType } from 'antd/es/table'
|
|
14
|
+
import { apiClient, api } from '../services/apiClient'
|
|
15
|
+
import type { AdminDashboardStats, AdminPlugin } from '@shared/modules/plugins'
|
|
16
|
+
|
|
17
|
+
const STATUS_CONFIG: Record<string, { color: string; label: string }> = {
|
|
18
|
+
pending: { color: 'orange', label: '待审核' },
|
|
19
|
+
approved: { color: 'green', label: '已通过' },
|
|
20
|
+
rejected: { color: 'red', label: '已拒绝' },
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const PluginDashboardPage: React.FC = () => {
|
|
24
|
+
const [stats, setStats] = useState<AdminDashboardStats | null>(null)
|
|
25
|
+
const [recentSubmissions, setRecentSubmissions] = useState<AdminPlugin[]>([])
|
|
26
|
+
const [loading, setLoading] = useState(true)
|
|
27
|
+
const [rejectModalVisible, setRejectModalVisible] = useState(false)
|
|
28
|
+
const [rejectTarget, setRejectTarget] = useState<string | null>(null)
|
|
29
|
+
const [rejectReason, setRejectReason] = useState('')
|
|
30
|
+
|
|
31
|
+
const fetchDashboard = useCallback(async () => {
|
|
32
|
+
setLoading(true)
|
|
33
|
+
try {
|
|
34
|
+
const data = await api(apiClient.api.stats.dashboard.$get()).withLoading('加载中...').json()
|
|
35
|
+
setStats(data)
|
|
36
|
+
setRecentSubmissions(data.recentSubmissions || [])
|
|
37
|
+
} catch {
|
|
38
|
+
// 错误已由 api-request 自动处理
|
|
39
|
+
} finally {
|
|
40
|
+
setLoading(false)
|
|
41
|
+
}
|
|
42
|
+
}, [])
|
|
43
|
+
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
fetchDashboard()
|
|
46
|
+
}, [fetchDashboard])
|
|
47
|
+
|
|
48
|
+
const handleApprove = async (slug: string) => {
|
|
49
|
+
try {
|
|
50
|
+
await api(apiClient.api.plugins[':slug'].approve.$put({ param: { slug } }))
|
|
51
|
+
.withLoading('审核通过中...')
|
|
52
|
+
.json()
|
|
53
|
+
message.success('插件已通过审核')
|
|
54
|
+
fetchDashboard()
|
|
55
|
+
} catch {
|
|
56
|
+
// 错误已由 api-request 自动处理
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const handleReject = async () => {
|
|
61
|
+
if (!rejectTarget || !rejectReason.trim()) {
|
|
62
|
+
message.warning('请填写拒绝原因')
|
|
63
|
+
return
|
|
64
|
+
}
|
|
65
|
+
try {
|
|
66
|
+
await api(
|
|
67
|
+
apiClient.api.plugins[':slug'].reject.$put({
|
|
68
|
+
param: { slug: rejectTarget },
|
|
69
|
+
json: { reason: rejectReason },
|
|
70
|
+
})
|
|
71
|
+
)
|
|
72
|
+
.withLoading('拒绝中...')
|
|
73
|
+
.json()
|
|
74
|
+
message.success('插件已拒绝')
|
|
75
|
+
setRejectModalVisible(false)
|
|
76
|
+
setRejectTarget(null)
|
|
77
|
+
setRejectReason('')
|
|
78
|
+
fetchDashboard()
|
|
79
|
+
} catch {
|
|
80
|
+
// 错误已由 api-request 自动处理
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const openRejectModal = (slug: string) => {
|
|
85
|
+
setRejectTarget(slug)
|
|
86
|
+
setRejectReason('')
|
|
87
|
+
setRejectModalVisible(true)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (loading && !stats) {
|
|
91
|
+
return (
|
|
92
|
+
<div className="flex items-center justify-center h-64">
|
|
93
|
+
<div className="text-gray-500">Loading...</div>
|
|
94
|
+
</div>
|
|
95
|
+
)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const statCards = [
|
|
99
|
+
{
|
|
100
|
+
title: '总插件数',
|
|
101
|
+
value: stats?.totalPlugins ?? 0,
|
|
102
|
+
icon: Package,
|
|
103
|
+
color: 'bg-blue-500',
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
title: '待审核',
|
|
107
|
+
value: stats?.pendingPlugins ?? 0,
|
|
108
|
+
icon: Clock,
|
|
109
|
+
color: 'bg-orange-500',
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
title: '总下载量',
|
|
113
|
+
value: stats?.totalDownloads ?? 0,
|
|
114
|
+
icon: Download,
|
|
115
|
+
color: 'bg-green-500',
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
title: '开发者数',
|
|
119
|
+
value: stats?.totalDevelopers ?? 0,
|
|
120
|
+
icon: Users,
|
|
121
|
+
color: 'bg-purple-500',
|
|
122
|
+
},
|
|
123
|
+
]
|
|
124
|
+
|
|
125
|
+
const columns: ColumnsType<AdminPlugin> = [
|
|
126
|
+
{
|
|
127
|
+
title: '插件名称',
|
|
128
|
+
dataIndex: 'name',
|
|
129
|
+
key: 'name',
|
|
130
|
+
render: (name: string, record: AdminPlugin) => (
|
|
131
|
+
<div>
|
|
132
|
+
<div className="font-medium">{name}</div>
|
|
133
|
+
<div className="text-xs text-gray-400">{record.slug}</div>
|
|
134
|
+
</div>
|
|
135
|
+
),
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
title: '作者',
|
|
139
|
+
dataIndex: 'authorName',
|
|
140
|
+
key: 'authorName',
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
title: '状态',
|
|
144
|
+
dataIndex: 'status',
|
|
145
|
+
key: 'status',
|
|
146
|
+
render: (status: string) => {
|
|
147
|
+
const cfg = STATUS_CONFIG[status]
|
|
148
|
+
return cfg ? <Tag color={cfg.color}>{cfg.label}</Tag> : <Tag>{status}</Tag>
|
|
149
|
+
},
|
|
150
|
+
},
|
|
151
|
+
{
|
|
152
|
+
title: '下载',
|
|
153
|
+
dataIndex: 'downloadCount',
|
|
154
|
+
key: 'downloadCount',
|
|
155
|
+
sorter: (a: AdminPlugin, b: AdminPlugin) => a.downloadCount - b.downloadCount,
|
|
156
|
+
render: (count: number) => (
|
|
157
|
+
<span className="flex items-center gap-1">
|
|
158
|
+
<Download className="w-3 h-3" /> {count}
|
|
159
|
+
</span>
|
|
160
|
+
),
|
|
161
|
+
},
|
|
162
|
+
{
|
|
163
|
+
title: '评分',
|
|
164
|
+
key: 'rating',
|
|
165
|
+
render: (_: unknown, record: AdminPlugin) =>
|
|
166
|
+
record.avgRating != null ? (
|
|
167
|
+
<span className="flex items-center gap-1">
|
|
168
|
+
<Star className="w-3 h-3 text-yellow-500" /> {record.avgRating.toFixed(1)}
|
|
169
|
+
</span>
|
|
170
|
+
) : (
|
|
171
|
+
'-'
|
|
172
|
+
),
|
|
173
|
+
},
|
|
174
|
+
{
|
|
175
|
+
title: '提交时间',
|
|
176
|
+
dataIndex: 'createdAt',
|
|
177
|
+
key: 'createdAt',
|
|
178
|
+
render: (ts: number) => new Date(ts).toLocaleString('zh-CN'),
|
|
179
|
+
},
|
|
180
|
+
{
|
|
181
|
+
title: '操作',
|
|
182
|
+
key: 'actions',
|
|
183
|
+
render: (_: unknown, record: AdminPlugin) => (
|
|
184
|
+
<Space>
|
|
185
|
+
{record.status === 'pending' && (
|
|
186
|
+
<>
|
|
187
|
+
<Popconfirm
|
|
188
|
+
title="确定通过此插件审核?"
|
|
189
|
+
onConfirm={() => handleApprove(record.slug)}
|
|
190
|
+
okText="通过"
|
|
191
|
+
cancelText="取消"
|
|
192
|
+
>
|
|
193
|
+
<Button type="link" size="small" icon={<CheckCircle className="w-4 h-4" />}>
|
|
194
|
+
通过
|
|
195
|
+
</Button>
|
|
196
|
+
</Popconfirm>
|
|
197
|
+
<Button
|
|
198
|
+
type="link"
|
|
199
|
+
size="small"
|
|
200
|
+
danger
|
|
201
|
+
icon={<XCircle className="w-4 h-4" />}
|
|
202
|
+
onClick={() => openRejectModal(record.slug)}
|
|
203
|
+
>
|
|
204
|
+
拒绝
|
|
205
|
+
</Button>
|
|
206
|
+
</>
|
|
207
|
+
)}
|
|
208
|
+
{record.status === 'approved' && (
|
|
209
|
+
<Tag color="green" icon={<CheckCircle className="w-3 h-3" />}>
|
|
210
|
+
已通过
|
|
211
|
+
</Tag>
|
|
212
|
+
)}
|
|
213
|
+
{record.status === 'rejected' && (
|
|
214
|
+
<Tooltip title={record.rejectReason}>
|
|
215
|
+
<Tag color="red">已拒绝</Tag>
|
|
216
|
+
</Tooltip>
|
|
217
|
+
)}
|
|
218
|
+
</Space>
|
|
219
|
+
),
|
|
220
|
+
},
|
|
221
|
+
]
|
|
222
|
+
|
|
223
|
+
return (
|
|
224
|
+
<div>
|
|
225
|
+
<div className="flex justify-between items-center mb-6">
|
|
226
|
+
<div>
|
|
227
|
+
<h1 className="text-2xl font-bold text-gray-900">插件市场</h1>
|
|
228
|
+
<p className="text-gray-600 mt-1">插件管理与审核概览</p>
|
|
229
|
+
</div>
|
|
230
|
+
</div>
|
|
231
|
+
|
|
232
|
+
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
|
233
|
+
{statCards.map((card, idx) => {
|
|
234
|
+
const Icon = card.icon
|
|
235
|
+
return (
|
|
236
|
+
<Card key={idx} className="shadow-sm">
|
|
237
|
+
<div className="flex items-center justify-between">
|
|
238
|
+
<div>
|
|
239
|
+
<div className="text-sm text-gray-500">{card.title}</div>
|
|
240
|
+
<div className="text-2xl font-bold mt-1">
|
|
241
|
+
{typeof card.value === 'number' ? card.value.toLocaleString() : card.value}
|
|
242
|
+
</div>
|
|
243
|
+
</div>
|
|
244
|
+
<div className={`p-3 rounded-lg ${card.color}`}>
|
|
245
|
+
<Icon className="w-6 h-6 text-white" />
|
|
246
|
+
</div>
|
|
247
|
+
</div>
|
|
248
|
+
</Card>
|
|
249
|
+
)
|
|
250
|
+
})}
|
|
251
|
+
</div>
|
|
252
|
+
|
|
253
|
+
<Card
|
|
254
|
+
title={
|
|
255
|
+
<span className="flex items-center gap-2">
|
|
256
|
+
<TrendingUp className="w-4 h-4" /> 最近提交
|
|
257
|
+
</span>
|
|
258
|
+
}
|
|
259
|
+
className="shadow-sm"
|
|
260
|
+
>
|
|
261
|
+
<Table
|
|
262
|
+
columns={columns}
|
|
263
|
+
dataSource={recentSubmissions}
|
|
264
|
+
rowKey="id"
|
|
265
|
+
pagination={{ pageSize: 5 }}
|
|
266
|
+
size="middle"
|
|
267
|
+
/>
|
|
268
|
+
</Card>
|
|
269
|
+
|
|
270
|
+
<Modal
|
|
271
|
+
title="拒绝插件"
|
|
272
|
+
open={rejectModalVisible}
|
|
273
|
+
onOk={handleReject}
|
|
274
|
+
onCancel={() => {
|
|
275
|
+
setRejectModalVisible(false)
|
|
276
|
+
setRejectTarget(null)
|
|
277
|
+
setRejectReason('')
|
|
278
|
+
}}
|
|
279
|
+
okText="确认拒绝"
|
|
280
|
+
cancelText="取消"
|
|
281
|
+
okButtonProps={{ danger: true }}
|
|
282
|
+
>
|
|
283
|
+
<div className="mb-4">
|
|
284
|
+
<p className="text-sm text-gray-500 mb-2">请填写拒绝原因:</p>
|
|
285
|
+
<Input.TextArea
|
|
286
|
+
rows={4}
|
|
287
|
+
value={rejectReason}
|
|
288
|
+
onChange={e => setRejectReason(e.target.value)}
|
|
289
|
+
placeholder="请输入拒绝原因..."
|
|
290
|
+
maxLength={500}
|
|
291
|
+
showCount
|
|
292
|
+
/>
|
|
293
|
+
</div>
|
|
294
|
+
</Modal>
|
|
295
|
+
</div>
|
|
296
|
+
)
|
|
297
|
+
}
|
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
import { useState, useEffect, useCallback } from 'react'
|
|
2
|
+
import { Table, Card, Tag, Button, Space, Select, Input, Modal, Popconfirm, message } from 'antd'
|
|
3
|
+
import { CheckCircle, XCircle, Star, Download, Trash2, Search, RefreshCw } from 'lucide-react'
|
|
4
|
+
import type { ColumnsType } from 'antd/es/table'
|
|
5
|
+
import { apiClient, api } from '../services/apiClient'
|
|
6
|
+
import type { Plugin } from '@shared/modules/plugins'
|
|
7
|
+
|
|
8
|
+
type PluginStatus = 'pending' | 'approved' | 'rejected'
|
|
9
|
+
|
|
10
|
+
const STATUS_VALUES = ['pending', 'approved', 'rejected'] as const
|
|
11
|
+
|
|
12
|
+
const STATUS_CONFIG: Record<string, { color: string; label: string }> = {
|
|
13
|
+
pending: { color: 'orange', label: '待审核' },
|
|
14
|
+
approved: { color: 'green', label: '已通过' },
|
|
15
|
+
rejected: { color: 'red', label: '已拒绝' },
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const STATUS_OPTIONS = [
|
|
19
|
+
{ value: '', label: '全部状态' },
|
|
20
|
+
{ value: 'pending', label: '待审核' },
|
|
21
|
+
{ value: 'approved', label: '已通过' },
|
|
22
|
+
{ value: 'rejected', label: '已拒绝' },
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
export const PluginManagementPage: React.FC = () => {
|
|
26
|
+
const [plugins, setPlugins] = useState<Plugin[]>([])
|
|
27
|
+
const [total, setTotal] = useState(0)
|
|
28
|
+
const [page, setPage] = useState(1)
|
|
29
|
+
const [loading, setLoading] = useState(false)
|
|
30
|
+
const [search, setSearch] = useState('')
|
|
31
|
+
const [statusFilter, setStatusFilter] = useState<string>('')
|
|
32
|
+
const [rejectModalVisible, setRejectModalVisible] = useState(false)
|
|
33
|
+
const [rejectTarget, setRejectTarget] = useState<string | null>(null)
|
|
34
|
+
const [rejectReason, setRejectReason] = useState('')
|
|
35
|
+
|
|
36
|
+
const fetchPlugins = useCallback(async () => {
|
|
37
|
+
setLoading(true)
|
|
38
|
+
try {
|
|
39
|
+
const query: Record<string, unknown> = { page, limit: 20 }
|
|
40
|
+
if (statusFilter && STATUS_VALUES.includes(statusFilter as PluginStatus)) {
|
|
41
|
+
query.status = statusFilter
|
|
42
|
+
}
|
|
43
|
+
const result = await api(apiClient.api.plugins.$get({ query })).withLoading().json()
|
|
44
|
+
setPlugins(result.plugins)
|
|
45
|
+
setTotal(result.total)
|
|
46
|
+
} catch {
|
|
47
|
+
// 错误已由 api-request 自动处理
|
|
48
|
+
} finally {
|
|
49
|
+
setLoading(false)
|
|
50
|
+
}
|
|
51
|
+
}, [page, statusFilter])
|
|
52
|
+
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
fetchPlugins()
|
|
55
|
+
}, [fetchPlugins])
|
|
56
|
+
|
|
57
|
+
const handleSearch = () => {
|
|
58
|
+
const term = search.toLowerCase().trim()
|
|
59
|
+
if (!term) {
|
|
60
|
+
fetchPlugins()
|
|
61
|
+
return
|
|
62
|
+
}
|
|
63
|
+
setPlugins(prev =>
|
|
64
|
+
prev.filter(
|
|
65
|
+
p =>
|
|
66
|
+
p.name.toLowerCase().includes(term) ||
|
|
67
|
+
p.authorName.toLowerCase().includes(term) ||
|
|
68
|
+
p.slug.toLowerCase().includes(term)
|
|
69
|
+
)
|
|
70
|
+
)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const handleApprove = async (slug: string) => {
|
|
74
|
+
try {
|
|
75
|
+
await api(apiClient.api.plugins[':slug'].approve.$put({ param: { slug } }))
|
|
76
|
+
.withLoading('审核通过中...')
|
|
77
|
+
.json()
|
|
78
|
+
message.success('插件已通过审核')
|
|
79
|
+
fetchPlugins()
|
|
80
|
+
} catch {
|
|
81
|
+
// 错误已由 api-request 自动处理
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const openRejectModal = (slug: string) => {
|
|
86
|
+
setRejectTarget(slug)
|
|
87
|
+
setRejectReason('')
|
|
88
|
+
setRejectModalVisible(true)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const handleReject = async () => {
|
|
92
|
+
if (!rejectTarget || !rejectReason.trim()) {
|
|
93
|
+
message.warning('请填写拒绝原因')
|
|
94
|
+
return
|
|
95
|
+
}
|
|
96
|
+
try {
|
|
97
|
+
await api(
|
|
98
|
+
apiClient.api.plugins[':slug'].reject.$put({
|
|
99
|
+
param: { slug: rejectTarget },
|
|
100
|
+
json: { reason: rejectReason },
|
|
101
|
+
})
|
|
102
|
+
)
|
|
103
|
+
.withLoading('拒绝中...')
|
|
104
|
+
.json()
|
|
105
|
+
message.success('插件已拒绝')
|
|
106
|
+
setRejectModalVisible(false)
|
|
107
|
+
setRejectTarget(null)
|
|
108
|
+
setRejectReason('')
|
|
109
|
+
fetchPlugins()
|
|
110
|
+
} catch {
|
|
111
|
+
// 错误已由 api-request 自动处理
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const handleToggleFeatured = async (slug: string, current: boolean) => {
|
|
116
|
+
try {
|
|
117
|
+
await api(apiClient.api.plugins[':slug'].feature.$put({ param: { slug } }))
|
|
118
|
+
.withLoading(current ? '取消推荐中...' : '设为推荐中...')
|
|
119
|
+
.json()
|
|
120
|
+
message.success(current ? '已取消推荐' : '已设为推荐')
|
|
121
|
+
fetchPlugins()
|
|
122
|
+
} catch {
|
|
123
|
+
// 错误已由 api-request 自动处理
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const handleRemove = async (slug: string) => {
|
|
128
|
+
try {
|
|
129
|
+
await api(apiClient.api.plugins[':slug'].$delete({ param: { slug } }))
|
|
130
|
+
.withLoading('删除中...')
|
|
131
|
+
.json()
|
|
132
|
+
message.success('插件已删除')
|
|
133
|
+
fetchPlugins()
|
|
134
|
+
} catch {
|
|
135
|
+
// 错误已由 api-request 自动处理
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const columns: ColumnsType<Plugin> = [
|
|
140
|
+
{
|
|
141
|
+
title: '插件名称',
|
|
142
|
+
dataIndex: 'name',
|
|
143
|
+
key: 'name',
|
|
144
|
+
render: (name: string, record: Plugin) => (
|
|
145
|
+
<div>
|
|
146
|
+
<div className="font-medium">{name}</div>
|
|
147
|
+
<div className="text-xs text-gray-400">{record.slug}</div>
|
|
148
|
+
</div>
|
|
149
|
+
),
|
|
150
|
+
},
|
|
151
|
+
{
|
|
152
|
+
title: '作者',
|
|
153
|
+
dataIndex: 'authorName',
|
|
154
|
+
key: 'authorName',
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
title: '状态',
|
|
158
|
+
dataIndex: 'status',
|
|
159
|
+
key: 'status',
|
|
160
|
+
render: (status: string) => {
|
|
161
|
+
const cfg = STATUS_CONFIG[status]
|
|
162
|
+
return cfg ? <Tag color={cfg.color}>{cfg.label}</Tag> : <Tag>{status}</Tag>
|
|
163
|
+
},
|
|
164
|
+
},
|
|
165
|
+
{
|
|
166
|
+
title: '下载量',
|
|
167
|
+
dataIndex: 'downloadCount',
|
|
168
|
+
key: 'downloadCount',
|
|
169
|
+
sorter: (a: Plugin, b: Plugin) => a.downloadCount - b.downloadCount,
|
|
170
|
+
render: (count: number) => (
|
|
171
|
+
<span className="flex items-center gap-1">
|
|
172
|
+
<Download className="w-3 h-3" /> {count.toLocaleString()}
|
|
173
|
+
</span>
|
|
174
|
+
),
|
|
175
|
+
},
|
|
176
|
+
{
|
|
177
|
+
title: '推荐',
|
|
178
|
+
dataIndex: 'featured',
|
|
179
|
+
key: 'featured',
|
|
180
|
+
render: (featured: boolean) =>
|
|
181
|
+
featured ? (
|
|
182
|
+
<Tag color="gold" icon={<Star className="w-3 h-3 inline" />}>
|
|
183
|
+
推荐
|
|
184
|
+
</Tag>
|
|
185
|
+
) : (
|
|
186
|
+
<Tag>普通</Tag>
|
|
187
|
+
),
|
|
188
|
+
filters: [
|
|
189
|
+
{ text: '推荐', value: true },
|
|
190
|
+
{ text: '普通', value: false },
|
|
191
|
+
],
|
|
192
|
+
onFilter: (value, record) => record.featured === value,
|
|
193
|
+
},
|
|
194
|
+
{
|
|
195
|
+
title: '版本',
|
|
196
|
+
dataIndex: 'version',
|
|
197
|
+
key: 'version',
|
|
198
|
+
render: (v: string) => <code className="text-xs">{v}</code>,
|
|
199
|
+
},
|
|
200
|
+
{
|
|
201
|
+
title: '更新时间',
|
|
202
|
+
dataIndex: 'updatedAt',
|
|
203
|
+
key: 'updatedAt',
|
|
204
|
+
render: (ts: number) => new Date(ts).toLocaleString('zh-CN'),
|
|
205
|
+
sorter: (a: Plugin, b: Plugin) => a.updatedAt - b.updatedAt,
|
|
206
|
+
defaultSortOrder: 'descend',
|
|
207
|
+
},
|
|
208
|
+
{
|
|
209
|
+
title: '操作',
|
|
210
|
+
key: 'actions',
|
|
211
|
+
width: 280,
|
|
212
|
+
render: (_: unknown, record: Plugin) => (
|
|
213
|
+
<Space size="small" wrap>
|
|
214
|
+
{record.status === 'pending' && (
|
|
215
|
+
<>
|
|
216
|
+
<Popconfirm
|
|
217
|
+
title="确定通过此插件审核?"
|
|
218
|
+
onConfirm={() => handleApprove(record.slug)}
|
|
219
|
+
okText="通过"
|
|
220
|
+
cancelText="取消"
|
|
221
|
+
>
|
|
222
|
+
<Button type="link" size="small" className="text-green-600">
|
|
223
|
+
<CheckCircle className="w-3.5 h-3.5 mr-1 inline" />
|
|
224
|
+
通过
|
|
225
|
+
</Button>
|
|
226
|
+
</Popconfirm>
|
|
227
|
+
<Button type="link" size="small" danger onClick={() => openRejectModal(record.slug)}>
|
|
228
|
+
<XCircle className="w-3.5 h-3.5 mr-1 inline" />
|
|
229
|
+
拒绝
|
|
230
|
+
</Button>
|
|
231
|
+
</>
|
|
232
|
+
)}
|
|
233
|
+
<Button
|
|
234
|
+
type="link"
|
|
235
|
+
size="small"
|
|
236
|
+
onClick={() => handleToggleFeatured(record.slug, record.featured)}
|
|
237
|
+
>
|
|
238
|
+
<Star className="w-3.5 h-3.5 mr-1 inline" />
|
|
239
|
+
{record.featured ? '取消推荐' : '推荐'}
|
|
240
|
+
</Button>
|
|
241
|
+
<Popconfirm
|
|
242
|
+
title="确定要删除此插件吗?此操作不可恢复"
|
|
243
|
+
onConfirm={() => handleRemove(record.slug)}
|
|
244
|
+
okText="删除"
|
|
245
|
+
cancelText="取消"
|
|
246
|
+
okButtonProps={{ danger: true }}
|
|
247
|
+
>
|
|
248
|
+
<Button type="link" size="small" danger>
|
|
249
|
+
<Trash2 className="w-3.5 h-3.5 mr-1 inline" />
|
|
250
|
+
删除
|
|
251
|
+
</Button>
|
|
252
|
+
</Popconfirm>
|
|
253
|
+
</Space>
|
|
254
|
+
),
|
|
255
|
+
},
|
|
256
|
+
]
|
|
257
|
+
|
|
258
|
+
return (
|
|
259
|
+
<div>
|
|
260
|
+
<div className="flex justify-between items-center mb-6">
|
|
261
|
+
<div>
|
|
262
|
+
<h1 className="text-2xl font-bold text-gray-900">插件管理</h1>
|
|
263
|
+
<p className="text-gray-600 mt-1">审核、管理和配置所有插件</p>
|
|
264
|
+
</div>
|
|
265
|
+
</div>
|
|
266
|
+
|
|
267
|
+
<Card className="shadow-sm">
|
|
268
|
+
<div className="flex items-center gap-4 mb-4">
|
|
269
|
+
<Input.Search
|
|
270
|
+
placeholder="搜索插件名称、作者、Slug..."
|
|
271
|
+
value={search}
|
|
272
|
+
onChange={e => setSearch(e.target.value)}
|
|
273
|
+
onSearch={handleSearch}
|
|
274
|
+
style={{ width: 320 }}
|
|
275
|
+
allowClear
|
|
276
|
+
enterButton={
|
|
277
|
+
<span className="flex items-center gap-1">
|
|
278
|
+
<Search className="w-4 h-4" /> 搜索
|
|
279
|
+
</span>
|
|
280
|
+
}
|
|
281
|
+
/>
|
|
282
|
+
<Select
|
|
283
|
+
placeholder="筛选状态"
|
|
284
|
+
style={{ width: 140 }}
|
|
285
|
+
value={statusFilter || undefined}
|
|
286
|
+
onChange={(val: string) => {
|
|
287
|
+
setStatusFilter(val || '')
|
|
288
|
+
setPage(1)
|
|
289
|
+
}}
|
|
290
|
+
options={STATUS_OPTIONS}
|
|
291
|
+
allowClear
|
|
292
|
+
/>
|
|
293
|
+
<Button icon={<RefreshCw className="w-4 h-4" />} onClick={fetchPlugins}>
|
|
294
|
+
刷新
|
|
295
|
+
</Button>
|
|
296
|
+
</div>
|
|
297
|
+
|
|
298
|
+
<Table
|
|
299
|
+
columns={columns}
|
|
300
|
+
dataSource={plugins}
|
|
301
|
+
rowKey="id"
|
|
302
|
+
loading={loading}
|
|
303
|
+
pagination={{
|
|
304
|
+
current: page,
|
|
305
|
+
pageSize: 20,
|
|
306
|
+
total,
|
|
307
|
+
showTotal: t => `共 ${t} 个插件`,
|
|
308
|
+
onChange: p => setPage(p),
|
|
309
|
+
}}
|
|
310
|
+
/>
|
|
311
|
+
</Card>
|
|
312
|
+
|
|
313
|
+
<Modal
|
|
314
|
+
title="拒绝插件"
|
|
315
|
+
open={rejectModalVisible}
|
|
316
|
+
onOk={handleReject}
|
|
317
|
+
onCancel={() => {
|
|
318
|
+
setRejectModalVisible(false)
|
|
319
|
+
setRejectTarget(null)
|
|
320
|
+
setRejectReason('')
|
|
321
|
+
}}
|
|
322
|
+
okText="确认拒绝"
|
|
323
|
+
cancelText="取消"
|
|
324
|
+
okButtonProps={{ danger: true }}
|
|
325
|
+
>
|
|
326
|
+
<div className="mb-4">
|
|
327
|
+
<p className="text-sm text-gray-500 mb-2">请填写拒绝原因:</p>
|
|
328
|
+
<Input.TextArea
|
|
329
|
+
rows={4}
|
|
330
|
+
value={rejectReason}
|
|
331
|
+
onChange={e => setRejectReason(e.target.value)}
|
|
332
|
+
placeholder="请输入拒绝原因..."
|
|
333
|
+
maxLength={500}
|
|
334
|
+
showCount
|
|
335
|
+
/>
|
|
336
|
+
</div>
|
|
337
|
+
</Modal>
|
|
338
|
+
</div>
|
|
339
|
+
)
|
|
340
|
+
}
|