@kernel-sig/console 0.1.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.
Files changed (198) hide show
  1. package/.env.example +50 -0
  2. package/LICENSE +127 -0
  3. package/Makefile +66 -0
  4. package/README.md +279 -0
  5. package/backend/.dockerignore +12 -0
  6. package/backend/Dockerfile +43 -0
  7. package/backend/alembic/env.py +75 -0
  8. package/backend/alembic/script.py.mako +25 -0
  9. package/backend/alembic/versions/0001_initial.py +88 -0
  10. package/backend/alembic/versions/350e2d9b6553_add_pr_comment_table.py +48 -0
  11. package/backend/alembic/versions/4f8342e727fe_sig_info_roster_fields_and_branch_.py +39 -0
  12. package/backend/alembic/versions/5a31ade90136_add_repository_credential_pull_request_.py +293 -0
  13. package/backend/alembic/versions/87a008142f17_add_classification_attention_and_ai_.py +436 -0
  14. package/backend/alembic/versions/a1c7d90e4b52_branch_belongs_to_a_repository.py +72 -0
  15. package/backend/alembic/versions/b41c9d7e5f28_extend_pr_kind_categories.py +43 -0
  16. package/backend/alembic/versions/c8e4f2a71b93_add_release_kind.py +40 -0
  17. package/backend/alembic/versions/d1275091dfdc_add_needs_detail_flag_to_pull_request.py +44 -0
  18. package/backend/alembic/versions/e5b27c9d3a41_downgrade_cve_without_ids.py +54 -0
  19. package/backend/alembic/versions/ee3159a4eff0_add_sig_meeting_member_and_release_.py +164 -0
  20. package/backend/alembic/versions/fcf3c186d63b_attention_rule_subscriptions.py +42 -0
  21. package/backend/alembic.ini +40 -0
  22. package/backend/app/__init__.py +0 -0
  23. package/backend/app/api/__init__.py +0 -0
  24. package/backend/app/api/deps.py +74 -0
  25. package/backend/app/api/v1/__init__.py +0 -0
  26. package/backend/app/api/v1/ai.py +386 -0
  27. package/backend/app/api/v1/analytics.py +48 -0
  28. package/backend/app/api/v1/attention.py +142 -0
  29. package/backend/app/api/v1/auth.py +106 -0
  30. package/backend/app/api/v1/classification.py +192 -0
  31. package/backend/app/api/v1/health.py +29 -0
  32. package/backend/app/api/v1/issues.py +143 -0
  33. package/backend/app/api/v1/pulls.py +388 -0
  34. package/backend/app/api/v1/repositories.py +273 -0
  35. package/backend/app/api/v1/router.py +32 -0
  36. package/backend/app/api/v1/sig.py +565 -0
  37. package/backend/app/api/v1/users.py +87 -0
  38. package/backend/app/api/v1/webhooks.py +135 -0
  39. package/backend/app/core/__init__.py +0 -0
  40. package/backend/app/core/config.py +72 -0
  41. package/backend/app/core/crypto.py +74 -0
  42. package/backend/app/core/db.py +79 -0
  43. package/backend/app/core/exceptions.py +60 -0
  44. package/backend/app/core/logging.py +69 -0
  45. package/backend/app/core/permissions.py +87 -0
  46. package/backend/app/core/queue.py +57 -0
  47. package/backend/app/core/security.py +69 -0
  48. package/backend/app/domain/__init__.py +0 -0
  49. package/backend/app/domain/attention.py +618 -0
  50. package/backend/app/domain/classification.py +689 -0
  51. package/backend/app/domain/meeting.py +408 -0
  52. package/backend/app/domain/release.py +163 -0
  53. package/backend/app/domain/review.py +416 -0
  54. package/backend/app/domain/sig.py +178 -0
  55. package/backend/app/domain/sig_info.py +201 -0
  56. package/backend/app/integrations/__init__.py +0 -0
  57. package/backend/app/integrations/atomgit/__init__.py +0 -0
  58. package/backend/app/integrations/atomgit/client.py +505 -0
  59. package/backend/app/integrations/atomgit/models.py +311 -0
  60. package/backend/app/integrations/llm/__init__.py +0 -0
  61. package/backend/app/integrations/llm/prompts.py +222 -0
  62. package/backend/app/integrations/llm/provider.py +326 -0
  63. package/backend/app/main.py +242 -0
  64. package/backend/app/middleware/__init__.py +0 -0
  65. package/backend/app/middleware/audit.py +129 -0
  66. package/backend/app/middleware/request_context.py +42 -0
  67. package/backend/app/models/__init__.py +112 -0
  68. package/backend/app/models/ai.py +214 -0
  69. package/backend/app/models/attention.py +131 -0
  70. package/backend/app/models/attention_settings.py +61 -0
  71. package/backend/app/models/audit.py +53 -0
  72. package/backend/app/models/base.py +35 -0
  73. package/backend/app/models/classification.py +144 -0
  74. package/backend/app/models/credential.py +56 -0
  75. package/backend/app/models/issue.py +132 -0
  76. package/backend/app/models/meeting.py +189 -0
  77. package/backend/app/models/pull_request.py +288 -0
  78. package/backend/app/models/repository.py +196 -0
  79. package/backend/app/models/sig.py +194 -0
  80. package/backend/app/models/user.py +44 -0
  81. package/backend/app/schemas/__init__.py +0 -0
  82. package/backend/app/schemas/ai.py +120 -0
  83. package/backend/app/schemas/attention.py +43 -0
  84. package/backend/app/schemas/auth.py +26 -0
  85. package/backend/app/schemas/classification.py +58 -0
  86. package/backend/app/schemas/common.py +43 -0
  87. package/backend/app/schemas/pull_request.py +214 -0
  88. package/backend/app/schemas/repository.py +94 -0
  89. package/backend/app/schemas/sig.py +246 -0
  90. package/backend/app/schemas/user.py +79 -0
  91. package/backend/app/services/__init__.py +0 -0
  92. package/backend/app/services/ai_service.py +569 -0
  93. package/backend/app/services/analytics_service.py +390 -0
  94. package/backend/app/services/attention_queue.py +451 -0
  95. package/backend/app/services/attention_service.py +432 -0
  96. package/backend/app/services/auth_service.py +74 -0
  97. package/backend/app/services/classification_service.py +658 -0
  98. package/backend/app/services/credential_service.py +105 -0
  99. package/backend/app/services/pull_query.py +273 -0
  100. package/backend/app/services/release_service.py +446 -0
  101. package/backend/app/services/repository_service.py +132 -0
  102. package/backend/app/services/sig_service.py +385 -0
  103. package/backend/app/services/sync_service.py +752 -0
  104. package/backend/app/services/user_service.py +68 -0
  105. package/backend/app/worker.py +389 -0
  106. package/backend/entrypoint.sh +10 -0
  107. package/backend/pyproject.toml +68 -0
  108. package/backend/tests/test_analysis_api.py +154 -0
  109. package/backend/tests/test_atomgit_client.py +360 -0
  110. package/backend/tests/test_atomgit_models.py +257 -0
  111. package/backend/tests/test_attention.py +291 -0
  112. package/backend/tests/test_audit_middleware.py +135 -0
  113. package/backend/tests/test_classification.py +498 -0
  114. package/backend/tests/test_config.py +41 -0
  115. package/backend/tests/test_crypto.py +68 -0
  116. package/backend/tests/test_exceptions.py +62 -0
  117. package/backend/tests/test_health.py +63 -0
  118. package/backend/tests/test_llm_provider.py +320 -0
  119. package/backend/tests/test_meeting_domain.py +169 -0
  120. package/backend/tests/test_permissions.py +69 -0
  121. package/backend/tests/test_pull_query_wiring.py +66 -0
  122. package/backend/tests/test_pull_schemas.py +82 -0
  123. package/backend/tests/test_release_domain.py +82 -0
  124. package/backend/tests/test_review_parser.py +301 -0
  125. package/backend/tests/test_schemas_user.py +97 -0
  126. package/backend/tests/test_security.py +92 -0
  127. package/cli/index.js +338 -0
  128. package/compose.yaml +105 -0
  129. package/frontend/.dockerignore +4 -0
  130. package/frontend/Dockerfile +27 -0
  131. package/frontend/index.html +14 -0
  132. package/frontend/nginx.conf +47 -0
  133. package/frontend/package-lock.json +5020 -0
  134. package/frontend/package.json +35 -0
  135. package/frontend/src/api/ai.ts +124 -0
  136. package/frontend/src/api/analytics.ts +66 -0
  137. package/frontend/src/api/attention.ts +181 -0
  138. package/frontend/src/api/auth.ts +74 -0
  139. package/frontend/src/api/classification.ts +170 -0
  140. package/frontend/src/api/issues.ts +90 -0
  141. package/frontend/src/api/pulls.ts +233 -0
  142. package/frontend/src/api/repositories.ts +95 -0
  143. package/frontend/src/api/sig.ts +232 -0
  144. package/frontend/src/app/antd-theme.ts +94 -0
  145. package/frontend/src/app/providers.tsx +59 -0
  146. package/frontend/src/app/router.tsx +411 -0
  147. package/frontend/src/app/search.ts +30 -0
  148. package/frontend/src/components/ClassificationBadge.tsx +58 -0
  149. package/frontend/src/components/GateBadge.tsx +13 -0
  150. package/frontend/src/components/SeverityBadge.tsx +20 -0
  151. package/frontend/src/components/layout/AppShell.tsx +16 -0
  152. package/frontend/src/components/layout/AuthLayout.tsx +32 -0
  153. package/frontend/src/components/layout/Sidebar.tsx +223 -0
  154. package/frontend/src/components/layout/TopBar.tsx +47 -0
  155. package/frontend/src/components/pulls/DiscussionTimeline.tsx +145 -0
  156. package/frontend/src/components/pulls/FacetRail.tsx +199 -0
  157. package/frontend/src/components/pulls/LabelChips.tsx +87 -0
  158. package/frontend/src/components/ui/alert.tsx +30 -0
  159. package/frontend/src/components/ui/badge.tsx +53 -0
  160. package/frontend/src/components/ui/button.tsx +51 -0
  161. package/frontend/src/components/ui/card.tsx +64 -0
  162. package/frontend/src/components/ui/chart-theme.ts +65 -0
  163. package/frontend/src/components/ui/data-table.tsx +39 -0
  164. package/frontend/src/components/ui/echart.tsx +70 -0
  165. package/frontend/src/components/ui/empty-state.tsx +22 -0
  166. package/frontend/src/components/ui/input.tsx +39 -0
  167. package/frontend/src/components/ui/lazy-chart.tsx +21 -0
  168. package/frontend/src/components/ui/skeleton.tsx +19 -0
  169. package/frontend/src/hooks/use-current-repository.ts +44 -0
  170. package/frontend/src/hooks/use-current-user.ts +38 -0
  171. package/frontend/src/lib/api-client.ts +93 -0
  172. package/frontend/src/lib/css-color.ts +60 -0
  173. package/frontend/src/lib/utils.ts +45 -0
  174. package/frontend/src/main.tsx +22 -0
  175. package/frontend/src/pages/attention/AttentionQueuePage.tsx +316 -0
  176. package/frontend/src/pages/attention/RuleSettingsPanel.tsx +177 -0
  177. package/frontend/src/pages/branches/BranchDetailPage.tsx +632 -0
  178. package/frontend/src/pages/branches/BranchListPage.tsx +308 -0
  179. package/frontend/src/pages/dashboard/DashboardPage.tsx +657 -0
  180. package/frontend/src/pages/issues/IssueListPage.tsx +284 -0
  181. package/frontend/src/pages/login/LoginPage.tsx +95 -0
  182. package/frontend/src/pages/meetings/MeetingDetailPage.tsx +403 -0
  183. package/frontend/src/pages/meetings/MeetingListPage.tsx +264 -0
  184. package/frontend/src/pages/members/MembersPage.tsx +534 -0
  185. package/frontend/src/pages/pulls/PullDetailPage.tsx +833 -0
  186. package/frontend/src/pages/pulls/PullListPage.tsx +399 -0
  187. package/frontend/src/pages/settings/AiSettingsPage.tsx +449 -0
  188. package/frontend/src/pages/settings/SettingsPage.tsx +321 -0
  189. package/frontend/src/pages/setup/SetupPage.tsx +144 -0
  190. package/frontend/src/styles/globals.css +147 -0
  191. package/frontend/tsconfig.json +22 -0
  192. package/frontend/vite.config.ts +57 -0
  193. package/package.json +48 -0
  194. package/scripts/e2e-auth-flow.py +131 -0
  195. package/scripts/e2e-pr-detail.py +128 -0
  196. package/scripts/e2e-verify.py +773 -0
  197. package/scripts/verify-ai-pipeline.py +616 -0
  198. package/scripts/verify-analysis-pipeline.py +303 -0
@@ -0,0 +1,534 @@
1
+ import { useQuery } from '@tanstack/react-query'
2
+ import { Tabs, Tooltip } from 'antd'
3
+ import type { ColumnsType } from 'antd/es/table'
4
+ import { ExternalLink } from 'lucide-react'
5
+ import { useMemo, useState } from 'react'
6
+
7
+ import { useCurrentRepository } from '@/hooks/use-current-repository'
8
+ import {
9
+ MEMBER_ROLE_META,
10
+ sigApi,
11
+ type Member,
12
+ type MemberStats,
13
+ type SubsystemOwner,
14
+ } from '@/api/sig'
15
+ import { Badge } from '@/components/ui/badge'
16
+ import { Card, CardBody, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
17
+ import { DataTable } from '@/components/ui/data-table'
18
+ import { CHART_THEME } from '@/components/ui/chart-theme'
19
+ import type { EChartsOption } from '@/components/ui/echart'
20
+ import { LazyChart } from '@/components/ui/lazy-chart'
21
+ import { Input } from '@/components/ui/input'
22
+ import { EmptyState } from '@/components/ui/empty-state'
23
+ import { Skeleton } from '@/components/ui/skeleton'
24
+ import { cn, formatNumber } from '@/lib/utils'
25
+
26
+ type Tab = 'stats' | 'roster' | 'modules'
27
+
28
+ /**
29
+ * SIG 人员与模块归属。
30
+ *
31
+ * 分工明确的两块:名单是**权威事实**(谁有权评审、谁负责合入,以 community
32
+ * 仓库为准),统计是**派生结果**(谁提交得多、谁评审得多,由平台现算)。
33
+ * 分开成两个标签页,是因为它们的可信度来源不同 —— 混在一起会让人
34
+ * 以为"合入数高的就是 Committer"。
35
+ */
36
+ export function MembersPage({ member }: { member?: string }) {
37
+ const [tab, setTab] = useState<Tab>('stats')
38
+
39
+ return (
40
+ <div className="space-y-4">
41
+ <header>
42
+ <h1 className="text-base font-semibold tracking-tight">SIG 成员与模块归属</h1>
43
+ <p className="mt-0.5 text-sm text-[var(--color-text-secondary)]">
44
+ 成员名单与模块归属同步自 openeuler/community 的 sig/Kernel 文档,
45
+ 贡献统计由本平台从 PR 与评审记录现算。
46
+ </p>
47
+ </header>
48
+
49
+ <Tabs
50
+ activeKey={tab}
51
+ onChange={(key) => setTab(key as Tab)}
52
+ items={[
53
+ {
54
+ key: 'stats',
55
+ label: '贡献统计',
56
+ // key 绑定到 member:再次点另一个负责人时组件要重挂载,
57
+ // 否则 useState 的初值只在首次挂载生效,筛选框会停在上一个人身上
58
+ children: <StatsTab key={member ?? 'all'} initialQuery={member} />,
59
+ },
60
+ { key: 'roster', label: '成员名单', children: <RosterTab /> },
61
+ { key: 'modules', label: '模块归属', children: <ModulesTab /> },
62
+ ]}
63
+ />
64
+ </div>
65
+ )
66
+ }
67
+
68
+ /**
69
+ * 贡献统计的列。
70
+ *
71
+ * 写成函数而不是常量:占合入比那一列要按总合入数算百分比,而总数是取数之后
72
+ * 才知道的。把 total 作为参数传进来,「这一列依赖它」在签名上就是明的。
73
+ */
74
+ function statColumns(mergedTotal: number): ColumnsType<MemberStats> {
75
+ return [
76
+ {
77
+ title: '成员',
78
+ render: (_, item) => (
79
+ <>
80
+ <span className="font-mono text-xs">{item.login}</span>
81
+ {item.name && (
82
+ <span className="ml-2 text-xs text-[var(--color-text-muted)]">{item.name}</span>
83
+ )}
84
+ </>
85
+ ),
86
+ },
87
+ {
88
+ title: '角色',
89
+ width: 110,
90
+ render: (_, item) =>
91
+ item.role ? (
92
+ <Badge tone="neutral" className="font-normal">
93
+ {MEMBER_ROLE_META[item.role]?.label ?? item.role}
94
+ </Badge>
95
+ ) : (
96
+ <span className="text-xs text-[var(--color-text-muted)]">贡献者</span>
97
+ ),
98
+ },
99
+ {
100
+ title: '提交',
101
+ width: 80,
102
+ align: 'right',
103
+ render: (_, item) => <span className="font-mono text-xs tabular-nums">{item.authored}</span>,
104
+ },
105
+ {
106
+ title: '合入',
107
+ width: 80,
108
+ align: 'right',
109
+ render: (_, item) => <span className="font-mono text-xs tabular-nums">{item.merged}</span>,
110
+ },
111
+ {
112
+ title: '占合入比',
113
+ width: 170,
114
+ render: (_, item) => (
115
+ <span className="flex items-center gap-2">
116
+ <span className="h-1.5 flex-1 overflow-hidden rounded-full bg-[var(--color-surface-3)]">
117
+ <span
118
+ className="block h-full rounded-full bg-[var(--color-accent)]"
119
+ style={{ width: `${Math.round((item.merged / mergedTotal) * 100)}%` }}
120
+ />
121
+ </span>
122
+ <span className="w-9 shrink-0 text-right font-mono text-2xs tabular-nums text-[var(--color-text-muted)]">
123
+ {((item.merged / mergedTotal) * 100).toFixed(1)}%
124
+ </span>
125
+ </span>
126
+ ),
127
+ },
128
+ {
129
+ title: '评审',
130
+ width: 80,
131
+ align: 'right',
132
+ render: (_, item) => (
133
+ <span className="font-mono text-xs tabular-nums">{item.reviews || '—'}</span>
134
+ ),
135
+ },
136
+ {
137
+ title: '负责模块',
138
+ width: 180,
139
+ render: (_, item) => <ModuleChips modules={item.subsystems} />,
140
+ },
141
+ ]
142
+ }
143
+
144
+ function StatsTab({ initialQuery }: { initialQuery?: string }) {
145
+ const { repository } = useCurrentRepository()
146
+ const [query, setQuery] = useState(initialQuery ?? '')
147
+
148
+ const { data, isLoading } = useQuery({
149
+ queryKey: ['member-stats', repository?.id],
150
+ queryFn: () => sigApi.memberStats(repository!.id, 100),
151
+ enabled: Boolean(repository),
152
+ })
153
+
154
+ const filtered = useMemo(() => {
155
+ if (!query.trim()) return data ?? []
156
+ const needle = query.trim().toLowerCase()
157
+ return (data ?? []).filter(
158
+ (item) =>
159
+ item.login.toLowerCase().includes(needle) ||
160
+ (item.name ?? '').toLowerCase().includes(needle),
161
+ )
162
+ }, [data, query])
163
+
164
+ const mergedTotal = (data ?? []).reduce((sum, item) => sum + item.merged, 0) || 1
165
+ // 列定义依赖总数,所以在拿到数之后再建一次
166
+ const columns = useMemo(() => statColumns(mergedTotal), [mergedTotal])
167
+
168
+ return (
169
+ <div className="space-y-4">
170
+ <div className="grid gap-3 lg:grid-cols-[1fr_1fr]">
171
+ <Card>
172
+ <CardHeader>
173
+ <CardTitle>合入量前列</CardTitle>
174
+ </CardHeader>
175
+ <CardContent>
176
+ <LazyChart
177
+ height={280}
178
+ option={rankOption(
179
+ (data ?? []).slice(0, 12).map((item) => ({
180
+ label: item.name ?? item.login,
181
+ value: item.merged,
182
+ highlight: Boolean(item.role),
183
+ })),
184
+ )}
185
+ />
186
+ </CardContent>
187
+ </Card>
188
+ <Card>
189
+ <CardHeader>
190
+ <CardTitle>评审工作量</CardTitle>
191
+ </CardHeader>
192
+ <CardContent>
193
+ <LazyChart
194
+ height={280}
195
+ option={rankOption(
196
+ (data ?? [])
197
+ .filter((item) => item.reviews > 0)
198
+ .sort((left, right) => right.reviews - left.reviews)
199
+ .slice(0, 12)
200
+ .map((item) => ({
201
+ label: item.name ?? item.login,
202
+ value: item.reviews,
203
+ highlight: true,
204
+ })),
205
+ CHART_THEME.info,
206
+ )}
207
+ />
208
+ </CardContent>
209
+ </Card>
210
+ </div>
211
+
212
+ <Input
213
+ placeholder="按登录名或姓名筛选"
214
+ value={query}
215
+ onChange={(event) => setQuery(event.target.value)}
216
+ className="h-8 max-w-sm text-sm"
217
+ />
218
+
219
+ <DataTable<MemberStats>
220
+ rowKey="login"
221
+ columns={columns}
222
+ dataSource={filtered}
223
+ loading={isLoading}
224
+ locale={{
225
+ emptyText: <EmptyState title="没有匹配的成员" description="换个关键词,或清空筛选。" />,
226
+ }}
227
+ pagination={false}
228
+ />
229
+ </div>
230
+ )
231
+ }
232
+
233
+ function ModuleChips({ modules }: { modules: string[] }) {
234
+ if (modules.length === 0) {
235
+ return <span className="text-xs text-[var(--color-text-muted)]">—</span>
236
+ }
237
+ const shown = modules.slice(0, 3)
238
+ return (
239
+ <span className="flex flex-wrap gap-1">
240
+ {shown.map((module) => (
241
+ <Badge key={module} tone="neutral" className="font-normal">
242
+ {module}
243
+ </Badge>
244
+ ))}
245
+ {modules.length > shown.length && (
246
+ <span
247
+ className="text-2xs text-[var(--color-text-muted)]"
248
+ title={modules.slice(3).join('、')}
249
+ >
250
+ +{modules.length - shown.length}
251
+ </span>
252
+ )}
253
+ </span>
254
+ )
255
+ }
256
+
257
+ function RosterTab() {
258
+ const [role, setRole] = useState<string>('')
259
+ const { data, isLoading } = useQuery({
260
+ queryKey: ['members', role],
261
+ queryFn: () => sigApi.members(role || undefined),
262
+ })
263
+
264
+ const counts = useMemo(() => {
265
+ const map = new Map<string, number>()
266
+ for (const member of data ?? []) map.set(member.role, (map.get(member.role) ?? 0) + 1)
267
+ return map
268
+ }, [data])
269
+
270
+ return (
271
+ <div className="space-y-4">
272
+ <div className="flex flex-wrap gap-2">
273
+ {['', 'maintainer', 'committer', 'contributor'].map((item) => (
274
+ <button
275
+ key={item || 'all'}
276
+ type="button"
277
+ onClick={() => setRole(item)}
278
+ aria-pressed={role === item}
279
+ className={cn(
280
+ 'rounded-full border px-3 py-1 text-xs transition-colors duration-150',
281
+ role === item
282
+ ? 'border-[var(--color-accent)] text-[var(--color-accent)]'
283
+ : 'border-[var(--color-border-subtle)] text-[var(--color-text-secondary)] hover:border-[var(--color-border-strong)]',
284
+ )}
285
+ >
286
+ {item ? (MEMBER_ROLE_META[item]?.label ?? item) : '全部'}
287
+ {item && counts.get(item) != null && (
288
+ <span className="ml-1.5 font-mono tabular-nums text-[var(--color-text-muted)]">
289
+ {counts.get(item)}
290
+ </span>
291
+ )}
292
+ </button>
293
+ ))}
294
+ </div>
295
+
296
+ {isLoading ? (
297
+ <Skeleton className="h-64 w-full" />
298
+ ) : !data || data.length === 0 ? (
299
+ <EmptyState
300
+ title="尚无成员名单"
301
+ description="名单同步自 openeuler/community 的 sig/Kernel 文档,由定时任务拉取。"
302
+ />
303
+ ) : (
304
+ <div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
305
+ {data.map((member) => (
306
+ <MemberCard key={member.id} member={member} />
307
+ ))}
308
+ </div>
309
+ )}
310
+ </div>
311
+ )
312
+ }
313
+
314
+ function MemberCard({ member }: { member: Member }) {
315
+ const handle = member.atomgit_id ?? member.gitee_id ?? member.github_id
316
+ const platform = member.atomgit_id ? 'atomgit' : member.gitee_id ? 'gitee' : 'github'
317
+ return (
318
+ <Card>
319
+ <CardBody className="flex items-start gap-3">
320
+ <span
321
+ aria-hidden
322
+ className="flex size-8 shrink-0 items-center justify-center rounded-full bg-[var(--color-surface-3)] text-xs font-medium"
323
+ >
324
+ {member.name.slice(0, 1).toUpperCase()}
325
+ </span>
326
+ <div className="min-w-0 flex-1">
327
+ <div className="flex flex-wrap items-center gap-2">
328
+ <span className="truncate text-sm font-medium">{member.name}</span>
329
+ <Badge tone="neutral" className="font-normal">
330
+ {MEMBER_ROLE_META[member.role]?.label ?? member.role}
331
+ </Badge>
332
+ </div>
333
+ <p className="mt-0.5 truncate text-xs text-[var(--color-text-muted)]">
334
+ {handle ? (
335
+ <a
336
+ href={`https://${platform}.com/${handle}`}
337
+ target="_blank"
338
+ rel="noreferrer noopener"
339
+ className="inline-flex items-center gap-1 hover:text-[var(--color-accent)]"
340
+ >
341
+ @{handle}
342
+ <ExternalLink className="size-3" aria-hidden />
343
+ </a>
344
+ ) : (
345
+ '未登记平台账号'
346
+ )}
347
+ {member.org && ` · ${member.org}`}
348
+ </p>
349
+ {member.email && (
350
+ <p className="truncate text-2xs text-[var(--color-text-muted)]">{member.email}</p>
351
+ )}
352
+ </div>
353
+ </CardBody>
354
+ </Card>
355
+ )
356
+ }
357
+
358
+ const OWNER_COLUMNS: ColumnsType<SubsystemOwner> = [
359
+ { title: '模块', width: 180, render: (_, owner) => <span className="font-medium">{owner.module}</span> },
360
+ {
361
+ title: '章节',
362
+ width: 140,
363
+ render: (_, owner) => (
364
+ <span className="text-xs text-[var(--color-text-muted)]">{owner.section ?? '—'}</span>
365
+ ),
366
+ },
367
+ {
368
+ title: '内核路径',
369
+ render: (_, owner) => {
370
+ const paths = owner.paths ?? []
371
+ return (
372
+ <span className="flex flex-wrap gap-1">
373
+ {paths.slice(0, 4).map((path) => (
374
+ <code key={path} className="text-2xs text-[var(--color-text-secondary)]">
375
+ {path}
376
+ </code>
377
+ ))}
378
+ {paths.length > 4 && (
379
+ <Tooltip title={paths.slice(4).join('\n')}>
380
+ <span className="text-2xs text-[var(--color-text-muted)]">
381
+ +{paths.length - 4}
382
+ </span>
383
+ </Tooltip>
384
+ )}
385
+ {paths.length === 0 && (
386
+ <span className="text-2xs text-[var(--color-text-muted)]">—</span>
387
+ )}
388
+ </span>
389
+ )
390
+ },
391
+ },
392
+ {
393
+ title: 'Committer',
394
+ width: 220,
395
+ render: (_, owner) => (
396
+ <span className="flex flex-wrap gap-1">
397
+ {(owner.committer_logins ?? []).map((login) => (
398
+ <a
399
+ key={login}
400
+ href={`https://atomgit.com/${login}`}
401
+ target="_blank"
402
+ rel="noreferrer noopener"
403
+ className="font-mono text-2xs text-[var(--color-accent)] hover:underline"
404
+ >
405
+ @{login}
406
+ </a>
407
+ ))}
408
+ </span>
409
+ ),
410
+ },
411
+ ]
412
+
413
+ function ModulesTab() {
414
+ const [query, setQuery] = useState('')
415
+ const { data, isLoading } = useQuery({
416
+ queryKey: ['subsystem-owners'],
417
+ queryFn: sigApi.subsystemOwners,
418
+ })
419
+
420
+ const filtered = useMemo(() => {
421
+ const list = data ?? []
422
+ if (!query.trim()) return list
423
+ const needle = query.trim().toLowerCase()
424
+ return list.filter(
425
+ (item) =>
426
+ item.module.toLowerCase().includes(needle) ||
427
+ (item.paths ?? []).some((path) => path.toLowerCase().includes(needle)) ||
428
+ (item.committer_logins ?? []).some((login) => login.toLowerCase().includes(needle)),
429
+ )
430
+ }, [data, query])
431
+
432
+ const sections = useMemo(() => {
433
+ const map = new Map<string, number>()
434
+ for (const item of filtered) {
435
+ const key = item.section ?? '未分组'
436
+ map.set(key, (map.get(key) ?? 0) + 1)
437
+ }
438
+ return [...map.entries()].sort((left, right) => right[1] - left[1])
439
+ }, [filtered])
440
+
441
+ return (
442
+ <div className="space-y-4">
443
+ <Input
444
+ placeholder="按模块名、内核路径或 Committer 筛选"
445
+ value={query}
446
+ onChange={(event) => setQuery(event.target.value)}
447
+ className="h-8 max-w-md text-sm"
448
+ />
449
+ {sections.length > 0 && (
450
+ <p className="text-xs text-[var(--color-text-muted)]">
451
+ {sections.map(([name, count]) => `${name} ${count}`).join(' · ')}
452
+ </p>
453
+ )}
454
+
455
+ {isLoading ? (
456
+ <Skeleton className="h-64 w-full" />
457
+ ) : filtered.length === 0 ? (
458
+ <EmptyState title="没有匹配的模块" description="换个关键词,或清空筛选。" />
459
+ ) : (
460
+ <DataTable<SubsystemOwner>
461
+ rowKey="id"
462
+ columns={OWNER_COLUMNS}
463
+ dataSource={filtered}
464
+ pagination={false}
465
+ />
466
+ )}
467
+ <p className="text-xs text-[var(--color-text-muted)]">
468
+ 共 {formatNumber(filtered.length)} 个模块。归属表来自 committers.md,
469
+ 是"这个 PR 该找谁看"的依据。
470
+ </p>
471
+ </div>
472
+ )
473
+ }
474
+
475
+
476
+ /**
477
+ * 排行榜。横向条形,按值升序喂给 ECharts —— 它自下而上画,
478
+ * 升序传入才能让最大的一条出现在最上面。
479
+ *
480
+ * 已登记在册的 Committer 用强调色,其余用中性色:名单来自上游文档,
481
+ * 是权威事实;贡献量是平台算出来的派生结果。两者用颜色区分开,
482
+ * 避免"合入多 = 是 Committer"这种误读。
483
+ */
484
+ function rankOption(
485
+ rows: { label: string; value: number; highlight: boolean }[],
486
+ accent: string = CHART_THEME.accent,
487
+ ): EChartsOption {
488
+ if (rows.length === 0) {
489
+ return {
490
+ title: {
491
+ text: '暂无数据',
492
+ left: 'center',
493
+ top: 'middle',
494
+ textStyle: { color: CHART_THEME.muted, fontSize: 12, fontWeight: 'normal' },
495
+ },
496
+ }
497
+ }
498
+ const sorted = [...rows].sort((left, right) => left.value - right.value)
499
+ return {
500
+ grid: { left: 8, right: 40, top: 8, bottom: 4, containLabel: true },
501
+ tooltip: {
502
+ trigger: 'item',
503
+ backgroundColor: CHART_THEME.surface,
504
+ borderColor: CHART_THEME.border,
505
+ textStyle: { color: CHART_THEME.text, fontSize: 12 },
506
+ },
507
+ xAxis: {
508
+ type: 'value',
509
+ splitLine: { lineStyle: { color: CHART_THEME.border, type: 'dashed' } },
510
+ axisLabel: { color: CHART_THEME.muted, fontSize: 10 },
511
+ },
512
+ yAxis: {
513
+ type: 'category',
514
+ data: sorted.map((row) => row.label),
515
+ axisLine: { lineStyle: { color: CHART_THEME.border } },
516
+ axisLabel: { color: CHART_THEME.text, fontSize: 11 },
517
+ axisTick: { show: false },
518
+ },
519
+ series: [
520
+ {
521
+ type: 'bar',
522
+ data: sorted.map((row) => ({
523
+ value: row.value,
524
+ itemStyle: {
525
+ color: row.highlight ? accent : CHART_THEME.muted,
526
+ borderRadius: [0, 3, 3, 0],
527
+ },
528
+ })),
529
+ label: { show: true, position: 'right', color: CHART_THEME.muted, fontSize: 10 },
530
+ barMaxWidth: 12,
531
+ },
532
+ ],
533
+ }
534
+ }