@svadmin/ui 0.42.3 → 0.44.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/app.css +462 -825
- package/dist/app.theme.css +462 -825
- package/dist/components/ErrorPage.svelte +4 -56
- package/dist/components/ResourceOperationsPage.svelte +84 -223
- package/dist/components/ResourceOperationsPage.svelte.d.ts +1 -1
- package/dist/components/TwoFactorAuthPage.svelte +44 -232
- package/dist/components/account/CompanyProfilePage.svelte +21 -142
- package/dist/components/account/GetStartedPage.svelte +45 -106
- package/dist/components/account/GetStartedPage.svelte.d.ts +1 -2
- package/dist/components/account/MembersStarterPage.svelte +49 -92
- package/dist/components/account/SecurityLogPage.svelte +16 -131
- package/dist/components/account/SettingsEnterprisePage.svelte +15 -275
- package/dist/components/account/SettingsPlainPage.svelte +10 -175
- package/dist/components/account/SettingsSidebarPage.svelte +11 -304
- package/dist/components/account/TeamMembersPage.svelte +18 -187
- package/dist/components/account/UserProfilePage.svelte +19 -196
- package/dist/components/account/UserProfilePage.svelte.d.ts +1 -2
- package/dist/components/content/ApiKeyList.svelte +13 -0
- package/dist/components/content/ApiKeyList.svelte.d.ts +16 -0
- package/dist/components/content/ContentPageHeader.svelte +31 -0
- package/dist/components/content/ContentPageHeader.svelte.d.ts +12 -0
- package/dist/components/content/ContentPageShell.svelte +39 -0
- package/dist/components/content/ContentPageShell.svelte.d.ts +14 -0
- package/dist/components/content/DataState.svelte +44 -0
- package/dist/components/content/DataState.svelte.d.ts +13 -0
- package/dist/components/content/DescriptionList.svelte +27 -0
- package/dist/components/content/DescriptionList.svelte.d.ts +14 -0
- package/dist/components/content/FileList.svelte +13 -0
- package/dist/components/content/FileList.svelte.d.ts +16 -0
- package/dist/components/content/FilterToolbar.svelte +24 -0
- package/dist/components/content/FilterToolbar.svelte.d.ts +11 -0
- package/dist/components/content/ImportDropzone.svelte +19 -0
- package/dist/components/content/ImportDropzone.svelte.d.ts +10 -0
- package/dist/components/content/IntegrationCard.svelte +16 -0
- package/dist/components/content/IntegrationCard.svelte.d.ts +15 -0
- package/dist/components/content/MemberList.svelte +16 -0
- package/dist/components/content/MemberList.svelte.d.ts +17 -0
- package/dist/components/content/MetricBlock.svelte +27 -0
- package/dist/components/content/MetricBlock.svelte.d.ts +13 -0
- package/dist/components/content/NetworkTable.svelte +18 -0
- package/dist/components/content/NetworkTable.svelte.d.ts +31 -0
- package/dist/components/content/NetworkTable.types.d.ts +4 -0
- package/dist/components/content/NetworkTable.types.js +1 -0
- package/dist/components/content/NetworkUserCard.svelte +17 -0
- package/dist/components/content/NetworkUserCard.svelte.d.ts +17 -0
- package/dist/components/content/OtpInput.svelte +22 -0
- package/dist/components/content/OtpInput.svelte.d.ts +10 -0
- package/dist/components/content/PageToolbar.svelte +18 -0
- package/dist/components/content/PageToolbar.svelte.d.ts +10 -0
- package/dist/components/content/ProjectCard.svelte +39 -0
- package/dist/components/content/ProjectCard.svelte.d.ts +20 -0
- package/dist/components/content/SectionHeader.svelte +18 -0
- package/dist/components/content/SectionHeader.svelte.d.ts +10 -0
- package/dist/components/content/SecurityEventTable.svelte +18 -0
- package/dist/components/content/SecurityEventTable.svelte.d.ts +15 -0
- package/dist/components/content/StatusBadge.svelte +19 -0
- package/dist/components/content/StatusBadge.svelte.d.ts +9 -0
- package/dist/components/content/SystemErrorState.svelte +18 -0
- package/dist/components/content/SystemErrorState.svelte.d.ts +10 -0
- package/dist/components/content/TeamCard.svelte +26 -0
- package/dist/components/content/TeamCard.svelte.d.ts +23 -0
- package/dist/components/content/TwoFactorStepper.svelte +11 -0
- package/dist/components/content/TwoFactorStepper.svelte.d.ts +8 -0
- package/dist/components/content/index.d.ts +32 -0
- package/dist/components/content/index.js +22 -0
- package/dist/components/network/TeamCrewTablePage.svelte +29 -181
- package/dist/components/network/UserCardsNFTPage.svelte +24 -137
- package/dist/components/profile/ActivityTimeline.svelte +17 -91
- package/dist/components/profile/ProfileCard.svelte +4 -4
- package/dist/components/profile/ProjectsGrid.svelte +28 -106
- package/dist/components/profile/ProjectsGrid.svelte.d.ts +2 -12
- package/dist/components/profile/PublicProfilePage.svelte +34 -186
- package/dist/components/profile/TeamsShowcase.svelte +12 -114
- package/dist/components/profile/TeamsShowcase.svelte.d.ts +2 -12
- package/dist/index.d.ts +36 -0
- package/dist/index.js +24 -0
- package/dist/reference-data.d.ts +171 -0
- package/dist/reference-data.js +47 -0
- package/package.json +2 -2
|
@@ -1,125 +1,64 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import { useTranslation } from '@svadmin/core/i18n';
|
|
3
|
+
import { ArrowRight, Building2, Check, KeyRound, Palette, Plug, ShieldCheck, UserRound, Users } from '@lucide/svelte';
|
|
3
4
|
import * as Card from '../ui/card/index.js';
|
|
4
|
-
import { Button } from '../ui/button/index.js';
|
|
5
5
|
import { Badge } from '../ui/badge/index.js';
|
|
6
|
-
import {
|
|
7
|
-
import
|
|
6
|
+
import { Button } from '../ui/button/index.js';
|
|
7
|
+
import ContentPageShell from '../content/ContentPageShell.svelte';
|
|
8
|
+
import ContentPageHeader from '../content/ContentPageHeader.svelte';
|
|
8
9
|
|
|
9
10
|
const i18n = useTranslation();
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
title:
|
|
14
|
-
description:
|
|
15
|
-
icon:
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
let currentStep = $state(0);
|
|
20
|
-
|
|
21
|
-
let steps = $state<Step[]>([
|
|
22
|
-
{ key: 'avatar', title: i18n.t('account.uploadAvatar'), description: i18n.t('account.getStartedDescription'), icon: Camera, completed: false },
|
|
23
|
-
{ key: 'profile', title: i18n.t('account.setupProfile'), description: i18n.t('account.getStartedDescription'), icon: UserPlus, completed: false },
|
|
24
|
-
{ key: 'connect', title: i18n.t('account.connectProvider'), description: i18n.t('account.getStartedDescription'), icon: Puzzle, completed: false },
|
|
25
|
-
{ key: 'team', title: i18n.t('account.setupTeam'), description: i18n.t('account.getStartedDescription'), icon: Users, completed: false },
|
|
11
|
+
let completed = $state<string[]>(['profile', 'security']);
|
|
12
|
+
const isZh = $derived(i18n.locale === 'zh-CN');
|
|
13
|
+
const entries = $derived([
|
|
14
|
+
{ id: 'profile', title: isZh ? '个人资料' : 'User profile', description: isZh ? '维护联系人、工作状态、文件和日历连接。' : 'Maintain contact details, work status, files, and calendar connections.', href: '#/account/home/user-profile', icon: UserRound },
|
|
15
|
+
{ id: 'company', title: isZh ? '企业资料' : 'Company profile', description: isZh ? '配置企业信息、品牌资料、成员和数据导入。' : 'Configure company details, branding, members, and data imports.', href: '#/account/home/company-profile', icon: Building2 },
|
|
16
|
+
{ id: 'members', title: isZh ? '成员与邀请' : 'Members and invites', description: isZh ? '邀请成员、分配角色或从 CSV 批量导入。' : 'Invite members, assign roles, or import a CSV roster.', href: '#/account/members/team-members', icon: Users },
|
|
17
|
+
{ id: 'security', title: isZh ? '安全设置' : 'Security setup', description: isZh ? '启用两步验证并检查账户安全事件。' : 'Enable two-factor authentication and review account security events.', href: '#/authentication/branded/2fa', icon: ShieldCheck },
|
|
18
|
+
{ id: 'appearance', title: isZh ? '外观与偏好' : 'Appearance and preferences', description: isZh ? '设置主题、密度、语言和辅助功能。' : 'Set theme, density, language, and accessibility preferences.', href: '#/account/appearance', icon: Palette },
|
|
19
|
+
{ id: 'integrations', title: isZh ? '集成与 API' : 'Integrations and API', description: isZh ? '连接外部服务,并管理 API 密钥和 Webhook。' : 'Connect external services and manage API keys and webhooks.', href: '#/account/integrations', icon: Plug },
|
|
26
20
|
]);
|
|
21
|
+
const progress = $derived(Math.round(completed.length / entries.length * 100));
|
|
27
22
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
const allCompleted = $derived(completedCount === steps.length);
|
|
31
|
-
|
|
32
|
-
function markComplete(index: number) {
|
|
33
|
-
steps[index] = { ...steps[index], completed: true };
|
|
34
|
-
if (currentStep < steps.length - 1) {
|
|
35
|
-
currentStep = index + 1;
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
function goToStep(index: number) {
|
|
40
|
-
if (index <= completedCount) {
|
|
41
|
-
currentStep = index;
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
function skipStep() {
|
|
46
|
-
if (currentStep < steps.length - 1) {
|
|
47
|
-
currentStep++;
|
|
48
|
-
}
|
|
23
|
+
function toggleCompleted(id: string) {
|
|
24
|
+
completed = completed.includes(id) ? completed.filter((item) => item !== id) : [...completed, id];
|
|
49
25
|
}
|
|
50
26
|
</script>
|
|
51
27
|
|
|
52
|
-
<
|
|
53
|
-
<div class="flex items-start justify-between
|
|
54
|
-
<
|
|
55
|
-
|
|
56
|
-
<p class="mt-1 text-sm text-muted-foreground">{i18n.t('account.getStartedDescription')}</p>
|
|
57
|
-
</div>
|
|
58
|
-
<Badge variant="secondary">{progressPercent}%</Badge>
|
|
28
|
+
<ContentPageShell pageId="account-get-started" width="wide">
|
|
29
|
+
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
|
30
|
+
<ContentPageHeader title={i18n.t('account.getStarted')} description={isZh ? '从账户功能入口快速完成组织、成员、安全和集成配置。' : 'Use the account hub to complete organization, member, security, and integration setup.'} />
|
|
31
|
+
<Badge variant="outline">{progress}% {isZh ? '已完成' : 'complete'}</Badge>
|
|
59
32
|
</div>
|
|
60
33
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
<Card.Card class="border-success/30 bg-success/5">
|
|
71
|
-
<Card.CardContent class="flex items-center gap-4 p-6">
|
|
72
|
-
<div class="flex h-12 w-12 items-center justify-center rounded-xl bg-success/10 text-success">
|
|
73
|
-
<Sparkles class="h-6 w-6" />
|
|
74
|
-
</div>
|
|
75
|
-
<div>
|
|
76
|
-
<h3 class="font-semibold text-success">{i18n.t('account.completeSetup')}</h3>
|
|
77
|
-
<p class="text-sm text-success/70">{i18n.t('account.getStartedDescription')}</p>
|
|
78
|
-
</div>
|
|
79
|
-
</Card.CardContent>
|
|
80
|
-
</Card.Card>
|
|
81
|
-
{:else}
|
|
82
|
-
<!-- Step cards -->
|
|
83
|
-
<div class="space-y-3">
|
|
84
|
-
{#each steps as step, i (i)}
|
|
85
|
-
<Card.Card class="border-border/60 transition-all {currentStep === i ? 'border-primary/40 shadow-sm' : ''} {step.completed ? 'opacity-60' : ''}">
|
|
86
|
-
<Card.CardContent class="p-4">
|
|
87
|
-
<div class="flex items-start gap-4">
|
|
88
|
-
<button
|
|
89
|
-
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl transition-colors
|
|
90
|
-
{step.completed ? 'bg-success/10 text-success' : currentStep === i ? 'bg-primary/10 text-primary' : 'bg-muted text-muted-foreground'}"
|
|
91
|
-
onclick={() => goToStep(i)}
|
|
92
|
-
>
|
|
93
|
-
{#if step.completed}
|
|
94
|
-
<CheckCircle2 class="h-5 w-5" />
|
|
95
|
-
{:else}
|
|
96
|
-
<step.icon class="h-5 w-5" />
|
|
97
|
-
{/if}
|
|
34
|
+
<div class="grid gap-6 xl:grid-cols-[minmax(0,1fr)_19rem]">
|
|
35
|
+
<div class="grid gap-4 md:grid-cols-2">
|
|
36
|
+
{#each entries as entry (entry.id)}
|
|
37
|
+
<Card.Card class="flex min-h-52 flex-col">
|
|
38
|
+
<Card.CardContent class="flex flex-1 flex-col p-5">
|
|
39
|
+
<div class="flex items-start justify-between gap-3">
|
|
40
|
+
<span class="flex size-9 items-center justify-center rounded-md border border-border bg-card text-primary"><entry.icon class="size-4" /></span>
|
|
41
|
+
<button type="button" class="flex size-8 items-center justify-center rounded-md border border-border text-muted-foreground hover:text-foreground" aria-label={isZh ? '切换完成状态' : 'Toggle completion'} onclick={() => toggleCompleted(entry.id)}>
|
|
42
|
+
{#if completed.includes(entry.id)}<Check class="size-4 text-success" />{/if}
|
|
98
43
|
</button>
|
|
99
|
-
<div class="flex-1 min-w-0">
|
|
100
|
-
<div class="flex items-center gap-2">
|
|
101
|
-
<h4 class="font-medium text-foreground">{step.title}</h4>
|
|
102
|
-
{#if currentStep === i && !step.completed}
|
|
103
|
-
<Badge variant="outline" class="text-[10px]">{i18n.t('account.step', { current: i + 1, total: steps.length })}</Badge>
|
|
104
|
-
{/if}
|
|
105
|
-
</div>
|
|
106
|
-
<p class="text-sm text-muted-foreground mt-0.5">{step.description}</p>
|
|
107
|
-
|
|
108
|
-
{#if currentStep === i && !step.completed}
|
|
109
|
-
<div class="mt-3 flex gap-2">
|
|
110
|
-
<Button size="sm" onclick={() => markComplete(i)}>
|
|
111
|
-
{i18n.t('account.completeSetup')} <ArrowRight class="h-3.5 w-3.5 ml-1" />
|
|
112
|
-
</Button>
|
|
113
|
-
<Button size="sm" variant="ghost" onclick={skipStep}>
|
|
114
|
-
{i18n.t('account.skipSetup')}
|
|
115
|
-
</Button>
|
|
116
|
-
</div>
|
|
117
|
-
{/if}
|
|
118
|
-
</div>
|
|
119
44
|
</div>
|
|
45
|
+
<h2 class="mt-4 text-base font-semibold text-foreground">{entry.title}</h2>
|
|
46
|
+
<p class="mt-1 flex-1 text-sm leading-6 text-muted-foreground">{entry.description}</p>
|
|
47
|
+
<Button href={entry.href} variant="link" class="mt-4 h-auto justify-start p-0">{isZh ? '打开设置' : 'Open settings'}<ArrowRight class="size-3.5" /></Button>
|
|
120
48
|
</Card.CardContent>
|
|
121
49
|
</Card.Card>
|
|
122
50
|
{/each}
|
|
123
51
|
</div>
|
|
124
|
-
|
|
125
|
-
|
|
52
|
+
|
|
53
|
+
<aside class="space-y-5">
|
|
54
|
+
<section class="rounded-lg border border-border bg-card p-5">
|
|
55
|
+
<div class="flex items-center gap-3"><span class="flex size-9 items-center justify-center rounded-md bg-primary/10 text-primary"><KeyRound class="size-4" /></span><div><h2 class="text-sm font-semibold text-foreground">{isZh ? '账户准备度' : 'Account readiness'}</h2><p class="text-xs text-muted-foreground">{completed.length} / {entries.length}</p></div></div>
|
|
56
|
+
<div class="mt-4 h-2 overflow-hidden rounded-full bg-muted"><div class="h-full rounded-full bg-primary" style:width={`${progress}%`}></div></div>
|
|
57
|
+
<div class="mt-4 divide-y divide-border border-y border-border">
|
|
58
|
+
{#each entries as entry (entry.id)}<div class="flex items-center justify-between gap-3 py-3 text-sm"><span class="text-muted-foreground">{entry.title}</span>{#if completed.includes(entry.id)}<Badge variant="secondary">{isZh ? '完成' : 'Done'}</Badge>{:else}<span class="text-xs text-muted-foreground">{isZh ? '待配置' : 'Pending'}</span>{/if}</div>{/each}
|
|
59
|
+
</div>
|
|
60
|
+
</section>
|
|
61
|
+
<Button class="w-full" onclick={() => completed = entries.map((entry) => entry.id)}>{isZh ? '标记全部完成' : 'Mark all complete'}</Button>
|
|
62
|
+
</aside>
|
|
63
|
+
</div>
|
|
64
|
+
</ContentPageShell>
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
|
|
2
|
-
declare const GetStartedPage: Component<Record<string, never>, {}, "">;
|
|
1
|
+
declare const GetStartedPage: import("svelte").Component<Record<string, never>, {}, "">;
|
|
3
2
|
type GetStartedPage = ReturnType<typeof GetStartedPage>;
|
|
4
3
|
export default GetStartedPage;
|
|
@@ -1,106 +1,63 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import { useTranslation } from '@svadmin/core/i18n';
|
|
3
|
+
import { ArrowRight, Check, Download, Mail, Upload, UserPlus, Users } from '@lucide/svelte';
|
|
3
4
|
import * as Card from '../ui/card/index.js';
|
|
4
5
|
import { Button } from '../ui/button/index.js';
|
|
5
|
-
import { Badge } from '../ui/badge/index.js';
|
|
6
|
-
import { Avatar } from '../ui/avatar/index.js';
|
|
7
6
|
import { Input } from '../ui/input/index.js';
|
|
8
|
-
import {
|
|
7
|
+
import { Badge } from '../ui/badge/index.js';
|
|
8
|
+
import ContentPageShell from '../content/ContentPageShell.svelte';
|
|
9
|
+
import ContentPageHeader from '../content/ContentPageHeader.svelte';
|
|
9
10
|
|
|
10
11
|
const i18n = useTranslation();
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
12
|
+
let inviteOpen = $state(false);
|
|
13
|
+
let email = $state('');
|
|
14
|
+
let invited = $state<string[]>([]);
|
|
15
|
+
const isZh = $derived(i18n.locale === 'zh-CN');
|
|
16
|
+
|
|
17
|
+
function sendInvite() {
|
|
18
|
+
const normalized = email.trim();
|
|
19
|
+
if (!normalized || invited.includes(normalized)) return;
|
|
20
|
+
invited = [...invited, normalized];
|
|
21
|
+
email = '';
|
|
20
22
|
}
|
|
21
|
-
|
|
22
|
-
let searchQuery = $state('');
|
|
23
|
-
|
|
24
|
-
let members = $state<Member[]>([
|
|
25
|
-
{ id: '1', name: 'Alex Chen', email: 'alex@acme.com', role: 'Admin', status: 'active', joinedAt: '2023-01-15' },
|
|
26
|
-
{ id: '2', name: 'Sarah Kim', email: 'sarah@acme.com', role: 'Editor', status: 'active', joinedAt: '2023-03-22' },
|
|
27
|
-
{ id: '3', name: 'Mike Johnson', email: 'mike@acme.com', role: 'Viewer', status: 'invited', joinedAt: '2024-01-10' },
|
|
28
|
-
{ id: '4', name: 'Lisa Wang', email: 'lisa@acme.com', role: 'Editor', status: 'active', joinedAt: '2023-06-05' },
|
|
29
|
-
{ id: '5', name: 'Tom Brown', email: 'tom@acme.com', role: 'Viewer', status: 'inactive', joinedAt: '2022-11-20' },
|
|
30
|
-
{ id: '6', name: 'Emma Davis', email: 'emma@acme.com', role: 'Admin', status: 'active', joinedAt: '2023-02-14' },
|
|
31
|
-
]);
|
|
32
|
-
|
|
33
|
-
const filteredMembers = $derived(
|
|
34
|
-
searchQuery
|
|
35
|
-
? members.filter(m => m.name.toLowerCase().includes(searchQuery.toLowerCase()) || m.email.toLowerCase().includes(searchQuery.toLowerCase()))
|
|
36
|
-
: members
|
|
37
|
-
);
|
|
38
|
-
|
|
39
|
-
const statusConfig = {
|
|
40
|
-
'active': { icon: CheckCircle2, color: 'text-green-500', label: i18n.t('account.active') },
|
|
41
|
-
'invited': { icon: Clock, color: 'text-amber-500', label: i18n.t('account.invited') },
|
|
42
|
-
'inactive': { icon: XCircle, color: 'text-muted-foreground', label: i18n.t('account.inactive') },
|
|
43
|
-
};
|
|
44
|
-
|
|
45
|
-
const initials = (name: string) => name.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2);
|
|
46
23
|
</script>
|
|
47
24
|
|
|
48
|
-
<
|
|
49
|
-
<
|
|
50
|
-
<div>
|
|
51
|
-
<h2 class="text-xl font-semibold text-foreground">{i18n.t('account.membersStarter')}</h2>
|
|
52
|
-
<p class="mt-1 text-sm text-muted-foreground">{i18n.t('account.membersStarterDescription')}</p>
|
|
53
|
-
</div>
|
|
54
|
-
<Button size="sm">
|
|
55
|
-
<UserPlus class="h-4 w-4 mr-1" />{i18n.t('account.inviteMember')}
|
|
56
|
-
</Button>
|
|
57
|
-
</div>
|
|
25
|
+
<ContentPageShell pageId="account-members-starter" width="wide">
|
|
26
|
+
<ContentPageHeader title={isZh ? '开始组建团队' : 'Start building your team'} description={isZh ? '这是零成员工作区的起始页。邀请第一位成员,或通过 CSV 批量导入。' : 'This is the starting state for a workspace with no members. Invite the first teammate or import a CSV roster.'} />
|
|
58
27
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
class="
|
|
66
|
-
|
|
67
|
-
</div>
|
|
28
|
+
<section class="grid min-h-[28rem] overflow-hidden rounded-lg border border-border bg-card lg:grid-cols-[minmax(0,1fr)_21rem]">
|
|
29
|
+
<div class="flex flex-col items-center justify-center px-6 py-12 text-center">
|
|
30
|
+
<span class="flex size-14 items-center justify-center rounded-lg bg-primary/10 text-primary"><Users class="size-6" /></span>
|
|
31
|
+
<Badge variant="outline" class="mt-5">0 {isZh ? '名成员' : 'members'}</Badge>
|
|
32
|
+
<h2 class="mt-4 text-xl font-semibold text-foreground">{isZh ? '邀请你的第一位团队成员' : 'Invite your first team member'}</h2>
|
|
33
|
+
<p class="mt-2 max-w-lg text-sm leading-6 text-muted-foreground">{isZh ? '成员加入后,你可以继续分配角色、设置权限、查看活动状态,并在团队成员页批量管理。' : 'After members join, assign roles, configure access, review activity, and manage them in the full team member table.'}</p>
|
|
34
|
+
<div class="mt-6 flex flex-wrap justify-center gap-2"><Button onclick={() => inviteOpen = true}><UserPlus class="size-4" />{isZh ? '邀请成员' : 'Invite member'}</Button><Button href="#/account/members/import-members" variant="outline"><Upload class="size-4" />{isZh ? '导入 CSV' : 'Import CSV'}</Button></div>
|
|
35
|
+
</div>
|
|
68
36
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
{
|
|
75
|
-
<div class="flex items-center gap-3 p-4 hover:bg-accent/30 transition-colors">
|
|
76
|
-
<div class="shrink-0">
|
|
77
|
-
{#if member.avatar}
|
|
78
|
-
<Avatar class="h-9 w-9"><img src={member.avatar} alt={member.name} /></Avatar>
|
|
79
|
-
{:else}
|
|
80
|
-
<div class="flex h-9 w-9 items-center justify-center rounded-full bg-primary/10 text-primary text-sm font-semibold">
|
|
81
|
-
{initials(member.name)}
|
|
82
|
-
</div>
|
|
83
|
-
{/if}
|
|
84
|
-
</div>
|
|
85
|
-
<div class="flex-1 min-w-0">
|
|
86
|
-
<div class="flex items-center gap-2">
|
|
87
|
-
<span class="text-sm font-medium text-foreground truncate">{member.name}</span>
|
|
88
|
-
<Badge variant="outline" class="text-[10px] shrink-0">{member.role}</Badge>
|
|
89
|
-
</div>
|
|
90
|
-
<p class="text-xs text-muted-foreground truncate">{member.email}</p>
|
|
91
|
-
</div>
|
|
92
|
-
<div class="shrink-0 flex items-center gap-3">
|
|
93
|
-
<span class="flex items-center gap-1 text-xs {sc.color}">
|
|
94
|
-
<sc.icon class="h-3 w-3" />
|
|
95
|
-
{sc.label}
|
|
96
|
-
</span>
|
|
97
|
-
<Button variant="ghost" size="icon-sm">
|
|
98
|
-
<MoreHorizontal class="h-4 w-4" />
|
|
99
|
-
</Button>
|
|
100
|
-
</div>
|
|
101
|
-
</div>
|
|
37
|
+
<aside class="border-t border-border bg-muted/15 p-5 lg:border-l lg:border-t-0">
|
|
38
|
+
<h2 class="text-sm font-semibold text-foreground">{isZh ? '团队准备清单' : 'Team setup checklist'}</h2>
|
|
39
|
+
<div class="mt-4 space-y-4">
|
|
40
|
+
{#each [isZh ? '邀请至少一名管理员' : 'Invite at least one admin', isZh ? '确认默认成员角色' : 'Confirm the default member role', isZh ? '准备成员 CSV 模板' : 'Prepare the member CSV template'] as item, index (item)}
|
|
41
|
+
{@const completed = index === 0 && invited.length > 0}
|
|
42
|
+
<div class="flex items-start gap-3" data-checklist-status={completed ? 'complete' : 'pending'}><span class={'mt-0.5 flex size-6 items-center justify-center rounded-md border ' + (completed ? 'border-success/30 bg-success/10 text-success' : 'border-border text-muted-foreground')}>{#if completed}<Check class="size-3.5" />{:else}<span class="text-xs">{index + 1}</span>{/if}</span><p class="text-sm leading-5 text-muted-foreground">{item}</p></div>
|
|
102
43
|
{/each}
|
|
103
44
|
</div>
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
</
|
|
45
|
+
<a href="#/account/members/team-members" class="mt-6 flex items-center gap-1 border-t border-border pt-4 text-sm font-medium text-primary">{isZh ? '查看完整成员管理' : 'Open full member management'}<ArrowRight class="size-3.5" /></a>
|
|
46
|
+
<Button variant="outline" size="sm" class="mt-3 w-full"><Download class="size-3.5" />{isZh ? '下载 CSV 模板' : 'Download CSV template'}</Button>
|
|
47
|
+
</aside>
|
|
48
|
+
</section>
|
|
49
|
+
|
|
50
|
+
{#if inviteOpen}
|
|
51
|
+
<Card.Card data-member-invite-panel>
|
|
52
|
+
<Card.CardContent class="grid gap-4 p-5 md:grid-cols-[auto_minmax(0,1fr)_auto] md:items-end">
|
|
53
|
+
<span class="flex size-10 items-center justify-center rounded-md bg-primary/10 text-primary"><Mail class="size-4" /></span>
|
|
54
|
+
<label><span class="text-sm font-medium text-foreground">{isZh ? '成员邮箱' : 'Member email'}</span><Input class="mt-2" type="email" bind:value={email} placeholder="teammate@example.com" /></label>
|
|
55
|
+
<div class="flex gap-2"><Button variant="outline" onclick={() => inviteOpen = false}>{i18n.t('common.cancel')}</Button><Button onclick={sendInvite}>{isZh ? '发送邀请' : 'Send invite'}</Button></div>
|
|
56
|
+
</Card.CardContent>
|
|
57
|
+
</Card.Card>
|
|
58
|
+
{/if}
|
|
59
|
+
|
|
60
|
+
{#if invited.length > 0}
|
|
61
|
+
<section class="rounded-lg border border-border bg-card"><div class="border-b border-border px-4 py-3"><h2 class="text-sm font-semibold text-foreground">{isZh ? '待接受邀请' : 'Pending invitations'}</h2></div><div class="divide-y divide-border">{#each invited as invite (invite)}<div class="flex items-center justify-between gap-3 px-4 py-3"><div><p class="text-sm font-medium text-foreground">{invite}</p><p class="text-xs text-muted-foreground">{isZh ? '刚刚发送' : 'Sent just now'}</p></div><Badge variant="outline">{isZh ? '已邀请' : 'Invited'}</Badge></div>{/each}</div></section>
|
|
62
|
+
{/if}
|
|
63
|
+
</ContentPageShell>
|
|
@@ -1,137 +1,22 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import { useTranslation } from '@svadmin/core/i18n';
|
|
3
|
-
import
|
|
3
|
+
import { Download } from '@lucide/svelte';
|
|
4
4
|
import { Button } from '../ui/button/index.js';
|
|
5
|
-
import
|
|
6
|
-
import
|
|
7
|
-
|
|
5
|
+
import ContentPageShell from '../content/ContentPageShell.svelte';
|
|
6
|
+
import ContentPageHeader from '../content/ContentPageHeader.svelte';
|
|
7
|
+
import FilterToolbar from '../content/FilterToolbar.svelte';
|
|
8
|
+
import SecurityEventTable from '../content/SecurityEventTable.svelte';
|
|
9
|
+
import type { SecurityEvent } from '../content/SecurityEventTable.svelte';
|
|
10
|
+
import { referenceDemoData } from '../../reference-data.js';
|
|
8
11
|
const i18n = useTranslation();
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
event: string;
|
|
13
|
-
eventLabel: string;
|
|
14
|
-
ip: string;
|
|
15
|
-
device: string;
|
|
16
|
-
location: string;
|
|
17
|
-
time: string;
|
|
18
|
-
severity: 'info' | 'warning' | 'critical';
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
let searchQuery = $state('');
|
|
22
|
-
let filterSeverity = $state<'all' | 'info' | 'warning' | 'critical'>('all');
|
|
23
|
-
|
|
24
|
-
const logs: LogEntry[] = [
|
|
25
|
-
{ id: '1', event: 'loginSuccess', eventLabel: i18n.t('securityLog.loginSuccess'), ip: '192.168.1.102', device: 'Chrome / macOS', location: 'Shanghai, CN', time: '2 minutes ago', severity: 'info' },
|
|
26
|
-
{ id: '2', event: 'apiKeyCreated', eventLabel: i18n.t('securityLog.apiKeyCreated'), ip: '192.168.1.102', device: 'Chrome / macOS', location: 'Shanghai, CN', time: '1 hour ago', severity: 'info' },
|
|
27
|
-
{ id: '3', event: 'loginFailed', eventLabel: i18n.t('securityLog.loginFailed'), ip: '203.0.113.42', device: 'Firefox / Windows', location: 'Unknown', time: '3 hours ago', severity: 'warning' },
|
|
28
|
-
{ id: '4', event: 'twoFactorEnabled', eventLabel: i18n.t('securityLog.twoFactorEnabled'), ip: '192.168.1.102', device: 'Chrome / macOS', location: 'Shanghai, CN', time: '1 day ago', severity: 'info' },
|
|
29
|
-
{ id: '5', event: 'passwordChanged', eventLabel: i18n.t('securityLog.passwordChanged'), ip: '192.168.1.102', device: 'Chrome / macOS', location: 'Shanghai, CN', time: '2 days ago', severity: 'info' },
|
|
30
|
-
{ id: '6', event: 'loginFailed', eventLabel: i18n.t('securityLog.loginFailed'), ip: '198.51.100.77', device: 'Safari / iOS', location: 'Beijing, CN', time: '3 days ago', severity: 'critical' },
|
|
31
|
-
{ id: '7', event: 'sessionRevoked', eventLabel: i18n.t('securityLog.sessionRevoked'), ip: '192.168.1.105', device: 'Safari / iOS', location: 'Shanghai, CN', time: '5 days ago', severity: 'info' },
|
|
32
|
-
{ id: '8', event: 'apiKeyRevoked', eventLabel: i18n.t('securityLog.apiKeyRevoked'), ip: '192.168.1.102', device: 'Chrome / macOS', location: 'Shanghai, CN', time: '1 week ago', severity: 'warning' },
|
|
33
|
-
];
|
|
34
|
-
|
|
35
|
-
const filteredLogs = $derived(
|
|
36
|
-
logs.filter(l => {
|
|
37
|
-
if (filterSeverity !== 'all' && l.severity !== filterSeverity) return false;
|
|
38
|
-
if (searchQuery) {
|
|
39
|
-
const q = searchQuery.toLowerCase();
|
|
40
|
-
return l.eventLabel.toLowerCase().includes(q) || l.ip.includes(q) || l.device.toLowerCase().includes(q);
|
|
41
|
-
}
|
|
42
|
-
return true;
|
|
43
|
-
})
|
|
44
|
-
);
|
|
45
|
-
|
|
12
|
+
let query = $state('');
|
|
13
|
+
const events: SecurityEvent[] = referenceDemoData.securityEvents;
|
|
14
|
+
const filtered = $derived(query ? events.filter((event) => `${event.event} ${event.actor} ${event.location}`.toLowerCase().includes(query.toLowerCase())) : events);
|
|
46
15
|
</script>
|
|
47
16
|
|
|
48
|
-
<
|
|
49
|
-
<
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
<Button variant="outline" size="sm">
|
|
55
|
-
<Download class="h-3.5 w-3.5 mr-1" />{i18n.t('common.export')}
|
|
56
|
-
</Button>
|
|
57
|
-
</div>
|
|
58
|
-
|
|
59
|
-
<!-- Filters -->
|
|
60
|
-
<div class="flex flex-wrap gap-3">
|
|
61
|
-
<div class="relative flex-1 min-w-[200px]">
|
|
62
|
-
<Search class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
|
63
|
-
<Input placeholder={i18n.t('common.search')} bind:value={searchQuery} class="pl-9" />
|
|
64
|
-
</div>
|
|
65
|
-
<div class="flex rounded-lg border border-input overflow-hidden">
|
|
66
|
-
{#each ['all', 'info', 'warning', 'critical'] as sev (sev)}
|
|
67
|
-
<button
|
|
68
|
-
class="px-3 py-1.5 text-xs font-medium transition-colors capitalize
|
|
69
|
-
{filterSeverity === sev ? 'bg-primary text-primary-foreground' : 'bg-background text-muted-foreground hover:bg-accent'}"
|
|
70
|
-
onclick={() => filterSeverity = sev as typeof filterSeverity}
|
|
71
|
-
>{sev === 'all' ? i18n.t('common.filter') : sev}</button>
|
|
72
|
-
{/each}
|
|
73
|
-
</div>
|
|
74
|
-
</div>
|
|
75
|
-
|
|
76
|
-
<!-- Log table -->
|
|
77
|
-
<Card.Card class="border-border/60">
|
|
78
|
-
<Card.CardContent class="p-0">
|
|
79
|
-
<div class="hidden overflow-x-auto md:block">
|
|
80
|
-
<table class="w-full">
|
|
81
|
-
<thead>
|
|
82
|
-
<tr class="border-b bg-muted/30">
|
|
83
|
-
<th class="px-4 py-3 text-left text-xs font-semibold text-muted-foreground">{i18n.t('securityLog.event')}</th>
|
|
84
|
-
<th class="px-4 py-3 text-left text-xs font-semibold text-muted-foreground">{i18n.t('securityLog.ip')}</th>
|
|
85
|
-
<th class="px-4 py-3 text-left text-xs font-semibold text-muted-foreground">{i18n.t('securityLog.device')}</th>
|
|
86
|
-
<th class="px-4 py-3 text-left text-xs font-semibold text-muted-foreground">{i18n.t('securityLog.location')}</th>
|
|
87
|
-
<th class="px-4 py-3 text-left text-xs font-semibold text-muted-foreground">{i18n.t('securityLog.time')}</th>
|
|
88
|
-
</tr>
|
|
89
|
-
</thead>
|
|
90
|
-
<tbody class="divide-y">
|
|
91
|
-
{#each filteredLogs as log (log.id)}
|
|
92
|
-
<tr class="hover:bg-accent/30 transition-colors">
|
|
93
|
-
<td class="px-4 py-3">
|
|
94
|
-
<div class="flex items-center gap-2">
|
|
95
|
-
<span class="inline-flex h-2 w-2 rounded-full {log.severity === 'critical' ? 'bg-red-500' : log.severity === 'warning' ? 'bg-amber-500' : 'bg-blue-500'}"></span>
|
|
96
|
-
<span class="text-sm text-foreground">{log.eventLabel}</span>
|
|
97
|
-
</div>
|
|
98
|
-
</td>
|
|
99
|
-
<td class="px-4 py-3 text-sm text-muted-foreground font-mono">{log.ip}</td>
|
|
100
|
-
<td class="px-4 py-3 text-sm text-muted-foreground">{log.device}</td>
|
|
101
|
-
<td class="px-4 py-3 text-sm text-muted-foreground">{log.location}</td>
|
|
102
|
-
<td class="px-4 py-3 text-sm text-muted-foreground whitespace-nowrap">{log.time}</td>
|
|
103
|
-
</tr>
|
|
104
|
-
{/each}
|
|
105
|
-
</tbody>
|
|
106
|
-
</table>
|
|
107
|
-
</div>
|
|
108
|
-
<div class="divide-y md:hidden">
|
|
109
|
-
{#each filteredLogs as log (log.id)}
|
|
110
|
-
<div class="space-y-3 p-4">
|
|
111
|
-
<div class="flex items-start justify-between gap-3">
|
|
112
|
-
<div class="flex min-w-0 items-center gap-2">
|
|
113
|
-
<span class="inline-flex h-2 w-2 shrink-0 rounded-full {log.severity === 'critical' ? 'bg-red-500' : log.severity === 'warning' ? 'bg-amber-500' : 'bg-blue-500'}"></span>
|
|
114
|
-
<span class="truncate text-sm font-medium text-foreground">{log.eventLabel}</span>
|
|
115
|
-
</div>
|
|
116
|
-
<span class="shrink-0 text-xs text-muted-foreground">{log.time}</span>
|
|
117
|
-
</div>
|
|
118
|
-
<div class="grid gap-2 text-xs text-muted-foreground">
|
|
119
|
-
<div class="flex items-center justify-between gap-3">
|
|
120
|
-
<span>{i18n.t('securityLog.ip')}</span>
|
|
121
|
-
<span class="font-mono text-foreground">{log.ip}</span>
|
|
122
|
-
</div>
|
|
123
|
-
<div class="flex items-center justify-between gap-3">
|
|
124
|
-
<span>{i18n.t('securityLog.device')}</span>
|
|
125
|
-
<span class="truncate text-foreground">{log.device}</span>
|
|
126
|
-
</div>
|
|
127
|
-
<div class="flex items-center justify-between gap-3">
|
|
128
|
-
<span>{i18n.t('securityLog.location')}</span>
|
|
129
|
-
<span class="truncate text-foreground">{log.location}</span>
|
|
130
|
-
</div>
|
|
131
|
-
</div>
|
|
132
|
-
</div>
|
|
133
|
-
{/each}
|
|
134
|
-
</div>
|
|
135
|
-
</Card.CardContent>
|
|
136
|
-
</Card.Card>
|
|
137
|
-
</div>
|
|
17
|
+
<ContentPageShell pageId="account-security-log" width="wide">
|
|
18
|
+
<ContentPageHeader title={i18n.t('account.securityLog')} description={i18n.t('account.securityLogDescription')} actions={undefined} />
|
|
19
|
+
<div class="flex justify-end"><Button variant="outline" size="sm"><Download class="size-3.5" />{i18n.t('common.export')}</Button></div>
|
|
20
|
+
<FilterToolbar bind:query placeholder={i18n.t('common.search')} />
|
|
21
|
+
<SecurityEventTable events={filtered} />
|
|
22
|
+
</ContentPageShell>
|