diff --git a/mail-vue/package.json b/mail-vue/package.json index 2bcf6e7bc3..e0b95fe17c 100644 --- a/mail-vue/package.json +++ b/mail-vue/package.json @@ -28,7 +28,8 @@ "pinia-plugin-persistedstate": "^4.2.0", "vue": "^3.5.13", "vue-i18n": "^11.1.10", - "vue-router": "^4.5.0" + "vue-router": "^4.5.0", + "xlsx": "0.18.5" }, "devDependencies": { "@vitejs/plugin-vue": "^5.2.1", diff --git a/mail-vue/pnpm-lock.yaml b/mail-vue/pnpm-lock.yaml index cedc54af05..7dd931eb51 100644 --- a/mail-vue/pnpm-lock.yaml +++ b/mail-vue/pnpm-lock.yaml @@ -19,7 +19,7 @@ importers: version: 12.8.2 axios: specifier: 1.15.2 - version: 1.15.2 + version: 1.15.2(debug@4.4.1) compressorjs: specifier: ^1.2.1 version: 1.2.1 @@ -62,6 +62,9 @@ importers: vue-router: specifier: ^4.5.0 version: 4.5.1(vue@3.5.20) + xlsx: + specifier: 0.18.5 + version: 0.18.5 devDependencies: '@vitejs/plugin-vue': specifier: ^5.2.1 @@ -1180,6 +1183,10 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + adler-32@1.3.1: + resolution: {integrity: sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==} + engines: {node: '>=0.8'} + ajv@8.17.1: resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==} @@ -1280,6 +1287,10 @@ packages: caniuse-lite@1.0.30001739: resolution: {integrity: sha512-y+j60d6ulelrNSwpPyrHdl+9mJnQzHBr08xm48Qno0nSk4h3Qojh+ziv2qE6rXf4k3tadF4o1J/1tAbVm1NtnA==} + cfb@1.2.2: + resolution: {integrity: sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==} + engines: {node: '>=0.8'} + chokidar@3.6.0: resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} engines: {node: '>= 8.10.0'} @@ -1288,6 +1299,10 @@ packages: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} + codepage@1.15.0: + resolution: {integrity: sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==} + engines: {node: '>=0.8'} + combined-stream@1.0.8: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} engines: {node: '>= 0.8'} @@ -1324,6 +1339,11 @@ packages: core-js-compat@3.45.1: resolution: {integrity: sha512-tqTt5T4PzsMIZ430XGviK4vzYSoeNJ6CXODi6c/voxOT6IZqBht5/EKaSNnYiEjjRYxjVz7DQIsOsY0XNi8PIA==} + crc-32@1.2.2: + resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==} + engines: {node: '>=0.8'} + hasBin: true + crypto-random-string@2.0.0: resolution: {integrity: sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA==} engines: {node: '>=8'} @@ -1517,6 +1537,10 @@ packages: resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} engines: {node: '>= 6'} + frac@1.1.2: + resolution: {integrity: sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==} + engines: {node: '>=0.8'} + fs-extra@9.1.0: resolution: {integrity: sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==} engines: {node: '>=10'} @@ -2183,6 +2207,10 @@ packages: resolution: {integrity: sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==} engines: {node: '>=0.10.0'} + ssf@0.11.2: + resolution: {integrity: sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==} + engines: {node: '>=0.8'} + stop-iteration-iterator@1.1.0: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} engines: {node: '>= 0.4'} @@ -2423,6 +2451,7 @@ packages: vue-i18n@11.1.11: resolution: {integrity: sha512-LvyteQoXeQiuILbzqv13LbyBna/TEv2Ha+4ZWK2AwGHUzZ8+IBaZS0TJkCgn5izSPLcgZwXy9yyTrewCb2u/MA==} engines: {node: '>= 16'} + deprecated: This version is NOT deprecated. Previous deprecation was a mistake. peerDependencies: vue: ^3.0.0 @@ -2464,6 +2493,14 @@ packages: resolution: {integrity: sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==} engines: {node: '>= 0.4'} + wmf@1.0.2: + resolution: {integrity: sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==} + engines: {node: '>=0.8'} + + word@0.3.0: + resolution: {integrity: sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==} + engines: {node: '>=0.8'} + workbox-background-sync@7.3.0: resolution: {integrity: sha512-PCSk3eK7Mxeuyatb22pcSx9dlgWNv3+M8PqPaYDokks8Y5/FX4soaOqj3yhAZr5k6Q5JWTOMYgaJBpbw11G9Eg==} @@ -2516,6 +2553,11 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + xlsx@0.18.5: + resolution: {integrity: sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==} + engines: {node: '>=0.8'} + hasBin: true + yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} @@ -3663,6 +3705,8 @@ snapshots: acorn@8.15.0: {} + adler-32@1.3.1: {} + ajv@8.17.1: dependencies: fast-deep-equal: 3.1.3 @@ -3704,9 +3748,9 @@ snapshots: dependencies: possible-typed-array-names: 1.1.0 - axios@1.15.2: + axios@1.15.2(debug@4.4.1): dependencies: - follow-redirects: 1.15.11 + follow-redirects: 1.15.11(debug@4.4.1) form-data: 4.0.5 proxy-from-env: 2.1.0 transitivePeerDependencies: @@ -3785,6 +3829,11 @@ snapshots: caniuse-lite@1.0.30001739: {} + cfb@1.2.2: + dependencies: + adler-32: 1.3.1 + crc-32: 1.2.2 + chokidar@3.6.0: dependencies: anymatch: 3.1.3 @@ -3801,6 +3850,8 @@ snapshots: dependencies: readdirp: 4.1.2 + codepage@1.15.0: {} + combined-stream@1.0.8: dependencies: delayed-stream: 1.0.0 @@ -3834,6 +3885,8 @@ snapshots: dependencies: browserslist: 4.25.4 + crc-32@1.2.2: {} + crypto-random-string@2.0.0: {} csstype@3.1.3: {} @@ -4078,7 +4131,9 @@ snapshots: dependencies: to-regex-range: 5.0.1 - follow-redirects@1.15.11: {} + follow-redirects@1.15.11(debug@4.4.1): + optionalDependencies: + debug: 4.4.1 for-each@0.3.5: dependencies: @@ -4092,6 +4147,8 @@ snapshots: hasown: 2.0.2 mime-types: 2.1.35 + frac@1.1.2: {} + fs-extra@9.1.0: dependencies: at-least-node: 1.0.0 @@ -4782,6 +4839,10 @@ snapshots: speakingurl@14.0.1: {} + ssf@0.11.2: + dependencies: + frac: 1.1.2 + stop-iteration-iterator@1.1.0: dependencies: es-errors: 1.3.0 @@ -5111,6 +5172,10 @@ snapshots: gopd: 1.2.0 has-tostringtag: 1.0.2 + wmf@1.0.2: {} + + word@0.3.0: {} + workbox-background-sync@7.3.0: dependencies: idb: 7.1.1 @@ -5226,6 +5291,16 @@ snapshots: wrappy@1.0.2: {} + xlsx@0.18.5: + dependencies: + adler-32: 1.3.1 + cfb: 1.2.2 + codepage: 1.15.0 + crc-32: 1.2.2 + ssf: 0.11.2 + wmf: 1.0.2 + word: 0.3.0 + yallist@3.1.1: {} zrender@5.6.1: diff --git a/mail-vue/src/i18n/en.js b/mail-vue/src/i18n/en.js index 4dd6946161..2c91448a62 100644 --- a/mail-vue/src/i18n/en.js +++ b/mail-vue/src/i18n/en.js @@ -80,6 +80,32 @@ const en = { btnBan: 'Ban', admin: 'Admin', addUser: 'Add User', + batchCreateUsers: 'Batch Create', + importUsers: 'Import Users', + exportUsers: 'Export Selected', + userCount: 'User Count', + usernamePrefixHint: 'Username/email prefix (random if blank)', + optionalPasswordHint: 'Password (random if blank)', + createAndExport: 'Create & Export Credentials', + downloadCsvTemplate: 'Download CSV Template', + downloadXlsxTemplate: 'Download Excel Template', + selectImportFile: 'Select a CSV or Excel file', + defaultRole: 'Default Role', + importRows: '{count} rows parsed', + importAndExport: 'Import & Export Credentials', + generatedCredentials: 'Generated User Credentials', + credentialsSecurityTip: 'Copy or download this list now. Plain-text passwords are not stored for security.', + downloadCredentials: 'Download Credentials CSV', + batchCreatedCount: '{count} users created successfully', + batchFailureCount: '{count} rows could not be created. Check email, password, or role.', + batchMaxUsers: 'A maximum of {count} users can be processed at once.', + importEmptyFile: 'The import file has no usable data rows.', + importParseFailed: 'Unable to parse the import file.', + copySuccess: 'Copied to clipboard.', + copyFailed: 'Copy failed. Please copy manually.', + selectUsersToExport: 'Select users to export first.', + existingPasswordNotExportable: 'Emails and password status were exported. Existing plain-text passwords are shown only at creation; the database stores irreversible hashes, so they cannot be recovered or exported.', + passwordNotStored: 'Plain-text password not stored (irreversible hash only)', select: 'Select', unknown: 'Unknown', changePerm: 'Change Role', diff --git a/mail-vue/src/i18n/zh.js b/mail-vue/src/i18n/zh.js index 62fa1f8d67..03fc0fca87 100644 --- a/mail-vue/src/i18n/zh.js +++ b/mail-vue/src/i18n/zh.js @@ -80,6 +80,32 @@ const zh = { btnBan: '禁用', admin: '超级管理员', addUser: '添加用户', + batchCreateUsers: '批量新建', + importUsers: '批量导入', + exportUsers: '导出选中用户', + userCount: '用户数量', + usernamePrefixHint: '用户名/邮箱前缀(留空随机)', + optionalPasswordHint: '密码(留空随机生成)', + createAndExport: '创建并导出凭据', + downloadCsvTemplate: '下载 CSV 模板', + downloadXlsxTemplate: '下载 Excel 模板', + selectImportFile: '选择 CSV 或 Excel 文件', + defaultRole: '默认权限身份', + importRows: '已解析 {count} 行', + importAndExport: '导入并导出凭据', + generatedCredentials: '本次生成的用户凭据', + credentialsSecurityTip: '请立即复制或下载此清单。为安全起见,系统不会保存明文密码。', + downloadCredentials: '下载凭据 CSV', + batchCreatedCount: '已成功创建 {count} 个用户', + batchFailureCount: '{count} 行未能创建,请检查邮箱、密码或角色。', + batchMaxUsers: '单次最多处理 {count} 个用户。', + importEmptyFile: '导入文件没有可用的数据行。', + importParseFailed: '无法解析导入文件。', + copySuccess: '已复制到剪贴板。', + copyFailed: '复制失败,请手动复制。', + selectUsersToExport: '请先选择要导出的用户。', + existingPasswordNotExportable: '已导出邮箱与密码状态。已有用户的明文密码仅在创建时显示,数据库只保存不可逆哈希,无法恢复或导出。', + passwordNotStored: '明文密码未保存(仅存不可逆哈希)', select: '请选择', unknown: '未知', changePerm: '修改权限', diff --git a/mail-vue/src/request/user.js b/mail-vue/src/request/user.js index f8720e325e..ec81b797f7 100644 --- a/mail-vue/src/request/user.js +++ b/mail-vue/src/request/user.js @@ -26,6 +26,18 @@ export function userAdd(form) { return http.post('/user/add', form) } +export function userBatchCreate(form) { + return http.post('/user/batchCreate', form) +} + +export function userBatchImport(form) { + return http.post('/user/batchImport', form) +} + +export function userExport(userIds) { + return http.get('/user/export', {params: {userIds: userIds.join(',')}}) +} + export function userRestSendCount(userId) { return http.put('/user/resetSendCount', {userId}) } diff --git a/mail-vue/src/utils/user-batch-file-utils.js b/mail-vue/src/utils/user-batch-file-utils.js new file mode 100644 index 0000000000..55a7f07a0e --- /dev/null +++ b/mail-vue/src/utils/user-batch-file-utils.js @@ -0,0 +1,58 @@ +import * as XLSX from 'xlsx'; + +function escapeCsv(value) { + const text = String(value ?? ''); + return /[",\n\r]/.test(text) ? `"${text.replaceAll('"', '""')}"` : text; +} + +export function downloadUserCsv(fileName, rows) { + const csv = rows.map((row) => row.map(escapeCsv).join(',')).join('\r\n'); + const blob = new Blob([`\uFEFF${csv}`], { type: 'text/csv;charset=utf-8' }); + const link = document.createElement('a'); + link.href = URL.createObjectURL(blob); + link.download = fileName; + link.click(); + URL.revokeObjectURL(link.href); +} + +export function downloadImportTemplate(format = 'csv') { + const rows = [ + ['email', 'password'], + ['alice@example.com', 'Password123'], + ]; + if (format === 'xlsx') { + const worksheet = XLSX.utils.aoa_to_sheet(rows); + const workbook = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet(workbook, worksheet, 'Users'); + XLSX.writeFile(workbook, 'cloud-mail-user-import-template.xlsx'); + return; + } + downloadUserCsv('cloud-mail-user-import-template.csv', rows); +} + +function normalizeHeader(value) { + return String(value || '').trim().toLowerCase(); +} + +export async function parseUserImportFile(file) { + const isCsv = file.name.toLowerCase().endsWith('.csv') || file.type === 'text/csv'; + let table; + if (isCsv) { + table = XLSX.utils.sheet_to_json(XLSX.read(await file.arrayBuffer(), { type: 'array' }).Sheets.Sheet1, { header: 1, defval: '' }); + } else { + const workbook = XLSX.read(await file.arrayBuffer(), { type: 'array' }); + const firstSheet = workbook.Sheets[workbook.SheetNames[0]]; + table = XLSX.utils.sheet_to_json(firstSheet, { header: 1, defval: '' }); + } + if (!table.length) return []; + const headers = table[0].map(normalizeHeader); + const emailIndex = headers.indexOf('email'); + const passwordIndex = headers.indexOf('password'); + if (emailIndex < 0) { + throw new Error('Import file must include an email column.'); + } + return table.slice(1).map((row) => ({ + email: String(row[emailIndex] || '').trim(), + password: passwordIndex >= 0 ? String(row[passwordIndex] || '') : '', + })).filter((row) => row.email || row.password); +} diff --git a/mail-vue/src/views/user/index.vue b/mail-vue/src/views/user/index.vue index 2640343d45..8abba6abce 100644 --- a/mail-vue/src/views/user/index.vue +++ b/mail-vue/src/views/user/index.vue @@ -2,6 +2,9 @@
{{ t('credentialsSecurityTip') }}
+