批量优化功能

This commit is contained in:
2026-09-30 00:57:36 +08:00
parent ca4220f92c
commit e3db229f41
43 changed files with 2959 additions and 207 deletions
-1
View File
@@ -28,7 +28,6 @@ declare module 'vue' {
ElCol: typeof import('element-plus/es')['ElCol']
ElCollapse: typeof import('element-plus/es')['ElCollapse']
ElCollapseItem: typeof import('element-plus/es')['ElCollapseItem']
ElColorPicker: typeof import('element-plus/es')['ElColorPicker']
ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider']
ElContainer: typeof import('element-plus/es')['ElContainer']
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
+4
View File
@@ -6,3 +6,7 @@ export const getPasswordStoreDetail = (id) => request({ url: `${resource}/detail
export const createPasswordStore = (data) => request({ url: `${resource}/create`, method: 'post', data })
export const updatePasswordStore = (id, data) => request({ url: `${resource}/update/${id}`, method: 'post', data })
export const deletePasswordStore = (id) => request({ url: `${resource}/delete/${id}`, method: 'delete' })
/** 导出全部记录(不受列表 200 条限制),返回 data.list */
export const exportPasswordStore = () => request({ url: `${resource}/export`, method: 'get' })
/** 批量导入:rows 为 Excel/CSV 解析后的行数据,返回 created/updated/failed/failures */
export const importPasswordStore = (rows) => request({ url: `${resource}/import`, method: 'post', data: { rows } })
+26
View File
@@ -29,6 +29,32 @@ export function testWechatMpConnection() {
});
}
/** 通过接口发布「扫码登录」菜单(需认证公众号) */
export function publishWechatMpMenu() {
return request({
url: "/platform/wechatMp/menu/publish",
method: "post",
});
}
/** 上传公众号「关注二维码」图片(formData 含 file 字段),返回 data.follow_qrcode */
export function uploadWechatFollowQrcode(formData) {
return request({
url: "/platform/wechatMp/followQrcode",
method: "post",
data: formData,
timeout: 0,
});
}
/** 删除公众号「关注二维码」 */
export function deleteWechatFollowQrcode() {
return request({
url: "/platform/wechatMp/followQrcode/delete",
method: "post",
});
}
/** 发起绑定:生成带参数二维码(扫码关注后公众号回复验证码) */
export function startWechatMpBind() {
return request({
@@ -364,6 +364,26 @@
<el-form-item label="模板消息ID">
<el-input v-model="wechatCfg.template_id" placeholder="认证后在公众号后台申请,用于提醒/公告推送" clearable />
</el-form-item>
<el-form-item label="关注二维码">
<div class="qr-upload">
<template v-if="wechatCfg.follow_qrcode">
<img :src="wechatCfg.follow_qrcode" class="qr-preview" alt="关注二维码" />
<el-button type="danger" plain size="small" @click="removeFollowQrcode">删除</el-button>
</template>
<el-upload
v-else
:show-file-list="false"
accept="image/jpeg,image/png,image/gif,image/webp,image/bmp"
:http-request="handleFollowQrcodeUpload"
>
<el-button :loading="followQrcodeUploading">上传二维码图片</el-button>
</el-upload>
</div>
<div class="form-tip">
上传公众号的「关注二维码」(公众号后台「账号设置 → 二维码」下载,2MB 内图片)。<br />
用户在租户端「绑定微信」页扫码进入公众号会话 → 发送「验证码」→ 回页填码完成绑定(未认证公众号无法生成带参二维码,统一使用此图片)。
</div>
</el-form-item>
<el-form-item label="服务器地址">
<el-input v-model="wechatCfg.callback_url" readonly>
<template #append>
@@ -379,12 +399,19 @@
<el-form-item>
<el-button type="primary" :loading="wechatSaving" @click="saveWechatConfig">保存配置</el-button>
<el-button :loading="wechatTesting" @click="testWechatConfig">连接测试</el-button>
<el-button type="warning" plain :loading="wechatMenuPublishing" @click="publishWechatMenu">
发布「扫码登录」菜单
</el-button>
<div class="form-tip">
在公众号底部菜单追加「扫码登录」(点击事件,键值 LOGIN):用户点一下即可收到确认登录链接,无需输入。<br />
菜单接口需公众号已认证;未认证会提示 errcode=48001,此时用「公众号内发送登录」的兜底路径。
</div>
</el-form-item>
</el-form>
</el-card>
<!-- 绑定我的微信 -->
<el-card shadow="never" class="settings-card bind-card">
<!-- <el-card shadow="never" class="settings-card bind-card">
<template #header>
<div class="card-header"><span>绑定我的微信(接收提醒 / 公告)</span></div>
</template>
@@ -426,10 +453,10 @@
</div>
</div>
</template>
</el-card>
</el-card> -->
<!-- 粉丝列表 -->
<el-card shadow="never" class="settings-card bind-card">
<!-- <el-card shadow="never" class="settings-card bind-card">
<template #header>
<div class="card-header">
<span>关注用户(粉丝)</span>
@@ -481,10 +508,10 @@
:page-size="followerQuery.pageSize"
@current-change="handleFollowerPage"
/>
</el-card>
</el-card> -->
<!-- 消息推送 -->
<el-card shadow="never" class="settings-card bind-card">
<!-- <el-card shadow="never" class="settings-card bind-card">
<template #header>
<div class="card-header"><span>消息推送(提醒 / 公告)</span></div>
</template>
@@ -526,7 +553,7 @@
</el-button>
</el-form-item>
</el-form>
</el-card>
</el-card> -->
</div>
</el-tab-pane>
@@ -597,6 +624,9 @@ import {
getWechatMpConfig,
saveWechatMpConfig,
testWechatMpConnection,
publishWechatMpMenu,
uploadWechatFollowQrcode,
deleteWechatFollowQrcode,
startWechatMpBind,
getWechatMpBindStatus,
confirmWechatMpBind,
@@ -942,6 +972,7 @@ const wechatCfg = reactive({
verified: 0,
enabled: 0,
callback_url: "",
follow_qrcode: "",
configured: false,
});
const wechatSaving = ref(false);
@@ -1018,6 +1049,57 @@ const saveWechatConfig = async () => {
}
};
// 关注二维码上传/删除(存 data URL,供租户端「绑定微信」页展示扫码)
const followQrcodeUploading = ref(false);
const handleFollowQrcodeUpload = async ({ file }) => {
followQrcodeUploading.value = true;
try {
const fd = new FormData();
fd.append("file", file);
const res = await uploadWechatFollowQrcode(fd);
if (res.code === 200) {
wechatCfg.follow_qrcode = res.data?.follow_qrcode || "";
ElMessage.success("二维码上传成功");
} else {
ElMessage.error(res.msg || "上传失败");
}
} catch (e) {
ElMessage.error(e?.message || "上传失败");
} finally {
followQrcodeUploading.value = false;
}
};
const removeFollowQrcode = async () => {
try {
const res = await deleteWechatFollowQrcode();
if (res.code === 200) {
wechatCfg.follow_qrcode = "";
ElMessage.success("已删除");
} else {
ElMessage.error(res.msg || "删除失败");
}
} catch (e) {
ElMessage.error(e?.message || "删除失败");
}
};
const wechatMenuPublishing = ref(false);
const publishWechatMenu = async () => {
wechatMenuPublishing.value = true;
try {
const res = await publishWechatMpMenu();
if (res.code === 200) {
ElMessage.success(res.msg || "菜单发布成功");
} else {
ElMessage.error(res.msg || "发布失败");
}
} catch (e) {
ElMessage.error(e?.message || "发布失败");
} finally {
wechatMenuPublishing.value = false;
}
};
const testWechatConfig = async () => {
wechatTesting.value = true;
try {
@@ -1453,6 +1535,22 @@ watch(activeSubTab, (tab) => {
margin-bottom: 16px;
}
.qr-upload {
display: flex;
align-items: center;
gap: 12px;
}
.qr-preview {
width: 120px;
height: 120px;
object-fit: contain;
border: 1px solid #dcdfe6;
border-radius: 6px;
padding: 4px;
background: #fff;
}
.push-url {
margin-top: 8px;
}
@@ -0,0 +1,177 @@
<template>
<el-dialog
v-model="visible"
title="批量导入密码库"
width="640px"
:close-on-click-modal="false"
append-to-body
>
<p class="hint">
支持 .xlsx / .xls / .csv。列顺序:<b>平台名称、网址、账号、密码、注册信息、账号备注、备注</b>。
多行「平台名称 + 网址」相同会合并为一条记录的多个账号;<b>重复检测</b>:文件内「同平台同账号」、
以及库内已存在的同名登录账号都会自动跳过(不计入新增/更新)。
</p>
<div class="actions">
<el-button link type="primary" :icon="Download" @click="handleDownloadTemplate">下载导入模板</el-button>
</div>
<el-upload
drag
action="#"
accept=".xlsx,.xls,.csv"
:auto-upload="false"
:limit="1"
:file-list="fileList"
:on-change="handleFileChange"
:on-remove="handleFileRemove"
:on-exceed="handleFileExceed"
>
<el-icon class="el-icon--upload"><UploadFilled /></el-icon>
<div class="el-upload__text">将 Excel / CSV 文件拖到此处,或<em>点击选择</em></div>
<template #tip>
<div class="el-upload__tip">单个文件,单行 7 列,首行需为表头</div>
</template>
</el-upload>
<div class="preview" v-if="parseRows.length">
已解析 {{ parseRows.length }} 行,点击「开始导入」写入数据库
</div>
<div class="submit-row">
<el-button type="primary" :loading="importing" :disabled="!parseRows.length" @click="handleImport">
开始导入
</el-button>
<el-button @click="visible = false">关闭</el-button>
</div>
<el-alert
v-if="result"
class="result"
:type="result.failed > 0 ? 'warning' : 'success'"
:closable="false"
show-icon
>
<template #title>
新增 {{ result.created }} 条,更新 {{ result.updated }} 条,跳过重复 {{ result.skipped }} 条,失败
{{ result.failed }} 条
</template>
<ul v-if="result.failures && result.failures.length" class="failures">
<li v-for="(item, index) in result.failures" :key="index">{{ item }}</li>
</ul>
</el-alert>
</el-dialog>
</template>
<script setup>
import { ref } from 'vue'
import { ElMessage } from 'element-plus'
import { UploadFilled, Download } from '@element-plus/icons-vue'
import { importPasswordStore } from '@/api/passwordStore'
import { downloadPasswordTemplate, parsePasswordFile } from '../passwordExcel'
const emit = defineEmits(['imported'])
const visible = ref(false)
const importing = ref(false)
const fileList = ref([])
const parseRows = ref([])
const result = ref(null)
const open = () => {
visible.value = true
reset()
}
const reset = () => {
fileList.value = []
parseRows.value = []
result.value = null
}
const handleDownloadTemplate = () => {
downloadPasswordTemplate()
}
const handleFileChange = async (file) => {
result.value = null
parseRows.value = []
const raw = file?.raw
if (!raw) return
try {
const rows = await parsePasswordFile(raw)
if (!rows.length) {
ElMessage.warning('未解析到有效数据,请检查文件内容')
fileList.value = []
return
}
parseRows.value = rows
} catch (e) {
ElMessage.error(e?.message || '文件解析失败')
fileList.value = []
}
}
const handleFileRemove = () => {
fileList.value = []
parseRows.value = []
result.value = null
}
const handleFileExceed = () => {
ElMessage.warning('每次仅支持导入 1 个文件')
}
const handleImport = async () => {
if (!parseRows.value.length) return
importing.value = true
try {
const res = await importPasswordStore(parseRows.value)
result.value = res?.data || res
ElMessage.success('导入完成')
emit('imported')
} catch (e) {
ElMessage.error(e?.message || '导入失败')
} finally {
importing.value = false
}
}
defineExpose({ open })
</script>
<style lang="less" scoped>
.hint {
margin: 0 0 12px;
color: #606266;
font-size: 13px;
line-height: 1.7;
}
.actions {
margin-bottom: 8px;
}
.preview {
margin-top: 10px;
font-size: 13px;
color: #67c23a;
}
.submit-row {
margin-top: 12px;
}
.result {
margin-top: 16px;
}
.failures {
margin: 8px 0 0;
padding-left: 18px;
max-height: 160px;
overflow-y: auto;
font-size: 12px;
line-height: 1.7;
}
</style>
@@ -14,11 +14,13 @@
clearable
style="width: 300px"
:prefix-icon="Search"
@keyup.enter="load"
@clear="load"
@keyup.enter="refresh"
@clear="refresh"
/>
<el-button type="primary" :icon="Plus" @click="openCreate">新增平台密码</el-button>
<el-button :icon="Refresh" @click="load" :loading="loading">刷新</el-button>
<el-button :icon="Download" @click="handleExport" :loading="exporting">导出 Excel</el-button>
<el-button type="primary" plain :icon="Upload" @click="openImport">批量导入</el-button>
<el-button :icon="Refresh" @click="refresh" :loading="loading">刷新</el-button>
</div>
</div>
</template>
@@ -94,18 +96,7 @@
<div class="field-item">
<span class="field-label">登录密码:</span>
<span class="field-value password-text selectable">
{{ showPasswords[`${row.id}_${accIdx}`] ? acc.password : '••••••••' }}
</span>
<el-button
v-if="acc.password"
link
type="primary"
size="small"
:icon="showPasswords[`${row.id}_${accIdx}`] ? Hide : View"
:title="showPasswords[`${row.id}_${accIdx}`] ? '隐藏密码' : '显示明文'"
@click="togglePasswordVisibility(`${row.id}_${accIdx}`)"
/>
<span class="field-value password-text selectable">{{ acc.password || '-' }}</span>
<el-button
v-if="acc.password"
link
@@ -209,12 +200,28 @@
<el-table-column label="操作" width="190" align="center" fixed="right">
<template #default="{ row }">
<!-- 详情功能暂时停用
<el-button link type="primary" size="small" :icon="View" @click="openDetail(row)">详情</el-button>
-->
<el-button link type="primary" size="small" :icon="Edit" @click="openEdit(row)">编辑</el-button>
<el-button link type="danger" size="small" :icon="Delete" @click="remove(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination-wrap">
<el-pagination
v-model:current-page="page"
v-model:page-size="pageSize"
:page-sizes="[20, 50, 100, 200]"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
background
@size-change="handleSizeChange"
@current-change="load"
/>
</div>
</el-card>
<!-- 新增 / 编辑 平台及挂载账号 对话框 -->
@@ -476,17 +483,7 @@
<div class="detail-field">
<span class="df-label">登录密码:</span>
<span class="df-val password-text selectable">
{{ showDetailPasswords[accIdx] ? acc.password : '••••••••' }}
</span>
<el-button
v-if="acc.password"
link
type="primary"
size="small"
:icon="showDetailPasswords[accIdx] ? Hide : View"
@click="showDetailPasswords[accIdx] = !showDetailPasswords[accIdx]"
/>
<span class="df-val password-text selectable">{{ acc.password || '-' }}</span>
<el-button
v-if="acc.password"
link
@@ -521,11 +518,14 @@
<el-button @click="detailVisible = false">关闭</el-button>
</template>
</el-dialog>
<!-- 批量导入 -->
<PasswordImportDialog ref="importDialogRef" @imported="load" />
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ref, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
Plus,
@@ -537,30 +537,35 @@ import {
Link,
User,
View,
Hide,
DocumentCopy
DocumentCopy,
Download,
Upload
} from '@element-plus/icons-vue'
import PasswordImportDialog from './components/PasswordImportDialog.vue'
import { downloadPasswordExcel } from './passwordExcel'
import {
getPasswordStoreList,
createPasswordStore,
updatePasswordStore,
deletePasswordStore
deletePasswordStore,
exportPasswordStore
} from '@/api/passwordStore'
const keyword = ref('')
const rows = ref([])
const loading = ref(false)
const exporting = ref(false)
const importDialogRef = ref()
// 分页:默认每页 20 条
const page = ref(1)
const pageSize = ref(20)
const total = ref(0)
const saving = ref(false)
const dialogVisible = ref(false)
const detailVisible = ref(false)
const detailData = ref(null)
const formRef = ref()
// 展开行密码显示状态 map: `${row.id}_${accIdx}` => boolean
const showPasswords = reactive({})
// 详情弹窗密码显示状态 map: accIdx => boolean
const showDetailPasswords = reactive({})
const rules = {
platform: [{ required: true, message: '请输入平台名称', trigger: 'blur' }]
}
@@ -629,10 +634,6 @@ async function copyText(text, label = '内容') {
}
}
function togglePasswordVisibility(key) {
showPasswords[key] = !showPasswords[key]
}
function copySingleAccount(platformRow, acc, idx) {
const parts = [
`平台:${platformRow.platform || '-'}`,
@@ -685,12 +686,17 @@ function generateRandomPassword(index) {
async function load() {
loading.value = true
try {
const r = await getPasswordStoreList({ keyword: keyword.value })
const r = await getPasswordStoreList({
keyword: keyword.value,
page: page.value,
pageSize: pageSize.value
})
if (r.code === 200) {
rows.value = (r.data?.list || []).map(item => ({
...item,
accounts: Array.isArray(item.accounts) ? item.accounts : []
}))
total.value = Number(r.data?.total || 0)
}
} catch (err) {
console.error(err)
@@ -699,6 +705,43 @@ async function load() {
}
}
/** 每页条数变化:回到第一页再查询 */
function handleSizeChange(size) {
pageSize.value = size
page.value = 1
load()
}
/** 搜索 / 刷新:回到第一页再查询 */
function refresh() {
page.value = 1
load()
}
/** 导出全部记录为 Excel(一个账号一行) */
async function handleExport() {
exporting.value = true
try {
const r = await exportPasswordStore()
const list = r?.data?.list || r?.list || []
if (!list.length) {
ElMessage.warning('暂无数据可导出')
return
}
const stamp = new Date().toISOString().slice(0, 19).replace(/[-:T]/g, '')
downloadPasswordExcel(list, `密码库_${stamp}.xlsx`)
ElMessage.success(`已导出 ${list.length} 条记录`)
} catch (err) {
ElMessage.error(err?.message || '导出失败')
} finally {
exporting.value = false
}
}
function openImport() {
importDialogRef.value?.open()
}
function openCreate() {
form.value = emptyForm()
dialogVisible.value = true
@@ -724,8 +767,6 @@ function openDetail(row) {
...row,
accounts: Array.isArray(row.accounts) ? row.accounts : []
}
// 重置详情密码隐藏状态
Object.keys(showDetailPasswords).forEach(k => delete showDetailPasswords[k])
detailVisible.value = true
}
@@ -1167,4 +1208,10 @@ onMounted(load)
color: var(--el-text-color-primary);
flex: 1;
}
.pagination-wrap {
margin-top: 16px;
display: flex;
justify-content: flex-end;
}
</style>
@@ -0,0 +1,126 @@
import * as XLSX from 'xlsx'
/**
* 密码库 Excel 导入 / 导出公共逻辑。
* 列顺序与数据库字段一一对应:
* 平台名称(platform) | 网址(url) | 账号(accounts.username) | 密码(accounts.password) |
* 注册信息(accounts.registration_info) | 账号备注(accounts.remark) | 备注(remark)
* 一个平台可挂多个账号:多行「平台名称 + 网址」相同,导入时合并为一条记录的多个账号。
*/
export const PASSWORD_HEADERS = ['平台名称', '网址', '账号', '密码', '注册信息', '账号备注', '备注']
const HEADER_ALIASES = {
平台名称: 'platform',
平台: 'platform',
网址: 'url',
地址: 'url',
url: 'url',
账号: 'username',
用户名: 'username',
密码: 'password',
注册信息: 'registration_info',
注册资料: 'registration_info',
账号备注: 'account_remark',
备注: 'remark'
}
/** 记录列表 → 二维表(一个账号一行;无账号的记录输出一行空账号) */
export function passwordRowsToAoa(list = []) {
const aoa = [PASSWORD_HEADERS.slice()]
list.forEach((row) => {
const accounts = Array.isArray(row.accounts) ? row.accounts : []
const base = [row.platform || '', row.url || '', '', '', '', '', row.remark || '']
if (accounts.length === 0) {
aoa.push(base.slice())
return
}
accounts.forEach((acc) => {
aoa.push([
row.platform || '',
row.url || '',
acc?.username || '',
acc?.password || '',
acc?.registration_info || '',
acc?.remark || '',
row.remark || ''
])
})
})
return aoa
}
/** 记录列表 → xlsx 文件下载 */
export function downloadPasswordExcel(list, filename) {
const wb = XLSX.utils.book_new()
const ws = XLSX.utils.aoa_to_sheet(passwordRowsToAoa(list))
// 列宽自适应,避免导出后内容挤在一起
ws['!cols'] = [18, 30, 20, 20, 30, 20, 30].map((wch) => ({ wch }))
XLSX.utils.book_append_sheet(wb, ws, '密码库')
XLSX.writeFile(wb, filename)
}
/** 下载导入模板(表头 + 示例行) */
export function downloadPasswordTemplate(filename = '密码库导入模板.xlsx') {
const aoa = [
PASSWORD_HEADERS.slice(),
['示例平台', 'https://example.com', 'admin', 'P@ssw0rd', '注册手机号 138****0000', '主账号', '备注信息'],
['示例平台', 'https://example.com', 'user2', 'abc123', '', '备用账号', '']
]
const wb = XLSX.utils.book_new()
const ws = XLSX.utils.aoa_to_sheet(aoa)
ws['!cols'] = [18, 30, 20, 20, 30, 20, 30].map((wch) => ({ wch }))
XLSX.utils.book_append_sheet(wb, ws, '导入模板')
XLSX.writeFile(wb, filename)
}
/**
* 解析上传的 Excel / CSV 文件为导入行。
* @returns {Promise<Array<{platform,url,username,password,registration_info,account_remark,remark}>>}
*/
export async function parsePasswordFile(file) {
const isCsv = /\.csv$/i.test(file.name || '')
let workbook
if (isCsv) {
const text = await readAsText(file)
workbook = XLSX.read(text, { type: 'string' })
} else {
const buf = await file.arrayBuffer()
workbook = XLSX.read(buf, { type: 'array' })
}
const sheetName = workbook.SheetNames?.[0]
if (!sheetName) return []
const sheet = workbook.Sheets[sheetName]
const aoa = XLSX.utils.sheet_to_json(sheet, { header: 1, defval: '', blankrows: false })
if (!aoa.length) return []
// 首行作为表头:优先按中文列名映射列序,否则按固定列序跳过首行
const headerRow = (aoa[0] || []).map((c) => String(c || '').trim())
const keyMap = headerRow.map((h) => HEADER_ALIASES[h] || '')
const hasHeader = keyMap.some(Boolean)
const body = hasHeader ? aoa.slice(1) : aoa
const order = hasHeader ? keyMap : ['platform', 'url', 'username', 'password', 'registration_info', 'account_remark', 'remark']
const rows = []
body.forEach((line) => {
const item = { platform: '', url: '', username: '', password: '', registration_info: '', account_remark: '', remark: '' }
let filled = false
order.forEach((key, idx) => {
if (!key) return
const val = line[idx] === undefined || line[idx] === null ? '' : String(line[idx]).trim()
item[key] = val
if (val) filled = true
})
if (filled) rows.push(item)
})
return rows
}
function readAsText(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(String(reader.result || ''))
reader.onerror = () => reject(new Error('文件读取失败'))
reader.readAsText(file, 'UTF-8')
})
}