Skip to content

Commit 20864d7

Browse files
committed
fix: refine impersonation toolbar UX
1 parent 215c440 commit 20864d7

4 files changed

Lines changed: 192 additions & 20 deletions

src/app/admin/impersonation-admin-toolbar.component.css

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
border-bottom: var(--bs-border-width) solid var(--bs-border-color);
1212
}
1313

14-
.field { width: 8rem; }
15-
.field-wide { width: 13rem; }
16-
.state { width: 4rem; }
14+
.field, .field-wide, .state { min-width: 0; }
15+
.field { flex: 0 1 8rem; }
16+
.field-wide { flex: 0 1 13rem; }
17+
.state { flex: 0 1 4rem; }
Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,31 @@
11
@if (impersonation.active && impersonation.adminContext) {
2-
<aside class="admin-toolbar shadow-sm p-2">
2+
<aside class="admin-toolbar shadow-sm px-3 py-2">
33
<div class="d-flex flex-wrap gap-2 align-items-center">
44
<strong>管理员工具 · {{ impersonation.adminContext.target }}</strong>
55
<input class="form-control form-control-sm field" placeholder="ExtId" [(ngModel)]="extId">
6-
<button class="btn btn-sm btn-outline-primary" (click)="run('bind-card-by-ext-id', {extId: +extId})">绑定</button>
7-
<button class="btn btn-sm btn-outline-danger" (click)="run('unbind-card-by-ext-id', {extId: +extId})">解绑</button>
8-
<button class="btn btn-sm btn-outline-primary" (click)="run('set-default-card', {extId: +extId})">设默认</button>
6+
<button class="btn btn-sm btn-outline-primary" [disabled]="pending || extIdValue === null"
7+
(click)="run('bind-card-by-ext-id', {extId: extIdValue})">绑定</button>
8+
<button class="btn btn-sm btn-outline-danger" [disabled]="pending || extIdValue === null"
9+
(click)="run('unbind-card-by-ext-id', {extId: extIdValue})">解绑</button>
10+
<button class="btn btn-sm btn-outline-primary" [disabled]="pending || extIdValue === null"
11+
(click)="run('set-default-card', {extId: extIdValue})">设默认</button>
912
<input class="form-control form-control-sm field-wide" placeholder="外部 Access Code" [(ngModel)]="externalLuid">
10-
<button class="btn btn-sm btn-outline-danger" (click)="run('remove-external-code', {extId: +extId, luid: externalLuid})">删除外部码</button>
13+
<button class="btn btn-sm btn-outline-danger"
14+
[disabled]="pending || extIdValue === null || externalLuid.length !== 20"
15+
(click)="run('remove-external-code', {extId: extIdValue, luid: externalLuid})">删除外部码</button>
1116
<select class="form-select form-select-sm field" [(ngModel)]="game"><option>CHUSAN</option><option>MAIMAI2</option><option>ONGEKI</option></select>
1217
<select class="form-select form-select-sm state" [(ngModel)]="status"><option [ngValue]="0">0</option><option [ngValue]="1">1</option><option [ngValue]="2">2</option></select>
13-
<button class="btn btn-sm btn-outline-primary" (click)="run('set-game-ban-state', {game, extId: +extId, status})">设游戏状态</button>
14-
<button class="btn btn-sm btn-outline-secondary" (click)="refresh()">刷新</button>
18+
<button class="btn btn-sm btn-outline-primary" [disabled]="pending || extIdValue === null"
19+
(click)="run('set-game-ban-state', {game, extId: extIdValue, status})">设游戏状态</button>
20+
<button class="btn btn-sm btn-outline-secondary" [disabled]="pending" (click)="refresh()">刷新</button>
1521
</div>
1622
@if (data?.account?.cards) {
1723
<div class="small mt-1">卡片:@for (card of data.account.cards; track card.extId) { <span class="me-2">{{ card.extId }} [C {{card.chusanBanState ?? '-'}} / M {{card.maimai2BanState ?? '-'}} / O {{card.ongekiBanState ?? '-'}}]</span> }</div>
1824
}
19-
@if (message) { <div class="small">{{ message }}</div> }
25+
@if (message) {
26+
<div class="small mt-1" [attr.role]="messageType === 'error' ? 'alert' : 'status'"
27+
[class.text-success]="messageType === 'success'"
28+
[class.text-danger]="messageType === 'error'">{{ message }}</div>
29+
}
2030
</aside>
2131
}

src/app/admin/impersonation-admin-toolbar.component.spec.ts

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ describe('ImpersonationAdminToolbarComponent', () => {
1212
};
1313

1414
beforeEach(async () => {
15+
impersonation.requestAdminAction.calls.reset();
16+
impersonation.requestAdminAction.and.resolveTo({account: {cards: []}});
1517
await TestBed.configureTestingModule({
1618
declarations: [ImpersonationAdminToolbarComponent],
1719
imports: [FormsModule],
@@ -44,4 +46,90 @@ describe('ImpersonationAdminToolbarComponent', () => {
4446
expect(styles.borderBottomColor).toBe('rgb(70, 80, 90)');
4547
expect(styles.borderBottomWidth).toBe('2px');
4648
});
49+
50+
it('aligns its content with the modal header using Bootstrap spacing', () => {
51+
const toolbar = fixture.nativeElement.querySelector('.admin-toolbar') as HTMLElement;
52+
53+
expect(toolbar.classList).toContain('px-3');
54+
expect(toolbar.classList).toContain('py-2');
55+
expect(toolbar.classList).not.toContain('p-2');
56+
expect(getComputedStyle(toolbar).paddingLeft).toBe('16px');
57+
});
58+
59+
it('loads and manually refreshes the admin context without a success message', async () => {
60+
await fixture.whenStable();
61+
expect(fixture.componentInstance.message).toBe('');
62+
63+
fixture.componentInstance.message = '卡片 123 已绑定';
64+
await fixture.componentInstance.refresh();
65+
66+
expect(fixture.componentInstance.message).toBe('');
67+
expect(impersonation.requestAdminAction).toHaveBeenCalledWith('refresh-admin-context', {});
68+
});
69+
70+
it('shows specific feedback after a write operation succeeds', async () => {
71+
await fixture.componentInstance.run('bind-card-by-ext-id', {extId: 12345678});
72+
fixture.detectChanges();
73+
74+
expect(fixture.componentInstance.message).toBe('卡片 12345678 已绑定');
75+
expect(fixture.nativeElement.querySelector('[role="status"].text-success').textContent)
76+
.toContain('卡片 12345678 已绑定');
77+
});
78+
79+
it('keeps refresh errors visible', async () => {
80+
impersonation.requestAdminAction.and.rejectWith(new Error('刷新失败'));
81+
82+
await fixture.componentInstance.refresh();
83+
fixture.detectChanges();
84+
85+
expect(fixture.componentInstance.message).toBe('刷新失败');
86+
expect(fixture.nativeElement.querySelector('[role="alert"].text-danger').textContent)
87+
.toContain('刷新失败');
88+
});
89+
90+
it('disables write actions until their required inputs are valid', async () => {
91+
await fixture.whenStable();
92+
fixture.detectChanges();
93+
const buttons = Array.from(fixture.nativeElement.querySelectorAll('button')) as HTMLButtonElement[];
94+
95+
expect(buttons.filter(button => button.textContent?.trim() !== '刷新').every(button => button.disabled)).toBeTrue();
96+
97+
const extIdInput = fixture.nativeElement.querySelector('input[placeholder="ExtId"]') as HTMLInputElement;
98+
extIdInput.value = '12345678';
99+
extIdInput.dispatchEvent(new Event('input'));
100+
fixture.detectChanges();
101+
const updatedButtons = Array.from(fixture.nativeElement.querySelectorAll('button')) as HTMLButtonElement[];
102+
103+
expect(fixture.componentInstance.extIdValue).toBe(12345678);
104+
expect(fixture.componentInstance.pending).toBeFalse();
105+
expect(updatedButtons.find(button => button.textContent?.trim() === '绑定')?.disabled).toBeFalse();
106+
expect(updatedButtons.find(button => button.textContent?.trim() === '删除外部码')?.disabled).toBeTrue();
107+
});
108+
109+
it('does not let an older refresh overwrite a newer write result', async () => {
110+
await fixture.whenStable();
111+
let resolveRefresh!: (value: any) => void;
112+
let resolveWrite!: (value: any) => void;
113+
const refreshResult = new Promise(resolve => resolveRefresh = resolve);
114+
const writeResult = new Promise(resolve => resolveWrite = resolve);
115+
impersonation.requestAdminAction.and.returnValues(refreshResult, writeResult);
116+
117+
const refresh = fixture.componentInstance.refresh();
118+
const write = fixture.componentInstance.run('bind-card-by-ext-id', {extId: 12345678});
119+
resolveWrite({account: {cards: [{extId: 12345678}]}});
120+
await write;
121+
resolveRefresh({account: {cards: []}});
122+
await refresh;
123+
124+
expect(fixture.componentInstance.data.account.cards).toEqual([{extId: 12345678}]);
125+
expect(fixture.componentInstance.message).toBe('卡片 12345678 已绑定');
126+
});
127+
128+
it('allows fields to shrink before the toolbar wraps on narrow viewports', () => {
129+
const field = fixture.nativeElement.querySelector('.field') as HTMLElement;
130+
const styles = getComputedStyle(field);
131+
132+
expect(styles.flexShrink).toBe('1');
133+
expect(styles.minWidth).toBe('0px');
134+
});
47135
});
Lines changed: 82 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import {Component, OnInit} from '@angular/core';
2-
import {ImpersonationService} from '../auth/impersonation.service';
2+
import {ImpersonationAdminAction, ImpersonationService} from '../auth/impersonation.service';
3+
4+
type WriteAdminAction = Exclude<ImpersonationAdminAction, 'refresh-admin-context'>;
35

46
@Component({selector: 'app-impersonation-admin-toolbar', templateUrl: './impersonation-admin-toolbar.component.html',
57
styleUrls: ['./impersonation-admin-toolbar.component.css'], standalone: false})
@@ -10,14 +12,85 @@ export class ImpersonationAdminToolbarComponent implements OnInit {
1012
game = 'CHUSAN';
1113
status = 0;
1214
message = '';
15+
messageType: 'success' | 'error' = 'success';
16+
pending = false;
17+
private requestVersion = 0;
1318
constructor(public impersonation: ImpersonationService) {}
14-
ngOnInit() { this.refresh(); }
15-
run(action: any, payload: any) {
16-
this.impersonation.requestAdminAction(action, payload).then(data => {
17-
this.message = '操作成功';
18-
if (data?.account) this.data = data;
19-
else this.refresh();
20-
}).catch(error => this.message = error.message);
19+
ngOnInit() { void this.refresh(); }
20+
21+
get extIdValue(): number | null {
22+
const input = this.extId.trim();
23+
if (!/^\d+$/.test(input)) return null;
24+
const value = Number(input);
25+
return Number.isSafeInteger(value) ? value : null;
26+
}
27+
28+
async run(action: WriteAdminAction, payload: any): Promise<void> {
29+
const requestVersion = ++this.requestVersion;
30+
this.pending = true;
31+
this.message = '';
32+
try {
33+
const data = await this.impersonation.requestAdminAction(action, payload);
34+
if (requestVersion !== this.requestVersion) return;
35+
const successMessage = this.successMessage(action, payload);
36+
if (data?.account) {
37+
this.data = data;
38+
} else {
39+
try {
40+
await this.loadContext(requestVersion);
41+
} catch (error) {
42+
if (requestVersion === this.requestVersion) {
43+
this.messageType = 'error';
44+
this.message = `${successMessage},但账户信息刷新失败:${this.errorMessage(error)}`;
45+
}
46+
return;
47+
}
48+
}
49+
if (requestVersion !== this.requestVersion) return;
50+
this.messageType = 'success';
51+
this.message = successMessage;
52+
} catch (error) {
53+
if (requestVersion === this.requestVersion) this.showError(error);
54+
} finally {
55+
if (requestVersion === this.requestVersion) this.pending = false;
56+
}
57+
}
58+
59+
async refresh(): Promise<void> {
60+
const requestVersion = ++this.requestVersion;
61+
this.pending = true;
62+
this.message = '';
63+
try {
64+
await this.loadContext(requestVersion);
65+
} catch (error) {
66+
if (requestVersion === this.requestVersion) this.showError(error);
67+
} finally {
68+
if (requestVersion === this.requestVersion) this.pending = false;
69+
}
70+
}
71+
72+
private async loadContext(requestVersion: number): Promise<void> {
73+
const data = await this.impersonation.requestAdminAction('refresh-admin-context', {});
74+
if (requestVersion === this.requestVersion && data?.account) this.data = data;
75+
}
76+
77+
private showError(error: unknown) {
78+
this.messageType = 'error';
79+
this.message = this.errorMessage(error);
80+
}
81+
82+
private errorMessage(error: unknown) {
83+
return error instanceof Error ? error.message : String(error);
84+
}
85+
86+
private successMessage(action: WriteAdminAction, payload: any) {
87+
switch (action) {
88+
case 'bind-card-by-ext-id': return `卡片 ${payload.extId} 已绑定`;
89+
case 'unbind-card-by-ext-id': return `卡片 ${payload.extId} 已解绑`;
90+
case 'set-default-card': return `卡片 ${payload.extId} 已设为默认卡片`;
91+
case 'remove-external-code': return `卡片 ${payload.extId} 的外部 Access Code 已删除`;
92+
case 'set-game-ban-state':
93+
return `${payload.game} 卡片 ${payload.extId} 的封禁状态已设为 ${payload.status}`;
94+
}
2195
}
22-
refresh() { this.run('refresh-admin-context', {}); }
2396
}

0 commit comments

Comments
 (0)