批量优化功能
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 createPasswordStore = (data) => request({ url: `${resource}/create`, method: 'post', data })
|
||||||
export const updatePasswordStore = (id, data) => request({ url: `${resource}/update/${id}`, 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' })
|
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() {
|
export function startWechatMpBind() {
|
||||||
return request({
|
return request({
|
||||||
url: "/backend/wechatMp/bind/start",
|
url: "/backend/wechatMp/bind/start",
|
||||||
|
|||||||
@@ -141,6 +141,13 @@ const staticRoutes = [
|
|||||||
component: () => import("@/views/auth/callback.vue"),
|
component: () => import("@/views/auth/callback.vue"),
|
||||||
meta: { requiresAuth: false }
|
meta: { requiresAuth: false }
|
||||||
},
|
},
|
||||||
|
// 微信公众号一次性链接登录页(公众号发「登录」/扫码确认 → 链接跳到这里兑换令牌)
|
||||||
|
{
|
||||||
|
path: "/mp-login",
|
||||||
|
name: "MpLogin",
|
||||||
|
component: () => import("@/views/auth/mpLogin.vue"),
|
||||||
|
meta: { requiresAuth: false }
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "/home",
|
path: "/home",
|
||||||
name: "Home",
|
name: "Home",
|
||||||
@@ -310,8 +317,8 @@ async function gotoAuthorize() {
|
|||||||
router.beforeEach(async (to, from, next) => {
|
router.beforeEach(async (to, from, next) => {
|
||||||
const token = localStorage.getItem("token");
|
const token = localStorage.getItem("token");
|
||||||
|
|
||||||
// 认证中心回跳页:始终放行,由页面自身完成 code 换令牌
|
// 认证中心回跳页 / 微信登录兑换页:始终放行,由页面自身完成令牌兑换
|
||||||
if (to.path === "/auth/callback") {
|
if (to.path === "/auth/callback" || to.path === "/mp-login") {
|
||||||
next();
|
next();
|
||||||
return;
|
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
|
clearable
|
||||||
style="width: 300px"
|
style="width: 300px"
|
||||||
:prefix-icon="Search"
|
:prefix-icon="Search"
|
||||||
@keyup.enter="load"
|
@keyup.enter="refresh"
|
||||||
@clear="load"
|
@clear="refresh"
|
||||||
/>
|
/>
|
||||||
<el-button type="primary" :icon="Plus" @click="openCreate">新增平台密码</el-button>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -94,18 +96,7 @@
|
|||||||
|
|
||||||
<div class="field-item">
|
<div class="field-item">
|
||||||
<span class="field-label">登录密码:</span>
|
<span class="field-label">登录密码:</span>
|
||||||
<span class="field-value password-text selectable">
|
<span class="field-value password-text selectable">{{ acc.password || '-' }}</span>
|
||||||
{{ 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}`)"
|
|
||||||
/>
|
|
||||||
<el-button
|
<el-button
|
||||||
v-if="acc.password"
|
v-if="acc.password"
|
||||||
link
|
link
|
||||||
@@ -209,12 +200,28 @@
|
|||||||
|
|
||||||
<el-table-column label="操作" width="190" align="center" fixed="right">
|
<el-table-column label="操作" width="190" align="center" fixed="right">
|
||||||
<template #default="{ row }">
|
<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="View" @click="openDetail(row)">详情</el-button>
|
||||||
|
-->
|
||||||
<el-button link type="primary" size="small" :icon="Edit" @click="openEdit(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>
|
<el-button link type="danger" size="small" :icon="Delete" @click="remove(row)">删除</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</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>
|
</el-card>
|
||||||
|
|
||||||
<!-- 新增 / 编辑 平台及挂载账号 对话框 -->
|
<!-- 新增 / 编辑 平台及挂载账号 对话框 -->
|
||||||
@@ -476,17 +483,7 @@
|
|||||||
|
|
||||||
<div class="detail-field">
|
<div class="detail-field">
|
||||||
<span class="df-label">登录密码:</span>
|
<span class="df-label">登录密码:</span>
|
||||||
<span class="df-val password-text selectable">
|
<span class="df-val password-text selectable">{{ acc.password || '-' }}</span>
|
||||||
{{ 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]"
|
|
||||||
/>
|
|
||||||
<el-button
|
<el-button
|
||||||
v-if="acc.password"
|
v-if="acc.password"
|
||||||
link
|
link
|
||||||
@@ -521,11 +518,14 @@
|
|||||||
<el-button @click="detailVisible = false">关闭</el-button>
|
<el-button @click="detailVisible = false">关闭</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
|
<!-- 批量导入 -->
|
||||||
|
<PasswordImportDialog ref="importDialogRef" @imported="load" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, reactive, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import {
|
import {
|
||||||
Plus,
|
Plus,
|
||||||
@@ -537,30 +537,35 @@ import {
|
|||||||
Link,
|
Link,
|
||||||
User,
|
User,
|
||||||
View,
|
View,
|
||||||
Hide,
|
DocumentCopy,
|
||||||
DocumentCopy
|
Download,
|
||||||
|
Upload
|
||||||
} from '@element-plus/icons-vue'
|
} from '@element-plus/icons-vue'
|
||||||
|
import PasswordImportDialog from './components/PasswordImportDialog.vue'
|
||||||
|
import { downloadPasswordExcel } from './passwordExcel'
|
||||||
import {
|
import {
|
||||||
getPasswordStoreList,
|
getPasswordStoreList,
|
||||||
createPasswordStore,
|
createPasswordStore,
|
||||||
updatePasswordStore,
|
updatePasswordStore,
|
||||||
deletePasswordStore
|
deletePasswordStore,
|
||||||
|
exportPasswordStore
|
||||||
} from '@/api/passwordStore'
|
} from '@/api/passwordStore'
|
||||||
|
|
||||||
const keyword = ref('')
|
const keyword = ref('')
|
||||||
const rows = ref([])
|
const rows = ref([])
|
||||||
const loading = ref(false)
|
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 saving = ref(false)
|
||||||
const dialogVisible = ref(false)
|
const dialogVisible = ref(false)
|
||||||
const detailVisible = ref(false)
|
const detailVisible = ref(false)
|
||||||
const detailData = ref(null)
|
const detailData = ref(null)
|
||||||
const formRef = ref()
|
const formRef = ref()
|
||||||
|
|
||||||
// 展开行密码显示状态 map: `${row.id}_${accIdx}` => boolean
|
|
||||||
const showPasswords = reactive({})
|
|
||||||
// 详情弹窗密码显示状态 map: accIdx => boolean
|
|
||||||
const showDetailPasswords = reactive({})
|
|
||||||
|
|
||||||
const rules = {
|
const rules = {
|
||||||
platform: [{ required: true, message: '请输入平台名称', trigger: 'blur' }]
|
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) {
|
function copySingleAccount(platformRow, acc, idx) {
|
||||||
const parts = [
|
const parts = [
|
||||||
`平台:${platformRow.platform || '-'}`,
|
`平台:${platformRow.platform || '-'}`,
|
||||||
@@ -685,12 +686,17 @@ function generateRandomPassword(index) {
|
|||||||
async function load() {
|
async function load() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
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) {
|
if (r.code === 200) {
|
||||||
rows.value = (r.data?.list || []).map(item => ({
|
rows.value = (r.data?.list || []).map(item => ({
|
||||||
...item,
|
...item,
|
||||||
accounts: Array.isArray(item.accounts) ? item.accounts : []
|
accounts: Array.isArray(item.accounts) ? item.accounts : []
|
||||||
}))
|
}))
|
||||||
|
total.value = Number(r.data?.total || 0)
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(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() {
|
function openCreate() {
|
||||||
form.value = emptyForm()
|
form.value = emptyForm()
|
||||||
dialogVisible.value = true
|
dialogVisible.value = true
|
||||||
@@ -724,8 +767,6 @@ function openDetail(row) {
|
|||||||
...row,
|
...row,
|
||||||
accounts: Array.isArray(row.accounts) ? row.accounts : []
|
accounts: Array.isArray(row.accounts) ? row.accounts : []
|
||||||
}
|
}
|
||||||
// 重置详情密码隐藏状态
|
|
||||||
Object.keys(showDetailPasswords).forEach(k => delete showDetailPasswords[k])
|
|
||||||
detailVisible.value = true
|
detailVisible.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1167,4 +1208,10 @@ onMounted(load)
|
|||||||
color: var(--el-text-color-primary);
|
color: var(--el-text-color-primary);
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pagination-wrap {
|
||||||
|
margin-top: 16px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
</style>
|
</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>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 未绑定:扫码关注 → 公众号回复验证码 → 输入完成绑定 -->
|
<!-- 未绑定:关注公众号 → 公众号发送「验证码」→ 输入验证码完成绑定 -->
|
||||||
<div v-else class="bind-box">
|
<div v-else class="bind-box">
|
||||||
<p class="tip">绑定微信后,可通过公众号接收系统提醒与公告推送。</p>
|
<p class="tip">绑定微信后,可在公众号接收系统提醒与公告推送,还可发送「登录」获取一次性登录链接。</p>
|
||||||
<el-steps :active="bindStep" simple class="bind-steps">
|
<el-steps :active="bindStep" simple class="bind-steps" finish-status="success">
|
||||||
<el-step title="关注公众号" />
|
<el-step title="关注公众号" />
|
||||||
<el-step title="获取验证码(扫码或发送「验证码」)" />
|
<el-step title="公众号发送「验证码」" />
|
||||||
<el-step title="输入验证码完成绑定" />
|
<el-step title="输入验证码完成绑定" />
|
||||||
</el-steps>
|
</el-steps>
|
||||||
<div class="bind-area">
|
<div class="bind-area">
|
||||||
<div class="qr-box">
|
<div class="qr-box">
|
||||||
<img v-if="qrUrl" :src="qrUrl" class="qr-img" alt="公众号二维码" />
|
<img v-if="qrUrl" :src="qrUrl" class="qr-img" alt="公众号关注二维码" />
|
||||||
<el-button v-else type="primary" size="small" :loading="starting" @click="handleStart">
|
<div v-else class="qr-empty">管理员尚未上传<br />公众号二维码</div>
|
||||||
获取绑定二维码
|
|
||||||
</el-button>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="code-box">
|
<div class="guide-box">
|
||||||
<el-input v-model="code" placeholder="输入公众号回复的验证码" maxlength="8" class="code-input" />
|
<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 type="primary" size="small" :loading="confirming" @click="handleConfirm">
|
||||||
完成绑定
|
完成绑定
|
||||||
</el-button>
|
</el-button>
|
||||||
<p class="tip">两种方式任选:① 扫描左侧二维码(未关注会自动关注);② 在公众号内发送「验证码」。都会回复 6 位验证码({{ ttlText }}),在上方输入即可绑定。</p>
|
</div>
|
||||||
|
<p class="tip">验证码 {{ ttlText }};未收到时在公众号重新发送「验证码」即可。</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -53,33 +56,44 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted, onUnmounted } from "vue";
|
import { ref, onMounted } from "vue";
|
||||||
import { ElMessage, ElMessageBox } from "element-plus";
|
import { ElMessage, ElMessageBox } from "element-plus";
|
||||||
// 该卡片展示到秒的时间:YYYY-MM-DD HH:mm:ss
|
// 该卡片展示到秒的时间:YYYY-MM-DD HH:mm:ss
|
||||||
import { formatDateTime } from "@/utils/datetime";
|
import { formatDateTime } from "@/utils/datetime";
|
||||||
import {
|
import {
|
||||||
startWechatMpBind,
|
startWechatMpBind,
|
||||||
getWechatMpBindStatus,
|
|
||||||
confirmWechatMpBind,
|
confirmWechatMpBind,
|
||||||
getMyWechatMpBinding,
|
getMyWechatMpBinding,
|
||||||
unbindWechatMp,
|
unbindWechatMp,
|
||||||
} from "@/api/wechatMp";
|
} from "@/api/wechatMp";
|
||||||
|
|
||||||
|
const defaultGuides = [
|
||||||
|
"用微信扫码关注公众号(或微信搜索公众号名称进入会话)",
|
||||||
|
"在公众号会话中直接发送文字「验证码」",
|
||||||
|
"将公众号回复的 6 位验证码填入下方输入框",
|
||||||
|
];
|
||||||
|
const guides = ref([...defaultGuides]);
|
||||||
|
|
||||||
const binding = ref({ bound: false });
|
const binding = ref({ bound: false });
|
||||||
const starting = ref(false);
|
|
||||||
const confirming = ref(false);
|
const confirming = ref(false);
|
||||||
const unbinding = ref(false);
|
const unbinding = ref(false);
|
||||||
const qrUrl = ref("");
|
const qrUrl = ref("");
|
||||||
const scene = ref("");
|
|
||||||
const code = ref("");
|
const code = ref("");
|
||||||
const bindStep = ref(0);
|
const bindStep = ref(0);
|
||||||
const ttlText = ref("5 分钟内有效");
|
const ttlText = ref("5 分钟内有效");
|
||||||
let pollTimer = null;
|
|
||||||
|
|
||||||
const stopPoll = () => {
|
// 进入页面即拉取绑定指引与公众号关注二维码(平台端上传)
|
||||||
if (pollTimer) {
|
const loadBindGuide = async () => {
|
||||||
clearInterval(pollTimer);
|
try {
|
||||||
pollTimer = null;
|
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 () => {
|
const handleConfirm = async () => {
|
||||||
// scene 可为空:在公众号内发送「验证码」获取时无需二维码,后端按验证码认领会话
|
// 不需要二维码:公众号内发送「验证码」取码后,后端按验证码认领绑定会话
|
||||||
if (!code.value.trim()) {
|
if (!code.value.trim()) {
|
||||||
ElMessage.warning("请输入公众号回复的验证码");
|
ElMessage.warning("请输入公众号回复的验证码");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
confirming.value = true;
|
confirming.value = true;
|
||||||
try {
|
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) {
|
if (res.code === 200) {
|
||||||
ElMessage.success("微信绑定成功");
|
ElMessage.success("微信绑定成功");
|
||||||
stopPoll();
|
|
||||||
qrUrl.value = "";
|
|
||||||
code.value = "";
|
code.value = "";
|
||||||
scene.value = "";
|
bindStep.value = 3;
|
||||||
bindStep.value = 0;
|
|
||||||
await loadBinding();
|
await loadBinding();
|
||||||
} else {
|
} else {
|
||||||
ElMessage.error(res.msg || "绑定失败");
|
ElMessage.error(res.msg || "绑定失败");
|
||||||
@@ -183,8 +156,10 @@ const handleUnbind = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
onMounted(loadBinding);
|
onMounted(() => {
|
||||||
onUnmounted(stopPoll);
|
loadBinding();
|
||||||
|
loadBindGuide();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -236,8 +211,9 @@ onUnmounted(stopPoll);
|
|||||||
}
|
}
|
||||||
|
|
||||||
.qr-box {
|
.qr-box {
|
||||||
width: 160px;
|
width: 150px;
|
||||||
height: 160px;
|
height: 150px;
|
||||||
|
flex-shrink: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -252,12 +228,32 @@ onUnmounted(stopPoll);
|
|||||||
object-fit: contain;
|
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;
|
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;
|
display: flex;
|
||||||
flex-direction: column;
|
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.code-input {
|
.code-input {
|
||||||
|
|||||||
@@ -86,6 +86,9 @@ jwt_rsa_kid = rsa-1
|
|||||||
wechat_mp_secret_key = 8f3c2a1d94b74e28a6c5f0187d3e9b4c72a5d0e6f13b8c47d92a6e5f0b7c3148
|
wechat_mp_secret_key = 8f3c2a1d94b74e28a6c5f0187d3e9b4c72a5d0e6f13b8c47d92a6e5f0b7c3148
|
||||||
# 微信服务器回调地址基址(默认回落 payment_callback_base):
|
# 微信服务器回调地址基址(默认回落 payment_callback_base):
|
||||||
# wechat_mp_callback_base = https://api.yunzer.cn
|
# wechat_mp_callback_base = https://api.yunzer.cn
|
||||||
|
# 公众号「登录」一次性链接的页面基址(前端应用对外可访问地址,链接形如 {基址}/#/mp-login?token=xxx):
|
||||||
|
# 缺省回落 wechat_mp_callback_base / payment_callback_base;本地开发可填 http://127.0.0.1:4401(仅同机可点)
|
||||||
|
# wechat_mp_login_base = https://back.yunzer.cn
|
||||||
|
|
||||||
# ==================== 支付模块 ====================
|
# ==================== 支付模块 ====================
|
||||||
# 渠道敏感参数(商户密钥/私钥/Secret)入库加密密钥:任意随机串即可,代码内部做 SHA-256 派生。
|
# 渠道敏感参数(商户密钥/私钥/Secret)入库加密密钥:任意随机串即可,代码内部做 SHA-256 派生。
|
||||||
|
|||||||
@@ -1,14 +1,18 @@
|
|||||||
package controllers
|
package controllers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"server/models"
|
||||||
|
authsvc "server/services/auth"
|
||||||
"server/services/wechatmp"
|
"server/services/wechatmp"
|
||||||
|
|
||||||
beego "github.com/beego/beego/v2/server/web"
|
beego "github.com/beego/beego/v2/server/web"
|
||||||
|
"github.com/beego/beego/v2/core/logs"
|
||||||
)
|
)
|
||||||
|
|
||||||
// WechatMpCallbackController 微信公众号服务器回调(微信服务器调用,无需平台鉴权)
|
// WechatMpCallbackController 微信公众号服务器回调(微信服务器调用,无需平台鉴权)
|
||||||
@@ -100,6 +104,10 @@ func (c *WechatMpCallbackController) Callback() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 排查用:记录微信推送的消息/事件类型
|
||||||
|
logs.Info("[wechat-mp] 收到推送: type=%s event=%s from=%s key=%s",
|
||||||
|
msg.MsgType, msg.Event, msg.FromUserName, msg.EventKey)
|
||||||
|
|
||||||
reply := wechatmp.HandleInbound(cfg, msg)
|
reply := wechatmp.HandleInbound(cfg, msg)
|
||||||
if strings.TrimSpace(reply) == "" {
|
if strings.TrimSpace(reply) == "" {
|
||||||
// 无需回复:返回空串,微信视为处理成功
|
// 无需回复:返回空串,微信视为处理成功
|
||||||
@@ -122,3 +130,144 @@ func (c *WechatMpCallbackController) Callback() {
|
|||||||
}
|
}
|
||||||
c.Ctx.Output.Body([]byte(plainReply))
|
c.Ctx.Output.Body([]byte(plainReply))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Login GET/POST /api/wechat/mp/login?token=xxx&client_id=yyy
|
||||||
|
// 公众号「确认登录」一次性令牌兑换登录态(无平台鉴权:令牌本身即凭证,一次性 + 短时效)。
|
||||||
|
// 已绑定身份:单企业直接签发令牌;多企业签发待选令牌并由前端展示企业选择。
|
||||||
|
// 同时认领等待中的 PC 扫码会话(存在时),PC 端轮询后自动登录。
|
||||||
|
func (c *WechatMpCallbackController) Login() {
|
||||||
|
token := strings.TrimSpace(c.GetString("token"))
|
||||||
|
clientID := strings.TrimSpace(c.GetString("client_id"))
|
||||||
|
if clientID == "" {
|
||||||
|
clientID = "yz-backend"
|
||||||
|
}
|
||||||
|
|
||||||
|
row, err := wechatmp.ConsumeLoginToken(token)
|
||||||
|
if err != nil {
|
||||||
|
c.Ctx.Output.SetStatus(400)
|
||||||
|
c.Data["json"] = map[string]interface{}{"code": 400, "msg": err.Error()}
|
||||||
|
_ = c.ServeJSON()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch row.BindType {
|
||||||
|
case models.WechatBindTypeTenantUser:
|
||||||
|
// 认领 PC 扫码会话:确认链接携带 scene 时精确认领;「登录」文本路径无 scene,FIFO 兜底
|
||||||
|
if scene := strings.TrimSpace(c.GetString("scene")); scene != "" {
|
||||||
|
wechatmp.ConfirmScanLoginByToken(scene, token, row.BindType, row.BindID, row.BindTid)
|
||||||
|
} else {
|
||||||
|
wechatmp.ConfirmOldestPendingScanLogin(row.BindType, row.BindID, row.BindTid)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 身份状态校验(禁用/锁定则拒绝登录)
|
||||||
|
var identity models.AuthIdentity
|
||||||
|
if err := models.Orm.QueryTable(new(models.AuthIdentity)).
|
||||||
|
Filter("id", row.BindID).One(&identity); err != nil {
|
||||||
|
c.failJSON(400, "账号不存在或已注销")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if identity.Status != models.AuthIdentityStatusEnabled {
|
||||||
|
c.failJSON(400, "账号已被禁用或锁定,无法登录")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
username := "微信用户"
|
||||||
|
if identity.Nickname != nil && strings.TrimSpace(*identity.Nickname) != "" {
|
||||||
|
username = *identity.Nickname
|
||||||
|
}
|
||||||
|
|
||||||
|
// 企业选择:单企业直接登录;多企业签发待选令牌,由前端展示选择界面
|
||||||
|
tenants, lerr := authsvc.ListTenantOptions(row.BindID)
|
||||||
|
if lerr != nil || len(tenants) == 0 {
|
||||||
|
c.failJSON(400, "该账号未加入任何企业,无法登录")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tid := uint64(0)
|
||||||
|
needChoose := false
|
||||||
|
if len(tenants) == 1 {
|
||||||
|
tid = tenants[0].Tid
|
||||||
|
} else {
|
||||||
|
tid = authsvc.PendingTenantID
|
||||||
|
needChoose = true
|
||||||
|
}
|
||||||
|
|
||||||
|
tokens, terr := c.issueMpLoginTokens(row.BindID, tid, clientID, username, "公众号确认链接登录成功", needChoose)
|
||||||
|
if terr != nil {
|
||||||
|
c.failJSON(400, terr.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
data := map[string]interface{}{
|
||||||
|
"type": "backend",
|
||||||
|
"tokens": tokens,
|
||||||
|
"tenant": row.BindTid,
|
||||||
|
}
|
||||||
|
if needChoose {
|
||||||
|
data["need_choose_tenant"] = true
|
||||||
|
data["tenants"] = tenants
|
||||||
|
}
|
||||||
|
c.Data["json"] = map[string]interface{}{
|
||||||
|
"code": 200,
|
||||||
|
"msg": "登录成功",
|
||||||
|
"data": data,
|
||||||
|
}
|
||||||
|
_ = c.ServeJSON()
|
||||||
|
default:
|
||||||
|
c.failJSON(400, "该微信绑定的账号类型暂不支持链接登录")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// issueMpLoginTokens 为绑定身份建会话并签发 UAC 令牌(含登录日志,失败不影响主流程)
|
||||||
|
// tid 为 authsvc.PendingTenantID 时签发待选企业用的短期令牌
|
||||||
|
func (c *WechatMpCallbackController) issueMpLoginTokens(identityID, tid uint64, clientID, username, logMsg string, needChoose bool) (*authsvc.TokenPair, error) {
|
||||||
|
accessTTL := 0
|
||||||
|
if needChoose {
|
||||||
|
accessTTL = 600 // 待选状态令牌只够用户完成企业选择
|
||||||
|
}
|
||||||
|
sess, serr := authsvc.CreateSession(authsvc.SessionInfo{
|
||||||
|
IdentityID: identityID,
|
||||||
|
Tid: tid,
|
||||||
|
ClientID: clientID,
|
||||||
|
IP: c.Ctx.Input.IP(),
|
||||||
|
UserAgent: c.Ctx.Request.UserAgent(),
|
||||||
|
LoginType: "wechat_mp",
|
||||||
|
Amr: "wechat_mp",
|
||||||
|
})
|
||||||
|
if serr != nil {
|
||||||
|
return nil, fmt.Errorf("登录失败:%s", serr.Error())
|
||||||
|
}
|
||||||
|
tokens, terr := authsvc.IssueTokens(authsvc.TokenIssue{
|
||||||
|
IdentityID: identityID,
|
||||||
|
Tid: tid,
|
||||||
|
ClientID: clientID,
|
||||||
|
Sid: sess.Sid,
|
||||||
|
Username: username,
|
||||||
|
Amr: "wechat_mp",
|
||||||
|
AccessTTL: accessTTL,
|
||||||
|
})
|
||||||
|
if terr != nil {
|
||||||
|
return nil, fmt.Errorf("签发令牌失败:%s", terr.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
identityIDCopy := identityID
|
||||||
|
tidCopy := tid
|
||||||
|
amr := "wechat_mp"
|
||||||
|
_, _ = models.Orm.Insert(&models.AuthLoginLog{
|
||||||
|
Tid: &tidCopy,
|
||||||
|
IdentityID: &identityIDCopy,
|
||||||
|
UserName: username,
|
||||||
|
ClientID: clientID,
|
||||||
|
LoginType: "wechat_mp",
|
||||||
|
Amr: &amr,
|
||||||
|
Status: 1,
|
||||||
|
Message: logMsg,
|
||||||
|
IP: c.Ctx.Input.IP(),
|
||||||
|
UserAgent: c.Ctx.Request.UserAgent(),
|
||||||
|
})
|
||||||
|
return tokens, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *WechatMpCallbackController) failJSON(bizCode int, msg string) {
|
||||||
|
c.Ctx.Output.SetStatus(400)
|
||||||
|
c.Data["json"] = map[string]interface{}{"code": bizCode, "msg": msg}
|
||||||
|
_ = c.ServeJSON()
|
||||||
|
}
|
||||||
|
|||||||
@@ -399,12 +399,24 @@ func bearerTokenLogin(c *AuthLoginController) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func setSessionCookieForCtx(ctx *context.Context, sid string) {
|
func setSessionCookieForCtx(ctx *context.Context, sid string) {
|
||||||
|
// Secure 仅在 https 下添加:本地 http 联调时浏览器会丢弃 Secure Cookie,
|
||||||
|
// 导致 authorize 识别不到会话又被送回登录页
|
||||||
|
flag := cookieSecureFlag(ctx)
|
||||||
ctx.Output.Header("Set-Cookie",
|
ctx.Output.Header("Set-Cookie",
|
||||||
fmt.Sprintf("%s=%s; Path=/; Max-Age=%d; HttpOnly; Secure; SameSite=Lax",
|
fmt.Sprintf("%s=%s; Path=/; Max-Age=%d; HttpOnly%s; SameSite=Lax",
|
||||||
sessionCookieName, sid, sessionCookieTTL))
|
sessionCookieName, sid, sessionCookieTTL, flag))
|
||||||
}
|
}
|
||||||
|
|
||||||
func clearSessionCookieForCtx(ctx *context.Context) {
|
func clearSessionCookieForCtx(ctx *context.Context) {
|
||||||
|
flag := cookieSecureFlag(ctx)
|
||||||
ctx.Output.Header("Set-Cookie",
|
ctx.Output.Header("Set-Cookie",
|
||||||
fmt.Sprintf("%s=; Path=/; Max-Age=0; HttpOnly; Secure; SameSite=Lax", sessionCookieName))
|
fmt.Sprintf("%s=; Path=/; Max-Age=0; HttpOnly%s; SameSite=Lax", sessionCookieName, flag))
|
||||||
|
}
|
||||||
|
|
||||||
|
// cookieSecureFlag https 请求(或网关带 X-Forwarded-Proto: https)时返回 "; Secure"
|
||||||
|
func cookieSecureFlag(ctx *context.Context) string {
|
||||||
|
if ctx.Request.TLS != nil || strings.EqualFold(ctx.Request.Header.Get("X-Forwarded-Proto"), "https") {
|
||||||
|
return "; Secure"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"server/models"
|
||||||
|
authsvc "server/services/auth"
|
||||||
|
"server/services/wechatmp"
|
||||||
|
)
|
||||||
|
|
||||||
|
// =============================================================
|
||||||
|
// 统一认证中心「微信扫码登录」(未认证公众号方案):
|
||||||
|
// 登录页展示「服务号关注二维码 + 6 位登录码」→ 用户微信扫码进入公众号会话
|
||||||
|
// → 发送登录码 → 服务号回复「确认登录」链接 → 用户点击(/mp-login 兑换时
|
||||||
|
// 携带 scene 回写确认)→ 本组接口轮询确认后建立 UAC 会话 Cookie,
|
||||||
|
// 前端跳回 authorize 完成 OAuth 发放。全程无极验验证码。
|
||||||
|
// =============================================================
|
||||||
|
|
||||||
|
// WechatScanStart POST /auth/wechat/scan/start
|
||||||
|
// 发起扫码登录会话并生成带参二维码(认证公众号);未认证回退普通关注二维码。
|
||||||
|
// 用户扫码后公众号自动回复「确认登录」链接,无需输入任何登录码。
|
||||||
|
func (c *AuthLoginController) WechatScanStart() {
|
||||||
|
scene, qrURL, notice, expireAt, err := wechatmp.StartScanLogin()
|
||||||
|
if err != nil {
|
||||||
|
c.serveJSON(map[string]interface{}{"code": 500, "msg": "发起扫码登录失败:" + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.serveJSON(map[string]interface{}{
|
||||||
|
"code": 200,
|
||||||
|
"msg": "success",
|
||||||
|
"data": map[string]interface{}{
|
||||||
|
"scene": scene,
|
||||||
|
"qrcode_url": qrURL, // 带参二维码(认证公众号);空 = 已回退
|
||||||
|
"follow_qrcode": wechatmp.FollowQrcodeURL(),
|
||||||
|
"notice": notice, // 回退原因(非空时前端醒目提示)
|
||||||
|
"expire_in": int(time.Until(expireAt).Seconds()),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// WechatScanStatus GET /auth/wechat/scan/status?scene=xxx&redirect=yyy
|
||||||
|
// 登录页轮询:待确认 status=0;已确认 → 建立 UAC 会话(写 Cookie)并返回回跳地址
|
||||||
|
// (多企业时回跳企业选择步骤);过期 status=2。
|
||||||
|
func (c *AuthLoginController) WechatScanStatus() {
|
||||||
|
redirectParam := c.GetString("redirect")
|
||||||
|
clientID := strings.TrimSpace(c.GetString("client_id"))
|
||||||
|
row, err := wechatmp.PickScanLogin(c.GetString("scene"))
|
||||||
|
if err != nil {
|
||||||
|
if err == wechatmp.ErrScanLoginInvalid {
|
||||||
|
c.serveJSON(map[string]interface{}{"code": 200, "data": map[string]interface{}{"status": 2}})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 待确认等业务提示:status=0 继续轮询
|
||||||
|
c.serveJSON(map[string]interface{}{"code": 200, "data": map[string]interface{}{"status": 0, "msg": err.Error()}})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 身份状态校验(禁用/锁定则拒绝)
|
||||||
|
var identity models.AuthIdentity
|
||||||
|
if err := models.Orm.QueryTable(new(models.AuthIdentity)).
|
||||||
|
Filter("id", row.BindID).One(&identity); err != nil || identity.Status != models.AuthIdentityStatusEnabled {
|
||||||
|
c.serveJSON(map[string]interface{}{"code": 400, "msg": "账号已被禁用或锁定,无法登录"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 企业选择:单企业直接进入;多企业进入登录页企业选择步骤
|
||||||
|
tenants, lerr := authsvc.ListTenantOptions(row.BindID)
|
||||||
|
if lerr != nil || len(tenants) == 0 {
|
||||||
|
c.serveJSON(map[string]interface{}{"code": 400, "msg": "该账号未加入任何企业,无法登录"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tid := uint64(authsvc.PendingTenantID)
|
||||||
|
if len(tenants) == 1 {
|
||||||
|
tid = tenants[0].Tid
|
||||||
|
}
|
||||||
|
|
||||||
|
username := "微信用户"
|
||||||
|
if identity.Nickname != nil && strings.TrimSpace(*identity.Nickname) != "" {
|
||||||
|
username = *identity.Nickname
|
||||||
|
}
|
||||||
|
ip := c.Ctx.Input.IP()
|
||||||
|
userAgent := c.Ctx.Request.UserAgent()
|
||||||
|
|
||||||
|
// 建立 UAC 会话并写 Cookie(与账号密码登录同一套会话体系,authorize 据此发码)
|
||||||
|
sess, serr := authsvc.CreateSession(authsvc.SessionInfo{
|
||||||
|
IdentityID: row.BindID,
|
||||||
|
Tid: tid,
|
||||||
|
ClientID: clientID,
|
||||||
|
IP: ip,
|
||||||
|
UserAgent: userAgent,
|
||||||
|
LoginType: "wechat_mp",
|
||||||
|
Amr: "wechat_mp",
|
||||||
|
})
|
||||||
|
if serr != nil {
|
||||||
|
c.serveJSON(map[string]interface{}{"code": 400, "msg": "登录失败:" + serr.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, terr := authsvc.IssueTokens(authsvc.TokenIssue{
|
||||||
|
IdentityID: row.BindID,
|
||||||
|
Tid: tid,
|
||||||
|
ClientID: clientID,
|
||||||
|
Sid: sess.Sid,
|
||||||
|
Username: username,
|
||||||
|
Amr: "wechat_mp",
|
||||||
|
}); terr != nil {
|
||||||
|
c.serveJSON(map[string]interface{}{"code": 500, "msg": "签发令牌失败:" + terr.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSessionCookieForCtx(c.Ctx, sess.Sid)
|
||||||
|
|
||||||
|
// 登录日志(失败不影响主流程)
|
||||||
|
identityID := row.BindID
|
||||||
|
amr := "wechat_mp"
|
||||||
|
_, _ = models.Orm.Insert(&models.AuthLoginLog{
|
||||||
|
Tid: &tid,
|
||||||
|
IdentityID: &identityID,
|
||||||
|
UserName: username,
|
||||||
|
ClientID: clientID,
|
||||||
|
LoginType: "wechat_mp",
|
||||||
|
Amr: &amr,
|
||||||
|
Status: 1,
|
||||||
|
Message: "公众号扫码登录成功",
|
||||||
|
IP: ip,
|
||||||
|
UserAgent: userAgent,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 默认回跳到原 authorize;多企业时回跳登录页企业选择步骤
|
||||||
|
back := decodeRedirect(redirectParam)
|
||||||
|
if len(tenants) > 1 {
|
||||||
|
back = "/auth/login?step=tenant&client_id=" + url.QueryEscape(clientID) +
|
||||||
|
"&redirect=" + url.QueryEscape(redirectParam)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.serveJSON(map[string]interface{}{
|
||||||
|
"code": 200,
|
||||||
|
"data": map[string]interface{}{
|
||||||
|
"status": 1,
|
||||||
|
"redirect": back,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -5,7 +5,6 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
|
||||||
|
|
||||||
"server/models"
|
"server/models"
|
||||||
"server/pkg/jwtutil"
|
"server/pkg/jwtutil"
|
||||||
@@ -76,6 +75,8 @@ func (c *BackendWechatMpController) readBody(out interface{}) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// BindStart POST /backend/wechatMp/bind/start
|
// BindStart POST /backend/wechatMp/bind/start
|
||||||
|
// 未认证公众号无法生成带参二维码(48001):不再下发二维码,
|
||||||
|
// 返回绑定指引(公众号内发送「验证码」取码 → 页面填码 → bind/confirm 核销)。
|
||||||
func (c *BackendWechatMpController) BindStart() {
|
func (c *BackendWechatMpController) BindStart() {
|
||||||
claims, err := c.claims()
|
claims, err := c.claims()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -88,17 +89,16 @@ func (c *BackendWechatMpController) BindStart() {
|
|||||||
c.jsonErr(401, 401, "未获取到用户信息")
|
c.jsonErr(401, 401, "未获取到用户信息")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
scene, _, qrURL, expireAt, err := wechatmp.CreateVerifyCode(models.WechatBindTypeTenantUser, uid, tid, 0)
|
if _, err := wechatmp.LoadEnabledConfig(); err != nil {
|
||||||
if err != nil {
|
c.jsonErr(400, 400, "公众号未启用:"+err.Error())
|
||||||
c.jsonErr(400, 400, "生成二维码失败:"+err.Error())
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.ok(map[string]interface{}{
|
c.ok(map[string]interface{}{
|
||||||
"scene": scene,
|
"mode": "mp_message",
|
||||||
"qrcode_url": qrURL,
|
"guides": wechatmp.BindGuides(),
|
||||||
"expire_at": expireAt,
|
"follow_qrcode": wechatmp.FollowQrcodeURL(),
|
||||||
"expires_in": int(time.Until(expireAt).Seconds()),
|
"expires_in": int(wechatmp.DefaultVerifyTTL.Seconds()),
|
||||||
}, "生成成功")
|
}, "请按指引完成绑定")
|
||||||
}
|
}
|
||||||
|
|
||||||
// BindStatus GET /backend/wechatMp/bind/status?scene=xxx
|
// BindStatus GET /backend/wechatMp/bind/status?scene=xxx
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package controllers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -105,6 +106,105 @@ func validPassword(p passwordPayload) bool {
|
|||||||
return p.Platform != ""
|
return p.Platform != ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sameAccount 判定两个账号是否重复:
|
||||||
|
// 登录账号都非空时按账号比较(忽略大小写);账号为空时按密码比较。
|
||||||
|
func sameAccount(a, b models.PasswordAccountItem) bool {
|
||||||
|
u1 := strings.TrimSpace(a.Username)
|
||||||
|
u2 := strings.TrimSpace(b.Username)
|
||||||
|
if u1 != "" && u2 != "" {
|
||||||
|
return strings.EqualFold(u1, u2)
|
||||||
|
}
|
||||||
|
if u1 != u2 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(a.Password) == strings.TrimSpace(b.Password)
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateAccountsUnique 校验同一平台下的登录账号不重复
|
||||||
|
func validateAccountsUnique(list []models.PasswordAccountItem) error {
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, item := range list {
|
||||||
|
u := strings.TrimSpace(item.Username)
|
||||||
|
if u == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key := strings.ToLower(u)
|
||||||
|
if seen[key] {
|
||||||
|
return fmt.Errorf("同一平台下登录账号「%s」重复,请合并后再保存", u)
|
||||||
|
}
|
||||||
|
seen[key] = true
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// dedupeAccounts 去掉同组内的重复账号(保留首次出现的一条),返回去重后的列表与去重数量
|
||||||
|
func dedupeAccounts(list []models.PasswordAccountItem) ([]models.PasswordAccountItem, int) {
|
||||||
|
out := make([]models.PasswordAccountItem, 0, len(list))
|
||||||
|
removed := 0
|
||||||
|
for _, item := range list {
|
||||||
|
dup := false
|
||||||
|
for _, keep := range out {
|
||||||
|
if sameAccount(keep, item) {
|
||||||
|
dup = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if dup {
|
||||||
|
removed++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, item)
|
||||||
|
}
|
||||||
|
return out, removed
|
||||||
|
}
|
||||||
|
|
||||||
|
// findSamePlatformRecord 查询同用户(同租户)下是否已存在「平台名称 + 网址」相同的记录
|
||||||
|
func findSamePlatformRecord(qs orm.QuerySeter, excludeID uint64, platform, url string) (uint64, error) {
|
||||||
|
q := qs.Filter("is_deleted", 0).Filter("platform", platform).Filter("url", url)
|
||||||
|
if excludeID > 0 {
|
||||||
|
q = q.Exclude("id", excludeID)
|
||||||
|
}
|
||||||
|
// 用 Values 只取 ID,避免不同模型的 One() 类型差异
|
||||||
|
var rows []orm.Params
|
||||||
|
if _, err := q.OrderBy("id").Limit(1).Values(&rows, "id"); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if len(rows) == 0 {
|
||||||
|
return 0, orm.ErrNoRows
|
||||||
|
}
|
||||||
|
id, _ := strconv.ParseUint(fmt.Sprintf("%v", rows[0]["Id"]), 10, 64)
|
||||||
|
if id == 0 {
|
||||||
|
id, _ = strconv.ParseUint(fmt.Sprintf("%v", rows[0]["id"]), 10, 64)
|
||||||
|
}
|
||||||
|
if id == 0 {
|
||||||
|
return 0, orm.ErrNoRows
|
||||||
|
}
|
||||||
|
return id, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// passwordPageParams 解析分页参数:page 默认 1,pageSize 默认 20、上限 200
|
||||||
|
func passwordPageParams(c *beego.Controller) (page, pageSize int) {
|
||||||
|
page, _ = c.GetInt("page", 1)
|
||||||
|
pageSize, _ = c.GetInt("pageSize", 20)
|
||||||
|
if page < 1 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
if pageSize < 1 || pageSize > 200 {
|
||||||
|
pageSize = 20
|
||||||
|
}
|
||||||
|
return page, pageSize
|
||||||
|
}
|
||||||
|
|
||||||
|
// passwordPageReply 分页列表统一响应(list + total + 当前分页信息)
|
||||||
|
func passwordPageReply(c *beego.Controller, msg string, list interface{}, total int64, page, pageSize int) {
|
||||||
|
passwordReply(c, 200, msg, map[string]interface{}{
|
||||||
|
"list": list,
|
||||||
|
"total": total,
|
||||||
|
"page": page,
|
||||||
|
"page_size": pageSize,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== 平台端 Password Store ====================
|
// ==================== 平台端 Password Store ====================
|
||||||
|
|
||||||
type PlatformPasswordStoreController struct{ beego.Controller }
|
type PlatformPasswordStoreController struct{ beego.Controller }
|
||||||
@@ -125,9 +225,11 @@ func (c *PlatformPasswordStoreController) List() {
|
|||||||
Or("remark__icontains", k)
|
Or("remark__icontains", k)
|
||||||
qs = qs.SetCond(orm.NewCondition().And("is_deleted", 0).And("user_id", cl.UserID).AndCond(cond))
|
qs = qs.SetCond(orm.NewCondition().And("is_deleted", 0).And("user_id", cl.UserID).AndCond(cond))
|
||||||
}
|
}
|
||||||
|
// 分页:默认每页 20 条,单页最多 200 条
|
||||||
|
page, pageSize := passwordPageParams(&c.Controller)
|
||||||
total, _ := qs.Count()
|
total, _ := qs.Count()
|
||||||
var list []models.PlatformPasswordStore
|
var list []models.PlatformPasswordStore
|
||||||
_, e = qs.OrderBy("-id").Limit(200).All(&list)
|
_, e = qs.OrderBy("-id").Limit(pageSize, (page-1)*pageSize).All(&list)
|
||||||
if e != nil && e != orm.ErrNoRows {
|
if e != nil && e != orm.ErrNoRows {
|
||||||
logs.Error("[passwordStore] platform list failed: %v", e)
|
logs.Error("[passwordStore] platform list failed: %v", e)
|
||||||
passwordReply(&c.Controller, 500, "查询失败: "+e.Error(), nil)
|
passwordReply(&c.Controller, 500, "查询失败: "+e.Error(), nil)
|
||||||
@@ -146,7 +248,7 @@ func (c *PlatformPasswordStoreController) List() {
|
|||||||
UpdateTime: row.UpdateTime,
|
UpdateTime: row.UpdateTime,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
passwordReply(&c.Controller, 200, "success", map[string]interface{}{"list": res, "total": total})
|
passwordPageReply(&c.Controller, "success", res, total, page, pageSize)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *PlatformPasswordStoreController) Detail() {
|
func (c *PlatformPasswordStoreController) Detail() {
|
||||||
@@ -186,6 +288,18 @@ func (c *PlatformPasswordStoreController) Create() {
|
|||||||
passwordReply(&c.Controller, 400, "平台名称不能为空", nil)
|
passwordReply(&c.Controller, 400, "平台名称不能为空", nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// 重复检测:平台名称 + 网址 已存在则拒绝新增
|
||||||
|
if existID, ferr := findSamePlatformRecord(
|
||||||
|
models.Orm.QueryTable(new(models.PlatformPasswordStore)).Filter("user_id", cl.UserID),
|
||||||
|
0, p.Platform, p.URL); ferr == nil && existID > 0 {
|
||||||
|
passwordReply(&c.Controller, 400, fmt.Sprintf("已存在同名同网址的平台「%s」(ID %d),请直接编辑该记录", p.Platform, existID), nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 重复检测:同一平台下的登录账号不能重复
|
||||||
|
if verr := validateAccountsUnique(p.Accounts); verr != nil {
|
||||||
|
passwordReply(&c.Controller, 400, verr.Error(), nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
accJSON := accountsToJSON(p.Accounts)
|
accJSON := accountsToJSON(p.Accounts)
|
||||||
v := &models.PlatformPasswordStore{
|
v := &models.PlatformPasswordStore{
|
||||||
Platform: p.Platform,
|
Platform: p.Platform,
|
||||||
@@ -232,6 +346,18 @@ func (c *PlatformPasswordStoreController) Update() {
|
|||||||
passwordReply(&c.Controller, 404, "记录不存在", nil)
|
passwordReply(&c.Controller, 404, "记录不存在", nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// 重复检测:改成的平台名称 + 网址 不能与自己的其它记录冲突
|
||||||
|
if existID, ferr := findSamePlatformRecord(
|
||||||
|
models.Orm.QueryTable(new(models.PlatformPasswordStore)).Filter("user_id", cl.UserID),
|
||||||
|
id, p.Platform, p.URL); ferr == nil && existID > 0 {
|
||||||
|
passwordReply(&c.Controller, 400, fmt.Sprintf("已存在同名同网址的平台「%s」(ID %d),请先合并或改名", p.Platform, existID), nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 重复检测:同一平台下的登录账号不能重复
|
||||||
|
if verr := validateAccountsUnique(p.Accounts); verr != nil {
|
||||||
|
passwordReply(&c.Controller, 400, verr.Error(), nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
accJSON := accountsToJSON(p.Accounts)
|
accJSON := accountsToJSON(p.Accounts)
|
||||||
_, e = models.Orm.QueryTable(new(models.PlatformPasswordStore)).Filter("id", id).Update(map[string]interface{}{
|
_, e = models.Orm.QueryTable(new(models.PlatformPasswordStore)).Filter("id", id).Update(map[string]interface{}{
|
||||||
@@ -299,9 +425,11 @@ func (c *BackendPasswordStoreController) List() {
|
|||||||
Or("remark__icontains", k)
|
Or("remark__icontains", k)
|
||||||
qs = qs.SetCond(orm.NewCondition().And("is_deleted", 0).And("tid", cl.TenantId).And("user_id", cl.UserID).AndCond(cond))
|
qs = qs.SetCond(orm.NewCondition().And("is_deleted", 0).And("tid", cl.TenantId).And("user_id", cl.UserID).AndCond(cond))
|
||||||
}
|
}
|
||||||
|
// 分页:默认每页 20 条,单页最多 200 条
|
||||||
|
page, pageSize := passwordPageParams(&c.Controller)
|
||||||
total, _ := qs.Count()
|
total, _ := qs.Count()
|
||||||
var list []models.BackendPasswordStore
|
var list []models.BackendPasswordStore
|
||||||
_, e = qs.OrderBy("-id").Limit(200).All(&list)
|
_, e = qs.OrderBy("-id").Limit(pageSize, (page-1)*pageSize).All(&list)
|
||||||
if e != nil && e != orm.ErrNoRows {
|
if e != nil && e != orm.ErrNoRows {
|
||||||
logs.Error("[passwordStore] backend list failed: %v", e)
|
logs.Error("[passwordStore] backend list failed: %v", e)
|
||||||
passwordReply(&c.Controller, 500, "查询失败: "+e.Error(), nil)
|
passwordReply(&c.Controller, 500, "查询失败: "+e.Error(), nil)
|
||||||
@@ -321,7 +449,7 @@ func (c *BackendPasswordStoreController) List() {
|
|||||||
UpdateTime: row.UpdateTime,
|
UpdateTime: row.UpdateTime,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
passwordReply(&c.Controller, 200, "success", map[string]interface{}{"list": res, "total": total})
|
passwordPageReply(&c.Controller, "success", res, total, page, pageSize)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *BackendPasswordStoreController) Detail() {
|
func (c *BackendPasswordStoreController) Detail() {
|
||||||
@@ -362,6 +490,18 @@ func (c *BackendPasswordStoreController) Create() {
|
|||||||
passwordReply(&c.Controller, 400, "平台名称不能为空", nil)
|
passwordReply(&c.Controller, 400, "平台名称不能为空", nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// 重复检测:同租户下平台名称 + 网址 已存在则拒绝新增
|
||||||
|
if existID, ferr := findSamePlatformRecord(
|
||||||
|
models.Orm.QueryTable(new(models.BackendPasswordStore)).Filter("tid", cl.TenantId).Filter("user_id", cl.UserID),
|
||||||
|
0, p.Platform, p.URL); ferr == nil && existID > 0 {
|
||||||
|
passwordReply(&c.Controller, 400, fmt.Sprintf("已存在同名同网址的平台「%s」(ID %d),请直接编辑该记录", p.Platform, existID), nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 重复检测:同一平台下的登录账号不能重复
|
||||||
|
if verr := validateAccountsUnique(p.Accounts); verr != nil {
|
||||||
|
passwordReply(&c.Controller, 400, verr.Error(), nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
accJSON := accountsToJSON(p.Accounts)
|
accJSON := accountsToJSON(p.Accounts)
|
||||||
v := &models.BackendPasswordStore{
|
v := &models.BackendPasswordStore{
|
||||||
Tid: cl.TenantId,
|
Tid: cl.TenantId,
|
||||||
@@ -410,6 +550,18 @@ func (c *BackendPasswordStoreController) Update() {
|
|||||||
passwordReply(&c.Controller, 404, "记录不存在", nil)
|
passwordReply(&c.Controller, 404, "记录不存在", nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// 重复检测:改成的平台名称 + 网址 不能与自己的其它记录冲突
|
||||||
|
if existID, ferr := findSamePlatformRecord(
|
||||||
|
models.Orm.QueryTable(new(models.BackendPasswordStore)).Filter("tid", cl.TenantId).Filter("user_id", cl.UserID),
|
||||||
|
id, p.Platform, p.URL); ferr == nil && existID > 0 {
|
||||||
|
passwordReply(&c.Controller, 400, fmt.Sprintf("已存在同名同网址的平台「%s」(ID %d),请先合并或改名", p.Platform, existID), nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 重复检测:同一平台下的登录账号不能重复
|
||||||
|
if verr := validateAccountsUnique(p.Accounts); verr != nil {
|
||||||
|
passwordReply(&c.Controller, 400, verr.Error(), nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
accJSON := accountsToJSON(p.Accounts)
|
accJSON := accountsToJSON(p.Accounts)
|
||||||
_, e = models.Orm.QueryTable(new(models.BackendPasswordStore)).Filter("id", id).Update(map[string]interface{}{
|
_, e = models.Orm.QueryTable(new(models.BackendPasswordStore)).Filter("id", id).Update(map[string]interface{}{
|
||||||
@@ -457,3 +609,314 @@ func (c *BackendPasswordStoreController) Delete() {
|
|||||||
}
|
}
|
||||||
passwordReply(&c.Controller, 200, "删除成功", nil)
|
passwordReply(&c.Controller, 200, "删除成功", nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== 批量导入 / 导出 ====================
|
||||||
|
//
|
||||||
|
// Excel 列顺序(与数据库字段一一对应):
|
||||||
|
// 平台名称 platform | 网址 url | 账号 username | 密码 password |
|
||||||
|
// 注册信息 registration_info | 账号备注 account_remark | 备注 remark
|
||||||
|
// 一个平台可挂多个账号:多行「平台名称 + 网址」相同即合并为一条记录的多个账号;
|
||||||
|
// 已存在的同平台同网址记录按「追加账号(跳过完全重复项)」处理。
|
||||||
|
|
||||||
|
type passwordImportRow struct {
|
||||||
|
Platform string `json:"platform"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
RegistrationInfo string `json:"registration_info"`
|
||||||
|
AccountRemark string `json:"account_remark"`
|
||||||
|
Remark string `json:"remark"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type passwordImportResult struct {
|
||||||
|
Created int `json:"created"`
|
||||||
|
Updated int `json:"updated"`
|
||||||
|
Skipped int `json:"skipped"`
|
||||||
|
Failed int `json:"failed"`
|
||||||
|
Failures []string `json:"failures"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type passwordImportGroup struct {
|
||||||
|
Platform string
|
||||||
|
URL string
|
||||||
|
Remark string
|
||||||
|
Accounts []models.PasswordAccountItem
|
||||||
|
}
|
||||||
|
|
||||||
|
func parsePasswordImportRows(c *beego.Controller) ([]passwordImportRow, error) {
|
||||||
|
b, e := io.ReadAll(c.Ctx.Request.Body)
|
||||||
|
var p struct {
|
||||||
|
Rows []passwordImportRow `json:"rows"`
|
||||||
|
}
|
||||||
|
if e == nil {
|
||||||
|
e = json.Unmarshal(b, &p)
|
||||||
|
}
|
||||||
|
return p.Rows, e
|
||||||
|
}
|
||||||
|
|
||||||
|
// groupPasswordImportRows 按「平台名称 + 网址」聚合成记录,返回有序分组与行级错误
|
||||||
|
func groupPasswordImportRows(rows []passwordImportRow) ([]*passwordImportGroup, []string) {
|
||||||
|
order := make([]string, 0, len(rows))
|
||||||
|
index := map[string]*passwordImportGroup{}
|
||||||
|
failures := make([]string, 0)
|
||||||
|
for i, r := range rows {
|
||||||
|
platform := strings.TrimSpace(r.Platform)
|
||||||
|
if platform == "" {
|
||||||
|
failures = append(failures, fmt.Sprintf("第%d行:平台名称不能为空", i+2))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
url := strings.TrimSpace(r.URL)
|
||||||
|
username := strings.TrimSpace(r.Username)
|
||||||
|
pwd := strings.TrimSpace(r.Password)
|
||||||
|
reg := strings.TrimSpace(r.RegistrationInfo)
|
||||||
|
accRemark := strings.TrimSpace(r.AccountRemark)
|
||||||
|
remark := strings.TrimSpace(r.Remark)
|
||||||
|
|
||||||
|
key := platform + "\n" + url
|
||||||
|
g, ok := index[key]
|
||||||
|
if !ok {
|
||||||
|
g = &passwordImportGroup{Platform: platform, URL: url}
|
||||||
|
index[key] = g
|
||||||
|
order = append(order, key)
|
||||||
|
}
|
||||||
|
if username != "" || pwd != "" || reg != "" || accRemark != "" {
|
||||||
|
item := models.PasswordAccountItem{
|
||||||
|
Username: username,
|
||||||
|
Password: pwd,
|
||||||
|
RegistrationInfo: reg,
|
||||||
|
Remark: accRemark,
|
||||||
|
}
|
||||||
|
// 文件内重复检测:同一「平台名称 + 网址」下登录账号重复则跳过
|
||||||
|
dup := false
|
||||||
|
for _, keep := range g.Accounts {
|
||||||
|
if sameAccount(keep, item) {
|
||||||
|
dup = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if dup {
|
||||||
|
label := username
|
||||||
|
if label == "" {
|
||||||
|
label = "(空账号)"
|
||||||
|
}
|
||||||
|
failures = append(failures, fmt.Sprintf("第%d行:平台「%s」下账号「%s」与前面某行重复,已跳过", i+2, platform, label))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
g.Accounts = append(g.Accounts, item)
|
||||||
|
}
|
||||||
|
if remark != "" {
|
||||||
|
g.Remark = remark
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out := make([]*passwordImportGroup, 0, len(order))
|
||||||
|
for _, k := range order {
|
||||||
|
out = append(out, index[k])
|
||||||
|
}
|
||||||
|
return out, failures
|
||||||
|
}
|
||||||
|
|
||||||
|
// mergeAccounts 追加导入账号到已有账号之后,返回合并结果与「实际新增条数」。
|
||||||
|
// 重复判定:登录账号相同(忽略大小写)即视为重复,账号为空时按密码比较。
|
||||||
|
func mergeAccounts(exist, incoming []models.PasswordAccountItem) ([]models.PasswordAccountItem, int) {
|
||||||
|
out := make([]models.PasswordAccountItem, 0, len(exist)+len(incoming))
|
||||||
|
out = append(out, exist...)
|
||||||
|
added := 0
|
||||||
|
for _, in := range incoming {
|
||||||
|
dup := false
|
||||||
|
for _, e := range exist {
|
||||||
|
if sameAccount(e, in) {
|
||||||
|
dup = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if dup {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, in)
|
||||||
|
added++
|
||||||
|
}
|
||||||
|
return out, added
|
||||||
|
}
|
||||||
|
|
||||||
|
// Import POST /platform/passwordStore/import { rows: [...] }
|
||||||
|
func (c *PlatformPasswordStoreController) Import() {
|
||||||
|
cl, e := passwordClaims(&c.Controller, "platform")
|
||||||
|
if e != nil {
|
||||||
|
passwordReply(&c.Controller, 401, "未登录或无权限", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rows, e := parsePasswordImportRows(&c.Controller)
|
||||||
|
if e != nil {
|
||||||
|
passwordReply(&c.Controller, 400, "参数错误", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
uid := uint64(cl.UserID)
|
||||||
|
groups, failures := groupPasswordImportRows(rows)
|
||||||
|
res := passwordImportResult{Failed: len(failures), Failures: failures}
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
for _, g := range groups {
|
||||||
|
var exist models.PlatformPasswordStore
|
||||||
|
err := models.Orm.QueryTable(new(models.PlatformPasswordStore)).
|
||||||
|
Filter("is_deleted", 0).Filter("user_id", uid).
|
||||||
|
Filter("platform", g.Platform).Filter("url", g.URL).One(&exist)
|
||||||
|
if err == nil {
|
||||||
|
merged, added := mergeAccounts(accountsFromJSON(exist.Accounts), g.Accounts)
|
||||||
|
if added == 0 {
|
||||||
|
res.Skipped++
|
||||||
|
res.Failures = append(res.Failures, fmt.Sprintf("平台「%s」的登录账号已全部存在,已跳过", g.Platform))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
accounts := accountsToJSON(merged)
|
||||||
|
remark := exist.Remark
|
||||||
|
if g.Remark != "" {
|
||||||
|
remark = g.Remark
|
||||||
|
}
|
||||||
|
if _, uerr := models.Orm.QueryTable(new(models.PlatformPasswordStore)).Filter("id", exist.ID).
|
||||||
|
Update(map[string]interface{}{"accounts": accounts, "remark": remark, "update_time": now}); uerr != nil {
|
||||||
|
res.Failed++
|
||||||
|
res.Failures = append(res.Failures, fmt.Sprintf("平台「%s」更新失败:%s", g.Platform, uerr.Error()))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
res.Updated++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
v := &models.PlatformPasswordStore{
|
||||||
|
Platform: g.Platform,
|
||||||
|
URL: g.URL,
|
||||||
|
Accounts: accountsToJSON(g.Accounts),
|
||||||
|
Remark: g.Remark,
|
||||||
|
UserID: uid,
|
||||||
|
}
|
||||||
|
if _, ierr := models.Orm.Insert(v); ierr != nil {
|
||||||
|
res.Failed++
|
||||||
|
res.Failures = append(res.Failures, fmt.Sprintf("平台「%s」新增失败:%s", g.Platform, ierr.Error()))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
res.Created++
|
||||||
|
}
|
||||||
|
passwordReply(&c.Controller, 200, "导入完成", res)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export GET /platform/passwordStore/export 导出全部(不受列表 200 条限制)
|
||||||
|
func (c *PlatformPasswordStoreController) Export() {
|
||||||
|
cl, e := passwordClaims(&c.Controller, "platform")
|
||||||
|
if e != nil {
|
||||||
|
passwordReply(&c.Controller, 401, "未登录或无权限", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var list []models.PlatformPasswordStore
|
||||||
|
if _, e = models.Orm.QueryTable(new(models.PlatformPasswordStore)).
|
||||||
|
Filter("is_deleted", 0).Filter("user_id", cl.UserID).
|
||||||
|
OrderBy("-id").All(&list); e != nil && e != orm.ErrNoRows {
|
||||||
|
passwordReply(&c.Controller, 500, "查询失败: "+e.Error(), nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
res := make([]PasswordStoreItemDTO, 0, len(list))
|
||||||
|
for _, row := range list {
|
||||||
|
res = append(res, PasswordStoreItemDTO{
|
||||||
|
ID: row.ID,
|
||||||
|
Platform: row.Platform,
|
||||||
|
URL: row.URL,
|
||||||
|
Accounts: accountsFromJSON(row.Accounts),
|
||||||
|
Remark: row.Remark,
|
||||||
|
UserID: row.UserID,
|
||||||
|
CreateTime: row.CreateTime,
|
||||||
|
UpdateTime: row.UpdateTime,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
passwordReply(&c.Controller, 200, "success", map[string]interface{}{"list": res, "total": len(res)})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Import POST /backend/passwordStore/import { rows: [...] }
|
||||||
|
func (c *BackendPasswordStoreController) Import() {
|
||||||
|
cl, e := passwordClaims(&c.Controller, "backend")
|
||||||
|
if e != nil || cl.TenantId <= 0 {
|
||||||
|
passwordReply(&c.Controller, 401, "未登录或无权限", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rows, e := parsePasswordImportRows(&c.Controller)
|
||||||
|
if e != nil {
|
||||||
|
passwordReply(&c.Controller, 400, "参数错误", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
uid := uint64(cl.UserID)
|
||||||
|
tid := cl.TenantId
|
||||||
|
groups, failures := groupPasswordImportRows(rows)
|
||||||
|
res := passwordImportResult{Failed: len(failures), Failures: failures}
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
for _, g := range groups {
|
||||||
|
var exist models.BackendPasswordStore
|
||||||
|
err := models.Orm.QueryTable(new(models.BackendPasswordStore)).
|
||||||
|
Filter("is_deleted", 0).Filter("tid", tid).Filter("user_id", uid).
|
||||||
|
Filter("platform", g.Platform).Filter("url", g.URL).One(&exist)
|
||||||
|
if err == nil {
|
||||||
|
merged, added := mergeAccounts(accountsFromJSON(exist.Accounts), g.Accounts)
|
||||||
|
if added == 0 {
|
||||||
|
res.Skipped++
|
||||||
|
res.Failures = append(res.Failures, fmt.Sprintf("平台「%s」的登录账号已全部存在,已跳过", g.Platform))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
accounts := accountsToJSON(merged)
|
||||||
|
remark := exist.Remark
|
||||||
|
if g.Remark != "" {
|
||||||
|
remark = g.Remark
|
||||||
|
}
|
||||||
|
if _, uerr := models.Orm.QueryTable(new(models.BackendPasswordStore)).Filter("id", exist.ID).
|
||||||
|
Update(map[string]interface{}{"accounts": accounts, "remark": remark, "update_time": now}); uerr != nil {
|
||||||
|
res.Failed++
|
||||||
|
res.Failures = append(res.Failures, fmt.Sprintf("平台「%s」更新失败:%s", g.Platform, uerr.Error()))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
res.Updated++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
v := &models.BackendPasswordStore{
|
||||||
|
Tid: tid,
|
||||||
|
Platform: g.Platform,
|
||||||
|
URL: g.URL,
|
||||||
|
Accounts: accountsToJSON(g.Accounts),
|
||||||
|
Remark: g.Remark,
|
||||||
|
UserID: uid,
|
||||||
|
}
|
||||||
|
if _, ierr := models.Orm.Insert(v); ierr != nil {
|
||||||
|
res.Failed++
|
||||||
|
res.Failures = append(res.Failures, fmt.Sprintf("平台「%s」新增失败:%s", g.Platform, ierr.Error()))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
res.Created++
|
||||||
|
}
|
||||||
|
passwordReply(&c.Controller, 200, "导入完成", res)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export GET /backend/passwordStore/export 导出当前租户下本人全部记录
|
||||||
|
func (c *BackendPasswordStoreController) Export() {
|
||||||
|
cl, e := passwordClaims(&c.Controller, "backend")
|
||||||
|
if e != nil || cl.TenantId <= 0 {
|
||||||
|
passwordReply(&c.Controller, 401, "未登录或无权限", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var list []models.BackendPasswordStore
|
||||||
|
if _, e = models.Orm.QueryTable(new(models.BackendPasswordStore)).
|
||||||
|
Filter("is_deleted", 0).Filter("tid", cl.TenantId).Filter("user_id", cl.UserID).
|
||||||
|
OrderBy("-id").All(&list); e != nil && e != orm.ErrNoRows {
|
||||||
|
passwordReply(&c.Controller, 500, "查询失败: "+e.Error(), nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
res := make([]PasswordStoreItemDTO, 0, len(list))
|
||||||
|
for _, row := range list {
|
||||||
|
res = append(res, PasswordStoreItemDTO{
|
||||||
|
ID: row.ID,
|
||||||
|
Tid: row.Tid,
|
||||||
|
Platform: row.Platform,
|
||||||
|
URL: row.URL,
|
||||||
|
Accounts: accountsFromJSON(row.Accounts),
|
||||||
|
Remark: row.Remark,
|
||||||
|
UserID: row.UserID,
|
||||||
|
CreateTime: row.CreateTime,
|
||||||
|
UpdateTime: row.UpdateTime,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
passwordReply(&c.Controller, 200, "success", map[string]interface{}{"list": res, "total": len(res)})
|
||||||
|
}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ type adminUserDTO struct {
|
|||||||
Email *string `json:"email"`
|
Email *string `json:"email"`
|
||||||
Qq *string `json:"qq"`
|
Qq *string `json:"qq"`
|
||||||
Sex uint8 `json:"sex"`
|
Sex uint8 `json:"sex"`
|
||||||
|
Birth *string `json:"birth"`
|
||||||
Avatar *string `json:"avatar"`
|
Avatar *string `json:"avatar"`
|
||||||
Rid uint64 `json:"rid"`
|
Rid uint64 `json:"rid"`
|
||||||
LoginCount uint64 `json:"login_count"`
|
LoginCount uint64 `json:"login_count"`
|
||||||
@@ -34,6 +35,18 @@ type adminUserDTO struct {
|
|||||||
UpdateTime *string `json:"update_time"`
|
UpdateTime *string `json:"update_time"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// formatPlatformBirth 空串统一返回 nil,避免前端拿到空字符串
|
||||||
|
func formatPlatformBirth(birth *string) *string {
|
||||||
|
if birth == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
s := strings.TrimSpace(*birth)
|
||||||
|
if s == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &s
|
||||||
|
}
|
||||||
|
|
||||||
func toAdminUserDTO(u models.AdminUser) adminUserDTO {
|
func toAdminUserDTO(u models.AdminUser) adminUserDTO {
|
||||||
var updateTime *string
|
var updateTime *string
|
||||||
if u.UpdateTime != nil {
|
if u.UpdateTime != nil {
|
||||||
@@ -48,6 +61,7 @@ func toAdminUserDTO(u models.AdminUser) adminUserDTO {
|
|||||||
Email: u.Email,
|
Email: u.Email,
|
||||||
Qq: u.Qq,
|
Qq: u.Qq,
|
||||||
Sex: u.Sex,
|
Sex: u.Sex,
|
||||||
|
Birth: formatPlatformBirth(u.Birth),
|
||||||
Avatar: u.Avatar,
|
Avatar: u.Avatar,
|
||||||
Rid: u.RoleID,
|
Rid: u.RoleID,
|
||||||
LoginCount: u.LoginCount,
|
LoginCount: u.LoginCount,
|
||||||
@@ -111,6 +125,7 @@ type adminAddUserPayload struct {
|
|||||||
Email *string `json:"email"`
|
Email *string `json:"email"`
|
||||||
Qq *string `json:"qq"`
|
Qq *string `json:"qq"`
|
||||||
Sex *uint8 `json:"sex"`
|
Sex *uint8 `json:"sex"`
|
||||||
|
Birth *string `json:"birth"`
|
||||||
Avatar *string `json:"avatar"`
|
Avatar *string `json:"avatar"`
|
||||||
Rid *uint64 `json:"rid"`
|
Rid *uint64 `json:"rid"`
|
||||||
Status *uint8 `json:"status"`
|
Status *uint8 `json:"status"`
|
||||||
@@ -152,7 +167,7 @@ func (c *PlatformAdminUserController) AddUser() {
|
|||||||
roleID = *p.Rid
|
roleID = *p.Rid
|
||||||
}
|
}
|
||||||
|
|
||||||
id, err := services.CreateAdminUser(p.Account, p.Password, p.Name, p.Phone, p.Email, p.Qq, p.Avatar, sex, roleID, status)
|
id, err := services.CreateAdminUser(p.Account, p.Password, p.Name, p.Phone, p.Email, p.Qq, p.Avatar, formatPlatformBirth(p.Birth), sex, roleID, status)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.Data["json"] = map[string]interface{}{"code": 500, "msg": "添加失败"}
|
c.Data["json"] = map[string]interface{}{"code": 500, "msg": "添加失败"}
|
||||||
_ = c.ServeJSON()
|
_ = c.ServeJSON()
|
||||||
@@ -175,6 +190,7 @@ type editUserPayload struct {
|
|||||||
Email *string `json:"email"`
|
Email *string `json:"email"`
|
||||||
Qq *string `json:"qq"`
|
Qq *string `json:"qq"`
|
||||||
Sex *uint8 `json:"sex"`
|
Sex *uint8 `json:"sex"`
|
||||||
|
Birth *string `json:"birth"`
|
||||||
Avatar *string `json:"avatar"`
|
Avatar *string `json:"avatar"`
|
||||||
Rid *uint64 `json:"rid"`
|
Rid *uint64 `json:"rid"`
|
||||||
Status *uint8 `json:"status"`
|
Status *uint8 `json:"status"`
|
||||||
@@ -220,6 +236,13 @@ func (c *PlatformAdminUserController) EditUser() {
|
|||||||
if p.Sex != nil {
|
if p.Sex != nil {
|
||||||
fields["sex"] = *p.Sex
|
fields["sex"] = *p.Sex
|
||||||
}
|
}
|
||||||
|
if p.Birth != nil {
|
||||||
|
if birth := formatPlatformBirth(p.Birth); birth != nil {
|
||||||
|
fields["birth"] = *birth
|
||||||
|
} else {
|
||||||
|
fields["birth"] = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
if p.Avatar != nil {
|
if p.Avatar != nil {
|
||||||
fields["avatar"] = *p.Avatar
|
fields["avatar"] = *p.Avatar
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ func (c *PlatformAuthController) LoginPlatform() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 控制器只做 HTTP 解析与响应编排,业务逻辑放 services 层
|
// 控制器只做 HTTP 解析与响应编排,业务逻辑放 services 层
|
||||||
token, loginUser, err := services.PlatformAdminLogin(req.Account, req.Password)
|
token, loginUser, err := services.PlatformAdminLogin(req.Account, req.Password, c.Ctx.Input.IP())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
RecordLoginLog(nil, 0, req.Account, "", "", "password", 0, err.Error(), c.Ctx.Input.IP(), c.Ctx.Request.UserAgent())
|
RecordLoginLog(nil, 0, req.Account, "", "", "password", 0, err.Error(), c.Ctx.Input.IP(), c.Ctx.Request.UserAgent())
|
||||||
c.Data["json"] = map[string]interface{}{
|
c.Data["json"] = map[string]interface{}{
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
package controllers
|
package controllers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -106,6 +108,7 @@ func (c *PlatformWechatMpController) GetConfig() {
|
|||||||
data["encrypt_mode"] = cfg.EncryptMode
|
data["encrypt_mode"] = cfg.EncryptMode
|
||||||
data["template_id"] = cfg.TemplateID
|
data["template_id"] = cfg.TemplateID
|
||||||
data["remark"] = cfg.Remark
|
data["remark"] = cfg.Remark
|
||||||
|
data["follow_qrcode"] = cfg.FollowQrcode
|
||||||
data["configured"] = cfg.AppID != ""
|
data["configured"] = cfg.AppID != ""
|
||||||
if cfg.Verified {
|
if cfg.Verified {
|
||||||
data["verified"] = 1
|
data["verified"] = 1
|
||||||
@@ -190,7 +193,8 @@ func (c *PlatformWechatMpController) TestConnection() {
|
|||||||
// ============================ 绑定(当前平台用户) ============================
|
// ============================ 绑定(当前平台用户) ============================
|
||||||
|
|
||||||
// BindStart POST /platform/wechatMp/bind/start
|
// BindStart POST /platform/wechatMp/bind/start
|
||||||
// 生成带参二维码;用户扫码关注后公众号将被动回复验证码
|
// 未认证公众号无法生成带参二维码(48001):不再下发二维码,
|
||||||
|
// 返回绑定指引(公众号内发送「验证码」取码 → 页面填码 → bind/confirm 核销)。
|
||||||
func (c *PlatformWechatMpController) BindStart() {
|
func (c *PlatformWechatMpController) BindStart() {
|
||||||
claims, err := c.claims()
|
claims, err := c.claims()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -202,18 +206,137 @@ func (c *PlatformWechatMpController) BindStart() {
|
|||||||
c.jsonErr(401, 401, "未获取到用户信息")
|
c.jsonErr(401, 401, "未获取到用户信息")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
scene, _, qrURL, expireAt, err := wechatmp.CreateVerifyCode(models.WechatBindTypePlatformUser, uid, 0, 0)
|
if _, err := wechatmp.LoadEnabledConfig(); err != nil {
|
||||||
if err != nil {
|
c.jsonErr(400, 400, "公众号未启用:"+err.Error())
|
||||||
c.jsonErr(400, 400, "生成二维码失败:"+err.Error())
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.ok(map[string]interface{}{
|
c.ok(map[string]interface{}{
|
||||||
"scene": scene,
|
"mode": "mp_message",
|
||||||
"qrcode_url": qrURL,
|
"guides": wechatmp.BindGuides(),
|
||||||
"expire_at": expireAt,
|
"follow_qrcode": wechatmp.FollowQrcodeURL(),
|
||||||
"expires_in": int(time.Until(expireAt).Seconds()),
|
"expires_in": int(wechatmp.DefaultVerifyTTL.Seconds()),
|
||||||
"callback_url": wechatmp.CallbackURL(),
|
"callback_url": wechatmp.CallbackURL(),
|
||||||
}, "生成成功")
|
}, "请按指引完成绑定")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================ 自定义菜单 ============================
|
||||||
|
|
||||||
|
// PublishMenu POST /platform/wechatMp/menu/publish
|
||||||
|
// 通过接口在公众号底部发布「扫码登录」菜单(点击 → 自动回复确认登录链接)。
|
||||||
|
// 需认证公众号;未认证(48001)时提示改用「公众号内发送登录」路径。
|
||||||
|
func (c *PlatformWechatMpController) PublishMenu() {
|
||||||
|
if _, err := c.claims(); err != nil {
|
||||||
|
c.jsonErr(401, 401, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cfg, err := wechatmp.LoadEnabledConfig()
|
||||||
|
if err != nil {
|
||||||
|
c.jsonErr(400, 400, "公众号未启用:"+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
res, merr := wechatmp.PublishLoginMenu(cfg)
|
||||||
|
if merr != nil {
|
||||||
|
msg := "发布菜单失败:" + merr.Error()
|
||||||
|
if hint, ok := wechatmp.ExplainError(merr); ok {
|
||||||
|
msg += ";" + hint
|
||||||
|
}
|
||||||
|
c.jsonErr(400, 400, msg)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 微信客户端有菜单缓存:发布后需重新关注或等待(最长 24 小时)才可见
|
||||||
|
msg := "菜单已发布:微信客户端有缓存,请重新关注公众号或等待刷新(最长 24 小时)后可见「扫码登录」"
|
||||||
|
if res.AlreadyExists {
|
||||||
|
msg = "菜单中已存在「扫码登录」入口,未重复发布;若底部未显示,请等待客户端刷新或重新关注"
|
||||||
|
}
|
||||||
|
if res.ConditionalMenuCount > 0 {
|
||||||
|
msg += fmt.Sprintf(";检测到个性化菜单 %d 条,个性化菜单会覆盖默认菜单,请到公众号后台确认",
|
||||||
|
res.ConditionalMenuCount)
|
||||||
|
}
|
||||||
|
c.ok(map[string]interface{}{
|
||||||
|
"menu": res.Buttons,
|
||||||
|
"already_exists": res.AlreadyExists,
|
||||||
|
"conditional_menu_count": res.ConditionalMenuCount,
|
||||||
|
}, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================ 关注二维码 ============================
|
||||||
|
|
||||||
|
// 二维码图片限制:2MB,常见图片格式
|
||||||
|
const followQrcodeMaxBytes = 2 * 1024 * 1024
|
||||||
|
|
||||||
|
var followQrcodeAllowedExts = map[string]bool{
|
||||||
|
"jpg": true, "jpeg": true, "png": true, "gif": true, "webp": true, "bmp": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// saveFollowQrcode 校验图片并写入配置表(data URL 存储),返回 data URL
|
||||||
|
func (c *PlatformWechatMpController) saveFollowQrcode() (string, error) {
|
||||||
|
if err := c.Ctx.Request.ParseMultipartForm(followQrcodeMaxBytes); err != nil {
|
||||||
|
return "", fmt.Errorf("解析上传失败: %w", err)
|
||||||
|
}
|
||||||
|
fh, header, err := c.GetFile("file")
|
||||||
|
if err != nil || fh == nil {
|
||||||
|
return "", fmt.Errorf("请选择要上传的二维码图片")
|
||||||
|
}
|
||||||
|
defer fh.Close()
|
||||||
|
if header != nil && header.Size > followQrcodeMaxBytes {
|
||||||
|
return "", fmt.Errorf("图片大小不能超过 2MB")
|
||||||
|
}
|
||||||
|
ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(header.Filename), "."))
|
||||||
|
if !followQrcodeAllowedExts[ext] {
|
||||||
|
return "", fmt.Errorf("仅支持 jpg / png / gif / webp / bmp 格式")
|
||||||
|
}
|
||||||
|
raw, err := io.ReadAll(fh)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("读取图片失败: %w", err)
|
||||||
|
}
|
||||||
|
if len(raw) == 0 {
|
||||||
|
return "", fmt.Errorf("图片内容为空")
|
||||||
|
}
|
||||||
|
mime := "image/png"
|
||||||
|
switch ext {
|
||||||
|
case "jpg", "jpeg":
|
||||||
|
mime = "image/jpeg"
|
||||||
|
case "gif":
|
||||||
|
mime = "image/gif"
|
||||||
|
case "webp":
|
||||||
|
mime = "image/webp"
|
||||||
|
case "bmp":
|
||||||
|
mime = "image/bmp"
|
||||||
|
}
|
||||||
|
return "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(raw), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UploadFollowQrcode POST /platform/wechatMp/followQrcode
|
||||||
|
// 上传公众号「关注二维码」图片(普通二维码,用户扫码进入公众号会话)
|
||||||
|
func (c *PlatformWechatMpController) UploadFollowQrcode() {
|
||||||
|
if _, err := c.claims(); err != nil {
|
||||||
|
c.jsonErr(401, 401, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dataURL, err := c.saveFollowQrcode()
|
||||||
|
if err != nil {
|
||||||
|
c.jsonErr(400, 400, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := wechatmp.SaveFollowQrcode(dataURL); err != nil {
|
||||||
|
c.jsonErr(500, 500, "保存失败:"+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.ok(map[string]interface{}{"follow_qrcode": dataURL}, "上传成功")
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteFollowQrcode POST /platform/wechatMp/followQrcode/delete
|
||||||
|
func (c *PlatformWechatMpController) DeleteFollowQrcode() {
|
||||||
|
if _, err := c.claims(); err != nil {
|
||||||
|
c.jsonErr(401, 401, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := wechatmp.SaveFollowQrcode(""); err != nil {
|
||||||
|
c.jsonErr(500, 500, "删除失败:"+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.ok(nil, "已删除")
|
||||||
}
|
}
|
||||||
|
|
||||||
// BindStatus GET /platform/wechatMp/bind/status?scene=xxx
|
// BindStatus GET /platform/wechatMp/bind/status?scene=xxx
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
-- 平台管理员用户表补充生日字段(yz_system_admin_user)
|
||||||
|
-- 用途:/platform/editUser/:id 支持 birth,/platform/getUserInfo/:id 返回 birth
|
||||||
|
ALTER TABLE `yz_system_admin_user`
|
||||||
|
ADD COLUMN `birth` varchar(20) DEFAULT NULL COMMENT '生日' AFTER `sex`;
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-- 服务号配置表补充「关注二维码」字段(未认证公众号无带参二维码,
|
||||||
|
-- 平台端上传普通关注二维码图片,绑定页展示供用户扫码关注后发送「验证码」绑定)
|
||||||
|
-- 存储格式:data URL(data:image/png;base64,...),前端可直接作为 <img> 源
|
||||||
|
ALTER TABLE `yz_platform_wechat_mp_config`
|
||||||
|
ADD COLUMN `follow_qrcode` longtext NULL COMMENT '公众号关注二维码图片(data URL)' AFTER `remark`;
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
-- 微信公众号一次性登录令牌(未认证公众号无法使用网页授权,
|
||||||
|
-- 用户在公众号发送「登录」→ 回复一次性链接 → 前端用令牌换取正式登录态)
|
||||||
|
CREATE TABLE IF NOT EXISTS `yz_platform_wechat_mp_login_token` (
|
||||||
|
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
`token` VARCHAR(64) NOT NULL COMMENT '一次性登录令牌',
|
||||||
|
`openid` VARCHAR(64) NOT NULL COMMENT '微信 openid',
|
||||||
|
`bind_type` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '绑定对象类型 platform_user/tenant_user',
|
||||||
|
`bind_id` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '绑定的用户 ID',
|
||||||
|
`bind_tid` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '绑定的租户 ID',
|
||||||
|
`expire_at` DATETIME NOT NULL COMMENT '过期时间',
|
||||||
|
`used_at` DATETIME DEFAULT NULL COMMENT '核销时间(一次性)',
|
||||||
|
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uk_token` (`token`),
|
||||||
|
KEY `idx_openid` (`openid`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='微信公众号一次性登录令牌';
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
-- 微信公众号扫码登录会话(未认证公众号无带参二维码,用「公众号二维码 + 6 位登录码」替代:
|
||||||
|
-- PC 端展示二维码与登录码 → 用户扫码进入公众号会话发送登录码 → 服务号回复确认登录链接 →
|
||||||
|
-- 点击链接(/mp-login 兑换)后 PC 端轮询本表拿到确认状态并签发令牌自动登录)
|
||||||
|
CREATE TABLE IF NOT EXISTS `yz_platform_wechat_mp_scan_login` (
|
||||||
|
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
`scene` VARCHAR(64) NOT NULL COMMENT 'PC 登录会话标识',
|
||||||
|
`code` VARCHAR(8) NOT NULL COMMENT '6 位扫码登录码',
|
||||||
|
`openid` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '确认用户 openid',
|
||||||
|
`status` TINYINT NOT NULL DEFAULT 0 COMMENT '0待确认 1已确认 2已过期 3令牌已领取',
|
||||||
|
`login_token` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '确认链接携带的一次性登录令牌',
|
||||||
|
`bind_type` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '绑定对象类型',
|
||||||
|
`bind_id` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '绑定用户 ID',
|
||||||
|
`bind_tid` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '绑定租户 ID',
|
||||||
|
`expire_at` DATETIME NOT NULL COMMENT '过期时间',
|
||||||
|
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
`update_time` DATETIME DEFAULT NULL,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uk_scene` (`scene`),
|
||||||
|
KEY `idx_code` (`code`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='微信公众号扫码登录会话';
|
||||||
@@ -12,6 +12,7 @@ type AdminUser struct {
|
|||||||
Email *string `orm:"column(email);size(255);null" json:"email"`
|
Email *string `orm:"column(email);size(255);null" json:"email"`
|
||||||
Qq *string `orm:"column(qq);size(16);null" json:"qq"`
|
Qq *string `orm:"column(qq);size(16);null" json:"qq"`
|
||||||
Sex uint8 `orm:"column(sex);default(0)" json:"sex"`
|
Sex uint8 `orm:"column(sex);default(0)" json:"sex"`
|
||||||
|
Birth *string `orm:"column(birth);size(20);null" json:"birth"`
|
||||||
Avatar *string `orm:"column(avatar);size(255);null" json:"avatar"`
|
Avatar *string `orm:"column(avatar);size(255);null" json:"avatar"`
|
||||||
RoleID uint64 `orm:"column(role_id)" json:"rid"`
|
RoleID uint64 `orm:"column(role_id)" json:"rid"`
|
||||||
LoginCount uint64 `orm:"column(login_count);default(0)" json:"login_count"`
|
LoginCount uint64 `orm:"column(login_count);default(0)" json:"login_count"`
|
||||||
|
|||||||
@@ -197,6 +197,8 @@ func Init(_ string) {
|
|||||||
new(WechatMpConfig),
|
new(WechatMpConfig),
|
||||||
new(WechatMpFollower),
|
new(WechatMpFollower),
|
||||||
new(WechatMpVerifyCode),
|
new(WechatMpVerifyCode),
|
||||||
|
new(WechatMpLoginToken),
|
||||||
|
new(WechatMpScanLogin),
|
||||||
|
|
||||||
// 统一认证中心(UAC):docs/sql/create_auth_tables.sql,人工执行建表
|
// 统一认证中心(UAC):docs/sql/create_auth_tables.sql,人工执行建表
|
||||||
new(AuthIdentity),
|
new(AuthIdentity),
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ type WechatMpConfig struct {
|
|||||||
Verified int8 `orm:"column(verified);default(0)" json:"verified"`
|
Verified int8 `orm:"column(verified);default(0)" json:"verified"`
|
||||||
Enabled int8 `orm:"column(enabled);default(0)" json:"enabled"`
|
Enabled int8 `orm:"column(enabled);default(0)" json:"enabled"`
|
||||||
Remark string `orm:"column(remark);size(255)" json:"remark"`
|
Remark string `orm:"column(remark);size(255)" json:"remark"`
|
||||||
|
FollowQrcode string `orm:"column(follow_qrcode);type(text);null" json:"follow_qrcode"` // 关注二维码(data URL)
|
||||||
CreateTime time.Time `orm:"column(create_time);type(datetime);auto_now_add" json:"create_time"`
|
CreateTime time.Time `orm:"column(create_time);type(datetime);auto_now_add" json:"create_time"`
|
||||||
UpdateTime *time.Time `orm:"column(update_time);type(datetime);null" json:"update_time"`
|
UpdateTime *time.Time `orm:"column(update_time);type(datetime);null" json:"update_time"`
|
||||||
}
|
}
|
||||||
@@ -83,3 +84,46 @@ type WechatMpVerifyCode struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *WechatMpVerifyCode) TableName() string { return "yz_platform_wechat_mp_verify_code" }
|
func (m *WechatMpVerifyCode) TableName() string { return "yz_platform_wechat_mp_verify_code" }
|
||||||
|
|
||||||
|
// WechatMpLoginToken 公众号一次性登录令牌 yz_platform_wechat_mp_login_token
|
||||||
|
// 用户在公众号内发送「登录」→ 生成一次性令牌并回复登录链接 →
|
||||||
|
// 用户在微信内打开链接,前端用令牌换取正式登录态(未认证公众号无法使用网页授权,用此方案替代)。
|
||||||
|
type WechatMpLoginToken struct {
|
||||||
|
ID uint64 `orm:"column(id);pk;auto" json:"id"`
|
||||||
|
Token string `orm:"column(token);size(64)" json:"token"`
|
||||||
|
OpenID string `orm:"column(openid);size(64)" json:"openid"`
|
||||||
|
BindType string `orm:"column(bind_type);size(32)" json:"bind_type"`
|
||||||
|
BindID uint64 `orm:"column(bind_id)" json:"bind_id"`
|
||||||
|
BindTid uint64 `orm:"column(bind_tid)" json:"bind_tid"`
|
||||||
|
ExpireAt time.Time `orm:"column(expire_at);type(datetime)" json:"expire_at"`
|
||||||
|
UsedAt *time.Time `orm:"column(used_at);type(datetime);null" json:"used_at"`
|
||||||
|
CreateTime time.Time `orm:"column(create_time);type(datetime);auto_now_add" json:"create_time"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *WechatMpLoginToken) TableName() string { return "yz_platform_wechat_mp_login_token" }
|
||||||
|
|
||||||
|
// 扫码登录会话状态
|
||||||
|
const (
|
||||||
|
WechatScanLoginPending = 0 // 待确认(等待用户点击确认链接)
|
||||||
|
WechatScanLoginOK = 1 // 已确认(待 PC 端领取令牌)
|
||||||
|
WechatScanLoginExpired = 2 // 已过期
|
||||||
|
WechatScanLoginPicked = 3 // 令牌已领取
|
||||||
|
)
|
||||||
|
|
||||||
|
// WechatMpScanLogin 公众号扫码登录会话 yz_platform_wechat_mp_scan_login
|
||||||
|
type WechatMpScanLogin struct {
|
||||||
|
ID uint64 `orm:"column(id);pk;auto" json:"id"`
|
||||||
|
Scene string `orm:"column(scene);size(64)" json:"scene"`
|
||||||
|
Code string `orm:"column(code);size(8)" json:"code"`
|
||||||
|
OpenID string `orm:"column(openid);size(64);default('')" json:"openid"`
|
||||||
|
Status int8 `orm:"column(status);default(0)" json:"status"`
|
||||||
|
LoginToken string `orm:"column(login_token);size(64);default('')" json:"login_token"`
|
||||||
|
BindType string `orm:"column(bind_type);size(32);default('')" json:"bind_type"`
|
||||||
|
BindID uint64 `orm:"column(bind_id);default(0)" json:"bind_id"`
|
||||||
|
BindTid uint64 `orm:"column(bind_tid);default(0)" json:"bind_tid"`
|
||||||
|
ExpireAt time.Time `orm:"column(expire_at);type(datetime)" json:"expire_at"`
|
||||||
|
CreateTime time.Time `orm:"column(create_time);type(datetime);auto_now_add" json:"create_time"`
|
||||||
|
UpdateTime *time.Time `orm:"column(update_time);type(datetime);null" json:"update_time"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *WechatMpScanLogin) TableName() string { return "yz_platform_wechat_mp_scan_login" }
|
||||||
|
|||||||
@@ -44,4 +44,8 @@ func Register() {
|
|||||||
beego.Router("/auth/sessions", &authctl.AuthLoginController{}, "get:Sessions")
|
beego.Router("/auth/sessions", &authctl.AuthLoginController{}, "get:Sessions")
|
||||||
beego.Router("/auth/sessions/kick", &authctl.AuthLoginController{}, "post:KickSession")
|
beego.Router("/auth/sessions/kick", &authctl.AuthLoginController{}, "post:KickSession")
|
||||||
beego.Router("/auth/verify-config", &authctl.AuthLoginController{}, "get:VerifyConfig")
|
beego.Router("/auth/verify-config", &authctl.AuthLoginController{}, "get:VerifyConfig")
|
||||||
|
|
||||||
|
// 微信扫码登录(未认证公众号:服务号二维码 + 登录码 + 公众号内确认链接)
|
||||||
|
beego.Router("/auth/wechat/scan/start", &authctl.AuthLoginController{}, "post:WechatScanStart")
|
||||||
|
beego.Router("/auth/wechat/scan/status", &authctl.AuthLoginController{}, "get:WechatScanStatus")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -261,6 +261,8 @@ func RegisterAuthRoutes() {
|
|||||||
beego.Router("/backend/passwordStore/create", &controllers.BackendPasswordStoreController{}, "post:Create")
|
beego.Router("/backend/passwordStore/create", &controllers.BackendPasswordStoreController{}, "post:Create")
|
||||||
beego.Router("/backend/passwordStore/update/:id", &controllers.BackendPasswordStoreController{}, "post:Update")
|
beego.Router("/backend/passwordStore/update/:id", &controllers.BackendPasswordStoreController{}, "post:Update")
|
||||||
beego.Router("/backend/passwordStore/delete/:id", &controllers.BackendPasswordStoreController{}, "delete:Delete")
|
beego.Router("/backend/passwordStore/delete/:id", &controllers.BackendPasswordStoreController{}, "delete:Delete")
|
||||||
|
beego.Router("/backend/passwordStore/import", &controllers.BackendPasswordStoreController{}, "post:Import")
|
||||||
|
beego.Router("/backend/passwordStore/export", &controllers.BackendPasswordStoreController{}, "get:Export")
|
||||||
|
|
||||||
// 记事本管理
|
// 记事本管理
|
||||||
beego.Router("/backend/notebook/list", &controllers.BackendNotebookController{}, "get:List")
|
beego.Router("/backend/notebook/list", &controllers.BackendNotebookController{}, "get:List")
|
||||||
|
|||||||
@@ -340,6 +340,8 @@ func Register() {
|
|||||||
beego.Router("/platform/passwordStore/create", &controllers.PlatformPasswordStoreController{}, "post:Create")
|
beego.Router("/platform/passwordStore/create", &controllers.PlatformPasswordStoreController{}, "post:Create")
|
||||||
beego.Router("/platform/passwordStore/update/:id", &controllers.PlatformPasswordStoreController{}, "post:Update")
|
beego.Router("/platform/passwordStore/update/:id", &controllers.PlatformPasswordStoreController{}, "post:Update")
|
||||||
beego.Router("/platform/passwordStore/delete/:id", &controllers.PlatformPasswordStoreController{}, "delete:Delete")
|
beego.Router("/platform/passwordStore/delete/:id", &controllers.PlatformPasswordStoreController{}, "delete:Delete")
|
||||||
|
beego.Router("/platform/passwordStore/import", &controllers.PlatformPasswordStoreController{}, "post:Import")
|
||||||
|
beego.Router("/platform/passwordStore/export", &controllers.PlatformPasswordStoreController{}, "get:Export")
|
||||||
|
|
||||||
// 智能体API管理(yz_platform_agent_api)
|
// 智能体API管理(yz_platform_agent_api)
|
||||||
// 注意:固定路径需先于 /:id 通配路径注册,避免 list/test/batchDelete 被当作 ID 解析
|
// 注意:固定路径需先于 /:id 通配路径注册,避免 list/test/batchDelete 被当作 ID 解析
|
||||||
|
|||||||
@@ -12,9 +12,16 @@ func RegisterWechatMpRoutes() {
|
|||||||
// 微信服务器回调(URL 填入公众号后台「服务器配置」,Token 与平台配置一致)
|
// 微信服务器回调(URL 填入公众号后台「服务器配置」,Token 与平台配置一致)
|
||||||
beego.Router("/api/wechat/mp/callback", &controllers.WechatMpCallbackController{}, "get:Callback;post:Callback")
|
beego.Router("/api/wechat/mp/callback", &controllers.WechatMpCallbackController{}, "get:Callback;post:Callback")
|
||||||
|
|
||||||
|
// 公众号一次性登录令牌兑换(无鉴权:令牌本身即凭证,一次性 + 短时效)
|
||||||
|
// 扫码登录的发起/轮询在统一认证中心:/auth/wechat/scan/start、/auth/wechat/scan/status
|
||||||
|
beego.Router("/api/wechat/mp/login", &controllers.WechatMpCallbackController{}, "get:Login;post:Login")
|
||||||
|
|
||||||
// 服务号配置
|
// 服务号配置
|
||||||
beego.Router("/platform/wechatMp/config", &controllers.PlatformWechatMpController{}, "get:GetConfig;post:SaveConfig")
|
beego.Router("/platform/wechatMp/config", &controllers.PlatformWechatMpController{}, "get:GetConfig;post:SaveConfig")
|
||||||
beego.Router("/platform/wechatMp/test", &controllers.PlatformWechatMpController{}, "post:TestConnection")
|
beego.Router("/platform/wechatMp/test", &controllers.PlatformWechatMpController{}, "post:TestConnection")
|
||||||
|
beego.Router("/platform/wechatMp/menu/publish", &controllers.PlatformWechatMpController{}, "post:PublishMenu")
|
||||||
|
beego.Router("/platform/wechatMp/followQrcode", &controllers.PlatformWechatMpController{}, "post:UploadFollowQrcode")
|
||||||
|
beego.Router("/platform/wechatMp/followQrcode/delete", &controllers.PlatformWechatMpController{}, "post:DeleteFollowQrcode")
|
||||||
|
|
||||||
// 绑定(平台用户)
|
// 绑定(平台用户)
|
||||||
beego.Router("/platform/wechatMp/bind/start", &controllers.PlatformWechatMpController{}, "post:BindStart")
|
beego.Router("/platform/wechatMp/bind/start", &controllers.PlatformWechatMpController{}, "post:BindStart")
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ func NormalizeAccount(s string) string {
|
|||||||
return strings.TrimSpace(s)
|
return strings.TrimSpace(s)
|
||||||
}
|
}
|
||||||
|
|
||||||
func CreateAdminUser(account, password string, name, phone, email, qq, avatar *string, sex uint8, roleID uint64, status uint8) (uint64, error) {
|
func CreateAdminUser(account, password string, name, phone, email, qq, avatar, birth *string, sex uint8, roleID uint64, status uint8) (uint64, error) {
|
||||||
hashed, err := passwordutil.Hash(password)
|
hashed, err := passwordutil.Hash(password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
@@ -24,6 +24,7 @@ func CreateAdminUser(account, password string, name, phone, email, qq, avatar *s
|
|||||||
Email: email,
|
Email: email,
|
||||||
Qq: qq,
|
Qq: qq,
|
||||||
Avatar: avatar,
|
Avatar: avatar,
|
||||||
|
Birth: birth,
|
||||||
Sex: sex,
|
Sex: sex,
|
||||||
RoleID: roleID,
|
RoleID: roleID,
|
||||||
Status: status,
|
Status: status,
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ func toPlatformLoginUser(user *models.AdminUser) *PlatformLoginUser {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// PlatformAdminLogin 平台端登录:仅校验 yz_system_admin_user,不需要租户。
|
// PlatformAdminLogin 平台端登录:仅校验 yz_system_admin_user,不需要租户。
|
||||||
func PlatformAdminLogin(account, password string) (string, *PlatformLoginUser, error) {
|
func PlatformAdminLogin(account, password, ip string) (string, *PlatformLoginUser, error) {
|
||||||
account = strings.TrimSpace(account)
|
account = strings.TrimSpace(account)
|
||||||
password = strings.TrimSpace(password)
|
password = strings.TrimSpace(password)
|
||||||
if account == "" || password == "" {
|
if account == "" || password == "" {
|
||||||
@@ -89,6 +89,14 @@ func PlatformAdminLogin(account, password string) (string, *PlatformLoginUser, e
|
|||||||
return "", nil, err
|
return "", nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 回写最后登录 IP 与登录次数(失败不影响登录)
|
||||||
|
_, _ = models.Orm.QueryTable(new(models.AdminUser)).
|
||||||
|
Filter("id", user.ID).
|
||||||
|
Update(map[string]interface{}{
|
||||||
|
"last_login_ip": ip,
|
||||||
|
"login_count": user.LoginCount + 1,
|
||||||
|
})
|
||||||
|
|
||||||
loginUser := toPlatformLoginUser(&user)
|
loginUser := toPlatformLoginUser(&user)
|
||||||
return token, loginUser, nil
|
return token, loginUser, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -398,14 +398,29 @@ func HandleInbound(cfg *Config, msg *InboundMessage) string {
|
|||||||
_, _ = UpsertSubscribe(openid, now)
|
_, _ = UpsertSubscribe(openid, now)
|
||||||
scene := strings.TrimPrefix(strings.TrimSpace(msg.EventKey), "qrscene_")
|
scene := strings.TrimPrefix(strings.TrimSpace(msg.EventKey), "qrscene_")
|
||||||
if scene != "" {
|
if scene != "" {
|
||||||
|
// 带参二维码:先看是否为扫码登录会话(自动回复确认链接)
|
||||||
|
if reply, ok := HandleScanLoginEvent(scene, openid); ok {
|
||||||
|
return reply
|
||||||
|
}
|
||||||
return handleScanScene(cfg, scene, openid)
|
return handleScanScene(cfg, scene, openid)
|
||||||
}
|
}
|
||||||
return "欢迎关注云泽平台!\n如需接收系统提醒、公告推送,请回到系统页面点击「绑定微信」,使用微信扫码获取专属验证码;已扫码但未收到时可发送「验证码」重新获取。"
|
// 普通关注:已绑定直接给「确认登录」链接,未绑定提示先绑定
|
||||||
|
return DirectLoginReply(openid)
|
||||||
case "scan":
|
case "scan":
|
||||||
return handleScanScene(cfg, strings.TrimSpace(msg.EventKey), openid)
|
scene := strings.TrimSpace(msg.EventKey)
|
||||||
|
if reply, ok := HandleScanLoginEvent(scene, openid); ok {
|
||||||
|
return reply
|
||||||
|
}
|
||||||
|
return handleScanScene(cfg, scene, openid)
|
||||||
case "unsubscribe":
|
case "unsubscribe":
|
||||||
_ = MarkUnsubscribe(openid, now)
|
_ = MarkUnsubscribe(openid, now)
|
||||||
return ""
|
return ""
|
||||||
|
case "click":
|
||||||
|
// 自定义菜单点击:键值为登录入口时直接回复确认登录链接
|
||||||
|
if isLoginMenuKey(msg.EventKey) {
|
||||||
|
return DirectLoginReply(openid)
|
||||||
|
}
|
||||||
|
return ""
|
||||||
}
|
}
|
||||||
case "text":
|
case "text":
|
||||||
return handleTextIntent(openid, msg.Content)
|
return handleTextIntent(openid, msg.Content)
|
||||||
@@ -414,11 +429,16 @@ func HandleInbound(cfg *Config, msg *InboundMessage) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// handleTextIntent 处理公众号文本消息:
|
// handleTextIntent 处理公众号文本消息:
|
||||||
|
// - 发送「登录」:已绑定用户获取一次性登录链接(见 login.go);
|
||||||
// - 发送「验证码」等关键词:返回该微信号的验证码(复用未核销会话;没有则在公众号侧
|
// - 发送「验证码」等关键词:返回该微信号的验证码(复用未核销会话;没有则在公众号侧
|
||||||
// 新建「待认领」会话),用户回系统页面输入即完成绑定,无需先扫码;
|
// 新建「待认领」会话),用户回系统页面输入即完成绑定,无需先扫码;
|
||||||
// - 其余文本:给出绑定引导。
|
// - 其余文本:给出绑定引导。
|
||||||
func handleTextIntent(openid, content string) string {
|
func handleTextIntent(openid, content string) string {
|
||||||
if !isBindKeyword(strings.TrimSpace(content)) {
|
text := strings.TrimSpace(content)
|
||||||
|
if isLoginKeyword(text) {
|
||||||
|
return DirectLoginReply(openid)
|
||||||
|
}
|
||||||
|
if !isBindKeyword(text) {
|
||||||
return textBindGuide()
|
return textBindGuide()
|
||||||
}
|
}
|
||||||
// 1) 复用该微信号未核销、未过期的会话(扫码会话或此前公众号发起的会话)
|
// 1) 复用该微信号未核销、未过期的会话(扫码会话或此前公众号发起的会话)
|
||||||
@@ -457,7 +477,17 @@ func isBindKeyword(text string) bool {
|
|||||||
|
|
||||||
// textBindGuide 非关键词文本的绑定引导
|
// textBindGuide 非关键词文本的绑定引导
|
||||||
func textBindGuide() string {
|
func textBindGuide() string {
|
||||||
return "如需绑定账号:请在系统页面点击「绑定微信」,然后任选一种方式——① 扫描页面上的二维码;② 直接在本公众号发送「验证码」。\n两种方式都会回复 6 位验证码,回到系统页面输入即可完成绑定。"
|
return "如需绑定账号:请在系统页面点击「绑定微信」,然后直接在本公众号发送「验证码」," +
|
||||||
|
"收到 6 位验证码后回到系统页面输入即可完成绑定。\n绑定后在本公众号发送「登录」可获取一次性登录链接。"
|
||||||
|
}
|
||||||
|
|
||||||
|
// BindGuides 系统页面「绑定微信」卡片的操作指引(未认证公众号无带参二维码,仅引导发码取码)
|
||||||
|
func BindGuides() []string {
|
||||||
|
return []string{
|
||||||
|
"① 用微信扫码关注本公众号(或微信搜索公众号名称进入会话)",
|
||||||
|
"② 在公众号会话中直接发送文字「验证码」",
|
||||||
|
"③ 公众号回复 6 位验证码,填入本页输入框完成绑定",
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleScanScene 扫码(关注/已关注)后的统一处理:写入验证码会话 + 回复验证码
|
// handleScanScene 扫码(关注/已关注)后的统一处理:写入验证码会话 + 回复验证码
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ type Config struct {
|
|||||||
Verified bool
|
Verified bool
|
||||||
Enabled bool
|
Enabled bool
|
||||||
Remark string
|
Remark string
|
||||||
|
|
||||||
|
FollowQrcode string // 关注二维码(data URL),绑定页展示供扫码关注
|
||||||
}
|
}
|
||||||
|
|
||||||
// ErrNotConfigured 未配置或未启用
|
// ErrNotConfigured 未配置或未启用
|
||||||
@@ -59,6 +61,7 @@ func LoadConfig() (*Config, error) {
|
|||||||
Verified: row.Verified == 1,
|
Verified: row.Verified == 1,
|
||||||
Enabled: row.Enabled == 1,
|
Enabled: row.Enabled == 1,
|
||||||
Remark: row.Remark,
|
Remark: row.Remark,
|
||||||
|
FollowQrcode: strings.TrimSpace(row.FollowQrcode),
|
||||||
}
|
}
|
||||||
if cfg.EncryptMode == "" {
|
if cfg.EncryptMode == "" {
|
||||||
cfg.EncryptMode = EncryptModePlain
|
cfg.EncryptMode = EncryptModePlain
|
||||||
@@ -171,6 +174,36 @@ func SaveConfig(in SaveInput) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SaveFollowQrcode 保存/清空关注二维码(data URL,空串表示删除)
|
||||||
|
func SaveFollowQrcode(dataURL string) error {
|
||||||
|
dataURL = strings.TrimSpace(dataURL)
|
||||||
|
now := time.Now()
|
||||||
|
var row models.WechatMpConfig
|
||||||
|
err := models.Orm.QueryTable(new(models.WechatMpConfig)).OrderBy("id").One(&row)
|
||||||
|
if err != nil {
|
||||||
|
if err == orm.ErrNoRows {
|
||||||
|
// 尚无配置行:仅存二维码,其余字段留空
|
||||||
|
row = models.WechatMpConfig{FollowQrcode: dataURL, EncryptMode: EncryptModePlain, CreateTime: now}
|
||||||
|
_, err = models.Orm.Insert(&row)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = models.Orm.QueryTable(new(models.WechatMpConfig)).
|
||||||
|
Filter("id", row.ID).
|
||||||
|
Update(map[string]interface{}{"follow_qrcode": dataURL, "update_time": now})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// FollowQrcodeURL 读取关注二维码(未配置时返回空串)
|
||||||
|
func FollowQrcodeURL() string {
|
||||||
|
cfg, err := LoadConfig()
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return cfg.FollowQrcode
|
||||||
|
}
|
||||||
|
|
||||||
// CallbackURL 微信服务器回调地址(供公众号后台「服务器配置」填写)。
|
// CallbackURL 微信服务器回调地址(供公众号后台「服务器配置」填写)。
|
||||||
// 优先读取 app.conf 的 wechat_mp_callback_base,其次回落 payment_callback_base(同为对外 API 域名)。
|
// 优先读取 app.conf 的 wechat_mp_callback_base,其次回落 payment_callback_base(同为对外 API 域名)。
|
||||||
func CallbackURL() string {
|
func CallbackURL() string {
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
package wechatmp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"server/models"
|
||||||
|
|
||||||
|
beego "github.com/beego/beego/v2/server/web"
|
||||||
|
)
|
||||||
|
|
||||||
|
// =============================================================
|
||||||
|
// 公众号微信登录(免认证方案):
|
||||||
|
// 用户在公众号内发送「登录」→ 后端校验绑定关系并生成一次性登录令牌 →
|
||||||
|
// 被动回复登录链接 → 用户在微信内打开链接 → 前端 /mp-login 页面
|
||||||
|
// 用令牌调用 /api/wechat/mp/login 换取正式登录态。
|
||||||
|
// 未认证公众号无法使用网页授权(snsapi_*),一次性链接是替代方案。
|
||||||
|
// =============================================================
|
||||||
|
|
||||||
|
// DefaultLoginTokenTTL 登录令牌默认有效期
|
||||||
|
const DefaultLoginTokenTTL = 5 * time.Minute
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrLoginTokenInvalid = errors.New("登录链接无效或已过期,请在公众号重新发送「登录」")
|
||||||
|
ErrLoginTokenUsed = errors.New("登录链接已被使用,请在公众号重新发送「登录」")
|
||||||
|
ErrLoginTokenExpired = errors.New("登录链接已过期,请在公众号重新发送「登录」")
|
||||||
|
)
|
||||||
|
|
||||||
|
func randomTokenHex(nBytes int) string {
|
||||||
|
b := make([]byte, nBytes)
|
||||||
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoginLinkURL 由一次性令牌拼出前端登录页链接。
|
||||||
|
// 页面基址读取 app.conf 的 wechat_mp_login_base(前端应用对外可访问地址,
|
||||||
|
// 如 https://back.yunzer.cn),缺省回落 wechat_mp_callback_base / payment_callback_base。
|
||||||
|
// 未配置时返回空串,由调用方给出提示。
|
||||||
|
func LoginLinkURL(token string) string {
|
||||||
|
base, _ := beego.AppConfig.String("wechat_mp_login_base")
|
||||||
|
if strings.TrimSpace(base) == "" {
|
||||||
|
base, _ = beego.AppConfig.String("wechat_mp_callback_base")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(base) == "" {
|
||||||
|
base, _ = beego.AppConfig.String("payment_callback_base")
|
||||||
|
}
|
||||||
|
base = strings.TrimRight(strings.TrimSpace(base), "/")
|
||||||
|
if base == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return base + "/#/mp-login?token=" + url.QueryEscape(token)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateLoginToken 为已绑定的粉丝生成一次性登录令牌。
|
||||||
|
func CreateLoginToken(f *models.WechatMpFollower, ttl time.Duration) (token string, expireAt time.Time, err error) {
|
||||||
|
if f == nil || strings.TrimSpace(f.BindType) == "" || f.BindID == 0 {
|
||||||
|
return "", time.Time{}, errors.New("该微信尚未绑定账号")
|
||||||
|
}
|
||||||
|
if ttl <= 0 {
|
||||||
|
ttl = DefaultLoginTokenTTL
|
||||||
|
}
|
||||||
|
token = randomTokenHex(32)
|
||||||
|
if token == "" {
|
||||||
|
return "", time.Time{}, errors.New("生成登录令牌失败")
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
expireAt = now.Add(ttl)
|
||||||
|
row := &models.WechatMpLoginToken{
|
||||||
|
Token: token,
|
||||||
|
OpenID: f.OpenID,
|
||||||
|
BindType: f.BindType,
|
||||||
|
BindID: f.BindID,
|
||||||
|
BindTid: f.BindTid,
|
||||||
|
ExpireAt: expireAt,
|
||||||
|
CreateTime: now,
|
||||||
|
}
|
||||||
|
if _, err := models.Orm.Insert(row); err != nil {
|
||||||
|
return "", time.Time{}, err
|
||||||
|
}
|
||||||
|
return token, expireAt, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConsumeLoginToken 核销一次性登录令牌(CAS 防重放),返回令牌携带的绑定信息。
|
||||||
|
func ConsumeLoginToken(token string) (*models.WechatMpLoginToken, error) {
|
||||||
|
token = strings.TrimSpace(token)
|
||||||
|
if token == "" {
|
||||||
|
return nil, ErrLoginTokenInvalid
|
||||||
|
}
|
||||||
|
var row models.WechatMpLoginToken
|
||||||
|
if err := models.Orm.QueryTable(new(models.WechatMpLoginToken)).
|
||||||
|
Filter("token", token).One(&row); err != nil {
|
||||||
|
return nil, ErrLoginTokenInvalid
|
||||||
|
}
|
||||||
|
if row.UsedAt != nil {
|
||||||
|
return nil, ErrLoginTokenUsed
|
||||||
|
}
|
||||||
|
if row.ExpireAt.Before(time.Now()) {
|
||||||
|
return nil, ErrLoginTokenExpired
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
affected, err := models.Orm.QueryTable(new(models.WechatMpLoginToken)).
|
||||||
|
Filter("id", row.ID).
|
||||||
|
Filter("used_at__isnull", true).
|
||||||
|
Update(map[string]interface{}{"used_at": now})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if affected == 0 {
|
||||||
|
return nil, ErrLoginTokenUsed
|
||||||
|
}
|
||||||
|
return &row, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// isLoginKeyword 判断文本是否为「登录」意图
|
||||||
|
func isLoginKeyword(text string) bool {
|
||||||
|
switch strings.TrimSpace(text) {
|
||||||
|
case "登录", "登陸", "login", "Login", "微信登录", "微信登陆":
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// isLoginMenuKey 判断自定义菜单点击(CLICK 事件)的 EventKey 是否为「登录」入口。
|
||||||
|
// 菜单在公众号后台手动配置即可(未认证公众号同样支持),
|
||||||
|
// 用户点菜单 → CLICK 事件 → 自动回复确认登录链接,免输入。
|
||||||
|
func isLoginMenuKey(key string) bool {
|
||||||
|
k := strings.TrimSpace(key)
|
||||||
|
if k == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
switch strings.ToLower(k) {
|
||||||
|
case "login", "登录", "扫码登录", "微信登录":
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
package wechatmp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/beego/beego/v2/core/logs"
|
||||||
|
)
|
||||||
|
|
||||||
|
// =============================================================
|
||||||
|
// 公众号自定义菜单:发布「扫码登录」菜单(点击事件 → CLICK 事件 → 自动回复
|
||||||
|
// 确认登录链接,用户不用输入任何内容)。
|
||||||
|
// 注意:菜单接口(menu/create)与带参二维码同属认证后能力,
|
||||||
|
// 未认证公众号会返回 48001,届时需改用「公众号内发送登录」路径。
|
||||||
|
// =============================================================
|
||||||
|
|
||||||
|
// LoginMenuKey 登录菜单的事件键值(与 isLoginMenuKey 保持一致)
|
||||||
|
const LoginMenuKey = "LOGIN"
|
||||||
|
|
||||||
|
type mpMenuButton struct {
|
||||||
|
Type string `json:"type,omitempty"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Key string `json:"key,omitempty"`
|
||||||
|
URL string `json:"url,omitempty"`
|
||||||
|
SubButton []mpMenuButton `json:"sub_button,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type mpMenuGetResp struct {
|
||||||
|
Menu struct {
|
||||||
|
Button []mpMenuButton `json:"button"`
|
||||||
|
} `json:"menu"`
|
||||||
|
// 个性化菜单:命中条件时会覆盖默认菜单(公众号底部看到的可能来自这里)
|
||||||
|
ConditionalMenu []map[string]interface{} `json:"conditionalmenu"`
|
||||||
|
ErrCode int `json:"errcode"`
|
||||||
|
ErrMsg string `json:"errmsg"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PublishMenuResult 发布结果(供页面展示与排查)
|
||||||
|
type PublishMenuResult struct {
|
||||||
|
Buttons []mpMenuButton `json:"button"`
|
||||||
|
AlreadyExists bool `json:"already_exists"`
|
||||||
|
ConditionalMenuCount int `json:"conditional_menu_count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type mpMenuCreateResp struct {
|
||||||
|
ErrCode int `json:"errcode"`
|
||||||
|
ErrMsg string `json:"errmsg"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PublishLoginMenu 在现有菜单基础上追加「扫码登录」菜单并发布。
|
||||||
|
// - 已存在登录菜单:不再重复发布(AlreadyExists=true);
|
||||||
|
// - 一级菜单满 3 个:返回错误提示;
|
||||||
|
// - 存在个性化菜单:一并返回条数(个性化菜单会覆盖默认菜单,需到公众号后台处理)。
|
||||||
|
//
|
||||||
|
// 注意:微信客户端有菜单缓存,发布后通常需重新关注或等待(最长 24 小时)才可见。
|
||||||
|
func PublishLoginMenu(cfg *Config) (*PublishMenuResult, error) {
|
||||||
|
token, err := GetAccessToken(cfg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1) 读取现有菜单;46003(菜单不存在)视为空菜单
|
||||||
|
var cur mpMenuGetResp
|
||||||
|
if gerr := httpGetJSON(apiBase+"/cgi-bin/menu/get?access_token="+url.QueryEscape(token), &cur); gerr != nil {
|
||||||
|
return nil, gerr
|
||||||
|
}
|
||||||
|
buttons := cur.Menu.Button
|
||||||
|
if cur.ErrCode != 0 && cur.ErrCode != 46003 {
|
||||||
|
return nil, &APIError{API: "读取自定义菜单", Code: cur.ErrCode, Msg: cur.ErrMsg}
|
||||||
|
}
|
||||||
|
logs.Info("[wechat-mp] 当前默认菜单 %d 项,个性化菜单 %d 条", len(buttons), len(cur.ConditionalMenu))
|
||||||
|
|
||||||
|
for i := range buttons {
|
||||||
|
if strings.EqualFold(strings.TrimSpace(buttons[i].Key), LoginMenuKey) {
|
||||||
|
return &PublishMenuResult{
|
||||||
|
Buttons: buttons,
|
||||||
|
AlreadyExists: true,
|
||||||
|
ConditionalMenuCount: len(cur.ConditionalMenu),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(buttons) >= 3 {
|
||||||
|
return nil, errors.New("一级菜单已达 3 个上限,请到公众号后台精简后再发布")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) 追加登录菜单并发布
|
||||||
|
buttons = append(buttons, mpMenuButton{Type: "click", Name: "扫码登录", Key: LoginMenuKey})
|
||||||
|
var created mpMenuCreateResp
|
||||||
|
if perr := httpPostJSON(apiBase+"/cgi-bin/menu/create?access_token="+url.QueryEscape(token),
|
||||||
|
map[string]interface{}{"button": buttons}, &created); perr != nil {
|
||||||
|
return nil, perr
|
||||||
|
}
|
||||||
|
if created.ErrCode != 0 {
|
||||||
|
return nil, &APIError{API: "发布自定义菜单", Code: created.ErrCode, Msg: created.ErrMsg}
|
||||||
|
}
|
||||||
|
logs.Info("[wechat-mp] 菜单发布成功,当前 %d 项", len(buttons))
|
||||||
|
|
||||||
|
return &PublishMenuResult{
|
||||||
|
Buttons: buttons,
|
||||||
|
ConditionalMenuCount: len(cur.ConditionalMenu),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
package wechatmp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"server/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// =============================================================
|
||||||
|
// 公众号扫码登录(带参二维码方案):
|
||||||
|
// PC 端 /auth/login 扫码面板发起会话并轮询 → 展示带参二维码(scene=会话标识)→
|
||||||
|
// 用户微信扫码:已关注触发 SCAN 事件 / 未关注关注后触发 subscribe 事件,
|
||||||
|
// 服务号【自动】回复「确认登录」一次性链接(无需用户输入任何内容)→
|
||||||
|
// 用户点击链接(/mp-login 兑换,链接携带 scene)→ 精确认领 PC 会话 →
|
||||||
|
// PC 端轮询到确认后建立 UAC 会话自动登录。全程无极验 / 无登录码。
|
||||||
|
// 注:带参二维码需认证公众号;未认证(48001)时回退普通关注二维码,
|
||||||
|
// 用户在公众号发送「登录」换取确认链接。
|
||||||
|
// =============================================================
|
||||||
|
|
||||||
|
// ScanLoginTTL 扫码登录会话有效期
|
||||||
|
const ScanLoginTTL = 5 * time.Minute
|
||||||
|
|
||||||
|
var ErrScanLoginInvalid = errors.New("扫码登录会话不存在或已过期,请刷新二维码重试")
|
||||||
|
|
||||||
|
// StartScanLogin 发起 PC 扫码登录会话,并生成带参二维码。
|
||||||
|
// 带参二维码生成失败(未认证公众号 48001 等)时回退普通关注二维码:
|
||||||
|
// qrURL 为空、notice 返回回退原因,前端需明确提示用户走「发送登录」路径。
|
||||||
|
func StartScanLogin() (scene, qrURL, notice string, expireAt time.Time, err error) {
|
||||||
|
scene = randomScene()
|
||||||
|
now := time.Now()
|
||||||
|
expireAt = now.Add(ScanLoginTTL)
|
||||||
|
row := &models.WechatMpScanLogin{
|
||||||
|
Scene: scene,
|
||||||
|
Code: "-",
|
||||||
|
ExpireAt: expireAt,
|
||||||
|
CreateTime: now,
|
||||||
|
}
|
||||||
|
if _, err = models.Orm.Insert(row); err != nil {
|
||||||
|
return "", "", "", time.Time{}, err
|
||||||
|
}
|
||||||
|
cfg, cerr := LoadEnabledConfig()
|
||||||
|
if cerr == nil {
|
||||||
|
qr, qerr := CreateQRCode(cfg, scene, int(ScanLoginTTL.Seconds())+120)
|
||||||
|
if qerr == nil {
|
||||||
|
return scene, qr, "", expireAt, nil
|
||||||
|
}
|
||||||
|
var apiErr *APIError
|
||||||
|
if errors.As(qerr, &apiErr) {
|
||||||
|
notice = "公众号未返回带参二维码(" + apiErr.Error() + ")"
|
||||||
|
if hint := apiErr.Explain(); hint != "" {
|
||||||
|
notice += ":" + hint
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
notice = "生成带参二维码失败:" + qerr.Error()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
notice = "公众号未配置或未启用,无法生成带参二维码"
|
||||||
|
}
|
||||||
|
notice += "。已回退普通二维码:已关注的用户扫码不会触发消息,请在公众号内发送「登录」获取确认链接。"
|
||||||
|
return scene, "", notice, expireAt, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetScanLogin 查询扫码登录会话(过期时惰性标记)
|
||||||
|
func GetScanLogin(scene string) (*models.WechatMpScanLogin, error) {
|
||||||
|
scene = strings.TrimSpace(scene)
|
||||||
|
if scene == "" {
|
||||||
|
return nil, ErrScanLoginInvalid
|
||||||
|
}
|
||||||
|
var row models.WechatMpScanLogin
|
||||||
|
if err := models.Orm.QueryTable(new(models.WechatMpScanLogin)).
|
||||||
|
Filter("scene", scene).One(&row); err != nil {
|
||||||
|
return nil, ErrScanLoginInvalid
|
||||||
|
}
|
||||||
|
if row.Status == models.WechatScanLoginPending && row.ExpireAt.Before(time.Now()) {
|
||||||
|
now := time.Now()
|
||||||
|
_, _ = models.Orm.QueryTable(new(models.WechatMpScanLogin)).
|
||||||
|
Filter("id", row.ID).
|
||||||
|
Update(map[string]interface{}{"status": models.WechatScanLoginExpired, "update_time": now})
|
||||||
|
row.Status = models.WechatScanLoginExpired
|
||||||
|
}
|
||||||
|
return &row, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleScanLoginEvent 带参二维码扫码事件(SCAN / subscribe 带 qrscene):
|
||||||
|
// scene 属于等待中的扫码登录会话时,自动回复「确认登录」链接(已绑定)
|
||||||
|
// 或绑定提示(未绑定),并返回 true;不属于登录会话返回 false 交由绑定流程处理。
|
||||||
|
func HandleScanLoginEvent(scene, openid string) (string, bool) {
|
||||||
|
scene = strings.TrimSpace(scene)
|
||||||
|
if scene == "" {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
row, err := GetScanLogin(scene)
|
||||||
|
if err != nil || row.Status != models.WechatScanLoginPending {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
f, ferr := GetFollowerByOpenID(openid)
|
||||||
|
if ferr != nil || f == nil || strings.TrimSpace(f.BindType) == "" || f.BindID == 0 {
|
||||||
|
return "当前微信尚未绑定账号,无法扫码登录:请先在系统页面完成「绑定微信」。", true
|
||||||
|
}
|
||||||
|
if f.BindType != models.WechatBindTypeTenantUser {
|
||||||
|
return "该微信绑定的是平台账号,暂不支持扫码登录。", true
|
||||||
|
}
|
||||||
|
|
||||||
|
token, _, terr := CreateLoginToken(f, ScanLoginTTL)
|
||||||
|
if terr != nil {
|
||||||
|
return "系统繁忙,请稍后重新扫码。", true
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
if _, uerr := models.Orm.QueryTable(new(models.WechatMpScanLogin)).
|
||||||
|
Filter("id", row.ID).
|
||||||
|
Update(map[string]interface{}{
|
||||||
|
"openid": openid,
|
||||||
|
"login_token": token,
|
||||||
|
"update_time": now,
|
||||||
|
}); uerr != nil {
|
||||||
|
return "系统繁忙,请稍后重新扫码。", true
|
||||||
|
}
|
||||||
|
|
||||||
|
link := LoginLinkURL(token) + "&scene=" + url.QueryEscape(scene)
|
||||||
|
minutes := 1
|
||||||
|
if m := int(time.Until(now.Add(ScanLoginTTL)).Minutes()); m > 1 {
|
||||||
|
minutes = m
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("【扫码登录确认】\n点击以下链接确认登录(%d 分钟内有效,仅可使用一次):\n%s\n\n确认后您的电脑将自动登录。如非本人操作,请忽略本条消息。", minutes, link), true
|
||||||
|
}
|
||||||
|
|
||||||
|
// DirectLoginReply 判断该微信的绑定状态并返回对应回复:
|
||||||
|
// - 已绑定:直接给出「确认登录」一次性链接(点击后 web 直接登录);
|
||||||
|
// - 未绑定:提示先去系统页面完成绑定。
|
||||||
|
//
|
||||||
|
// 用于普通关注(无 scene)与公众号内发送「登录」的兜底路径。
|
||||||
|
func DirectLoginReply(openid string) string {
|
||||||
|
f, err := GetFollowerByOpenID(openid)
|
||||||
|
if err != nil || f == nil || strings.TrimSpace(f.BindType) == "" || f.BindID == 0 {
|
||||||
|
return "当前微信尚未绑定账号:请先在系统页面「绑定微信」处完成绑定(关注后发送「验证码」,再到页面填码)。"
|
||||||
|
}
|
||||||
|
if f.BindType != models.WechatBindTypeTenantUser {
|
||||||
|
return "该微信绑定的是平台账号,暂不支持扫码登录。"
|
||||||
|
}
|
||||||
|
token, expireAt, terr := CreateLoginToken(f, ScanLoginTTL)
|
||||||
|
if terr != nil {
|
||||||
|
return "系统繁忙,请稍后重新发送「登录」。"
|
||||||
|
}
|
||||||
|
link := LoginLinkURL(token)
|
||||||
|
if link == "" {
|
||||||
|
return "管理员尚未配置登录页地址(app.conf 的 wechat_mp_login_base),请联系管理员。"
|
||||||
|
}
|
||||||
|
minutes := 1
|
||||||
|
if m := int(time.Until(expireAt).Minutes()); m > 1 {
|
||||||
|
minutes = m
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("【确认登录】\n点击以下链接直接登录(%d 分钟内有效,仅可使用一次):\n%s\n\n如非本人操作,请忽略本条消息。", minutes, link)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConfirmScanLoginByToken 兑换确认链接时精确认领 PC 扫码会话(scene + 令牌双重匹配,CAS)
|
||||||
|
func ConfirmScanLoginByToken(scene, loginToken, bindType string, bindID, bindTid uint64) {
|
||||||
|
scene = strings.TrimSpace(scene)
|
||||||
|
if scene == "" || strings.TrimSpace(loginToken) == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
_, _ = models.Orm.QueryTable(new(models.WechatMpScanLogin)).
|
||||||
|
Filter("scene", scene).
|
||||||
|
Filter("login_token", strings.TrimSpace(loginToken)).
|
||||||
|
Filter("status", models.WechatScanLoginPending).
|
||||||
|
Update(map[string]interface{}{
|
||||||
|
"status": models.WechatScanLoginOK,
|
||||||
|
"bind_type": bindType,
|
||||||
|
"bind_id": bindID,
|
||||||
|
"bind_tid": bindTid,
|
||||||
|
"update_time": now,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConfirmOldestPendingScanLogin 无 scene 的兜底认领(「登录」文本路径):
|
||||||
|
// 认领最早一条待确认 PC 会话;没有等待中的会话时静默跳过。
|
||||||
|
func ConfirmOldestPendingScanLogin(bindType string, bindID, bindTid uint64) {
|
||||||
|
now := time.Now()
|
||||||
|
var row models.WechatMpScanLogin
|
||||||
|
err := models.Orm.QueryTable(new(models.WechatMpScanLogin)).
|
||||||
|
Filter("status", models.WechatScanLoginPending).
|
||||||
|
Filter("expire_at__gt", now).
|
||||||
|
OrderBy("id").
|
||||||
|
One(&row)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
models.Orm.QueryTable(new(models.WechatMpScanLogin)).
|
||||||
|
Filter("id", row.ID).
|
||||||
|
Filter("status", models.WechatScanLoginPending).
|
||||||
|
Update(map[string]interface{}{
|
||||||
|
"status": models.WechatScanLoginOK,
|
||||||
|
"bind_type": bindType,
|
||||||
|
"bind_id": bindID,
|
||||||
|
"bind_tid": bindTid,
|
||||||
|
"update_time": now,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// PickScanLogin PC 端领取确认结果:CAS 把「已确认」置为「已领取」,防止重复领取
|
||||||
|
func PickScanLogin(scene string) (*models.WechatMpScanLogin, error) {
|
||||||
|
row, err := GetScanLogin(scene)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if row.Status == models.WechatScanLoginPending {
|
||||||
|
return nil, errors.New("等待微信确认:请在公众号内点击「确认登录」链接")
|
||||||
|
}
|
||||||
|
if row.Status == models.WechatScanLoginExpired {
|
||||||
|
return nil, ErrScanLoginInvalid
|
||||||
|
}
|
||||||
|
if row.Status != models.WechatScanLoginOK || row.BindID == 0 {
|
||||||
|
return nil, errors.New("该二维码已完成登录,请刷新后重新扫码")
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
affected, err := models.Orm.QueryTable(new(models.WechatMpScanLogin)).
|
||||||
|
Filter("id", row.ID).
|
||||||
|
Filter("status", models.WechatScanLoginOK).
|
||||||
|
Update(map[string]interface{}{"status": models.WechatScanLoginPicked, "update_time": now})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if affected == 0 {
|
||||||
|
return nil, errors.New("该二维码已完成登录,请刷新后重新扫码")
|
||||||
|
}
|
||||||
|
return row, nil
|
||||||
|
}
|
||||||
+132
-2
@@ -324,6 +324,26 @@
|
|||||||
|
|
||||||
.back { text-align: center; margin-top: 22px; font-size: 13px; }
|
.back { text-align: center; margin-top: 22px; font-size: 13px; }
|
||||||
|
|
||||||
|
/* 微信扫码登录 */
|
||||||
|
.scan-flex { display: flex; justify-content: center; }
|
||||||
|
.scan-qr {
|
||||||
|
width: 180px; height: 180px; flex: 0 0 180px;
|
||||||
|
border: 1px solid var(--border); border-radius: 10px;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
overflow: hidden; background: #fff;
|
||||||
|
}
|
||||||
|
.scan-qr .qr-img { width: 100%; height: 100%; object-fit: contain; }
|
||||||
|
.scan-qr .qr-mask {
|
||||||
|
display: flex; flex-direction: column; align-items: center; gap: 8px;
|
||||||
|
font-size: 12px; color: var(--muted); text-align: center; padding: 8px;
|
||||||
|
white-space: pre-line;
|
||||||
|
}
|
||||||
|
.scan-btn {
|
||||||
|
border: none; background: var(--primary); color: #fff;
|
||||||
|
border-radius: 8px; padding: 6px 16px; font-size: 12px; cursor: pointer;
|
||||||
|
}
|
||||||
|
.scan-right { display: none; }
|
||||||
|
|
||||||
.foot-tip {
|
.foot-tip {
|
||||||
margin-top: 26px;
|
margin-top: 26px;
|
||||||
padding-top: 18px;
|
padding-top: 18px;
|
||||||
@@ -485,6 +505,30 @@
|
|||||||
<div class="divider"><span>其他登录方式</span></div>
|
<div class="divider"><span>其他登录方式</span></div>
|
||||||
<div class="third-list" id="thirdList"></div>
|
<div class="third-list" id="thirdList"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 微信扫码登录(未认证公众号:服务号二维码 + 登录码,公众号内确认后自动登录) -->
|
||||||
|
<div class="divider"><span>其他登录方式</span></div>
|
||||||
|
<div class="third-list" style="justify-content:flex-start">
|
||||||
|
<button class="third-btn" type="button" id="wechatScanEntry" onclick="showWechatScan()">
|
||||||
|
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M4 4h16v12H8l-4 4z"/><path d="M8.5 9.5h.01M12 9.5h.01M15.5 9.5h.01"/>
|
||||||
|
</svg>
|
||||||
|
微信登录
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="wechatScanArea" style="display:none; margin-top:18px;">
|
||||||
|
<div class="scan-flex">
|
||||||
|
<div class="scan-qr">
|
||||||
|
<img id="scanQrImg" class="qr-img" alt="微信扫码登录" style="display:none" />
|
||||||
|
<div class="qr-mask" id="scanQrMask">
|
||||||
|
<span id="scanQrMaskText">正在获取二维码…</span>
|
||||||
|
<button class="scan-btn" onclick="restartWechatScan()" style="display:none" id="scanQrRefreshBtn">刷新</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="back"><a class="link" onclick="hideWechatScan()">返回账号密码登录</a></div>
|
||||||
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
<div class="foot-tip">
|
<div class="foot-tip">
|
||||||
@@ -617,9 +661,11 @@
|
|||||||
.then(function (r) { return r.json(); })
|
.then(function (r) { return r.json(); })
|
||||||
.then(function (res) {
|
.then(function (res) {
|
||||||
var list = (res && res.data) || [];
|
var list = (res && res.data) || [];
|
||||||
var enabled = list.filter(function (i) { return i.enabled; });
|
// 微信走「公众号扫码登录」独立入口,第三方列表里不再重复展示
|
||||||
if (!enabled.length) return;
|
var enabled = list.filter(function (i) { return i.enabled && i.provider !== 'wechat'; });
|
||||||
var box = document.getElementById('thirdList');
|
var box = document.getElementById('thirdList');
|
||||||
|
box.innerHTML = ''; // 防止重复渲染
|
||||||
|
if (!enabled.length) return;
|
||||||
enabled.forEach(function (item) {
|
enabled.forEach(function (item) {
|
||||||
var btn = document.createElement('button');
|
var btn = document.createElement('button');
|
||||||
btn.className = 'third-btn';
|
btn.className = 'third-btn';
|
||||||
@@ -793,6 +839,90 @@
|
|||||||
window.location.href = '/auth/login?client_id=' + encodeURIComponent(CLIENT_ID) + '&redirect=' + encodeURIComponent(REDIRECT);
|
window.location.href = '/auth/login?client_id=' + encodeURIComponent(CLIENT_ID) + '&redirect=' + encodeURIComponent(REDIRECT);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== 微信扫码登录(未认证公众号:服务号二维码 + 登录码) ====================
|
||||||
|
var scanScene = '';
|
||||||
|
var scanExpireAt = 0;
|
||||||
|
var scanPollTimer = null;
|
||||||
|
|
||||||
|
function showWechatScan() {
|
||||||
|
hideError();
|
||||||
|
document.getElementById('loginForm').style.display = 'none';
|
||||||
|
document.getElementById('thirdArea').style.display = 'none';
|
||||||
|
document.getElementById('wechatScanEntry').parentElement.style.display = 'none';
|
||||||
|
document.getElementById('wechatScanArea').style.display = 'block';
|
||||||
|
restartWechatScan();
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideWechatScan() {
|
||||||
|
stopWechatScan();
|
||||||
|
document.getElementById('wechatScanArea').style.display = 'none';
|
||||||
|
document.getElementById('loginForm').style.display = 'block';
|
||||||
|
document.getElementById('wechatScanEntry').parentElement.style.display = 'flex';
|
||||||
|
loadThirdProviders();
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopWechatScan() {
|
||||||
|
if (scanPollTimer) { clearInterval(scanPollTimer); scanPollTimer = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function setScanQrMask(text, showRefresh) {
|
||||||
|
var img = document.getElementById('scanQrImg');
|
||||||
|
var mask = document.getElementById('scanQrMask');
|
||||||
|
img.style.display = 'none';
|
||||||
|
mask.style.display = 'flex';
|
||||||
|
document.getElementById('scanQrMaskText').textContent = text || '';
|
||||||
|
document.getElementById('scanQrRefreshBtn').style.display = showRefresh ? 'block' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function restartWechatScan() {
|
||||||
|
stopWechatScan();
|
||||||
|
setScanQrMask('获取中…', false);
|
||||||
|
fetch('/auth/wechat/scan/start', { method: 'POST' })
|
||||||
|
.then(function (r) { return r.json(); })
|
||||||
|
.then(function (res) {
|
||||||
|
if (res.code !== 200) {
|
||||||
|
setScanQrMask('获取失败', true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var d = res.data || {};
|
||||||
|
scanScene = d.scene || '';
|
||||||
|
var qr = d.qrcode_url || d.follow_qrcode || '';
|
||||||
|
if (qr) {
|
||||||
|
var img = document.getElementById('scanQrImg');
|
||||||
|
img.src = qr;
|
||||||
|
img.style.display = 'block';
|
||||||
|
document.getElementById('scanQrMask').style.display = 'none';
|
||||||
|
} else {
|
||||||
|
setScanQrMask('暂无二维码', true);
|
||||||
|
}
|
||||||
|
// 回退原因只写控制台,不在页面展示
|
||||||
|
if (d.notice && window.console) console.warn('[wechat-scan]', d.notice);
|
||||||
|
scanPollTimer = setInterval(pollWechatScan, 3000);
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
setScanQrMask('网络异常', true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function pollWechatScan() {
|
||||||
|
if (!scanScene) return;
|
||||||
|
fetch('/auth/wechat/scan/status?scene=' + encodeURIComponent(scanScene) +
|
||||||
|
'&client_id=' + encodeURIComponent(CLIENT_ID) +
|
||||||
|
'&redirect=' + encodeURIComponent(REDIRECT))
|
||||||
|
.then(function (r) { return r.json(); })
|
||||||
|
.then(function (res) {
|
||||||
|
var d = (res && res.data) || {};
|
||||||
|
if (Number(d.status) === 1) {
|
||||||
|
stopWechatScan();
|
||||||
|
window.location.href = d.redirect || ('/auth/login?client_id=' + encodeURIComponent(CLIENT_ID));
|
||||||
|
} else if (Number(d.status) === 2) {
|
||||||
|
stopWechatScan();
|
||||||
|
setScanQrMask('已过期', true);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function () { /* 下个周期重试 */ });
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Vendored
-1
@@ -28,7 +28,6 @@ declare module 'vue' {
|
|||||||
ElCol: typeof import('element-plus/es')['ElCol']
|
ElCol: typeof import('element-plus/es')['ElCol']
|
||||||
ElCollapse: typeof import('element-plus/es')['ElCollapse']
|
ElCollapse: typeof import('element-plus/es')['ElCollapse']
|
||||||
ElCollapseItem: typeof import('element-plus/es')['ElCollapseItem']
|
ElCollapseItem: typeof import('element-plus/es')['ElCollapseItem']
|
||||||
ElColorPicker: typeof import('element-plus/es')['ElColorPicker']
|
|
||||||
ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider']
|
ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider']
|
||||||
ElContainer: typeof import('element-plus/es')['ElContainer']
|
ElContainer: typeof import('element-plus/es')['ElContainer']
|
||||||
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
|
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
|
||||||
|
|||||||
@@ -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 createPasswordStore = (data) => request({ url: `${resource}/create`, method: 'post', data })
|
||||||
export const updatePasswordStore = (id, data) => request({ url: `${resource}/update/${id}`, 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' })
|
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 } })
|
||||||
|
|||||||
@@ -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() {
|
export function startWechatMpBind() {
|
||||||
return request({
|
return request({
|
||||||
|
|||||||
@@ -364,6 +364,26 @@
|
|||||||
<el-form-item label="模板消息ID">
|
<el-form-item label="模板消息ID">
|
||||||
<el-input v-model="wechatCfg.template_id" placeholder="认证后在公众号后台申请,用于提醒/公告推送" clearable />
|
<el-input v-model="wechatCfg.template_id" placeholder="认证后在公众号后台申请,用于提醒/公告推送" clearable />
|
||||||
</el-form-item>
|
</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-form-item label="服务器地址">
|
||||||
<el-input v-model="wechatCfg.callback_url" readonly>
|
<el-input v-model="wechatCfg.callback_url" readonly>
|
||||||
<template #append>
|
<template #append>
|
||||||
@@ -379,12 +399,19 @@
|
|||||||
<el-form-item>
|
<el-form-item>
|
||||||
<el-button type="primary" :loading="wechatSaving" @click="saveWechatConfig">保存配置</el-button>
|
<el-button type="primary" :loading="wechatSaving" @click="saveWechatConfig">保存配置</el-button>
|
||||||
<el-button :loading="wechatTesting" @click="testWechatConfig">连接测试</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-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
<!-- 绑定我的微信 -->
|
<!-- 绑定我的微信 -->
|
||||||
<el-card shadow="never" class="settings-card bind-card">
|
<!-- <el-card shadow="never" class="settings-card bind-card">
|
||||||
<template #header>
|
<template #header>
|
||||||
<div class="card-header"><span>绑定我的微信(接收提醒 / 公告)</span></div>
|
<div class="card-header"><span>绑定我的微信(接收提醒 / 公告)</span></div>
|
||||||
</template>
|
</template>
|
||||||
@@ -426,10 +453,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</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>
|
<template #header>
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<span>关注用户(粉丝)</span>
|
<span>关注用户(粉丝)</span>
|
||||||
@@ -481,10 +508,10 @@
|
|||||||
:page-size="followerQuery.pageSize"
|
:page-size="followerQuery.pageSize"
|
||||||
@current-change="handleFollowerPage"
|
@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>
|
<template #header>
|
||||||
<div class="card-header"><span>消息推送(提醒 / 公告)</span></div>
|
<div class="card-header"><span>消息推送(提醒 / 公告)</span></div>
|
||||||
</template>
|
</template>
|
||||||
@@ -526,7 +553,7 @@
|
|||||||
</el-button>
|
</el-button>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
</el-card>
|
</el-card> -->
|
||||||
</div>
|
</div>
|
||||||
</el-tab-pane>
|
</el-tab-pane>
|
||||||
|
|
||||||
@@ -597,6 +624,9 @@ import {
|
|||||||
getWechatMpConfig,
|
getWechatMpConfig,
|
||||||
saveWechatMpConfig,
|
saveWechatMpConfig,
|
||||||
testWechatMpConnection,
|
testWechatMpConnection,
|
||||||
|
publishWechatMpMenu,
|
||||||
|
uploadWechatFollowQrcode,
|
||||||
|
deleteWechatFollowQrcode,
|
||||||
startWechatMpBind,
|
startWechatMpBind,
|
||||||
getWechatMpBindStatus,
|
getWechatMpBindStatus,
|
||||||
confirmWechatMpBind,
|
confirmWechatMpBind,
|
||||||
@@ -942,6 +972,7 @@ const wechatCfg = reactive({
|
|||||||
verified: 0,
|
verified: 0,
|
||||||
enabled: 0,
|
enabled: 0,
|
||||||
callback_url: "",
|
callback_url: "",
|
||||||
|
follow_qrcode: "",
|
||||||
configured: false,
|
configured: false,
|
||||||
});
|
});
|
||||||
const wechatSaving = ref(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 () => {
|
const testWechatConfig = async () => {
|
||||||
wechatTesting.value = true;
|
wechatTesting.value = true;
|
||||||
try {
|
try {
|
||||||
@@ -1453,6 +1535,22 @@ watch(activeSubTab, (tab) => {
|
|||||||
margin-bottom: 16px;
|
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 {
|
.push-url {
|
||||||
margin-top: 8px;
|
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
|
clearable
|
||||||
style="width: 300px"
|
style="width: 300px"
|
||||||
:prefix-icon="Search"
|
:prefix-icon="Search"
|
||||||
@keyup.enter="load"
|
@keyup.enter="refresh"
|
||||||
@clear="load"
|
@clear="refresh"
|
||||||
/>
|
/>
|
||||||
<el-button type="primary" :icon="Plus" @click="openCreate">新增平台密码</el-button>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -94,18 +96,7 @@
|
|||||||
|
|
||||||
<div class="field-item">
|
<div class="field-item">
|
||||||
<span class="field-label">登录密码:</span>
|
<span class="field-label">登录密码:</span>
|
||||||
<span class="field-value password-text selectable">
|
<span class="field-value password-text selectable">{{ acc.password || '-' }}</span>
|
||||||
{{ 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}`)"
|
|
||||||
/>
|
|
||||||
<el-button
|
<el-button
|
||||||
v-if="acc.password"
|
v-if="acc.password"
|
||||||
link
|
link
|
||||||
@@ -209,12 +200,28 @@
|
|||||||
|
|
||||||
<el-table-column label="操作" width="190" align="center" fixed="right">
|
<el-table-column label="操作" width="190" align="center" fixed="right">
|
||||||
<template #default="{ row }">
|
<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="View" @click="openDetail(row)">详情</el-button>
|
||||||
|
-->
|
||||||
<el-button link type="primary" size="small" :icon="Edit" @click="openEdit(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>
|
<el-button link type="danger" size="small" :icon="Delete" @click="remove(row)">删除</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</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>
|
</el-card>
|
||||||
|
|
||||||
<!-- 新增 / 编辑 平台及挂载账号 对话框 -->
|
<!-- 新增 / 编辑 平台及挂载账号 对话框 -->
|
||||||
@@ -476,17 +483,7 @@
|
|||||||
|
|
||||||
<div class="detail-field">
|
<div class="detail-field">
|
||||||
<span class="df-label">登录密码:</span>
|
<span class="df-label">登录密码:</span>
|
||||||
<span class="df-val password-text selectable">
|
<span class="df-val password-text selectable">{{ acc.password || '-' }}</span>
|
||||||
{{ 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]"
|
|
||||||
/>
|
|
||||||
<el-button
|
<el-button
|
||||||
v-if="acc.password"
|
v-if="acc.password"
|
||||||
link
|
link
|
||||||
@@ -521,11 +518,14 @@
|
|||||||
<el-button @click="detailVisible = false">关闭</el-button>
|
<el-button @click="detailVisible = false">关闭</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
|
<!-- 批量导入 -->
|
||||||
|
<PasswordImportDialog ref="importDialogRef" @imported="load" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, reactive, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import {
|
import {
|
||||||
Plus,
|
Plus,
|
||||||
@@ -537,30 +537,35 @@ import {
|
|||||||
Link,
|
Link,
|
||||||
User,
|
User,
|
||||||
View,
|
View,
|
||||||
Hide,
|
DocumentCopy,
|
||||||
DocumentCopy
|
Download,
|
||||||
|
Upload
|
||||||
} from '@element-plus/icons-vue'
|
} from '@element-plus/icons-vue'
|
||||||
|
import PasswordImportDialog from './components/PasswordImportDialog.vue'
|
||||||
|
import { downloadPasswordExcel } from './passwordExcel'
|
||||||
import {
|
import {
|
||||||
getPasswordStoreList,
|
getPasswordStoreList,
|
||||||
createPasswordStore,
|
createPasswordStore,
|
||||||
updatePasswordStore,
|
updatePasswordStore,
|
||||||
deletePasswordStore
|
deletePasswordStore,
|
||||||
|
exportPasswordStore
|
||||||
} from '@/api/passwordStore'
|
} from '@/api/passwordStore'
|
||||||
|
|
||||||
const keyword = ref('')
|
const keyword = ref('')
|
||||||
const rows = ref([])
|
const rows = ref([])
|
||||||
const loading = ref(false)
|
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 saving = ref(false)
|
||||||
const dialogVisible = ref(false)
|
const dialogVisible = ref(false)
|
||||||
const detailVisible = ref(false)
|
const detailVisible = ref(false)
|
||||||
const detailData = ref(null)
|
const detailData = ref(null)
|
||||||
const formRef = ref()
|
const formRef = ref()
|
||||||
|
|
||||||
// 展开行密码显示状态 map: `${row.id}_${accIdx}` => boolean
|
|
||||||
const showPasswords = reactive({})
|
|
||||||
// 详情弹窗密码显示状态 map: accIdx => boolean
|
|
||||||
const showDetailPasswords = reactive({})
|
|
||||||
|
|
||||||
const rules = {
|
const rules = {
|
||||||
platform: [{ required: true, message: '请输入平台名称', trigger: 'blur' }]
|
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) {
|
function copySingleAccount(platformRow, acc, idx) {
|
||||||
const parts = [
|
const parts = [
|
||||||
`平台:${platformRow.platform || '-'}`,
|
`平台:${platformRow.platform || '-'}`,
|
||||||
@@ -685,12 +686,17 @@ function generateRandomPassword(index) {
|
|||||||
async function load() {
|
async function load() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
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) {
|
if (r.code === 200) {
|
||||||
rows.value = (r.data?.list || []).map(item => ({
|
rows.value = (r.data?.list || []).map(item => ({
|
||||||
...item,
|
...item,
|
||||||
accounts: Array.isArray(item.accounts) ? item.accounts : []
|
accounts: Array.isArray(item.accounts) ? item.accounts : []
|
||||||
}))
|
}))
|
||||||
|
total.value = Number(r.data?.total || 0)
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(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() {
|
function openCreate() {
|
||||||
form.value = emptyForm()
|
form.value = emptyForm()
|
||||||
dialogVisible.value = true
|
dialogVisible.value = true
|
||||||
@@ -724,8 +767,6 @@ function openDetail(row) {
|
|||||||
...row,
|
...row,
|
||||||
accounts: Array.isArray(row.accounts) ? row.accounts : []
|
accounts: Array.isArray(row.accounts) ? row.accounts : []
|
||||||
}
|
}
|
||||||
// 重置详情密码隐藏状态
|
|
||||||
Object.keys(showDetailPasswords).forEach(k => delete showDetailPasswords[k])
|
|
||||||
detailVisible.value = true
|
detailVisible.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1167,4 +1208,10 @@ onMounted(load)
|
|||||||
color: var(--el-text-color-primary);
|
color: var(--el-text-color-primary);
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pagination-wrap {
|
||||||
|
margin-top: 16px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
</style>
|
</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')
|
||||||
|
})
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user