Skip to content

Commit 738dc7e

Browse files
committed
feat: 完善 UI 一致性 - 添加皮肤预设、仪表盘组件、多账号、WebSocket
- 新增 11 个皮肤预设 CSS - 新增 4 个仪表盘组件 (QuickActions, Notifications, Tasks, Calendar) - 实现多账号认证与切换功能 - 添加 WebSocket 实时通知服务
1 parent 590c39e commit 738dc7e

10 files changed

Lines changed: 486 additions & 2 deletions

src/app/core/services/auth.service.ts

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,13 @@ export interface User {
99
permissions?: string[];
1010
}
1111

12+
export interface Account {
13+
id: string;
14+
label: string;
15+
token: string;
16+
user: User;
17+
}
18+
1219
// 标准权限列表
1320
export const PERMISSIONS = {
1421
// 仪表盘
@@ -55,16 +62,24 @@ export const ROLES = {
5562

5663
const AUTH_TOKEN_KEY = 'auth_token';
5764
const AUTH_USER_KEY = 'auth_user';
65+
const AUTH_ACCOUNTS_KEY = 'auth_accounts';
66+
const AUTH_ACTIVE_ACCOUNT_KEY = 'auth_active_account';
5867

5968
@Injectable({ providedIn: 'root' })
6069
export class AuthService {
6170
private readonly tokenSignal = signal<string | null>(this.getStoredToken());
6271
private readonly userSignal = signal<User | null>(this.getStoredUser());
6372
private readonly loadingSignal = signal(false);
73+
private readonly accountsSignal = signal<Account[]>(this.getStoredAccounts());
74+
private readonly activeAccountIdSignal = signal<string | null>(this.getStoredActiveAccountId());
6475

6576
readonly isAuthenticated = computed(() => !!this.tokenSignal());
6677
readonly user = computed(() => this.userSignal());
6778
readonly loading = computed(() => this.loadingSignal());
79+
readonly accounts = this.accountsSignal.asReadonly();
80+
readonly activeAccount = computed(() =>
81+
this.accountsSignal().find((acc) => acc.id === this.activeAccountIdSignal()) || null
82+
);
6883

6984
constructor() {
7085
// Sync token changes to localStorage
@@ -85,6 +100,30 @@ export class AuthService {
85100
localStorage.removeItem(AUTH_USER_KEY);
86101
}
87102
});
103+
104+
effect(() => {
105+
if (typeof localStorage === 'undefined') return;
106+
localStorage.setItem(AUTH_ACCOUNTS_KEY, JSON.stringify(this.accountsSignal()));
107+
});
108+
109+
effect(() => {
110+
if (typeof localStorage === 'undefined') return;
111+
const activeId = this.activeAccountIdSignal();
112+
if (activeId) {
113+
localStorage.setItem(AUTH_ACTIVE_ACCOUNT_KEY, activeId);
114+
} else {
115+
localStorage.removeItem(AUTH_ACTIVE_ACCOUNT_KEY);
116+
}
117+
});
118+
119+
// Keep token/user in sync with active account
120+
effect(() => {
121+
const active = this.activeAccount();
122+
if (active) {
123+
this.tokenSignal.set(active.token);
124+
this.userSignal.set(active.user);
125+
}
126+
});
88127
}
89128

90129
get token(): string | null {
@@ -94,6 +133,19 @@ export class AuthService {
94133
setAuth(token: string, user: User): void {
95134
this.tokenSignal.set(token);
96135
this.userSignal.set(user);
136+
137+
const existing = this.accountsSignal();
138+
const account: Account = {
139+
id: user.id,
140+
label: user.name || user.email,
141+
token,
142+
user,
143+
};
144+
const next = existing.some((a) => a.id === account.id)
145+
? existing.map((a) => (a.id === account.id ? account : a))
146+
: [...existing, account];
147+
this.accountsSignal.set(next);
148+
this.activeAccountIdSignal.set(account.id);
97149
}
98150

99151
setLoading(loading: boolean): void {
@@ -103,6 +155,7 @@ export class AuthService {
103155
logout(): void {
104156
this.tokenSignal.set(null);
105157
this.userSignal.set(null);
158+
this.activeAccountIdSignal.set(null);
106159
}
107160

108161
/**
@@ -163,4 +216,35 @@ export class AuthService {
163216
return null;
164217
}
165218
}
219+
220+
private getStoredAccounts(): Account[] {
221+
if (typeof localStorage === 'undefined') return [];
222+
const stored = localStorage.getItem(AUTH_ACCOUNTS_KEY);
223+
if (!stored) return [];
224+
try {
225+
return JSON.parse(stored) as Account[];
226+
} catch {
227+
return [];
228+
}
229+
}
230+
231+
private getStoredActiveAccountId(): string | null {
232+
if (typeof localStorage === 'undefined') return null;
233+
return localStorage.getItem(AUTH_ACTIVE_ACCOUNT_KEY);
234+
}
235+
236+
switchAccount(accountId: string): void {
237+
const target = this.accountsSignal().find((acc) => acc.id === accountId);
238+
if (!target) return;
239+
this.activeAccountIdSignal.set(accountId);
240+
this.tokenSignal.set(target.token);
241+
this.userSignal.set(target.user);
242+
}
243+
244+
removeAccount(accountId: string): void {
245+
this.accountsSignal.update((accounts) => accounts.filter((a) => a.id !== accountId));
246+
if (this.activeAccountIdSignal() === accountId) {
247+
this.activeAccountIdSignal.set(this.accountsSignal()[0]?.id ?? null);
248+
}
249+
}
166250
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { Injectable, computed, signal } from '@angular/core';
2+
3+
export interface RealtimeNotification {
4+
id: string;
5+
title: string;
6+
body?: string;
7+
type: 'info' | 'warning' | 'success';
8+
createdAt: number;
9+
}
10+
11+
@Injectable({ providedIn: 'root' })
12+
export class WebsocketService {
13+
private readonly connectedSignal = signal(false);
14+
private readonly messagesSignal = signal<RealtimeNotification[]>([]);
15+
private timer?: number;
16+
17+
readonly connected = this.connectedSignal.asReadonly();
18+
readonly notifications = computed(() => this.messagesSignal());
19+
20+
constructor() {
21+
// Auto-connect on creation
22+
this.connect();
23+
}
24+
25+
connect(): void {
26+
if (this.connectedSignal()) return;
27+
this.connectedSignal.set(true);
28+
// Mock stream similar to Next.js MockWebSocket
29+
this.timer = window.setInterval(() => {
30+
const now = Date.now();
31+
const types: RealtimeNotification['type'][] = ['info', 'warning', 'success'];
32+
const titles = [
33+
'新用户注册',
34+
'系统通知',
35+
'任务更新',
36+
'安全提醒',
37+
'数据备份完成',
38+
'服务器资源警告',
39+
];
40+
const next: RealtimeNotification = {
41+
id: `${now}`,
42+
title: titles[now % titles.length],
43+
body: '这是通过 WebSocket 服务推送的模拟通知。',
44+
type: types[now % types.length],
45+
createdAt: now,
46+
};
47+
this.messagesSignal.update((list) => [next, ...list].slice(0, 20));
48+
}, 8000);
49+
}
50+
51+
disconnect(): void {
52+
if (this.timer) {
53+
clearInterval(this.timer);
54+
this.timer = undefined;
55+
}
56+
this.connectedSignal.set(false);
57+
}
58+
59+
// Allow components to mark messages as read
60+
clear(): void {
61+
this.messagesSignal.set([]);
62+
}
63+
}

src/app/features/dashboard/dashboard.page.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { ButtonComponent } from '../../shared/components/ui';
55
import { LineChartComponent, BarChartComponent, PieChartComponent } from '../../shared/components/charts';
66
import { LucideAngularModule, Edit, Check, RotateCcw, Plus } from 'lucide-angular';
77
import { DashboardStore, DashboardWidget } from './dashboard.store';
8-
import { WidgetWrapperComponent, StatsWidgetComponent, RecentActivityWidgetComponent } from './widgets';
8+
import { WidgetWrapperComponent, StatsWidgetComponent, RecentActivityWidgetComponent, QuickActionsWidgetComponent, NotificationsWidgetComponent, TasksWidgetComponent, CalendarWidgetComponent } from './widgets';
99

1010
@Component({
1111
selector: 'app-dashboard-page',
@@ -20,6 +20,10 @@ import { WidgetWrapperComponent, StatsWidgetComponent, RecentActivityWidgetCompo
2020
WidgetWrapperComponent,
2121
StatsWidgetComponent,
2222
RecentActivityWidgetComponent,
23+
QuickActionsWidgetComponent,
24+
NotificationsWidgetComponent,
25+
TasksWidgetComponent,
26+
CalendarWidgetComponent,
2327
],
2428
template: `
2529
<div class="space-y-4">
@@ -92,6 +96,18 @@ import { WidgetWrapperComponent, StatsWidgetComponent, RecentActivityWidgetCompo
9296
@case ('recent-activity') {
9397
<app-recent-activity-widget />
9498
}
99+
@case ('quick-actions') {
100+
<app-quick-actions-widget />
101+
}
102+
@case ('notifications') {
103+
<app-notifications-widget />
104+
}
105+
@case ('tasks') {
106+
<app-tasks-widget />
107+
}
108+
@case ('calendar') {
109+
<app-calendar-widget />
110+
}
95111
}
96112
</app-widget-wrapper>
97113
</gridster-item>

src/app/features/dashboard/dashboard.store.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,16 @@ import { GridsterItem } from 'angular-gridster2';
33

44
export interface WidgetConfig {
55
id: string;
6-
type: 'stats' | 'chart-line' | 'chart-bar' | 'chart-pie' | 'recent-activity' | 'quick-actions';
6+
type:
7+
| 'stats'
8+
| 'chart-line'
9+
| 'chart-bar'
10+
| 'chart-pie'
11+
| 'recent-activity'
12+
| 'quick-actions'
13+
| 'notifications'
14+
| 'tasks'
15+
| 'calendar';
716
title: string;
817
settings?: Record<string, unknown>;
918
}
@@ -24,6 +33,10 @@ const defaultWidgets: DashboardWidget[] = [
2433
{ id: 'chart-pie', x: 6, y: 2, cols: 6, rows: 4, widget: { id: 'chart-pie', type: 'chart-pie', title: '用户分布' } },
2534
{ id: 'chart-bar', x: 0, y: 6, cols: 6, rows: 4, widget: { id: 'chart-bar', type: 'chart-bar', title: '月度收入' } },
2635
{ id: 'recent-activity', x: 6, y: 6, cols: 6, rows: 4, widget: { id: 'recent-activity', type: 'recent-activity', title: '最近活动' } },
36+
{ id: 'quick-actions', x: 0, y: 10, cols: 4, rows: 3, widget: { id: 'quick-actions', type: 'quick-actions', title: '快捷操作' } },
37+
{ id: 'notifications', x: 4, y: 10, cols: 4, rows: 3, widget: { id: 'notifications', type: 'notifications', title: '通知' } },
38+
{ id: 'tasks', x: 8, y: 10, cols: 4, rows: 3, widget: { id: 'tasks', type: 'tasks', title: '任务' } },
39+
{ id: 'calendar', x: 0, y: 13, cols: 12, rows: 4, widget: { id: 'calendar', type: 'calendar', title: '日历' } },
2740
];
2841

2942
@Injectable({ providedIn: 'root' })
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { Component, computed, signal } from '@angular/core';
2+
import { LucideAngularModule, ChevronLeft, ChevronRight } from 'lucide-angular';
3+
4+
interface CalendarDay {
5+
date: number;
6+
label: string;
7+
highlight?: boolean;
8+
}
9+
10+
@Component({
11+
selector: 'app-calendar-widget',
12+
standalone: true,
13+
imports: [LucideAngularModule],
14+
template: `
15+
<div class="space-y-3">
16+
<div class="flex items-center justify-between">
17+
<button type="button" class="h-8 w-8 flex items-center justify-center rounded-md hover:bg-accent" (click)="prevMonth()">
18+
<lucide-angular [img]="ChevronLeftIcon" class="h-4 w-4" />
19+
</button>
20+
<div class="text-sm font-semibold text-foreground">{{ currentLabel() }}</div>
21+
<button type="button" class="h-8 w-8 flex items-center justify-center rounded-md hover:bg-accent" (click)="nextMonth()">
22+
<lucide-angular [img]="ChevronRightIcon" class="h-4 w-4" />
23+
</button>
24+
</div>
25+
<div class="grid grid-cols-7 gap-2 text-center text-xs text-muted-foreground">
26+
<span>一</span><span>二</span><span>三</span><span>四</span><span>五</span><span class="font-medium text-foreground">六</span><span class="font-medium text-foreground">日</span>
27+
</div>
28+
<div class="grid grid-cols-7 gap-2">
29+
@for (day of days(); track day.date) {
30+
<div class="h-10 rounded-md border border-border/60 flex items-center justify-center text-sm"
31+
[class.bg-primary/10]="day.highlight"
32+
[class.text-primary]="day.highlight">
33+
<span>{{ day.label }}</span>
34+
</div>
35+
}
36+
</div>
37+
</div>
38+
`,
39+
})
40+
export class CalendarWidgetComponent {
41+
readonly ChevronLeftIcon = ChevronLeft;
42+
readonly ChevronRightIcon = ChevronRight;
43+
44+
private readonly month = signal(0); // month offset from current
45+
46+
readonly currentLabel = computed(() => {
47+
const date = new Date();
48+
date.setMonth(date.getMonth() + this.month());
49+
return `${date.getFullYear()}${date.getMonth() + 1} 月`;
50+
});
51+
52+
readonly days = computed<CalendarDay[]>(() => {
53+
const date = new Date();
54+
date.setDate(1);
55+
date.setMonth(date.getMonth() + this.month());
56+
const totalDays = new Date(date.getFullYear(), date.getMonth() + 1, 0).getDate();
57+
return Array.from({ length: totalDays }).map((_, idx) => ({
58+
date: idx + 1,
59+
label: `${idx + 1}`,
60+
highlight: (idx + 1) % 5 === 0, // placeholder highlight days
61+
}));
62+
});
63+
64+
prevMonth(): void {
65+
this.month.update((m) => m - 1);
66+
}
67+
68+
nextMonth(): void {
69+
this.month.update((m) => m + 1);
70+
}
71+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
11
export { WidgetWrapperComponent } from './widget-wrapper.component';
22
export { StatsWidgetComponent } from './stats-widget.component';
33
export { RecentActivityWidgetComponent } from './recent-activity-widget.component';
4+
export { QuickActionsWidgetComponent } from './quick-actions-widget.component';
5+
export { NotificationsWidgetComponent } from './notifications-widget.component';
6+
export { TasksWidgetComponent } from './tasks-widget.component';
7+
export { CalendarWidgetComponent } from './calendar-widget.component';

0 commit comments

Comments
 (0)