Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion mail-vue/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
83 changes: 79 additions & 4 deletions mail-vue/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

26 changes: 26 additions & 0 deletions mail-vue/src/i18n/en.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
26 changes: 26 additions & 0 deletions mail-vue/src/i18n/zh.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: '修改权限',
Expand Down
12 changes: 12 additions & 0 deletions mail-vue/src/request/user.js
Original file line number Diff line number Diff line change
Expand Up @@ -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})
}
Expand Down
58 changes: 58 additions & 0 deletions mail-vue/src/utils/user-batch-file-utils.js
Original file line number Diff line number Diff line change
@@ -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);
}
Loading