@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.
- package/.env.example +50 -0
- package/LICENSE +127 -0
- package/Makefile +66 -0
- package/README.md +279 -0
- package/backend/.dockerignore +12 -0
- package/backend/Dockerfile +43 -0
- package/backend/alembic/env.py +75 -0
- package/backend/alembic/script.py.mako +25 -0
- package/backend/alembic/versions/0001_initial.py +88 -0
- package/backend/alembic/versions/350e2d9b6553_add_pr_comment_table.py +48 -0
- package/backend/alembic/versions/4f8342e727fe_sig_info_roster_fields_and_branch_.py +39 -0
- package/backend/alembic/versions/5a31ade90136_add_repository_credential_pull_request_.py +293 -0
- package/backend/alembic/versions/87a008142f17_add_classification_attention_and_ai_.py +436 -0
- package/backend/alembic/versions/a1c7d90e4b52_branch_belongs_to_a_repository.py +72 -0
- package/backend/alembic/versions/b41c9d7e5f28_extend_pr_kind_categories.py +43 -0
- package/backend/alembic/versions/c8e4f2a71b93_add_release_kind.py +40 -0
- package/backend/alembic/versions/d1275091dfdc_add_needs_detail_flag_to_pull_request.py +44 -0
- package/backend/alembic/versions/e5b27c9d3a41_downgrade_cve_without_ids.py +54 -0
- package/backend/alembic/versions/ee3159a4eff0_add_sig_meeting_member_and_release_.py +164 -0
- package/backend/alembic/versions/fcf3c186d63b_attention_rule_subscriptions.py +42 -0
- package/backend/alembic.ini +40 -0
- package/backend/app/__init__.py +0 -0
- package/backend/app/api/__init__.py +0 -0
- package/backend/app/api/deps.py +74 -0
- package/backend/app/api/v1/__init__.py +0 -0
- package/backend/app/api/v1/ai.py +386 -0
- package/backend/app/api/v1/analytics.py +48 -0
- package/backend/app/api/v1/attention.py +142 -0
- package/backend/app/api/v1/auth.py +106 -0
- package/backend/app/api/v1/classification.py +192 -0
- package/backend/app/api/v1/health.py +29 -0
- package/backend/app/api/v1/issues.py +143 -0
- package/backend/app/api/v1/pulls.py +388 -0
- package/backend/app/api/v1/repositories.py +273 -0
- package/backend/app/api/v1/router.py +32 -0
- package/backend/app/api/v1/sig.py +565 -0
- package/backend/app/api/v1/users.py +87 -0
- package/backend/app/api/v1/webhooks.py +135 -0
- package/backend/app/core/__init__.py +0 -0
- package/backend/app/core/config.py +72 -0
- package/backend/app/core/crypto.py +74 -0
- package/backend/app/core/db.py +79 -0
- package/backend/app/core/exceptions.py +60 -0
- package/backend/app/core/logging.py +69 -0
- package/backend/app/core/permissions.py +87 -0
- package/backend/app/core/queue.py +57 -0
- package/backend/app/core/security.py +69 -0
- package/backend/app/domain/__init__.py +0 -0
- package/backend/app/domain/attention.py +618 -0
- package/backend/app/domain/classification.py +689 -0
- package/backend/app/domain/meeting.py +408 -0
- package/backend/app/domain/release.py +163 -0
- package/backend/app/domain/review.py +416 -0
- package/backend/app/domain/sig.py +178 -0
- package/backend/app/domain/sig_info.py +201 -0
- package/backend/app/integrations/__init__.py +0 -0
- package/backend/app/integrations/atomgit/__init__.py +0 -0
- package/backend/app/integrations/atomgit/client.py +505 -0
- package/backend/app/integrations/atomgit/models.py +311 -0
- package/backend/app/integrations/llm/__init__.py +0 -0
- package/backend/app/integrations/llm/prompts.py +222 -0
- package/backend/app/integrations/llm/provider.py +326 -0
- package/backend/app/main.py +242 -0
- package/backend/app/middleware/__init__.py +0 -0
- package/backend/app/middleware/audit.py +129 -0
- package/backend/app/middleware/request_context.py +42 -0
- package/backend/app/models/__init__.py +112 -0
- package/backend/app/models/ai.py +214 -0
- package/backend/app/models/attention.py +131 -0
- package/backend/app/models/attention_settings.py +61 -0
- package/backend/app/models/audit.py +53 -0
- package/backend/app/models/base.py +35 -0
- package/backend/app/models/classification.py +144 -0
- package/backend/app/models/credential.py +56 -0
- package/backend/app/models/issue.py +132 -0
- package/backend/app/models/meeting.py +189 -0
- package/backend/app/models/pull_request.py +288 -0
- package/backend/app/models/repository.py +196 -0
- package/backend/app/models/sig.py +194 -0
- package/backend/app/models/user.py +44 -0
- package/backend/app/schemas/__init__.py +0 -0
- package/backend/app/schemas/ai.py +120 -0
- package/backend/app/schemas/attention.py +43 -0
- package/backend/app/schemas/auth.py +26 -0
- package/backend/app/schemas/classification.py +58 -0
- package/backend/app/schemas/common.py +43 -0
- package/backend/app/schemas/pull_request.py +214 -0
- package/backend/app/schemas/repository.py +94 -0
- package/backend/app/schemas/sig.py +246 -0
- package/backend/app/schemas/user.py +79 -0
- package/backend/app/services/__init__.py +0 -0
- package/backend/app/services/ai_service.py +569 -0
- package/backend/app/services/analytics_service.py +390 -0
- package/backend/app/services/attention_queue.py +451 -0
- package/backend/app/services/attention_service.py +432 -0
- package/backend/app/services/auth_service.py +74 -0
- package/backend/app/services/classification_service.py +658 -0
- package/backend/app/services/credential_service.py +105 -0
- package/backend/app/services/pull_query.py +273 -0
- package/backend/app/services/release_service.py +446 -0
- package/backend/app/services/repository_service.py +132 -0
- package/backend/app/services/sig_service.py +385 -0
- package/backend/app/services/sync_service.py +752 -0
- package/backend/app/services/user_service.py +68 -0
- package/backend/app/worker.py +389 -0
- package/backend/entrypoint.sh +10 -0
- package/backend/pyproject.toml +68 -0
- package/backend/tests/test_analysis_api.py +154 -0
- package/backend/tests/test_atomgit_client.py +360 -0
- package/backend/tests/test_atomgit_models.py +257 -0
- package/backend/tests/test_attention.py +291 -0
- package/backend/tests/test_audit_middleware.py +135 -0
- package/backend/tests/test_classification.py +498 -0
- package/backend/tests/test_config.py +41 -0
- package/backend/tests/test_crypto.py +68 -0
- package/backend/tests/test_exceptions.py +62 -0
- package/backend/tests/test_health.py +63 -0
- package/backend/tests/test_llm_provider.py +320 -0
- package/backend/tests/test_meeting_domain.py +169 -0
- package/backend/tests/test_permissions.py +69 -0
- package/backend/tests/test_pull_query_wiring.py +66 -0
- package/backend/tests/test_pull_schemas.py +82 -0
- package/backend/tests/test_release_domain.py +82 -0
- package/backend/tests/test_review_parser.py +301 -0
- package/backend/tests/test_schemas_user.py +97 -0
- package/backend/tests/test_security.py +92 -0
- package/cli/index.js +338 -0
- package/compose.yaml +105 -0
- package/frontend/.dockerignore +4 -0
- package/frontend/Dockerfile +27 -0
- package/frontend/index.html +14 -0
- package/frontend/nginx.conf +47 -0
- package/frontend/package-lock.json +5020 -0
- package/frontend/package.json +35 -0
- package/frontend/src/api/ai.ts +124 -0
- package/frontend/src/api/analytics.ts +66 -0
- package/frontend/src/api/attention.ts +181 -0
- package/frontend/src/api/auth.ts +74 -0
- package/frontend/src/api/classification.ts +170 -0
- package/frontend/src/api/issues.ts +90 -0
- package/frontend/src/api/pulls.ts +233 -0
- package/frontend/src/api/repositories.ts +95 -0
- package/frontend/src/api/sig.ts +232 -0
- package/frontend/src/app/antd-theme.ts +94 -0
- package/frontend/src/app/providers.tsx +59 -0
- package/frontend/src/app/router.tsx +411 -0
- package/frontend/src/app/search.ts +30 -0
- package/frontend/src/components/ClassificationBadge.tsx +58 -0
- package/frontend/src/components/GateBadge.tsx +13 -0
- package/frontend/src/components/SeverityBadge.tsx +20 -0
- package/frontend/src/components/layout/AppShell.tsx +16 -0
- package/frontend/src/components/layout/AuthLayout.tsx +32 -0
- package/frontend/src/components/layout/Sidebar.tsx +223 -0
- package/frontend/src/components/layout/TopBar.tsx +47 -0
- package/frontend/src/components/pulls/DiscussionTimeline.tsx +145 -0
- package/frontend/src/components/pulls/FacetRail.tsx +199 -0
- package/frontend/src/components/pulls/LabelChips.tsx +87 -0
- package/frontend/src/components/ui/alert.tsx +30 -0
- package/frontend/src/components/ui/badge.tsx +53 -0
- package/frontend/src/components/ui/button.tsx +51 -0
- package/frontend/src/components/ui/card.tsx +64 -0
- package/frontend/src/components/ui/chart-theme.ts +65 -0
- package/frontend/src/components/ui/data-table.tsx +39 -0
- package/frontend/src/components/ui/echart.tsx +70 -0
- package/frontend/src/components/ui/empty-state.tsx +22 -0
- package/frontend/src/components/ui/input.tsx +39 -0
- package/frontend/src/components/ui/lazy-chart.tsx +21 -0
- package/frontend/src/components/ui/skeleton.tsx +19 -0
- package/frontend/src/hooks/use-current-repository.ts +44 -0
- package/frontend/src/hooks/use-current-user.ts +38 -0
- package/frontend/src/lib/api-client.ts +93 -0
- package/frontend/src/lib/css-color.ts +60 -0
- package/frontend/src/lib/utils.ts +45 -0
- package/frontend/src/main.tsx +22 -0
- package/frontend/src/pages/attention/AttentionQueuePage.tsx +316 -0
- package/frontend/src/pages/attention/RuleSettingsPanel.tsx +177 -0
- package/frontend/src/pages/branches/BranchDetailPage.tsx +632 -0
- package/frontend/src/pages/branches/BranchListPage.tsx +308 -0
- package/frontend/src/pages/dashboard/DashboardPage.tsx +657 -0
- package/frontend/src/pages/issues/IssueListPage.tsx +284 -0
- package/frontend/src/pages/login/LoginPage.tsx +95 -0
- package/frontend/src/pages/meetings/MeetingDetailPage.tsx +403 -0
- package/frontend/src/pages/meetings/MeetingListPage.tsx +264 -0
- package/frontend/src/pages/members/MembersPage.tsx +534 -0
- package/frontend/src/pages/pulls/PullDetailPage.tsx +833 -0
- package/frontend/src/pages/pulls/PullListPage.tsx +399 -0
- package/frontend/src/pages/settings/AiSettingsPage.tsx +449 -0
- package/frontend/src/pages/settings/SettingsPage.tsx +321 -0
- package/frontend/src/pages/setup/SetupPage.tsx +144 -0
- package/frontend/src/styles/globals.css +147 -0
- package/frontend/tsconfig.json +22 -0
- package/frontend/vite.config.ts +57 -0
- package/package.json +48 -0
- package/scripts/e2e-auth-flow.py +131 -0
- package/scripts/e2e-pr-detail.py +128 -0
- package/scripts/e2e-verify.py +773 -0
- package/scripts/verify-ai-pipeline.py +616 -0
- package/scripts/verify-analysis-pipeline.py +303 -0
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import type { PRLabel } from '@/api/pulls'
|
|
2
|
+
import { cn } from '@/lib/utils'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* 上游标签。
|
|
6
|
+
*
|
|
7
|
+
* 色值直接用 AtomGit 下发的十六进制,不做本地映射:标签颜色是上游约定的一部分
|
|
8
|
+
* (approved / lgtm / ci_successful 各有固定色),换成语义色就丢掉了
|
|
9
|
+
* "这个标签在上游长什么样"这层信息,而维护者常靠颜色一眼扫过。
|
|
10
|
+
*
|
|
11
|
+
* 代价是上游色板里存在低亮度色,深色底上直接当前景色会看不清,
|
|
12
|
+
* 因此按相对亮度决定要不要往白里提亮。
|
|
13
|
+
*/
|
|
14
|
+
export function LabelChips({
|
|
15
|
+
labels,
|
|
16
|
+
className,
|
|
17
|
+
}: {
|
|
18
|
+
labels: PRLabel[]
|
|
19
|
+
className?: string
|
|
20
|
+
}) {
|
|
21
|
+
if (labels.length === 0) return null
|
|
22
|
+
return (
|
|
23
|
+
<div className={cn('flex flex-wrap gap-1.5', className)}>
|
|
24
|
+
{labels.map((label) => (
|
|
25
|
+
<LabelChip key={label.name} label={label} />
|
|
26
|
+
))}
|
|
27
|
+
</div>
|
|
28
|
+
)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function LabelChip({ label }: { label: PRLabel }) {
|
|
32
|
+
const foreground = readableForeground(label.color)
|
|
33
|
+
return (
|
|
34
|
+
<span
|
|
35
|
+
className={cn(
|
|
36
|
+
'inline-flex items-center rounded-full border px-2 py-0.5',
|
|
37
|
+
'whitespace-nowrap text-2xs font-medium leading-none',
|
|
38
|
+
!foreground &&
|
|
39
|
+
'border-[var(--color-border-subtle)] bg-[var(--color-surface-2)] text-[var(--color-text-secondary)]',
|
|
40
|
+
)}
|
|
41
|
+
style={
|
|
42
|
+
foreground
|
|
43
|
+
? {
|
|
44
|
+
color: foreground,
|
|
45
|
+
backgroundColor: `color-mix(in oklch, ${foreground} 16%, transparent)`,
|
|
46
|
+
borderColor: `color-mix(in oklch, ${foreground} 38%, transparent)`,
|
|
47
|
+
}
|
|
48
|
+
: undefined
|
|
49
|
+
}
|
|
50
|
+
>
|
|
51
|
+
{label.name}
|
|
52
|
+
</span>
|
|
53
|
+
)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* 把上游色转成深色底上能看清的前景色。
|
|
58
|
+
*
|
|
59
|
+
* 亮度阈值取 0.45:低于它的颜色(深蓝、深红等)压在 surface-0 上对比度不足,
|
|
60
|
+
* 混白提亮后仍保留色相,标签之间依然能靠颜色区分。
|
|
61
|
+
* 非法色值返回 null,由调用方回退到中性样式 —— 上游字段是自由文本,
|
|
62
|
+
* 不能假设它一定是合法颜色。
|
|
63
|
+
*/
|
|
64
|
+
function readableForeground(color: string | null | undefined): string | null {
|
|
65
|
+
const rgb = parseHexColor(color)
|
|
66
|
+
if (!rgb) return null
|
|
67
|
+
const luminance = (0.2126 * rgb[0] + 0.7152 * rgb[1] + 0.0722 * rgb[2]) / 255
|
|
68
|
+
return luminance < 0.45 ? `color-mix(in oklch, ${color} 62%, white)` : (color as string)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function parseHexColor(color: string | null | undefined): [number, number, number] | null {
|
|
72
|
+
if (!color) return null
|
|
73
|
+
const value = color.trim().replace(/^#/, '')
|
|
74
|
+
const expanded =
|
|
75
|
+
value.length === 3
|
|
76
|
+
? value
|
|
77
|
+
.split('')
|
|
78
|
+
.map((char) => char + char)
|
|
79
|
+
.join('')
|
|
80
|
+
: value
|
|
81
|
+
if (!/^[0-9a-fA-F]{6}$/.test(expanded)) return null
|
|
82
|
+
return [
|
|
83
|
+
parseInt(expanded.slice(0, 2), 16),
|
|
84
|
+
parseInt(expanded.slice(2, 4), 16),
|
|
85
|
+
parseInt(expanded.slice(4, 6), 16),
|
|
86
|
+
]
|
|
87
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { Alert as AntAlert } from 'antd'
|
|
2
|
+
import type { ReactNode } from 'react'
|
|
3
|
+
|
|
4
|
+
type Tone = 'danger' | 'warning' | 'info' | 'success'
|
|
5
|
+
|
|
6
|
+
/** 只有 danger 对不上:AntD 管这一档叫 error。 */
|
|
7
|
+
const TYPES: Record<Tone, 'error' | 'warning' | 'info' | 'success'> = {
|
|
8
|
+
danger: 'error',
|
|
9
|
+
warning: 'warning',
|
|
10
|
+
info: 'info',
|
|
11
|
+
success: 'success',
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface AlertProps {
|
|
15
|
+
tone?: Tone
|
|
16
|
+
className?: string
|
|
17
|
+
children?: ReactNode
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* 行内提示。
|
|
22
|
+
*
|
|
23
|
+
* 内容走 `message` —— AntD 把提示正文放在那个槽位里,`children` 不是它的入口。
|
|
24
|
+
*
|
|
25
|
+
* 不传 `showIcon`,用它的默认值(不显示图标),与这一版界面里提示一直是
|
|
26
|
+
* 纯文字相符。要图标就显式打开,别指望它是默认的 —— 实测默认没有。
|
|
27
|
+
*/
|
|
28
|
+
export function Alert({ tone = 'info', className, children }: AlertProps) {
|
|
29
|
+
return <AntAlert type={TYPES[tone]} className={className} message={children} />
|
|
30
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { HTMLAttributes } from 'react'
|
|
2
|
+
|
|
3
|
+
import { cn } from '@/lib/utils'
|
|
4
|
+
|
|
5
|
+
export type BadgeTone = 'neutral' | 'success' | 'warning' | 'danger' | 'info' | 'accent'
|
|
6
|
+
|
|
7
|
+
const tones: Record<BadgeTone, string> = {
|
|
8
|
+
neutral:
|
|
9
|
+
'bg-[var(--color-surface-2)] text-[var(--color-text-secondary)] border-[var(--color-border-subtle)]',
|
|
10
|
+
success:
|
|
11
|
+
'bg-[color-mix(in_oklch,var(--color-success)_16%,transparent)] text-[var(--color-success)] border-[color-mix(in_oklch,var(--color-success)_35%,transparent)]',
|
|
12
|
+
warning:
|
|
13
|
+
'bg-[color-mix(in_oklch,var(--color-warning)_16%,transparent)] text-[var(--color-warning)] border-[color-mix(in_oklch,var(--color-warning)_35%,transparent)]',
|
|
14
|
+
danger:
|
|
15
|
+
'bg-[color-mix(in_oklch,var(--color-danger)_16%,transparent)] text-[var(--color-danger)] border-[color-mix(in_oklch,var(--color-danger)_35%,transparent)]',
|
|
16
|
+
info: 'bg-[color-mix(in_oklch,var(--color-info)_16%,transparent)] text-[var(--color-info)] border-[color-mix(in_oklch,var(--color-info)_35%,transparent)]',
|
|
17
|
+
accent:
|
|
18
|
+
'bg-[color-mix(in_oklch,var(--color-accent)_16%,transparent)] text-[var(--color-accent)] border-[color-mix(in_oklch,var(--color-accent)_35%,transparent)]',
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* 语义色到 CSS 变量的映射。
|
|
23
|
+
*
|
|
24
|
+
* 徽章之外的地方(进度条、图表、圆点)也要用同一套语义色,
|
|
25
|
+
* 从徽章里取色而不是各写一遍 —— 否则"危险"在一处是红、在另一处是橙,
|
|
26
|
+
* 使用者就得重新学一遍颜色的含义。
|
|
27
|
+
*/
|
|
28
|
+
export const TONE_COLOR: Record<BadgeTone, string> = {
|
|
29
|
+
neutral: 'var(--color-text-muted)',
|
|
30
|
+
success: 'var(--color-success)',
|
|
31
|
+
warning: 'var(--color-warning)',
|
|
32
|
+
danger: 'var(--color-danger)',
|
|
33
|
+
info: 'var(--color-info)',
|
|
34
|
+
accent: 'var(--color-accent)',
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface BadgeProps extends HTMLAttributes<HTMLSpanElement> {
|
|
38
|
+
tone?: BadgeTone
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function Badge({ className, tone = 'neutral', ...props }: BadgeProps) {
|
|
42
|
+
return (
|
|
43
|
+
<span
|
|
44
|
+
className={cn(
|
|
45
|
+
'inline-flex items-center gap-1 rounded-full border px-2 py-0.5',
|
|
46
|
+
'whitespace-nowrap text-2xs font-medium leading-none',
|
|
47
|
+
tones[tone],
|
|
48
|
+
className,
|
|
49
|
+
)}
|
|
50
|
+
{...props}
|
|
51
|
+
/>
|
|
52
|
+
)
|
|
53
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { Button as AntButton, type ButtonProps as AntButtonProps } from 'antd'
|
|
2
|
+
|
|
3
|
+
type Variant = 'primary' | 'secondary' | 'ghost' | 'danger'
|
|
4
|
+
type Size = 'sm' | 'md'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* 四个变体映射到 AntD 的 color + variant。
|
|
8
|
+
*
|
|
9
|
+
* 用这一对新 API 而不是 `type` 与 `danger`:后者是 v6 保留下来兼容旧代码的
|
|
10
|
+
* 写法,新代码再往上写等于一开始就欠一笔迁移。
|
|
11
|
+
*/
|
|
12
|
+
const VARIANTS: Record<Variant, Pick<AntButtonProps, 'color' | 'variant'>> = {
|
|
13
|
+
primary: { color: 'primary', variant: 'solid' },
|
|
14
|
+
secondary: { color: 'default', variant: 'outlined' },
|
|
15
|
+
ghost: { color: 'default', variant: 'text' },
|
|
16
|
+
danger: { color: 'danger', variant: 'solid' },
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* 尺寸沿用原来的两档语义,具体高度由主题里的 token 决定。
|
|
21
|
+
*
|
|
22
|
+
* 这里刻意不写 Tailwind 的高度类(`h-7`/`h-9`):AntD 注入的是无层样式,
|
|
23
|
+
* 优先级高于 Tailwind 的 utilities,写上去会被 `.ant-btn` 吃掉 —— 加不加
|
|
24
|
+
* 都一样,只会让人以为调过了。
|
|
25
|
+
*/
|
|
26
|
+
const SIZES: Record<Size, AntButtonProps['size']> = { sm: 'small', md: 'middle' }
|
|
27
|
+
|
|
28
|
+
export interface ButtonProps
|
|
29
|
+
extends Omit<AntButtonProps, 'variant' | 'size' | 'color' | 'type' | 'htmlType'> {
|
|
30
|
+
variant?: Variant
|
|
31
|
+
size?: Size
|
|
32
|
+
/**
|
|
33
|
+
* HTML 的按钮类型,不是外观。AntD 把外观叫 `type`、把这个叫 `htmlType`,
|
|
34
|
+
* 沿用谁都别扭,这里保留调用方原本的直觉:`type="submit"` 就是提交表单。
|
|
35
|
+
*/
|
|
36
|
+
type?: 'submit' | 'button' | 'reset'
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* 按钮。内部是 AntD 的 Button,对调用方保持原来四个变体与两档尺寸的契约 ——
|
|
41
|
+
* 二十多处调用点不必为一个纯实现替换而逐个改动。
|
|
42
|
+
*
|
|
43
|
+
* 那个 `type` 的转译不只是改名:AntD 自己的 `type` 里没有 `submit` 这个值,
|
|
44
|
+
* 直接透传的话表单页上四处 `<Button type="submit">` 会变成"外观参数无效",
|
|
45
|
+
* 按钮还在、点了没反应,而报错信息一条都不会有。
|
|
46
|
+
*
|
|
47
|
+
* `loading` 与 `disabled` 直接透传:AntD 原生就有,原先手搓的 spinner 随之删掉。
|
|
48
|
+
*/
|
|
49
|
+
export function Button({ variant = 'primary', size = 'md', type, ...props }: ButtonProps) {
|
|
50
|
+
return <AntButton {...VARIANTS[variant]} size={SIZES[size]} htmlType={type} {...props} />
|
|
51
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { HTMLAttributes } from 'react'
|
|
2
|
+
|
|
3
|
+
import { cn } from '@/lib/utils'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* 卡片。
|
|
7
|
+
*
|
|
8
|
+
* 阴影而不是只靠描边:深色下 1px 描边 + 0.04 明度差区分不出层次,
|
|
9
|
+
* 一屏十几张卡片会糊成一片。可点的卡片在悬浮时再抬高一档 ——
|
|
10
|
+
* 这也是"整行/整卡可点"的唯一视觉提示。
|
|
11
|
+
*/
|
|
12
|
+
export function Card({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
|
13
|
+
return (
|
|
14
|
+
<div
|
|
15
|
+
className={cn(
|
|
16
|
+
'rounded-[var(--radius-card)] bg-[var(--color-surface-1)]',
|
|
17
|
+
'border border-[var(--color-border-subtle)]',
|
|
18
|
+
'shadow-[var(--shadow-card)]',
|
|
19
|
+
className,
|
|
20
|
+
)}
|
|
21
|
+
{...props}
|
|
22
|
+
/>
|
|
23
|
+
)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function CardHeader({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
|
27
|
+
return <div className={cn('flex flex-col gap-1 p-5 pb-3', className)} {...props} />
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function CardTitle({ className, ...props }: HTMLAttributes<HTMLHeadingElement>) {
|
|
31
|
+
return <h3 className={cn('text-sm font-semibold tracking-tight', className)} {...props} />
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* 卡片标题下的说明。
|
|
36
|
+
*
|
|
37
|
+
* 比标题小一级而不是同号:同号时只剩字重差别,标题与说明在扫读时
|
|
38
|
+
* 分不开 —— 一屏十几张卡片,读者需要的是"先看到标题"。
|
|
39
|
+
*/
|
|
40
|
+
export function CardDescription({ className, ...props }: HTMLAttributes<HTMLParagraphElement>) {
|
|
41
|
+
return <p className={cn('text-xs text-[var(--color-text-secondary)]', className)} {...props} />
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* 卡片的正文,用于**紧跟标题**的情形。
|
|
46
|
+
*
|
|
47
|
+
* 上边距为 0:标题自己带了下边距,再加一层会让标题与正文之间
|
|
48
|
+
* 比左右内边距宽出一截。
|
|
49
|
+
*/
|
|
50
|
+
export function CardContent({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
|
51
|
+
return <div className={cn('p-5 pt-0', className)} {...props} />
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* 无标题卡片的正文。
|
|
56
|
+
*
|
|
57
|
+
* 没有标题就不该借用 CardContent 的 ``pt-0`` —— 那样内容会顶到上边框,
|
|
58
|
+
* 各页面只好各写一个 ``pt-4`` / ``pt-5`` 去补,补出来的数值还不一样:
|
|
59
|
+
* 横向 20px、纵向 16px。两张卡片并排时文字对不齐,看的人说不出哪里不对,
|
|
60
|
+
* 只觉得"没排好"。指标卡、单行卡片一律用这个。
|
|
61
|
+
*/
|
|
62
|
+
export function CardBody({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
|
63
|
+
return <div className={cn('p-5', className)} {...props} />
|
|
64
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { cssVar } from '@/lib/css-color'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 图表用色。
|
|
5
|
+
*
|
|
6
|
+
* 单独成文件而不是放在 echart.tsx 里:echarts 体积很大(约 340KB gzip),
|
|
7
|
+
* 必须按需加载;而构建图表配置的代码需要这些颜色值,若从 echart.tsx
|
|
8
|
+
* 引入就会把 echarts 一起静态打进主包,动态加载也就白做了。
|
|
9
|
+
*
|
|
10
|
+
* 取值**从 globals.css 的令牌现读**,不再抄一份 oklch 字面量。抄一份的话
|
|
11
|
+
* 改令牌时图表不会跟着变,界面上就会出现"卡片换了皮、图表还是旧色",
|
|
12
|
+
* 而且从任何一处代码都看不出来。曾经这里就有一份副本,其中的
|
|
13
|
+
* 六个值与令牌一字不差 —— 一字不差正是没人会去核对的那种重复。
|
|
14
|
+
*
|
|
15
|
+
* 用 getter 而不是模块级常量:常量在模块求值时就要读样式表,
|
|
16
|
+
* 而那张表由 Vite 注入,先后顺序没有保证。getter 在图表真正构建
|
|
17
|
+
* 配置时才读,那时页面早已挂载。
|
|
18
|
+
*/
|
|
19
|
+
const FALLBACK = {
|
|
20
|
+
text: '#c4c4cc',
|
|
21
|
+
muted: '#8a8a94',
|
|
22
|
+
border: '#3c3c46',
|
|
23
|
+
surface: '#1c1c22',
|
|
24
|
+
accent: '#689cfe',
|
|
25
|
+
info: '#6ea8f5',
|
|
26
|
+
} as const
|
|
27
|
+
|
|
28
|
+
export const CHART_THEME = {
|
|
29
|
+
get text() {
|
|
30
|
+
return cssVar('--color-text-secondary') || FALLBACK.text
|
|
31
|
+
},
|
|
32
|
+
get muted() {
|
|
33
|
+
return cssVar('--color-text-muted') || FALLBACK.muted
|
|
34
|
+
},
|
|
35
|
+
get border() {
|
|
36
|
+
return cssVar('--color-border-subtle') || FALLBACK.border
|
|
37
|
+
},
|
|
38
|
+
get surface() {
|
|
39
|
+
return cssVar('--color-surface-1') || FALLBACK.surface
|
|
40
|
+
},
|
|
41
|
+
get accent() {
|
|
42
|
+
return cssVar('--color-accent') || FALLBACK.accent
|
|
43
|
+
},
|
|
44
|
+
get info() {
|
|
45
|
+
return cssVar('--color-info') || FALLBACK.info
|
|
46
|
+
},
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* 把 ``var(--token)`` 解析成具体色值。
|
|
51
|
+
*
|
|
52
|
+
* ECharts 画在 canvas 上,**拿不到 CSS 变量**:把 ``var(--color-kind-cve)``
|
|
53
|
+
* 直接递给它会解析失败并静默退回默认色,界面上表现为"所有类别的柱子
|
|
54
|
+
* 是同一个灰",而代码读起来完全正确 —— 这种偏差只能靠看渲染结果发现。
|
|
55
|
+
*
|
|
56
|
+
* 令牌仍然只在 globals.css 里维护一份,这里只是取当前计算值。
|
|
57
|
+
* 非 ``var()`` 的取值原样返回,因此可以直接包在别处已写好的颜色外面。
|
|
58
|
+
*/
|
|
59
|
+
export function chartColor(value: string): string {
|
|
60
|
+
const match = /^var\((--[^)]+)\)$/.exec(value.trim())
|
|
61
|
+
if (!match) return value
|
|
62
|
+
// 这里不做 sRGB 归一化:ECharts 的 zrender 认得 oklch,
|
|
63
|
+
// 而归一化要过一次 canvas 像素读写,没必要(AntD 那边才需要)。
|
|
64
|
+
return cssVar(match[1]) || value
|
|
65
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { Table } from 'antd'
|
|
2
|
+
import type { TableProps } from 'antd'
|
|
3
|
+
|
|
4
|
+
import { cn } from '@/lib/utils'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* 列表页共用的表格。
|
|
8
|
+
*
|
|
9
|
+
* 四个列表页要的是同样的几件事:紧凑的行高、随页粘住的表头、窄屏横向滚动、
|
|
10
|
+
* 以及与卡片一致的圆角边框。收在一处,各页只管自己的列与分页。
|
|
11
|
+
*
|
|
12
|
+
* 两条约束必须写在这里,因为它们踩错了不会报错,只会"看着有点不对":
|
|
13
|
+
*
|
|
14
|
+
* - **不能加 `overflow-hidden`**。它会把 sticky 表头的参照框从滚动容器
|
|
15
|
+
* 收缩到表格自身,表头于是跟着内容一起滚走 —— 而粘住的表头正是换成
|
|
16
|
+
* AntD Table 的主要理由。圆角因此改由表头自己带。
|
|
17
|
+
* - **sticky 的容器要显式指定**。滚动发生在 AppShell 的 `<main>` 上,
|
|
18
|
+
* 不是窗口;用默认值的话表头不会粘。
|
|
19
|
+
*/
|
|
20
|
+
const SHELL = cn(
|
|
21
|
+
'rounded-[var(--radius-card)] border border-[var(--color-border-subtle)]',
|
|
22
|
+
'[&_.ant-table-header]:rounded-t-[var(--radius-card)]',
|
|
23
|
+
'[&_.ant-table-header>table]:rounded-t-[var(--radius-card)]',
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
export function DataTable<T extends object>({ className, ...props }: TableProps<T>) {
|
|
27
|
+
return (
|
|
28
|
+
<Table<T>
|
|
29
|
+
// 默认档的单元格内边距是 16px,三十行下来比原来高出一大截,
|
|
30
|
+
// 而列表是用来扫读的,不是用来看的
|
|
31
|
+
size="small"
|
|
32
|
+
// 列宽固定,窄屏时横向滚动,而不是把每列压到不可读
|
|
33
|
+
scroll={{ x: 'max-content' }}
|
|
34
|
+
sticky={{ getContainer: () => document.querySelector('main') ?? window }}
|
|
35
|
+
className={cn(SHELL, className)}
|
|
36
|
+
{...props}
|
|
37
|
+
/>
|
|
38
|
+
)
|
|
39
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import * as echarts from 'echarts/core'
|
|
2
|
+
import { BarChart as BarSeries, LineChart as LineSeries } from 'echarts/charts'
|
|
3
|
+
import {
|
|
4
|
+
GridComponent,
|
|
5
|
+
LegendComponent,
|
|
6
|
+
TitleComponent,
|
|
7
|
+
TooltipComponent,
|
|
8
|
+
} from 'echarts/components'
|
|
9
|
+
import { CanvasRenderer } from 'echarts/renderers'
|
|
10
|
+
import { useEffect, useRef } from 'react'
|
|
11
|
+
|
|
12
|
+
import { cn } from '@/lib/utils'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* ECharts 封装。
|
|
16
|
+
*
|
|
17
|
+
* 直接用 echarts 核心 API 而不是某个 React 包装库:包装库的价值在于
|
|
18
|
+
* 声明式写法,代价是它要跟随 React 大版本升级,而这个项目在 React 19 上 ——
|
|
19
|
+
* 依赖一个自己维护渲染生命周期的包装库,比写这三十行更冒险。
|
|
20
|
+
*
|
|
21
|
+
* 按需注册(core + 具体图表类型)而不是 `import * as echarts from 'echarts'`:
|
|
22
|
+
* 后者会把全部图表类型打进包里,从 40KB 涨到 1MB。
|
|
23
|
+
*/
|
|
24
|
+
echarts.use([
|
|
25
|
+
BarSeries,
|
|
26
|
+
LineSeries,
|
|
27
|
+
GridComponent,
|
|
28
|
+
LegendComponent,
|
|
29
|
+
TitleComponent,
|
|
30
|
+
TooltipComponent,
|
|
31
|
+
CanvasRenderer,
|
|
32
|
+
])
|
|
33
|
+
|
|
34
|
+
export type EChartsOption = Parameters<echarts.ECharts['setOption']>[0]
|
|
35
|
+
|
|
36
|
+
export function EChart({
|
|
37
|
+
option,
|
|
38
|
+
className,
|
|
39
|
+
height = 260,
|
|
40
|
+
}: {
|
|
41
|
+
option: EChartsOption
|
|
42
|
+
className?: string
|
|
43
|
+
height?: number
|
|
44
|
+
}) {
|
|
45
|
+
const container = useRef<HTMLDivElement>(null)
|
|
46
|
+
const chart = useRef<echarts.ECharts | null>(null)
|
|
47
|
+
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
if (!container.current) return
|
|
50
|
+
chart.current = echarts.init(container.current, undefined, { renderer: 'canvas' })
|
|
51
|
+
|
|
52
|
+
// 容器宽度随侧栏折叠、窗口缩放而变,不监听就会留下拉伸变形的画布
|
|
53
|
+
const observer = new ResizeObserver(() => chart.current?.resize())
|
|
54
|
+
observer.observe(container.current)
|
|
55
|
+
|
|
56
|
+
return () => {
|
|
57
|
+
observer.disconnect()
|
|
58
|
+
chart.current?.dispose()
|
|
59
|
+
chart.current = null
|
|
60
|
+
}
|
|
61
|
+
}, [])
|
|
62
|
+
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
// notMerge=true:换数据时不合并旧配置,否则切换维度后残留的上一个
|
|
65
|
+
// 系列会留在图上(比如从 12 个月的柱状切到 6 个月,尾部仍有两根旧柱)
|
|
66
|
+
chart.current?.setOption(option, { notMerge: true })
|
|
67
|
+
}, [option])
|
|
68
|
+
|
|
69
|
+
return <div ref={container} className={cn('w-full', className)} style={{ height }} />
|
|
70
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 空状态。
|
|
3
|
+
*
|
|
4
|
+
* 给的是"接下来该做什么",而不是一句"暂无数据" —— 后者只说明这里没东西,
|
|
5
|
+
* 不说明为什么,也不说明能做什么。
|
|
6
|
+
*/
|
|
7
|
+
export function EmptyState({
|
|
8
|
+
title,
|
|
9
|
+
description,
|
|
10
|
+
}: {
|
|
11
|
+
title: string
|
|
12
|
+
description?: string
|
|
13
|
+
}) {
|
|
14
|
+
return (
|
|
15
|
+
<div className="flex flex-col items-center justify-center gap-1 px-6 py-14 text-center">
|
|
16
|
+
<p className="text-sm font-medium text-[var(--color-text-secondary)]">{title}</p>
|
|
17
|
+
{description && (
|
|
18
|
+
<p className="max-w-md text-sm text-[var(--color-text-muted)]">{description}</p>
|
|
19
|
+
)}
|
|
20
|
+
</div>
|
|
21
|
+
)
|
|
22
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { Input as AntInput, type InputProps as AntInputProps } from 'antd'
|
|
2
|
+
import type { LabelHTMLAttributes } from 'react'
|
|
3
|
+
|
|
4
|
+
import { cn } from '@/lib/utils'
|
|
5
|
+
|
|
6
|
+
export interface InputProps extends Omit<AntInputProps, 'status'> {
|
|
7
|
+
invalid?: boolean
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* 单行输入框。
|
|
12
|
+
*
|
|
13
|
+
* `invalid` 转到 AntD 的 `status="error"`:除了边框变红,读屏器也会念出
|
|
14
|
+
* 这是错误状态,而改边框颜色对读屏器什么都不是。
|
|
15
|
+
*
|
|
16
|
+
* 高度不再由这里写死。原来是 `h-9`(36px)而旁边的下拉是 32px,同一行里
|
|
17
|
+
* 差着 4px —— 换成 AntD 的控件高度后两者同高。想调就调主题里的
|
|
18
|
+
* `controlHeight`,不要往这个组件上挂高度类:AntD 的无层样式会把它们吃掉。
|
|
19
|
+
*/
|
|
20
|
+
export function Input({ invalid, ...props }: InputProps) {
|
|
21
|
+
return <AntInput status={invalid ? 'error' : undefined} {...props} />
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function Label({ className, ...props }: LabelHTMLAttributes<HTMLLabelElement>) {
|
|
25
|
+
return (
|
|
26
|
+
<label
|
|
27
|
+
className={cn(
|
|
28
|
+
'mb-1.5 block text-sm font-medium text-[var(--color-text-secondary)]',
|
|
29
|
+
className,
|
|
30
|
+
)}
|
|
31
|
+
{...props}
|
|
32
|
+
/>
|
|
33
|
+
)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function FieldError({ children }: { children?: React.ReactNode }) {
|
|
37
|
+
if (!children) return null
|
|
38
|
+
return <p className="mt-1.5 text-xs text-[var(--color-danger)]">{children}</p>
|
|
39
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { lazy, Suspense } from 'react'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 按需加载的图表组件。
|
|
5
|
+
*
|
|
6
|
+
* ECharts 本身约 340KB(gzip),而它只出现在版本页与成员页 —— 把它放进主包,
|
|
7
|
+
* 会让只看 PR 列表的人一起承担这段下载。这里用动态 import 把它拆成独立 chunk,
|
|
8
|
+
* 只有真正渲染图表时才取。
|
|
9
|
+
*/
|
|
10
|
+
const EChart = lazy(() =>
|
|
11
|
+
import('@/components/ui/echart').then((module) => ({ default: module.EChart })),
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
export function LazyChart({ option, height = 260 }: { option: unknown; height?: number }) {
|
|
15
|
+
return (
|
|
16
|
+
// 占位块与最终画布等高,避免图表加载完成时页面跳动
|
|
17
|
+
<Suspense fallback={<div style={{ height }} />}>
|
|
18
|
+
<EChart option={option as never} height={height} />
|
|
19
|
+
</Suspense>
|
|
20
|
+
)
|
|
21
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { cn } from '@/lib/utils'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 加载骨架。避免全屏 spinner 造成的布局跳动。
|
|
5
|
+
*
|
|
6
|
+
* 自己用 div 而不是 AntD 的 `Skeleton`,这一条是实测出来的:调用方靠
|
|
7
|
+
* Tailwind 类给尺寸(`h-9 w-full`、`h-64 w-full`),而 AntD 注入的是
|
|
8
|
+
* **无层样式**,优先级高于 Tailwind 的 utilities 层 —— 传进去的类会被
|
|
9
|
+
* 吃掉。实测 `Skeleton.Node` 不管给什么类都渲染成 96×96 的方块,
|
|
10
|
+
* 列表页上就是十几个错位的灰方块。
|
|
11
|
+
*
|
|
12
|
+
* AntD 的 Skeleton 面向的是"按内容形状渲染"(标题条、段落行、头像),
|
|
13
|
+
* 而这里要的是"占住任意高度的一块",只有 size 无法表达。
|
|
14
|
+
*/
|
|
15
|
+
export function Skeleton({ className }: { className?: string }) {
|
|
16
|
+
return (
|
|
17
|
+
<div aria-hidden className={cn('animate-pulse rounded bg-[var(--color-surface-2)]', className)} />
|
|
18
|
+
)
|
|
19
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { useQuery } from '@tanstack/react-query'
|
|
2
|
+
import { useRouterState } from '@tanstack/react-router'
|
|
3
|
+
|
|
4
|
+
import { repositoriesApi, repoKey, type Repository } from '@/api/repositories'
|
|
5
|
+
import type { RepoSearch } from '@/app/search'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* 当前上下文里的仓库。
|
|
9
|
+
*
|
|
10
|
+
* 仓库来自**地址栏**,不是某个全局变量。纳管多个仓库之后,"第一个仓库"
|
|
11
|
+
* 这种说法就没有意义了:同一个 `/branches/master` 在两个仓库里指的是两件事,
|
|
12
|
+
* 而地址栏是唯一能把这层意思带下去的地方(链接、书签、转发)。
|
|
13
|
+
*
|
|
14
|
+
* 没带 repo 参数时退回列表里的第一个,老链接因此仍然可用。
|
|
15
|
+
*/
|
|
16
|
+
export function useCurrentRepository(): {
|
|
17
|
+
repository: Repository | undefined
|
|
18
|
+
repositories: Repository[]
|
|
19
|
+
key: string | undefined
|
|
20
|
+
isLoading: boolean
|
|
21
|
+
} {
|
|
22
|
+
const { data, isLoading } = useQuery({
|
|
23
|
+
queryKey: ['repositories'],
|
|
24
|
+
queryFn: repositoriesApi.list,
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
// 从路由状态直接读,而不是 useSearch({ from })。这个 hook 要给侧栏用,
|
|
28
|
+
// 它不属于任何一个具体路由,写死 from 就只能在那一页工作。
|
|
29
|
+
const wanted = useRouterState({
|
|
30
|
+
select: (state) => (state.location.search as RepoSearch | undefined)?.repo,
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
const repositories = data ?? []
|
|
34
|
+
const repository = wanted
|
|
35
|
+
? repositories.find((item) => repoKey(item) === wanted)
|
|
36
|
+
: repositories[0]
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
repository,
|
|
40
|
+
repositories,
|
|
41
|
+
key: repository ? repoKey(repository) : undefined,
|
|
42
|
+
isLoading,
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { useQuery } from '@tanstack/react-query'
|
|
2
|
+
|
|
3
|
+
import { authApi, type CurrentUser, type Permission, type Role } from '@/api/auth'
|
|
4
|
+
|
|
5
|
+
const ROLE_ORDER: Role[] = ['VIEWER', 'REVIEWER', 'COMMITTER', 'MAINTAINER', 'ADMIN']
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* 当前登录用户。
|
|
9
|
+
*
|
|
10
|
+
* 直接以 React Query 缓存为唯一来源,**不再镜像到独立 store** ——
|
|
11
|
+
* 镜像需要 useEffect 同步,而 effect 在渲染之后才执行,
|
|
12
|
+
* 会造成「数据已到但组件仍读到 null」的渲染时序空窗,
|
|
13
|
+
* 表现为登录后被弹回登录页、刷新后会话丢失。
|
|
14
|
+
*/
|
|
15
|
+
export function useCurrentUser() {
|
|
16
|
+
const query = useQuery({
|
|
17
|
+
queryKey: ['me'],
|
|
18
|
+
queryFn: authApi.me,
|
|
19
|
+
retry: false,
|
|
20
|
+
staleTime: 30_000,
|
|
21
|
+
})
|
|
22
|
+
return {
|
|
23
|
+
user: query.data ?? null,
|
|
24
|
+
isLoading: query.isLoading,
|
|
25
|
+
isAuthenticated: Boolean(query.data),
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** 是否具备某项权限。前端判断仅供 UI 显隐,授权由后端强制执行。 */
|
|
30
|
+
export function can(user: CurrentUser | null, permission: Permission): boolean {
|
|
31
|
+
return user?.permissions.includes(permission) ?? false
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** 是否达到最低角色等级。 */
|
|
35
|
+
export function hasRole(user: CurrentUser | null, minimum: Role): boolean {
|
|
36
|
+
if (!user) return false
|
|
37
|
+
return ROLE_ORDER.indexOf(user.role) >= ROLE_ORDER.indexOf(minimum)
|
|
38
|
+
}
|