@ddj-v2/user-management 2.1.0 → 2.3.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/README.md +40 -45
- package/index.ts +143 -7
- package/package.json +1 -1
- package/templates/domain_user.html +109 -0
- package/templates/user_manage_detail.html +0 -7
- package/templates/user_manage_main.html +58 -8
- package/asset/fig1.png +0 -0
- package/asset/fig2.png +0 -0
- package/pages/user_manage_detail.page.js +0 -268
- package/pages/user_manage_main.page.js +0 -200
package/README.md
CHANGED
|
@@ -1,77 +1,72 @@
|
|
|
1
|
-
# HydroOJ插件
|
|
1
|
+
# HydroOJ插件 使用者管理面板
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
修改自 https://github.com/SummerofOrange/hydrooj-user-management
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
這是一個為 HydroOJ 提供可視化使用者管理功能的插件,讓管理員能在控制面板中方便地管理使用者資訊、權限與狀態。
|
|
6
|
+
|
|
7
|
+
> 程式碼很簡單,佛系不定期更新~
|
|
6
8
|
>
|
|
7
|
-
>
|
|
9
|
+
> 如果覺得好用請幫我點個 star,感激不盡。
|
|
8
10
|
|
|
9
|
-
##
|
|
11
|
+
## 安裝方法
|
|
10
12
|
|
|
11
13
|
```bash
|
|
12
14
|
sudo su
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
hydrooj addon add /root/.hydro/hydrooj-user-management
|
|
15
|
+
yarn global add @ddj-v2/user-management
|
|
16
|
+
hydrooj addon add @ddj-v2/user-management
|
|
16
17
|
pm2 restart hydrooj
|
|
17
18
|
```
|
|
18
19
|
|
|
19
20
|
## 使用方法
|
|
20
21
|
|
|
21
|
-
1.
|
|
22
|
-
2.
|
|
23
|
-
3.
|
|
24
|
-
4.
|
|
25
|
-
5.
|
|
26
|
-
6.
|
|
22
|
+
1. **進入使用者管理**:登入 HydroOJ 後,在控制面板側邊欄找到「使用者管理」選單項
|
|
23
|
+
2. **搜尋使用者**:在使用者列表頁面使用搜尋框查找特定使用者
|
|
24
|
+
3. **編輯使用者**:點擊使用者列表中的「編輯」按鈕進入使用者詳細頁面
|
|
25
|
+
4. **管理權限**:在使用者詳細頁面的「權限管理」部分設定使用者權限
|
|
26
|
+
5. **重設密碼**:在「密碼管理」部分為使用者重設新密碼
|
|
27
|
+
6. **封鎖使用者**:在「使用者狀態」部分封鎖或解封使用者
|
|
28
|
+
|
|
27
29
|
|
|
28
|
-
|
|
30
|
+
1. **域用戶管理**: 可看見 default role
|
|
31
|
+
## 權限說明
|
|
29
32
|
|
|
30
|
-
|
|
33
|
+
插件使用以下權限等級:
|
|
31
34
|
|
|
32
|
-
- **-1
|
|
33
|
-
- **0
|
|
34
|
-
- **4
|
|
35
|
-
- **8
|
|
36
|
-
- **16842756
|
|
37
|
-
-
|
|
35
|
+
- **-1**:root(超級管理員)
|
|
36
|
+
- **0**:已封鎖使用者
|
|
37
|
+
- **4**:系統保留
|
|
38
|
+
- **8**:訪客使用者
|
|
39
|
+
- **16842756**:預設使用者權限
|
|
40
|
+
- **其他值**:自訂權限
|
|
38
41
|
|
|
39
|
-
##
|
|
42
|
+
## 介面展示
|
|
40
43
|
|
|
41
|
-
###
|
|
44
|
+
### 使用者列表頁面
|
|
42
45
|
|
|
43
|
-

|
|
44
47
|
|
|
45
|
-
###
|
|
48
|
+
### 使用者詳細頁面
|
|
46
49
|
|
|
47
|
-

|
|
48
51
|
|
|
49
52
|
## 安全特性
|
|
50
53
|
|
|
51
|
-
- ✅
|
|
52
|
-
- ✅
|
|
53
|
-
- ✅
|
|
54
|
-
- ✅
|
|
55
|
-
- ✅
|
|
54
|
+
- ✅ 權限驗證:只有具有系統管理權限的使用者才能訪問
|
|
55
|
+
- ✅ 操作確認:重要操作(如重設密碼、封鎖使用者)需確認
|
|
56
|
+
- ✅ 權限保護:防止非超級管理員修改超級管理員帳戶
|
|
57
|
+
- ✅ 資料驗證:自動驗證使用者名稱與電子郵件唯一性
|
|
58
|
+
- ✅ 輸入驗證:前端與後端雙重驗證使用者輸入
|
|
56
59
|
|
|
57
|
-
##
|
|
60
|
+
## 開發說明
|
|
58
61
|
|
|
59
|
-
###
|
|
62
|
+
### 貢獻程式碼
|
|
60
63
|
|
|
61
|
-
|
|
64
|
+
歡迎提交 Issue 與 Pull Request 改進此插件。
|
|
62
65
|
|
|
63
|
-
##
|
|
66
|
+
## 授權
|
|
64
67
|
|
|
65
68
|
MIT License
|
|
66
69
|
|
|
67
|
-
## 支持
|
|
68
|
-
|
|
69
|
-
如果您在使用过程中遇到问题,请:
|
|
70
|
-
|
|
71
|
-
1. 查看 [Issues](https://github.com/SummerofOrange/hydrooj-user-management/issues) 页面
|
|
72
|
-
2. 提交新的 Issue 描述您的问题
|
|
73
|
-
3. 联系作者获取技术支持
|
|
74
|
-
|
|
75
70
|
---
|
|
76
71
|
|
|
77
|
-
|
|
72
|
+
**注意**:此插件需 HydroOJ v5.0.0-beta.6 或更高版本。使用前請確保您有足夠的系統管理權限。
|
package/index.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import {
|
|
2
2
|
Context, Handler, param, PRIV, Types, UserModel, DomainModel,
|
|
3
|
-
ValidationError, UserNotFoundError, PermissionError, Time, SystemModel, moment
|
|
3
|
+
ValidationError, UserNotFoundError, PermissionError, Time, SystemModel, moment,
|
|
4
|
+
PERM
|
|
4
5
|
} from 'hydrooj';
|
|
6
|
+
import domain from 'hydrooj/src/model/domain';
|
|
5
7
|
|
|
6
8
|
declare module 'hydrooj' {
|
|
7
9
|
interface Collections {
|
|
@@ -98,11 +100,11 @@ class UserManageDetailHandler extends UserManageHandler {
|
|
|
98
100
|
if (!udoc) throw new UserNotFoundError(uid);
|
|
99
101
|
|
|
100
102
|
if (operation === 'edit') {
|
|
101
|
-
await this.postEdit(domainId, uid);
|
|
103
|
+
await this.postEdit(domainId, uid, this.args.mail, this.args.uname, this.args.bio);
|
|
102
104
|
} else if (operation === 'resetPassword') {
|
|
103
|
-
await this.postResetPassword(domainId, uid);
|
|
105
|
+
await this.postResetPassword(domainId, uid, this.args.password);
|
|
104
106
|
} else if (operation === 'setPriv') {
|
|
105
|
-
await this.postSetPriv(domainId, uid);
|
|
107
|
+
await this.postSetPriv(domainId, uid, this.args.priv);
|
|
106
108
|
} else if (operation === 'ban') {
|
|
107
109
|
await this.postBan(domainId, uid);
|
|
108
110
|
} else if (operation === 'unban') {
|
|
@@ -115,9 +117,8 @@ class UserManageDetailHandler extends UserManageHandler {
|
|
|
115
117
|
@param('uid', Types.Int)
|
|
116
118
|
@param('mail', Types.Email, true)
|
|
117
119
|
@param('uname', Types.Username, true)
|
|
118
|
-
@param('school', Types.String, true)
|
|
119
120
|
@param('bio', Types.Content, true)
|
|
120
|
-
async postEdit(domainId: string, uid: number, mail?: string, uname?: string,
|
|
121
|
+
async postEdit(domainId: string, uid: number, mail?: string, uname?: string, bio?: string) {
|
|
121
122
|
const udoc = await UserModel.getById(domainId, uid);
|
|
122
123
|
if (!udoc) throw new UserNotFoundError(uid);
|
|
123
124
|
|
|
@@ -140,7 +141,6 @@ class UserManageDetailHandler extends UserManageHandler {
|
|
|
140
141
|
}
|
|
141
142
|
|
|
142
143
|
const updates: any = {};
|
|
143
|
-
if (school !== undefined) updates.school = school;
|
|
144
144
|
if (bio !== undefined) updates.bio = bio;
|
|
145
145
|
|
|
146
146
|
if (Object.keys(updates).length > 0) {
|
|
@@ -210,6 +210,77 @@ export async function apply(ctx: Context) {
|
|
|
210
210
|
// 在控制面板侧边栏添加用户管理菜单项
|
|
211
211
|
ctx.injectUI('ControlPanel', 'user_manage_main', { icon: 'user' });
|
|
212
212
|
|
|
213
|
+
ctx.withHandlerClass('DomainUser', (DomainUserHandler: { prototype: any }) => {
|
|
214
|
+
const originalGet = DomainUserHandler.prototype.get;
|
|
215
|
+
|
|
216
|
+
// 包裝原方法
|
|
217
|
+
DomainUserHandler.prototype.get = async function() {
|
|
218
|
+
const { domainId } = this.args;
|
|
219
|
+
const format = this.args.format || 'default';
|
|
220
|
+
console.log('DomainUserHandler get called with domainId:', domainId, 'format:', format);
|
|
221
|
+
const [dudocs, roles] = await Promise.all([
|
|
222
|
+
domain.collUser.aggregate([
|
|
223
|
+
{
|
|
224
|
+
$match: {
|
|
225
|
+
// TODO: add a page to display users who joined but with default role
|
|
226
|
+
role: {
|
|
227
|
+
$nin: ['guest'],
|
|
228
|
+
$ne: null,
|
|
229
|
+
},
|
|
230
|
+
domainId,
|
|
231
|
+
},
|
|
232
|
+
},
|
|
233
|
+
{
|
|
234
|
+
$lookup: {
|
|
235
|
+
from: 'user',
|
|
236
|
+
let: { uid: '$uid' },
|
|
237
|
+
pipeline: [
|
|
238
|
+
{
|
|
239
|
+
$match: {
|
|
240
|
+
$expr: { $eq: ['$_id', '$$uid'] },
|
|
241
|
+
priv: { $bitsAllSet: PRIV.PRIV_USER_PROFILE },
|
|
242
|
+
},
|
|
243
|
+
},
|
|
244
|
+
{
|
|
245
|
+
$project: {
|
|
246
|
+
_id: 1,
|
|
247
|
+
uname: 1,
|
|
248
|
+
avatar: 1,
|
|
249
|
+
},
|
|
250
|
+
},
|
|
251
|
+
],
|
|
252
|
+
as: 'user',
|
|
253
|
+
},
|
|
254
|
+
},
|
|
255
|
+
{ $unwind: '$user' },
|
|
256
|
+
{
|
|
257
|
+
$project: {
|
|
258
|
+
user: 1,
|
|
259
|
+
role: 1,
|
|
260
|
+
join: 1,
|
|
261
|
+
...(this.user.hasPerm(PERM.PERM_VIEW_USER_PRIVATE_INFO) ? { displayName: 1 } : {}),
|
|
262
|
+
},
|
|
263
|
+
},
|
|
264
|
+
]).toArray(),
|
|
265
|
+
domain.getRoles(domainId),
|
|
266
|
+
]);
|
|
267
|
+
const users = dudocs.map((dudoc) => {
|
|
268
|
+
const u = {
|
|
269
|
+
...dudoc,
|
|
270
|
+
...dudoc.user,
|
|
271
|
+
};
|
|
272
|
+
delete u.user;
|
|
273
|
+
return u;
|
|
274
|
+
});
|
|
275
|
+
const rudocs: Record<string, any[]> = {};
|
|
276
|
+
for (const role of roles) rudocs[role._id] = users.filter((udoc) => udoc.role === role._id);
|
|
277
|
+
this.response.template = format === 'raw' ? 'domain_user_raw.html' : 'domain_user.html';
|
|
278
|
+
this.response.body = {
|
|
279
|
+
roles, rudocs, domain: this.domain,
|
|
280
|
+
};
|
|
281
|
+
};
|
|
282
|
+
return DomainUserHandler;
|
|
283
|
+
});
|
|
213
284
|
// 添加国际化支持
|
|
214
285
|
ctx.i18n.load('zh', {
|
|
215
286
|
'user_manage_main': '用户管理',
|
|
@@ -275,6 +346,71 @@ export async function apply(ctx: Context) {
|
|
|
275
346
|
'Copy User ID': '复制用户ID'
|
|
276
347
|
});
|
|
277
348
|
|
|
349
|
+
// 添加国际化支持
|
|
350
|
+
ctx.i18n.load('zh_TW', {
|
|
351
|
+
'user_manage_main': '用戶管理',
|
|
352
|
+
'user_manage_detail': '用戶詳情',
|
|
353
|
+
|
|
354
|
+
'User Management': '用戶管理',
|
|
355
|
+
'User List': '用戶列表',
|
|
356
|
+
'Search Users': '搜尋用戶',
|
|
357
|
+
'Search by': '搜尋方式',
|
|
358
|
+
'Username': '用戶名',
|
|
359
|
+
'Email': '電子郵件',
|
|
360
|
+
'User ID': '用戶ID',
|
|
361
|
+
'Keyword': '關鍵字',
|
|
362
|
+
'Sort by': '排序方式',
|
|
363
|
+
'Registration Time': '註冊時間',
|
|
364
|
+
'Last Login': '最後登入',
|
|
365
|
+
'Privilege': '權限',
|
|
366
|
+
'Order': '順序',
|
|
367
|
+
'Ascending': '升序',
|
|
368
|
+
'Descending': '降序',
|
|
369
|
+
'Search': '搜尋',
|
|
370
|
+
'Clear': '清除',
|
|
371
|
+
'Refresh': '刷新',
|
|
372
|
+
|
|
373
|
+
'Normal User': '普通用戶',
|
|
374
|
+
'Admin': '管理員',
|
|
375
|
+
'Banned': '已封禁',
|
|
376
|
+
'Super Admin': '超級管理員',
|
|
377
|
+
'Active': '活躍',
|
|
378
|
+
'Inactive': '不活躍',
|
|
379
|
+
'Actions': '操作',
|
|
380
|
+
'View': '查看',
|
|
381
|
+
'Edit': '編輯',
|
|
382
|
+
'Ban': '封禁',
|
|
383
|
+
'Unban': '解封',
|
|
384
|
+
'Set Privilege': '設置權限',
|
|
385
|
+
'Status': '狀態',
|
|
386
|
+
'School': '學校',
|
|
387
|
+
'Bio': '個人簡介',
|
|
388
|
+
'Never': '從未',
|
|
389
|
+
'Not set': '未設置',
|
|
390
|
+
'Previous': '上一頁',
|
|
391
|
+
'Next': '下一頁',
|
|
392
|
+
'Page': '頁',
|
|
393
|
+
'of': '共',
|
|
394
|
+
'users': '用戶',
|
|
395
|
+
'Total': '總計',
|
|
396
|
+
'Showing': '顯示',
|
|
397
|
+
'to': '到',
|
|
398
|
+
'User Details': '用戶詳情',
|
|
399
|
+
'Basic Information': '基本資訊',
|
|
400
|
+
'User Statistics': '用戶統計',
|
|
401
|
+
'Privilege Management': '權限管理',
|
|
402
|
+
'Password Management': '密碼管理',
|
|
403
|
+
'User Status': '用戶狀態',
|
|
404
|
+
'Back to List': '返回列表',
|
|
405
|
+
'Save Changes': '保存更改',
|
|
406
|
+
'Cancel': '取消',
|
|
407
|
+
'Reset Password': '重設密碼',
|
|
408
|
+
'Current Privilege': '當前權限',
|
|
409
|
+
'Ban User': '封禁用戶',
|
|
410
|
+
'Unban User': '解封用戶',
|
|
411
|
+
'Copy User ID': '複製用戶ID'
|
|
412
|
+
});
|
|
413
|
+
|
|
278
414
|
ctx.i18n.load('en', {
|
|
279
415
|
'user_manage_main': 'User Management',
|
|
280
416
|
'user_manage_detail': 'User Detail',
|
package/package.json
CHANGED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
{% extends "domain_base.html" %}
|
|
2
|
+
{% block domain_content %}
|
|
3
|
+
{{ set(UiContext, 'roles', roles.map(eval('(i) => i._id'))) }}
|
|
4
|
+
{{ set(UiContext, 'canForceJoin', model.system.get('server.allowInvite') or handler.user.hasPriv(PRIV.PRIV_MANAGE_ALL_DOMAIN)) }}
|
|
5
|
+
{% set _rolesSelect = roles.filter(eval("(i) => i._id !== 'guest'")).map(eval('(role) => [role._id, role._id]')) %}
|
|
6
|
+
<div class="section">
|
|
7
|
+
<div class="section__header">
|
|
8
|
+
<h1 class="section__title">{{ _('{0}: Users').format(domain.name) }}</h1>
|
|
9
|
+
<div class="section__tools">
|
|
10
|
+
<button class="primary rounded button" name="add_user">{{ _('Add User') }}</button>
|
|
11
|
+
</div>
|
|
12
|
+
<!-- Search bar for Role -->
|
|
13
|
+
<div class="section__search" style="margin-top: 10px;">
|
|
14
|
+
<input type="text" id="roleSearch" placeholder="{{ _('Search by Role') }}" class="input" style="width: 200px;">
|
|
15
|
+
</div>
|
|
16
|
+
</div>
|
|
17
|
+
{{ noscript_note.render() }}
|
|
18
|
+
<div class="section__body no-padding domain-users">
|
|
19
|
+
<table class="data-table" id="userTable">
|
|
20
|
+
<colgroup>
|
|
21
|
+
<col class="col--checkbox">
|
|
22
|
+
<col class="col--uid">
|
|
23
|
+
<col class="col--user">
|
|
24
|
+
<col class="col--role">
|
|
25
|
+
</colgroup>
|
|
26
|
+
<thead>
|
|
27
|
+
<tr>
|
|
28
|
+
<th class="col--checkbox">
|
|
29
|
+
<label class="compact checkbox">
|
|
30
|
+
<input type="checkbox" name="select_all" data-checkbox-toggle="user">
|
|
31
|
+
</label>
|
|
32
|
+
</th>
|
|
33
|
+
<th class="col--uid">{{ _('User ID') }}</th>
|
|
34
|
+
<th class="col--user">{{ _('Username') }}</th>
|
|
35
|
+
<th class="col--role">{{ _('Role') }}</th>
|
|
36
|
+
</tr>
|
|
37
|
+
</thead>
|
|
38
|
+
<tbody>
|
|
39
|
+
{%- for role, udocs in rudocs -%}
|
|
40
|
+
{%- if udocs|length > 50 -%}
|
|
41
|
+
<tr data-role="{{ role }}">
|
|
42
|
+
<td colspan="3" style="text-wrap: wrap;"><div style="max-height: 300px; overflow-y: auto">
|
|
43
|
+
{%- for udoc in udocs -%}
|
|
44
|
+
{% set is_disabled=(rudoc._id == handler.user._id) %}
|
|
45
|
+
<input type="checkbox" data-uid="{{udoc._id}}" data-checkbox-group="user" {% if is_disabled %}disabled{% else %}data-checkbox-range{% endif %}>
|
|
46
|
+
[{{udoc._id}}]{{ user.render_inline(udoc, avatar=false, badge=false) }}{% if not udoc.join %}
|
|
47
|
+
<span class="not-joined">({{ _('Not joined yet') }})</span>
|
|
48
|
+
{% endif %}
|
|
49
|
+
{%- endfor -%}
|
|
50
|
+
</div></td>
|
|
51
|
+
<td>{{ role }}</td>
|
|
52
|
+
</tr>
|
|
53
|
+
{%- else -%}
|
|
54
|
+
{%- for rudoc in udocs -%}
|
|
55
|
+
{% set is_disabled=(rudoc._id == handler.user._id) %}
|
|
56
|
+
<tr data-role="{{ rudoc.role }}" data-uid="{{ rudoc._id }}">
|
|
57
|
+
<td class="col--checkbox">
|
|
58
|
+
<label class="compact checkbox">
|
|
59
|
+
<input type="checkbox" data-checkbox-group="user" {% if is_disabled %}disabled{% else %}data-checkbox-range{% endif %}>
|
|
60
|
+
</label>
|
|
61
|
+
</td>
|
|
62
|
+
<td class="col--uid">
|
|
63
|
+
{{ rudoc._id }}
|
|
64
|
+
</td>
|
|
65
|
+
<td class="col--user">
|
|
66
|
+
{{ user.render_inline(rudoc, badge=false) }}
|
|
67
|
+
{% if not rudoc.join %}<span class="text-orange">({{ _('Not joined yet') }})</span>{% endif %}
|
|
68
|
+
</td>
|
|
69
|
+
<td class="col--role">
|
|
70
|
+
{{ form.select({
|
|
71
|
+
options:_rolesSelect,
|
|
72
|
+
name:'role',
|
|
73
|
+
value:rudoc.role,
|
|
74
|
+
extra_class:'compact',
|
|
75
|
+
disabled:is_disabled
|
|
76
|
+
}) }}
|
|
77
|
+
</td>
|
|
78
|
+
</tr>
|
|
79
|
+
{%- endfor -%}
|
|
80
|
+
{%- endif -%}
|
|
81
|
+
{%- endfor -%}
|
|
82
|
+
</tbody>
|
|
83
|
+
</table>
|
|
84
|
+
</div>
|
|
85
|
+
<div class="section__body">
|
|
86
|
+
<button class="rounded button" name="remove_selected">{{ _('Remove Selected User') }}</button>
|
|
87
|
+
<button class="rounded button" name="set_roles">{{ _('Set Roles for Selected User') }}</button>
|
|
88
|
+
</div>
|
|
89
|
+
</div>
|
|
90
|
+
<script>
|
|
91
|
+
document.addEventListener('DOMContentLoaded', function() {
|
|
92
|
+
var searchInput = document.getElementById('roleSearch');
|
|
93
|
+
searchInput.addEventListener('input', function() {
|
|
94
|
+
var filter = searchInput.value.toLowerCase();
|
|
95
|
+
var rows = document.querySelectorAll('#userTable tbody tr');
|
|
96
|
+
rows.forEach(function(row) {
|
|
97
|
+
var role = row.getAttribute('data-role');
|
|
98
|
+
if (role && role.toLowerCase().indexOf(filter) !== -1) {
|
|
99
|
+
row.style.display = '';
|
|
100
|
+
} else if (filter === '') {
|
|
101
|
+
row.style.display = '';
|
|
102
|
+
} else {
|
|
103
|
+
row.style.display = 'none';
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
</script>
|
|
109
|
+
{% endblock %}
|
|
@@ -50,13 +50,6 @@
|
|
|
50
50
|
</div>
|
|
51
51
|
</div>
|
|
52
52
|
<div class="row">
|
|
53
|
-
<div class="medium-6 columns">
|
|
54
|
-
{{ form.form_text({
|
|
55
|
-
label: _('School'),
|
|
56
|
-
name: 'school',
|
|
57
|
-
value: udoc.school or ''
|
|
58
|
-
}) }}
|
|
59
|
-
</div>
|
|
60
53
|
<div class="medium-6 columns">
|
|
61
54
|
{{ form.form_text({
|
|
62
55
|
label: _('Registration Time'),
|
|
@@ -134,14 +134,43 @@
|
|
|
134
134
|
</div>
|
|
135
135
|
|
|
136
136
|
<style>
|
|
137
|
-
.
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
.
|
|
143
|
-
|
|
144
|
-
|
|
137
|
+
.section__body.no-padding {
|
|
138
|
+
max-width: 100%;
|
|
139
|
+
overflow-x: hidden;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
.data-table {
|
|
143
|
+
width: 100%;
|
|
144
|
+
table-layout: fixed;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
.data-table th,
|
|
148
|
+
.data-table td {
|
|
149
|
+
word-break: break-word;
|
|
150
|
+
overflow-wrap: anywhere;
|
|
151
|
+
white-space: normal;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
.data-table td {
|
|
155
|
+
max-width: 0;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
.data-table td a,
|
|
159
|
+
.data-table td span,
|
|
160
|
+
.data-table td small {
|
|
161
|
+
word-break: break-word;
|
|
162
|
+
overflow-wrap: anywhere;
|
|
163
|
+
white-space: normal;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
.col--uid { width: 8%; }
|
|
167
|
+
.col--user { width: 17%; }
|
|
168
|
+
.col--email { width: 19%; }
|
|
169
|
+
.col--regat { width: 13%; }
|
|
170
|
+
.col--loginat { width: 13%; }
|
|
171
|
+
.col--priv { width: 10%; }
|
|
172
|
+
.col--status { width: 8%; }
|
|
173
|
+
.col--actions { width: 12%; }
|
|
145
174
|
|
|
146
175
|
.badge {
|
|
147
176
|
display: inline-block;
|
|
@@ -171,6 +200,27 @@
|
|
|
171
200
|
padding: 4px 8px;
|
|
172
201
|
font-size: 12px;
|
|
173
202
|
}
|
|
203
|
+
|
|
204
|
+
.col--actions .button--small {
|
|
205
|
+
display: block;
|
|
206
|
+
width: 100%;
|
|
207
|
+
max-width: 100%;
|
|
208
|
+
min-width: 0;
|
|
209
|
+
box-sizing: border-box;
|
|
210
|
+
padding: 4px 4px;
|
|
211
|
+
line-height: 1.2;
|
|
212
|
+
white-space: normal !important;
|
|
213
|
+
overflow-wrap: anywhere;
|
|
214
|
+
word-break: break-word;
|
|
215
|
+
text-align: center;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
@media (max-width: 768px) {
|
|
219
|
+
.button--small {
|
|
220
|
+
padding: 3px 6px;
|
|
221
|
+
font-size: 11px;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
174
224
|
</style>
|
|
175
225
|
|
|
176
226
|
<script>
|
package/asset/fig1.png
DELETED
|
Binary file
|
package/asset/fig2.png
DELETED
|
Binary file
|
|
@@ -1,268 +0,0 @@
|
|
|
1
|
-
import $ from 'jquery';
|
|
2
|
-
import { NamedPage } from 'vj/misc/Page';
|
|
3
|
-
import Notification from 'vj/components/notification';
|
|
4
|
-
import { request } from 'vj/utils';
|
|
5
|
-
import i18n from 'vj/utils/i18n';
|
|
6
|
-
import ActionDialog from 'vj/components/dialog/ActionDialog';
|
|
7
|
-
|
|
8
|
-
const page = new NamedPage('user_manage_detail', () => {
|
|
9
|
-
const uid = window.location.pathname.split('/').pop();
|
|
10
|
-
|
|
11
|
-
// 编辑用户信息
|
|
12
|
-
$('#edit-user-form').on('submit', async function(e) {
|
|
13
|
-
e.preventDefault();
|
|
14
|
-
|
|
15
|
-
const formData = {
|
|
16
|
-
operation: 'updateInfo',
|
|
17
|
-
uname: $('#uname').val().trim(),
|
|
18
|
-
email: $('#email').val().trim(),
|
|
19
|
-
school: $('#school').val().trim(),
|
|
20
|
-
bio: $('#bio').val().trim()
|
|
21
|
-
};
|
|
22
|
-
|
|
23
|
-
// 验证用户名
|
|
24
|
-
if (!formData.uname) {
|
|
25
|
-
Notification.error(i18n('Username cannot be empty'));
|
|
26
|
-
return;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
// 验证邮箱
|
|
30
|
-
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
31
|
-
if (!emailRegex.test(formData.email)) {
|
|
32
|
-
Notification.error(i18n('Invalid email format'));
|
|
33
|
-
return;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
try {
|
|
37
|
-
const response = await request.post(`/manage/users/${uid}`, formData);
|
|
38
|
-
|
|
39
|
-
if (response.success) {
|
|
40
|
-
Notification.success(i18n('User information updated successfully'));
|
|
41
|
-
// 更新页面显示
|
|
42
|
-
$('#display-uname').text(formData.uname);
|
|
43
|
-
$('#display-email').text(formData.email);
|
|
44
|
-
$('#display-school').text(formData.school || i18n('Not set'));
|
|
45
|
-
$('#display-bio').text(formData.bio || i18n('Not set'));
|
|
46
|
-
} else {
|
|
47
|
-
Notification.error(response.message || i18n('Update failed'));
|
|
48
|
-
}
|
|
49
|
-
} catch (error) {
|
|
50
|
-
console.error('Error:', error);
|
|
51
|
-
Notification.error(i18n('Update failed'));
|
|
52
|
-
}
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
// 重置密码
|
|
56
|
-
$('#reset-password').on('click', async function() {
|
|
57
|
-
const username = $('#display-uname').text();
|
|
58
|
-
|
|
59
|
-
if (!confirm(i18n('Are you sure to reset password for user {0}? A new random password will be generated.', username))) {
|
|
60
|
-
return;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
try {
|
|
64
|
-
const response = await request.post(`/manage/users/${uid}`, {
|
|
65
|
-
operation: 'resetPassword'
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
if (response.success) {
|
|
69
|
-
// 显示新密码
|
|
70
|
-
const dialog = new ActionDialog({
|
|
71
|
-
$body: $(`
|
|
72
|
-
<div class="typo">
|
|
73
|
-
<h3>${i18n('Password Reset Successfully')}</h3>
|
|
74
|
-
<p>${i18n('New password for user {0}:', username)}</p>
|
|
75
|
-
<div class="password-display">
|
|
76
|
-
<input type="text" value="${response.newPassword}" readonly style="width: 100%; font-family: monospace; font-size: 14px; padding: 8px; border: 1px solid #ccc; border-radius: 4px;">
|
|
77
|
-
</div>
|
|
78
|
-
<p class="text-red">${i18n('Please save this password and inform the user. This password will not be shown again.')}</p>
|
|
79
|
-
<button class="button primary" onclick="navigator.clipboard.writeText('${response.newPassword}'); $(this).text('${i18n('Copied!')}')">${i18n('Copy Password')}</button>
|
|
80
|
-
</div>
|
|
81
|
-
`),
|
|
82
|
-
canCancel: false
|
|
83
|
-
});
|
|
84
|
-
dialog.open();
|
|
85
|
-
} else {
|
|
86
|
-
Notification.error(response.message || i18n('Password reset failed'));
|
|
87
|
-
}
|
|
88
|
-
} catch (error) {
|
|
89
|
-
console.error('Error:', error);
|
|
90
|
-
Notification.error(i18n('Password reset failed'));
|
|
91
|
-
}
|
|
92
|
-
});
|
|
93
|
-
|
|
94
|
-
// 设置权限
|
|
95
|
-
$('#set-privilege').on('click', async function() {
|
|
96
|
-
const username = $('#display-uname').text();
|
|
97
|
-
const currentPriv = $(this).data('current-priv');
|
|
98
|
-
|
|
99
|
-
const newPriv = prompt(i18n('Enter new privilege value for user {0}:', username), currentPriv);
|
|
100
|
-
if (newPriv === null || newPriv === '') {
|
|
101
|
-
return;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
const privValue = parseInt(newPriv, 10);
|
|
105
|
-
if (isNaN(privValue)) {
|
|
106
|
-
Notification.error(i18n('Invalid privilege value'));
|
|
107
|
-
return;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
if (!confirm(i18n('Are you sure to set privilege of user {0} to {1}?', username, privValue))) {
|
|
111
|
-
return;
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
try {
|
|
115
|
-
const response = await request.post(`/manage/users/${uid}`, {
|
|
116
|
-
operation: 'setPriv',
|
|
117
|
-
priv: privValue
|
|
118
|
-
});
|
|
119
|
-
|
|
120
|
-
if (response.success) {
|
|
121
|
-
Notification.success(i18n('Privilege updated successfully'));
|
|
122
|
-
// 更新页面显示
|
|
123
|
-
$('#current-privilege').text(privValue);
|
|
124
|
-
$(this).data('current-priv', privValue);
|
|
125
|
-
|
|
126
|
-
// 更新权限徽章
|
|
127
|
-
const $badge = $('#privilege-badge');
|
|
128
|
-
$badge.removeClass('success warning alert');
|
|
129
|
-
if (privValue === 0) {
|
|
130
|
-
$badge.addClass('alert').text(i18n('Banned'));
|
|
131
|
-
} else if (privValue === 1) {
|
|
132
|
-
$badge.addClass('success').text(i18n('Normal User'));
|
|
133
|
-
} else {
|
|
134
|
-
$badge.addClass('warning').text(i18n('Admin'));
|
|
135
|
-
}
|
|
136
|
-
} else {
|
|
137
|
-
Notification.error(response.message || i18n('Privilege update failed'));
|
|
138
|
-
}
|
|
139
|
-
} catch (error) {
|
|
140
|
-
console.error('Error:', error);
|
|
141
|
-
Notification.error(i18n('Privilege update failed'));
|
|
142
|
-
}
|
|
143
|
-
});
|
|
144
|
-
|
|
145
|
-
// 封禁/解封用户
|
|
146
|
-
$('#ban-user, #unban-user').on('click', async function() {
|
|
147
|
-
const username = $('#display-uname').text();
|
|
148
|
-
const action = $(this).attr('id') === 'ban-user' ? 'ban' : 'unban';
|
|
149
|
-
|
|
150
|
-
const confirmMessage = action === 'ban'
|
|
151
|
-
? i18n('Are you sure to ban user {0}? This will set their privilege to 0.', username)
|
|
152
|
-
: i18n('Are you sure to unban user {0}? This will restore their privilege to 1.', username);
|
|
153
|
-
|
|
154
|
-
if (!confirm(confirmMessage)) {
|
|
155
|
-
return;
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
try {
|
|
159
|
-
const response = await request.post(`/manage/users/${uid}`, {
|
|
160
|
-
operation: action
|
|
161
|
-
});
|
|
162
|
-
|
|
163
|
-
if (response.success) {
|
|
164
|
-
Notification.success(action === 'ban' ? i18n('User banned successfully') : i18n('User unbanned successfully'));
|
|
165
|
-
|
|
166
|
-
// 更新页面显示
|
|
167
|
-
const newPriv = action === 'ban' ? 0 : 1;
|
|
168
|
-
$('#current-privilege').text(newPriv);
|
|
169
|
-
$('#set-privilege').data('current-priv', newPriv);
|
|
170
|
-
|
|
171
|
-
// 更新权限徽章
|
|
172
|
-
const $badge = $('#privilege-badge');
|
|
173
|
-
$badge.removeClass('success warning alert');
|
|
174
|
-
if (action === 'ban') {
|
|
175
|
-
$badge.addClass('alert').text(i18n('Banned'));
|
|
176
|
-
$('#ban-user').hide();
|
|
177
|
-
$('#unban-user').show();
|
|
178
|
-
} else {
|
|
179
|
-
$badge.addClass('success').text(i18n('Normal User'));
|
|
180
|
-
$('#ban-user').show();
|
|
181
|
-
$('#unban-user').hide();
|
|
182
|
-
}
|
|
183
|
-
} else {
|
|
184
|
-
Notification.error(response.message || i18n('Operation failed'));
|
|
185
|
-
}
|
|
186
|
-
} catch (error) {
|
|
187
|
-
console.error('Error:', error);
|
|
188
|
-
Notification.error(i18n('Operation failed'));
|
|
189
|
-
}
|
|
190
|
-
});
|
|
191
|
-
|
|
192
|
-
// 返回用户列表
|
|
193
|
-
$('#back-to-list').on('click', function() {
|
|
194
|
-
window.location.href = '/manage/users';
|
|
195
|
-
});
|
|
196
|
-
|
|
197
|
-
// 刷新用户信息
|
|
198
|
-
$('#refresh-info').on('click', function() {
|
|
199
|
-
window.location.reload();
|
|
200
|
-
});
|
|
201
|
-
|
|
202
|
-
// 切换编辑模式
|
|
203
|
-
$('#toggle-edit').on('click', function() {
|
|
204
|
-
const $form = $('#edit-user-form');
|
|
205
|
-
const $display = $('#user-info-display');
|
|
206
|
-
|
|
207
|
-
if ($form.is(':visible')) {
|
|
208
|
-
$form.hide();
|
|
209
|
-
$display.show();
|
|
210
|
-
$(this).text(i18n('Edit'));
|
|
211
|
-
} else {
|
|
212
|
-
$form.show();
|
|
213
|
-
$display.hide();
|
|
214
|
-
$(this).text(i18n('Cancel'));
|
|
215
|
-
}
|
|
216
|
-
});
|
|
217
|
-
|
|
218
|
-
// 复制用户ID
|
|
219
|
-
$('#copy-uid').on('click', function() {
|
|
220
|
-
const uid = $(this).data('uid');
|
|
221
|
-
navigator.clipboard.writeText(uid).then(() => {
|
|
222
|
-
Notification.success(i18n('User ID copied to clipboard'));
|
|
223
|
-
}).catch(() => {
|
|
224
|
-
Notification.error(i18n('Failed to copy user ID'));
|
|
225
|
-
});
|
|
226
|
-
});
|
|
227
|
-
|
|
228
|
-
// 键盘快捷键
|
|
229
|
-
$(document).on('keydown', function(e) {
|
|
230
|
-
// Esc 键取消编辑
|
|
231
|
-
if (e.key === 'Escape' && $('#edit-user-form').is(':visible')) {
|
|
232
|
-
$('#toggle-edit').click();
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
// Ctrl+S 保存编辑
|
|
236
|
-
if (e.ctrlKey && e.key === 's' && $('#edit-user-form').is(':visible')) {
|
|
237
|
-
e.preventDefault();
|
|
238
|
-
$('#edit-user-form').submit();
|
|
239
|
-
}
|
|
240
|
-
});
|
|
241
|
-
|
|
242
|
-
// 初始化提示
|
|
243
|
-
$('[data-tooltip]').each(function() {
|
|
244
|
-
$(this).attr('title', $(this).data('tooltip'));
|
|
245
|
-
});
|
|
246
|
-
|
|
247
|
-
// 表单验证
|
|
248
|
-
$('#uname').on('input', function() {
|
|
249
|
-
const value = $(this).val().trim();
|
|
250
|
-
if (value.length < 3) {
|
|
251
|
-
$(this).addClass('error');
|
|
252
|
-
} else {
|
|
253
|
-
$(this).removeClass('error');
|
|
254
|
-
}
|
|
255
|
-
});
|
|
256
|
-
|
|
257
|
-
$('#email').on('input', function() {
|
|
258
|
-
const value = $(this).val().trim();
|
|
259
|
-
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
260
|
-
if (!emailRegex.test(value)) {
|
|
261
|
-
$(this).addClass('error');
|
|
262
|
-
} else {
|
|
263
|
-
$(this).removeClass('error');
|
|
264
|
-
}
|
|
265
|
-
});
|
|
266
|
-
});
|
|
267
|
-
|
|
268
|
-
export default page;
|
|
@@ -1,200 +0,0 @@
|
|
|
1
|
-
import $ from 'jquery';
|
|
2
|
-
import { NamedPage } from 'vj/misc/Page';
|
|
3
|
-
import Notification from 'vj/components/notification';
|
|
4
|
-
import { request } from 'vj/utils';
|
|
5
|
-
import i18n from 'vj/utils/i18n';
|
|
6
|
-
import pjax from 'vj/utils/pjax';
|
|
7
|
-
|
|
8
|
-
const page = new NamedPage('user_manage_main', () => {
|
|
9
|
-
// 搜索功能
|
|
10
|
-
$('#search-form').on('submit', function(e) {
|
|
11
|
-
e.preventDefault();
|
|
12
|
-
const keyword = $('#search-keyword').val().trim();
|
|
13
|
-
const type = $('#search-type').val();
|
|
14
|
-
const sort = $('#sort-by').val();
|
|
15
|
-
const order = $('#sort-order').val();
|
|
16
|
-
|
|
17
|
-
let url = window.location.pathname + '?';
|
|
18
|
-
const params = [];
|
|
19
|
-
|
|
20
|
-
if (keyword) {
|
|
21
|
-
params.push(`${type}=${encodeURIComponent(keyword)}`);
|
|
22
|
-
}
|
|
23
|
-
if (sort) {
|
|
24
|
-
params.push(`sort=${sort}`);
|
|
25
|
-
}
|
|
26
|
-
if (order) {
|
|
27
|
-
params.push(`order=${order}`);
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
url += params.join('&');
|
|
31
|
-
pjax.request({ url });
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
// 清空搜索
|
|
35
|
-
$('#clear-search').on('click', function() {
|
|
36
|
-
$('#search-keyword').val('');
|
|
37
|
-
$('#search-type').val('uname');
|
|
38
|
-
$('#sort-by').val('_id');
|
|
39
|
-
$('#sort-order').val('desc');
|
|
40
|
-
pjax.request({ url: window.location.pathname });
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
// 快速封禁/解封用户
|
|
44
|
-
$('.ban-user').on('click', async function(e) {
|
|
45
|
-
e.preventDefault();
|
|
46
|
-
const uid = $(this).data('uid');
|
|
47
|
-
const username = $(this).data('username');
|
|
48
|
-
const action = $(this).data('action');
|
|
49
|
-
|
|
50
|
-
const confirmMessage = action === 'ban'
|
|
51
|
-
? i18n('Are you sure to ban user {0}?', username)
|
|
52
|
-
: i18n('Are you sure to unban user {0}?', username);
|
|
53
|
-
|
|
54
|
-
if (!confirm(confirmMessage)) {
|
|
55
|
-
return;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
try {
|
|
59
|
-
const response = await request.post(`/manage/users/${uid}`, {
|
|
60
|
-
operation: action === 'ban' ? 'ban' : 'unban'
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
if (response.success) {
|
|
64
|
-
Notification.success(action === 'ban' ? i18n('User banned successfully') : i18n('User unbanned successfully'));
|
|
65
|
-
// 刷新页面
|
|
66
|
-
window.location.reload();
|
|
67
|
-
} else {
|
|
68
|
-
Notification.error(response.message || i18n('Operation failed'));
|
|
69
|
-
}
|
|
70
|
-
} catch (error) {
|
|
71
|
-
console.error('Error:', error);
|
|
72
|
-
Notification.error(i18n('Operation failed'));
|
|
73
|
-
}
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
// 快速设置权限
|
|
77
|
-
$('.set-priv').on('click', async function(e) {
|
|
78
|
-
e.preventDefault();
|
|
79
|
-
const uid = $(this).data('uid');
|
|
80
|
-
const username = $(this).data('username');
|
|
81
|
-
const currentPriv = $(this).data('priv');
|
|
82
|
-
|
|
83
|
-
const newPriv = prompt(i18n('Enter new privilege value for user {0}:', username), currentPriv);
|
|
84
|
-
if (newPriv === null || newPriv === '') {
|
|
85
|
-
return;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
const privValue = parseInt(newPriv, 10);
|
|
89
|
-
if (isNaN(privValue)) {
|
|
90
|
-
Notification.error(i18n('Invalid privilege value'));
|
|
91
|
-
return;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
if (!confirm(i18n('Are you sure to set privilege of user {0} to {1}?', username, privValue))) {
|
|
95
|
-
return;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
try {
|
|
99
|
-
const response = await request.post(`/manage/users/${uid}`, {
|
|
100
|
-
operation: 'setPriv',
|
|
101
|
-
priv: privValue
|
|
102
|
-
});
|
|
103
|
-
|
|
104
|
-
if (response.success) {
|
|
105
|
-
Notification.success(i18n('Privilege updated successfully'));
|
|
106
|
-
// 刷新页面
|
|
107
|
-
window.location.reload();
|
|
108
|
-
} else {
|
|
109
|
-
Notification.error(response.message || i18n('Operation failed'));
|
|
110
|
-
}
|
|
111
|
-
} catch (error) {
|
|
112
|
-
console.error('Error:', error);
|
|
113
|
-
Notification.error(i18n('Operation failed'));
|
|
114
|
-
}
|
|
115
|
-
});
|
|
116
|
-
|
|
117
|
-
// 分页链接处理
|
|
118
|
-
$('.pagination a').on('click', function(e) {
|
|
119
|
-
e.preventDefault();
|
|
120
|
-
const url = $(this).attr('href');
|
|
121
|
-
if (url && url !== '#') {
|
|
122
|
-
pjax.request({ url });
|
|
123
|
-
}
|
|
124
|
-
});
|
|
125
|
-
|
|
126
|
-
// 排序链接处理
|
|
127
|
-
$('.sortable').on('click', function(e) {
|
|
128
|
-
e.preventDefault();
|
|
129
|
-
const sort = $(this).data('sort');
|
|
130
|
-
const currentSort = new URLSearchParams(window.location.search).get('sort');
|
|
131
|
-
const currentOrder = new URLSearchParams(window.location.search).get('order') || 'desc';
|
|
132
|
-
|
|
133
|
-
let newOrder = 'desc';
|
|
134
|
-
if (currentSort === sort && currentOrder === 'desc') {
|
|
135
|
-
newOrder = 'asc';
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
const url = new URL(window.location);
|
|
139
|
-
url.searchParams.set('sort', sort);
|
|
140
|
-
url.searchParams.set('order', newOrder);
|
|
141
|
-
|
|
142
|
-
pjax.request({ url: url.toString() });
|
|
143
|
-
});
|
|
144
|
-
|
|
145
|
-
// 批量操作按钮
|
|
146
|
-
$('#batch-operations').on('click', function() {
|
|
147
|
-
window.location.href = '/manage/users/batch';
|
|
148
|
-
});
|
|
149
|
-
|
|
150
|
-
// 导出用户数据
|
|
151
|
-
$('#export-users').on('click', async function() {
|
|
152
|
-
try {
|
|
153
|
-
const response = await request.get('/manage/users', {
|
|
154
|
-
export: 'csv'
|
|
155
|
-
});
|
|
156
|
-
|
|
157
|
-
// 创建下载链接
|
|
158
|
-
const blob = new Blob([response], { type: 'text/csv' });
|
|
159
|
-
const url = window.URL.createObjectURL(blob);
|
|
160
|
-
const a = document.createElement('a');
|
|
161
|
-
a.href = url;
|
|
162
|
-
a.download = `users_${new Date().toISOString().split('T')[0]}.csv`;
|
|
163
|
-
document.body.appendChild(a);
|
|
164
|
-
a.click();
|
|
165
|
-
document.body.removeChild(a);
|
|
166
|
-
window.URL.revokeObjectURL(url);
|
|
167
|
-
|
|
168
|
-
Notification.success(i18n('Export completed'));
|
|
169
|
-
} catch (error) {
|
|
170
|
-
console.error('Export error:', error);
|
|
171
|
-
Notification.error(i18n('Export failed'));
|
|
172
|
-
}
|
|
173
|
-
});
|
|
174
|
-
|
|
175
|
-
// 刷新按钮
|
|
176
|
-
$('#refresh-list').on('click', function() {
|
|
177
|
-
window.location.reload();
|
|
178
|
-
});
|
|
179
|
-
|
|
180
|
-
// 键盘快捷键
|
|
181
|
-
$(document).on('keydown', function(e) {
|
|
182
|
-
// Ctrl+F 聚焦搜索框
|
|
183
|
-
if (e.ctrlKey && e.key === 'f') {
|
|
184
|
-
e.preventDefault();
|
|
185
|
-
$('#search-keyword').focus();
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
// Enter 键提交搜索
|
|
189
|
-
if (e.key === 'Enter' && $('#search-keyword').is(':focus')) {
|
|
190
|
-
$('#search-form').submit();
|
|
191
|
-
}
|
|
192
|
-
});
|
|
193
|
-
|
|
194
|
-
// 初始化提示
|
|
195
|
-
$('[data-tooltip]').each(function() {
|
|
196
|
-
$(this).attr('title', $(this).data('tooltip'));
|
|
197
|
-
});
|
|
198
|
-
});
|
|
199
|
-
|
|
200
|
-
export default page;
|