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:
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user