批量优化功能
This commit is contained in:
@@ -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 } })
|
||||
|
||||
@@ -5,7 +5,7 @@ import request from "@/utils/request";
|
||||
* 流程:生成二维码 → 微信扫码关注 → 公众号回复验证码 → 输入验证码完成绑定
|
||||
*************************************************/
|
||||
|
||||
/** 发起绑定:生成带参数二维码 */
|
||||
/** 发起绑定:返回绑定指引(未认证公众号无带参二维码,公众号内发送「验证码」取码) */
|
||||
export function startWechatMpBind() {
|
||||
return request({
|
||||
url: "/backend/wechatMp/bind/start",
|
||||
|
||||
@@ -141,6 +141,13 @@ const staticRoutes = [
|
||||
component: () => import("@/views/auth/callback.vue"),
|
||||
meta: { requiresAuth: false }
|
||||
},
|
||||
// 微信公众号一次性链接登录页(公众号发「登录」/扫码确认 → 链接跳到这里兑换令牌)
|
||||
{
|
||||
path: "/mp-login",
|
||||
name: "MpLogin",
|
||||
component: () => import("@/views/auth/mpLogin.vue"),
|
||||
meta: { requiresAuth: false }
|
||||
},
|
||||
{
|
||||
path: "/home",
|
||||
name: "Home",
|
||||
@@ -310,8 +317,8 @@ async function gotoAuthorize() {
|
||||
router.beforeEach(async (to, from, next) => {
|
||||
const token = localStorage.getItem("token");
|
||||
|
||||
// 认证中心回跳页:始终放行,由页面自身完成 code 换令牌
|
||||
if (to.path === "/auth/callback") {
|
||||
// 认证中心回跳页 / 微信登录兑换页:始终放行,由页面自身完成令牌兑换
|
||||
if (to.path === "/auth/callback" || to.path === "/mp-login") {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
<template>
|
||||
<div class="mp-login">
|
||||
<div class="box">
|
||||
<!-- 企业选择 -->
|
||||
<template v-if="choosing">
|
||||
<p class="title">选择要进入的企业</p>
|
||||
<ul class="tenant-list">
|
||||
<li v-for="t in tenants" :key="t.tid" class="tenant-item" @click="chooseTenant(t)">
|
||||
<span class="t-name">{{ t.tenant_name }}</span>
|
||||
<span v-if="t.name" class="t-meta">{{ t.name }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-if="chooseErr" class="msg err">{{ chooseErr }}</p>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div v-if="error" class="error">
|
||||
<p class="title">微信登录失败</p>
|
||||
<p class="msg">{{ error }}</p>
|
||||
<button class="btn" @click="retry">返回重试</button>
|
||||
</div>
|
||||
<div v-else class="loading">
|
||||
<div class="spinner"></div>
|
||||
<p>正在通过微信登录,请稍候…</p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, ref } from "vue";
|
||||
import request from "@/utils/request";
|
||||
import { setTokens, fetchUserInfo, resolveTenantName } from "@/utils/authClient";
|
||||
|
||||
const error = ref("");
|
||||
const choosing = ref(false);
|
||||
const tenants = ref([]);
|
||||
const chooseErr = ref("");
|
||||
let pendingTokens = null;
|
||||
|
||||
/**
|
||||
* 微信公众号「确认登录」一次性链接兑换页:
|
||||
* 公众号(关注/发送「登录」)→ 收到一次性链接(/mp-login?token=xxx)→
|
||||
* 本页用 token 调 /api/wechat/mp/login 换取正式令牌并写入本地登录态。
|
||||
* 多企业账号会先展示企业选择界面。
|
||||
* 令牌一次性、5 分钟内有效,由公众号消息(已验证 openid)签发。
|
||||
*/
|
||||
async function finish() {
|
||||
// hash 路由:参数位于 location.hash 之后,从整段 URL 中取出
|
||||
const raw = window.location.href;
|
||||
const queryStr = raw.includes("?") ? raw.slice(raw.indexOf("?") + 1) : "";
|
||||
const query = Object.fromEntries(new URLSearchParams(queryStr));
|
||||
const token = (query.token || "").trim();
|
||||
if (!token) {
|
||||
error.value = "登录链接无效:请回到公众号重新获取链接";
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await request({
|
||||
url: `/api/wechat/mp/login?token=${encodeURIComponent(token)}&client_id=yz-backend`,
|
||||
method: "post",
|
||||
});
|
||||
if (data?.need_choose_tenant) {
|
||||
pendingTokens = data.tokens;
|
||||
tenants.value = data.tenants || [];
|
||||
choosing.value = true;
|
||||
return;
|
||||
}
|
||||
await finishLogin(data?.tokens);
|
||||
} catch (e) {
|
||||
error.value = e?.message || "微信登录失败,请回到公众号重新获取链接";
|
||||
}
|
||||
}
|
||||
|
||||
async function finishLogin(tokens) {
|
||||
setTokens(tokens);
|
||||
await saveUserInfo();
|
||||
// 与 /auth/callback 一致:replaceState 改写 hash 后整页重载,避免旧令牌请求 401
|
||||
window.history.replaceState({}, document.title, `${window.location.pathname}#/home`);
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
async function chooseTenant(t) {
|
||||
chooseErr.value = "";
|
||||
try {
|
||||
const res = await request({
|
||||
url: "/auth/switch-tenant",
|
||||
method: "post",
|
||||
data: { tid: t.tid, client_id: "yz-backend" },
|
||||
});
|
||||
const tokens = res?.data?.tokens || res?.tokens;
|
||||
await finishLogin(tokens);
|
||||
} catch (e) {
|
||||
chooseErr.value = e?.message || "进入企业失败,请重试";
|
||||
}
|
||||
}
|
||||
|
||||
/** 写入本地用户信息(与 auth/callback.vue 保持一致口径) */
|
||||
async function saveUserInfo() {
|
||||
try {
|
||||
const info = await fetchUserInfo();
|
||||
const userInfo = {
|
||||
id: info.id || info.sub || "",
|
||||
account: info.account || info.mobile || "",
|
||||
name: info.name || info.nickname || "",
|
||||
group_id: info.group_id || "",
|
||||
tid: info.tid || "",
|
||||
tenant_name: resolveTenantName(info),
|
||||
avatar: info.avatar || "",
|
||||
type: "backend",
|
||||
};
|
||||
localStorage.setItem("userInfo", JSON.stringify(userInfo));
|
||||
} catch (e) {
|
||||
console.error("获取用户信息失败:", e);
|
||||
}
|
||||
}
|
||||
|
||||
function retry() {
|
||||
error.value = "";
|
||||
window.history.replaceState({}, document.title, `${window.location.pathname}#/home`);
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
onMounted(finish);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.mp-login {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
.box {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 40px 36px;
|
||||
width: 360px;
|
||||
text-align: center;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
.spinner {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
margin: 0 auto 16px;
|
||||
border: 3px solid #e4e7ee;
|
||||
border-top-color: #667eea;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
.title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.msg {
|
||||
font-size: 13px;
|
||||
color: #5a6072;
|
||||
margin-bottom: 20px;
|
||||
word-break: break-all;
|
||||
}
|
||||
.msg.err {
|
||||
color: #cf1322;
|
||||
}
|
||||
.tenant-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
text-align: left;
|
||||
}
|
||||
.tenant-item {
|
||||
border: 1px solid #e3e9f5;
|
||||
border-radius: 10px;
|
||||
padding: 14px 16px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.tenant-item:hover {
|
||||
border-color: #3973ff;
|
||||
background: #f5f9ff;
|
||||
}
|
||||
.t-name {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: #303133;
|
||||
}
|
||||
.t-meta {
|
||||
font-size: 12px;
|
||||
color: #8a94ad;
|
||||
}
|
||||
.btn {
|
||||
height: 40px;
|
||||
padding: 0 24px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
@@ -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')
|
||||
})
|
||||
}
|
||||
@@ -25,27 +25,30 @@
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 未绑定:扫码关注 → 公众号回复验证码 → 输入完成绑定 -->
|
||||
<!-- 未绑定:关注公众号 → 公众号发送「验证码」→ 输入验证码完成绑定 -->
|
||||
<div v-else class="bind-box">
|
||||
<p class="tip">绑定微信后,可通过公众号接收系统提醒与公告推送。</p>
|
||||
<el-steps :active="bindStep" simple class="bind-steps">
|
||||
<p class="tip">绑定微信后,可在公众号接收系统提醒与公告推送,还可发送「登录」获取一次性登录链接。</p>
|
||||
<el-steps :active="bindStep" simple class="bind-steps" finish-status="success">
|
||||
<el-step title="关注公众号" />
|
||||
<el-step title="获取验证码(扫码或发送「验证码」)" />
|
||||
<el-step title="公众号发送「验证码」" />
|
||||
<el-step title="输入验证码完成绑定" />
|
||||
</el-steps>
|
||||
<div class="bind-area">
|
||||
<div class="qr-box">
|
||||
<img v-if="qrUrl" :src="qrUrl" class="qr-img" alt="公众号二维码" />
|
||||
<el-button v-else type="primary" size="small" :loading="starting" @click="handleStart">
|
||||
获取绑定二维码
|
||||
</el-button>
|
||||
<img v-if="qrUrl" :src="qrUrl" class="qr-img" alt="公众号关注二维码" />
|
||||
<div v-else class="qr-empty">管理员尚未上传<br />公众号二维码</div>
|
||||
</div>
|
||||
<div class="code-box">
|
||||
<el-input v-model="code" placeholder="输入公众号回复的验证码" maxlength="8" class="code-input" />
|
||||
<el-button type="primary" size="small" :loading="confirming" @click="handleConfirm">
|
||||
完成绑定
|
||||
</el-button>
|
||||
<p class="tip">两种方式任选:① 扫描左侧二维码(未关注会自动关注);② 在公众号内发送「验证码」。都会回复 6 位验证码({{ ttlText }}),在上方输入即可绑定。</p>
|
||||
<div class="guide-box">
|
||||
<ol class="guide-list">
|
||||
<li v-for="(g, i) in guides" :key="i">{{ g }}</li>
|
||||
</ol>
|
||||
<div class="code-line">
|
||||
<el-input v-model="code" placeholder="输入公众号回复的 6 位验证码" maxlength="8" class="code-input" @keyup.enter="handleConfirm" />
|
||||
<el-button type="primary" size="small" :loading="confirming" @click="handleConfirm">
|
||||
完成绑定
|
||||
</el-button>
|
||||
</div>
|
||||
<p class="tip">验证码 {{ ttlText }};未收到时在公众号重新发送「验证码」即可。</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -53,33 +56,44 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted } from "vue";
|
||||
import { ref, onMounted } from "vue";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
// 该卡片展示到秒的时间:YYYY-MM-DD HH:mm:ss
|
||||
import { formatDateTime } from "@/utils/datetime";
|
||||
import {
|
||||
startWechatMpBind,
|
||||
getWechatMpBindStatus,
|
||||
confirmWechatMpBind,
|
||||
getMyWechatMpBinding,
|
||||
unbindWechatMp,
|
||||
} from "@/api/wechatMp";
|
||||
|
||||
const defaultGuides = [
|
||||
"用微信扫码关注公众号(或微信搜索公众号名称进入会话)",
|
||||
"在公众号会话中直接发送文字「验证码」",
|
||||
"将公众号回复的 6 位验证码填入下方输入框",
|
||||
];
|
||||
const guides = ref([...defaultGuides]);
|
||||
|
||||
const binding = ref({ bound: false });
|
||||
const starting = ref(false);
|
||||
const confirming = ref(false);
|
||||
const unbinding = ref(false);
|
||||
const qrUrl = ref("");
|
||||
const scene = ref("");
|
||||
const code = ref("");
|
||||
const bindStep = ref(0);
|
||||
const ttlText = ref("5 分钟内有效");
|
||||
let pollTimer = null;
|
||||
|
||||
const stopPoll = () => {
|
||||
if (pollTimer) {
|
||||
clearInterval(pollTimer);
|
||||
pollTimer = null;
|
||||
// 进入页面即拉取绑定指引与公众号关注二维码(平台端上传)
|
||||
const loadBindGuide = async () => {
|
||||
try {
|
||||
const res = await startWechatMpBind();
|
||||
if (res.code === 200 && res.data) {
|
||||
if (Array.isArray(res.data.guides) && res.data.guides.length) {
|
||||
guides.value = res.data.guides;
|
||||
}
|
||||
qrUrl.value = res.data.follow_qrcode || "";
|
||||
}
|
||||
} catch {
|
||||
// 指引拉取失败不影响手动输入验证码绑定
|
||||
}
|
||||
};
|
||||
|
||||
@@ -94,60 +108,19 @@ const loadBinding = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleStart = async () => {
|
||||
starting.value = true;
|
||||
try {
|
||||
const res = await startWechatMpBind();
|
||||
if (res.code !== 200) {
|
||||
ElMessage.error(res.msg || "获取二维码失败,请联系管理员检查公众号配置");
|
||||
return;
|
||||
}
|
||||
scene.value = res.data?.scene || "";
|
||||
qrUrl.value = res.data?.qrcode_url || "";
|
||||
bindStep.value = 0;
|
||||
const ttl = Number(res.data?.expires_in || 300);
|
||||
ttlText.value = ttl >= 60 ? `${Math.floor(ttl / 60)} 分钟内有效` : `${ttl} 秒内有效`;
|
||||
stopPoll();
|
||||
pollTimer = setInterval(async () => {
|
||||
if (!scene.value) return;
|
||||
try {
|
||||
const st = await getWechatMpBindStatus(scene.value);
|
||||
const status = Number(st?.data?.status ?? 0);
|
||||
if (status === 1) {
|
||||
bindStep.value = 2; // 已扫码,公众号已回复验证码
|
||||
}
|
||||
if (status === 3) {
|
||||
stopPoll();
|
||||
qrUrl.value = "";
|
||||
ElMessage.warning("二维码已过期,请重新获取");
|
||||
}
|
||||
} catch {
|
||||
// 轮询失败忽略
|
||||
}
|
||||
}, 3000);
|
||||
} catch (e) {
|
||||
ElMessage.error(e?.message || "获取二维码失败");
|
||||
} finally {
|
||||
starting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirm = async () => {
|
||||
// scene 可为空:在公众号内发送「验证码」获取时无需二维码,后端按验证码认领会话
|
||||
// 不需要二维码:公众号内发送「验证码」取码后,后端按验证码认领绑定会话
|
||||
if (!code.value.trim()) {
|
||||
ElMessage.warning("请输入公众号回复的验证码");
|
||||
return;
|
||||
}
|
||||
confirming.value = true;
|
||||
try {
|
||||
const res = await confirmWechatMpBind({ scene: scene.value, code: code.value.trim() });
|
||||
const res = await confirmWechatMpBind({ scene: "", code: code.value.trim() });
|
||||
if (res.code === 200) {
|
||||
ElMessage.success("微信绑定成功");
|
||||
stopPoll();
|
||||
qrUrl.value = "";
|
||||
code.value = "";
|
||||
scene.value = "";
|
||||
bindStep.value = 0;
|
||||
bindStep.value = 3;
|
||||
await loadBinding();
|
||||
} else {
|
||||
ElMessage.error(res.msg || "绑定失败");
|
||||
@@ -183,8 +156,10 @@ const handleUnbind = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(loadBinding);
|
||||
onUnmounted(stopPoll);
|
||||
onMounted(() => {
|
||||
loadBinding();
|
||||
loadBindGuide();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -236,8 +211,9 @@ onUnmounted(stopPoll);
|
||||
}
|
||||
|
||||
.qr-box {
|
||||
width: 160px;
|
||||
height: 160px;
|
||||
width: 150px;
|
||||
height: 150px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -252,12 +228,32 @@ onUnmounted(stopPoll);
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.code-box {
|
||||
.qr-empty {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
text-align: center;
|
||||
line-height: 1.8;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.guide-box {
|
||||
flex: 1;
|
||||
min-width: 220px;
|
||||
min-width: 240px;
|
||||
}
|
||||
|
||||
.guide-list {
|
||||
margin: 0 0 12px;
|
||||
padding-left: 18px;
|
||||
font-size: 13px;
|
||||
color: #606266;
|
||||
line-height: 2;
|
||||
}
|
||||
|
||||
.code-line {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.code-input {
|
||||
|
||||
Reference in New Issue
Block a user