feat: 企微绑定重构 — 回调事件 + 支局长手动绑定/解绑

绑定流程改为 rural-optical-rectify 方案:
- 企微菜单「绑定账号」发送 click 事件(而非 OAuth 跳转)
- 回调 POST 接收事件 → 生成绑定 token → 推送绑定链接
- 前端 WecomBind.vue (/wecom-bind) 确认绑定
- 新增 POST /api/wecom/bind-confirm (JWT 保护)

支局长手动管理:
- 新增 PUT /api/users/{id}/wecom 绑定/解绑端点
- UserManage.vue 编辑对话框新增企微 UserID 字段
- 表格操作列新增「解绑」按钮
- 重复绑定检测

wecom.py 新增:
- in-memory bind token store (TTL 600s)
- send_text_card() 卡片消息方法
- access_token 属性暴露

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-06-26 12:10:38 +08:00
parent 1d2dacc3ab
commit 7264184315
6 changed files with 363 additions and 60 deletions
+6
View File
@@ -22,6 +22,12 @@ const router = createRouter({
component: () => import('@/views/BindWechat.vue'),
meta: { public: true },
},
{
path: '/wecom-bind',
name: 'WecomBind',
component: () => import('@/views/WecomBind.vue'),
meta: { public: true },
},
// Mobile routes (manager-facing)
{
path: '/m',
+104
View File
@@ -0,0 +1,104 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { useAuthStore } from '@/stores/auth'
import api from '@/api/index'
const route = useRoute()
const router = useRouter()
const auth = useAuthStore()
const binding = ref(false)
const bound = ref(false)
const errorMsg = ref('')
const bindToken = (route.query.token as string) || ''
onMounted(() => {
if (!bindToken) {
errorMsg.value = '无效的绑定链接'
setTimeout(() => router.replace('/'), 2000)
}
})
async function doBind() {
if (!bindToken) return
binding.value = true
try {
const res = await api.post('/wecom/bind-confirm', { token: bindToken })
if (res.data.code === 200) {
bound.value = true
auth.user!.wecom_userid = res.data.wecom_userid
ElMessage.success('绑定成功!')
setTimeout(() => router.replace('/'), 1500)
} else {
ElMessage.error(res.data.msg || '绑定失败')
}
} catch (e: any) {
ElMessage.error(e.response?.data?.detail || '绑定失败,请重试')
} finally {
binding.value = false
}
}
</script>
<template>
<div class="wecom-bind-page">
<div v-if="errorMsg" class="bind-error">
<p>{{ errorMsg }}</p>
</div>
<template v-else>
<div class="bind-icon">💬</div>
<h2 class="bind-title">企业微信账号绑定</h2>
<p class="bind-desc">绑定后可接收填报提醒支持企微一键登录</p>
<div class="bind-info">
<div class="info-row">
<span class="info-label">当前账号</span>
<span class="info-value">{{ auth.user?.name || '--' }}</span>
</div>
<div class="info-row">
<span class="info-label">绑定状态</span>
<span :class="['info-tag', auth.user?.wecom_userid ? 'bound' : 'unbound']">
{{ auth.user?.wecom_userid ? '已绑定' : '未绑定' }}
</span>
</div>
<div v-if="auth.user?.wecom_userid" class="info-row">
<span class="info-label">企微ID</span>
<span class="info-value">{{ auth.user.wecom_userid }}</span>
</div>
</div>
<div class="bind-actions">
<el-button
v-if="!bound && !auth.user?.wecom_userid"
type="primary"
size="large"
:loading="binding"
@click="doBind"
>确认绑定</el-button>
<el-button v-else type="success" size="large" disabled>已绑定 </el-button>
</div>
<p class="bind-hint">链接有效期 10 分钟过期请重新在企微发送绑定</p>
</template>
</div>
</template>
<style scoped>
.wecom-bind-page { max-width: 400px; margin: 80px auto; padding: 32px 24px; text-align: center; }
.bind-error { color: var(--vermilion); font-size: 15px; }
.bind-icon { font-size: 56px; margin-bottom: 16px; }
.bind-title { margin: 0 0 8px; font-family: 'ZCOOL XiaoWei', STSong, serif; font-size: 22px; font-weight: 400; color: var(--ink); }
.bind-desc { margin: 0 0 28px; font-size: 14px; color: var(--c-text-muted); }
.bind-info { text-align: left; background: var(--c-bg-light, #faf9f6); border-radius: 8px; padding: 16px 20px; margin-bottom: 28px; }
.info-row { display: flex; justify-content: space-between; align-items: center; padding: 8px 0; }
.info-row + .info-row { border-top: 1px solid var(--c-border, #e8e5df); }
.info-label { font-size: 14px; color: var(--c-text-muted); }
.info-value { font-size: 14px; color: var(--ink); font-weight: 500; }
.info-tag { font-size: 12px; padding: 2px 10px; border-radius: 10px; }
.info-tag.bound { background: var(--sage); color: #fff; }
.info-tag.unbound { background: var(--gold); color: #fff; }
.bind-actions { margin-bottom: 16px; }
.bind-hint { font-size: 12px; color: var(--c-text-muted); }
</style>
+33 -5
View File
@@ -9,6 +9,7 @@ const editDialogVisible = ref(false)
const editUser = ref<any>(null)
const editRole = ref('')
const editDepartment = ref('')
const editWecomId = ref('')
const roleOptions = [
{ label: '客户经理', value: 'manager' },
@@ -38,6 +39,7 @@ function openEdit(user: any) {
editUser.value = user
editRole.value = user.role
editDepartment.value = user.department || ''
editWecomId.value = user.wecom_userid || ''
editDialogVisible.value = true
}
@@ -45,11 +47,25 @@ async function handleSave() {
if (!editUser.value) return
try {
await api.put(`/users/${editUser.value.id}/role`, { role: editRole.value, department: editDepartment.value })
ElMessage.success('角色已更新')
const newWecomId = editWecomId.value.trim()
if (newWecomId !== (editUser.value.wecom_userid || '')) {
await api.put(`/users/${editUser.value.id}/wecom`, { wecom_userid: newWecomId || null })
}
ElMessage.success('已更新')
editDialogVisible.value = false
await loadUsers()
} catch (e: any) { ElMessage.error('更新失败: ' + (e.response?.data?.detail || e.message)) }
}
async function handleUnbind(row: any) {
if (!row.wecom_userid) return
try {
await ElMessageBox.confirm(`确定解除「${row.name}」的企微绑定?`, '确认解绑', { type: 'warning' })
await api.put(`/users/${row.id}/wecom`, { wecom_userid: null })
ElMessage.success('已解绑')
await loadUsers()
} catch (e: any) { if (e !== 'cancel') ElMessage.error('操作失败') }
}
</script>
<template>
@@ -67,18 +83,24 @@ async function handleSave() {
<el-tag :type="roleTagType[row.role]">{{ roleLabel[row.role] || row.role }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="wecom_userid" label="企微 UserID" width="140">
<template #default="{ row }">{{ row.wecom_userid || '-' }}</template>
<el-table-column prop="wecom_userid" label="企微 UserID" min-width="140">
<template #default="{ row }">
<template v-if="row.wecom_userid">
<span class="wecom-id">{{ row.wecom_userid }}</span>
<el-button text size="small" type="danger" @click="handleUnbind(row)" style="margin-left:6px">解绑</el-button>
</template>
<span v-else style="color:var(--c-text-muted)">未绑定</span>
</template>
</el-table-column>
<el-table-column label="操作" width="100" fixed="right">
<template #default="{ row }">
<el-button text size="small" type="primary" @click="openEdit(row)">修改角色</el-button>
<el-button text size="small" type="primary" @click="openEdit(row)">编辑</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<el-dialog v-model="editDialogVisible" title="修改用户角色" width="420px">
<el-dialog v-model="editDialogVisible" title="编辑用户" width="440px">
<template v-if="editUser">
<el-form label-position="top">
<el-form-item label="用户"><el-input :value="editUser.name" disabled /></el-form-item>
@@ -88,6 +110,10 @@ async function handleSave() {
</el-select>
</el-form-item>
<el-form-item label="部门(可选)"><el-input v-model="editDepartment" placeholder="如:XX支局" /></el-form-item>
<el-form-item label="企业微信 UserID">
<el-input v-model="editWecomId" placeholder="从企微后台通讯录获取,留空则解绑" clearable />
<div class="form-hint">在企微后台 通讯录 成员详情 账号 中查看</div>
</el-form-item>
</el-form>
</template>
<template #footer>
@@ -107,4 +133,6 @@ async function handleSave() {
color: var(--ink); letter-spacing: 0.06em;
}
.page-rule { width: 32px; height: 3px; background: var(--gold); margin-top: 12px; }
.wecom-id { font-family: 'JetBrains Mono', monospace; font-size: 12px; color: var(--sage); background: var(--c-bg-light, #f0ede5); padding: 2px 8px; border-radius: 4px; }
.form-hint { font-size: 12px; color: var(--c-text-muted); margin-top: 4px; }
</style>