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
+39
View File
@@ -80,3 +80,42 @@ async def update_user_role(
"role": user.role, "role": user.role,
"department": user.department, "department": user.department,
} }
class UpdateWecomRequest(BaseModel):
wecom_userid: Optional[str] = None # None or "" to unbind
@router.put("/{user_id}/wecom")
async def update_user_wecom(
user_id: str,
data: UpdateWecomRequest,
current_user: dict = Depends(require_director),
db: AsyncSession = Depends(get_db),
):
"""Director binds or unbinds a user's WeChat Work account."""
result = await db.execute(select(User).where(User.id == uuid.UUID(user_id)))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="User not found")
new_id = (data.wecom_userid or "").strip()
# Check duplicate
if new_id:
dup = await db.execute(
select(User).where(User.wecom_userid == new_id, User.id != uuid.UUID(user_id))
)
existing = dup.scalar_one_or_none()
if existing:
raise HTTPException(status_code=400, detail=f"该企微ID已被「{existing.name}」绑定")
user.wecom_userid = new_id if new_id else None
await db.commit()
await db.refresh(user)
return {
"id": str(user.id),
"name": user.name,
"wecom_userid": user.wecom_userid,
}
+122 -30
View File
@@ -2,6 +2,7 @@ import hashlib
import struct import struct
import base64 import base64
import uuid import uuid
import xml.etree.ElementTree as ET
from typing import Optional from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query, Request from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi.responses import PlainTextResponse from fastapi.responses import PlainTextResponse
@@ -12,7 +13,7 @@ from app.config import settings
from app.database import get_db from app.database import get_db
from app.middleware.auth import get_current_user, require_director from app.middleware.auth import get_current_user, require_director
from app.models.user import User from app.models.user import User
from app.services.wecom import wecom_client from app.services.wecom import wecom_client, store_bind_token, consume_bind_token
from app.services.scheduler import check_daily_reporting from app.services.scheduler import check_daily_reporting
router = APIRouter(prefix="/wecom", tags=["WeChatWork"]) router = APIRouter(prefix="/wecom", tags=["WeChatWork"])
@@ -27,36 +28,33 @@ class AnnouncementRequest(BaseModel):
content: str content: str
# ── Callback verification helpers ── class BindConfirmRequest(BaseModel):
token: str
# ── Crypto helpers ──
def _verify_signature(token: str, timestamp: str, nonce: str, encrypted: str, signature: str) -> bool: def _verify_signature(token: str, timestamp: str, nonce: str, encrypted: str, signature: str) -> bool:
"""Verify WeChat Work callback signature.""" items = sorted([token, timestamp, nonce, encrypted])
params = sorted([token, timestamp, nonce, encrypted]) raw = "".join(items)
raw = "".join(params) return hashlib.sha1(raw.encode()).hexdigest() == signature
computed = hashlib.sha1(raw.encode()).hexdigest()
return computed == signature
def _decrypt_echostr(encrypted: str) -> str: def _decrypt_msg(encrypted: str) -> str:
"""Decrypt WeChat Work callback echostr. Returns plaintext.""" """Decrypt WeChat Work callback message. Returns plaintext XML."""
key = base64.b64decode(settings.WECOM_ENCODING_AES_KEY + "=") key = base64.b64decode(settings.WECOM_ENCODING_AES_KEY + "=")
ciphertext = base64.b64decode(encrypted) ciphertext = base64.b64decode(encrypted)
# AES-256-CBC decrypt
from Crypto.Cipher import AES from Crypto.Cipher import AES
cipher = AES.new(key, AES.MODE_CBC, iv=key[:16]) cipher = AES.new(key, AES.MODE_CBC, iv=key[:16])
plaintext = cipher.decrypt(ciphertext) plaintext = cipher.decrypt(ciphertext)
# Strip PKCS#7 padding
pad_len = plaintext[-1] pad_len = plaintext[-1]
plaintext = plaintext[:-pad_len] plaintext = plaintext[:-pad_len]
# Format: random(16) + msg_len(4) + msg + corpid
msg_len = struct.unpack(">I", plaintext[16:20])[0] msg_len = struct.unpack(">I", plaintext[16:20])[0]
msg = plaintext[20:20 + msg_len].decode("utf-8") return plaintext[20:20 + msg_len].decode("utf-8")
return msg
# ── Callback ──
@router.get("/callback") @router.get("/callback")
async def wecom_callback_verify( async def wecom_callback_verify(
msg_signature: str = Query(...), msg_signature: str = Query(...),
@@ -68,13 +66,10 @@ async def wecom_callback_verify(
token = settings.WECOM_TOKEN token = settings.WECOM_TOKEN
if not token or not settings.WECOM_ENCODING_AES_KEY: if not token or not settings.WECOM_ENCODING_AES_KEY:
raise HTTPException(status_code=500, detail="WeCom callback not configured") raise HTTPException(status_code=500, detail="WeCom callback not configured")
if not _verify_signature(token, timestamp, nonce, echostr, msg_signature): if not _verify_signature(token, timestamp, nonce, echostr, msg_signature):
raise HTTPException(status_code=403, detail="Signature verification failed") raise HTTPException(status_code=403, detail="Signature mismatch")
try: try:
plaintext = _decrypt_echostr(echostr) plaintext = _decrypt_msg(echostr)
# Must return raw plaintext (not JSON) — WeChat Work requires exact match
return PlainTextResponse(content=plaintext, status_code=200) return PlainTextResponse(content=plaintext, status_code=200)
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail=f"Decrypt failed: {e}") raise HTTPException(status_code=500, detail=f"Decrypt failed: {e}")
@@ -82,13 +77,110 @@ async def wecom_callback_verify(
@router.post("/callback") @router.post("/callback")
async def wecom_callback_event(request: Request): async def wecom_callback_event(request: Request):
"""WeChat Work callback event receiver (POST). Placeholder for future use.""" """WeChat Work callback event receiver (POST). Handles menu clicks and text messages."""
body = await request.body() token = settings.WECOM_TOKEN
# TODO: decrypt and handle events (app install, user enter, etc.) if not token or not settings.WECOM_ENCODING_AES_KEY:
return "ok" return PlainTextResponse(content="success")
# Parse XML envelope
try:
root = ET.fromstring(await request.body())
encrypt = root.findtext("Encrypt", "")
except ET.ParseError:
return PlainTextResponse(content="success")
# Verify signature
msg_signature = request.query_params.get("msg_signature", "")
timestamp = request.query_params.get("timestamp", "")
nonce = request.query_params.get("nonce", "")
if not _verify_signature(token, timestamp, nonce, encrypt, msg_signature):
return PlainTextResponse(content="success")
# Decrypt inner XML
try:
xml_str = _decrypt_msg(encrypt)
msg = ET.fromstring(xml_str)
except Exception:
return PlainTextResponse(content="success")
msg_type = msg.findtext("MsgType", "")
from_user = msg.findtext("FromUserName", "")
content = (msg.findtext("Content", "") or "").strip()
event = (msg.findtext("Event", "") or "").strip()
event_key = (msg.findtext("EventKey", "") or "").strip()
# Triggers for binding
if (msg_type == "text" and content == "绑定") or (msg_type == "event" and event == "click" and event_key == "BIND_ACCOUNT"):
_handle_bind_request(from_user)
return PlainTextResponse(content="success")
return PlainTextResponse(content="success")
# ── Manual actions ── def _handle_bind_request(wecom_userid: str):
"""Generate bind token, store it, and push a bind link to the user."""
if not wecom_userid:
return
import asyncio
bind_token = store_bind_token(wecom_userid)
content = (
f"【账号绑定】\n\n"
f"请在10分钟内点击以下链接完成账号绑定:\n"
f"https://qj.dhdx.fun/wecom-bind?token={bind_token}\n\n"
f"绑定后可使用企微一键登录,并接收填报提醒通知。"
)
try:
# Must run async in sync context
loop = asyncio.get_event_loop()
except RuntimeError:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
loop.run_until_complete(
wecom_client.send_text_message([wecom_userid], content)
)
# ── Bind confirmation (JWT-protected) ──
@router.post("/bind-confirm")
async def bind_confirm(
data: BindConfirmRequest,
current_user: dict = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""User confirms binding after clicking the link in WeChat Work message."""
if not data.token:
raise HTTPException(status_code=400, detail="token 不能为空")
wecom_userid = consume_bind_token(data.token)
if not wecom_userid:
raise HTTPException(status_code=400, detail="绑定链接已过期或无效,请重新在企微发送「绑定」")
# Check if this wecom_userid is already bound to another user
result = await db.execute(
select(User).where(User.wecom_userid == wecom_userid)
)
existing = result.scalar_one_or_none()
if existing and str(existing.id) != current_user["user_id"]:
raise HTTPException(status_code=400, detail=f"该企业微信已绑定账号「{existing.name}")
# Bind
result = await db.execute(
select(User).where(User.id == uuid.UUID(current_user["user_id"]))
)
user = result.scalar_one()
user.wecom_userid = wecom_userid
await db.commit()
return {"code": 200, "msg": "绑定成功", "wecom_userid": wecom_userid}
# ── Manual actions (director only) ──
@router.post("/remind") @router.post("/remind")
async def send_reminder( async def send_reminder(
@@ -139,13 +231,13 @@ async def test_message(
@router.post("/setup-menu") @router.post("/setup-menu")
async def setup_menu(current_user: dict = Depends(require_director)): async def setup_menu(current_user: dict = Depends(require_director)):
"""Create the standard app menu (开始填报 + 绑定账号).""" """Create the standard app menu (开始填报 view + 绑定账号 click)."""
buttons = [ buttons = [
{"type": "view", "name": "开始填报", "url": "https://qj.dhdx.fun/m"}, {"type": "view", "name": "开始填报", "url": "https://qj.dhdx.fun/m"},
{"type": "view", "name": "绑定账号", "url": "https://qj.dhdx.fun/bind-wechat"}, {"type": "click", "name": "绑定账号", "key": "BIND_ACCOUNT"},
] ]
success = await wecom_client.create_menu(buttons) success = await wecom_client.create_menu(buttons)
return {"success": success, "message": "菜单已部署" if success else "菜单创建失败"} return {"success": success, "message": "菜单已部署 (view + click)" if success else "菜单创建失败"}
@router.get("/menu") @router.get("/menu")
+59 -25
View File
@@ -1,7 +1,31 @@
import time import time
import uuid
import httpx import httpx
from app.config import settings from app.config import settings
# In-memory bind token store (TTL 600s). Replace with Redis if scaling to multiple workers.
_bind_tokens: dict[str, tuple[str, float]] = {} # token → (wecom_userid, expires_at)
def store_bind_token(wecom_userid: str, ttl: int = 600) -> str:
"""Store a bind token → wecom_userid mapping. Returns the token."""
token = uuid.uuid4().hex
_bind_tokens[token] = (wecom_userid, time.time() + ttl)
# Cleanup expired tokens
now = time.time()
for k in list(_bind_tokens):
if _bind_tokens[k][1] < now:
del _bind_tokens[k]
return token
def consume_bind_token(token: str) -> str | None:
"""Lookup and consume a bind token. Returns wecom_userid or None."""
entry = _bind_tokens.pop(token, None)
if entry and entry[1] > time.time():
return entry[0]
return None
class WecomClient: class WecomClient:
"""WeChat Work API client — token management, message sending, OAuth.""" """WeChat Work API client — token management, message sending, OAuth."""
@@ -13,9 +37,12 @@ class WecomClient:
self._access_token: str | None = None self._access_token: str | None = None
self._token_expires_at: float = 0 # epoch seconds self._token_expires_at: float = 0 # epoch seconds
@property
def access_token(self) -> str | None:
return self._access_token
async def _get_token(self) -> str: async def _get_token(self) -> str:
"""Get a valid access token, refreshing if expired.""" """Get a valid access token, refreshing if expired."""
# Token valid for 7200s; refresh 60s early to be safe
if self._access_token and time.time() < self._token_expires_at - 60: if self._access_token and time.time() < self._token_expires_at - 60:
return self._access_token return self._access_token
@@ -45,17 +72,14 @@ class WecomClient:
if errcode == 0: if errcode == 0:
return data return data
# Token expired mid-request — clear and retry once
if errcode == 42001: if errcode == 42001:
self._access_token = None self._access_token = None
self._token_expires_at = 0 self._token_expires_at = 0
if attempt < max_retries - 1: if attempt < max_retries - 1:
continue continue
# Rate limit — wait and retry
if errcode == 45009: if errcode == 45009:
if attempt < max_retries - 1: if attempt < max_retries - 1:
await httpx.AsyncClient().aclose()
time.sleep(1 * (attempt + 1)) time.sleep(1 * (attempt + 1))
continue continue
@@ -67,6 +91,8 @@ class WecomClient:
raise Exception(f"WeCom API error after {max_retries} retries: {last_error}") raise Exception(f"WeCom API error after {max_retries} retries: {last_error}")
# ── OAuth ──
async def get_userinfo_by_code(self, code: str) -> dict | None: async def get_userinfo_by_code(self, code: str) -> dict | None:
"""Exchange OAuth2 code for userid (used in silent login).""" """Exchange OAuth2 code for userid (used in silent login)."""
try: try:
@@ -81,6 +107,8 @@ class WecomClient:
except Exception: except Exception:
return None return None
# ── Messaging ──
async def send_text_message(self, user_ids: list[str], content: str) -> bool: async def send_text_message(self, user_ids: list[str], content: str) -> bool:
"""Send a text app message to specified users.""" """Send a text app message to specified users."""
if not settings.WECOM_AGENT_ID: if not settings.WECOM_AGENT_ID:
@@ -117,13 +145,30 @@ class WecomClient:
except Exception: except Exception:
return False return False
async def send_text_card(self, user_id: str, title: str, description: str, url: str) -> bool:
"""Send a textcard message (clickable card) to a single user."""
if not settings.WECOM_AGENT_ID:
return False
try:
body = {
"touser": user_id,
"msgtype": "textcard",
"agentid": int(settings.WECOM_AGENT_ID),
"textcard": {
"title": title,
"description": description,
"url": url,
},
}
await self._post_with_retry(
f"{settings.WECOM_API_BASE}/cgi-bin/message/send", body
)
return True
except Exception:
return False
async def send_template_card( async def send_template_card(
self, self, user_ids: list[str], title: str, desc: str, url: str, btn_text: str = "查看详情"
user_ids: list[str],
title: str,
desc: str,
url: str,
btn_text: str = "查看详情",
) -> bool: ) -> bool:
"""Send a text_notice template card with a deep-link button.""" """Send a text_notice template card with a deep-link button."""
if not settings.WECOM_AGENT_ID: if not settings.WECOM_AGENT_ID:
@@ -136,17 +181,8 @@ class WecomClient:
"template_card": { "template_card": {
"card_type": "text_notice", "card_type": "text_notice",
"main_title": {"title": title, "desc": desc}, "main_title": {"title": title, "desc": desc},
"card_action": { "card_action": {"type": 1, "url": url},
"type": 1, # jump to URL "button_list": [{"text": btn_text, "style": 1, "key": "open_url"}],
"url": url,
},
"button_list": [
{
"text": btn_text,
"style": 1, # primary
"key": "open_url",
}
],
}, },
} }
await self._post_with_retry( await self._post_with_retry(
@@ -156,18 +192,16 @@ class WecomClient:
except Exception: except Exception:
return False return False
# ── Menu management ── # ── Menu management ──
async def create_menu(self, buttons: list[dict]) -> bool: async def create_menu(self, buttons: list[dict]) -> bool:
"""Create/replace the app's custom menu (visible in chat window).""" """Create/replace the app's custom menu."""
if not settings.WECOM_AGENT_ID: if not settings.WECOM_AGENT_ID:
return False return False
try: try:
body = {"button": buttons} body = {"button": buttons}
await self._post_with_retry( await self._post_with_retry(
f"{settings.WECOM_API_BASE}/cgi-bin/menu/create?agentid={settings.WECOM_AGENT_ID}", f"{settings.WECOM_API_BASE}/cgi-bin/menu/create?agentid={settings.WECOM_AGENT_ID}", body
body,
) )
return True return True
except Exception: except Exception:
+6
View File
@@ -22,6 +22,12 @@ const router = createRouter({
component: () => import('@/views/BindWechat.vue'), component: () => import('@/views/BindWechat.vue'),
meta: { public: true }, meta: { public: true },
}, },
{
path: '/wecom-bind',
name: 'WecomBind',
component: () => import('@/views/WecomBind.vue'),
meta: { public: true },
},
// Mobile routes (manager-facing) // Mobile routes (manager-facing)
{ {
path: '/m', 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 editUser = ref<any>(null)
const editRole = ref('') const editRole = ref('')
const editDepartment = ref('') const editDepartment = ref('')
const editWecomId = ref('')
const roleOptions = [ const roleOptions = [
{ label: '客户经理', value: 'manager' }, { label: '客户经理', value: 'manager' },
@@ -38,6 +39,7 @@ function openEdit(user: any) {
editUser.value = user editUser.value = user
editRole.value = user.role editRole.value = user.role
editDepartment.value = user.department || '' editDepartment.value = user.department || ''
editWecomId.value = user.wecom_userid || ''
editDialogVisible.value = true editDialogVisible.value = true
} }
@@ -45,11 +47,25 @@ async function handleSave() {
if (!editUser.value) return if (!editUser.value) return
try { try {
await api.put(`/users/${editUser.value.id}/role`, { role: editRole.value, department: editDepartment.value }) 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 editDialogVisible.value = false
await loadUsers() await loadUsers()
} catch (e: any) { ElMessage.error('更新失败: ' + (e.response?.data?.detail || e.message)) } } 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> </script>
<template> <template>
@@ -67,18 +83,24 @@ async function handleSave() {
<el-tag :type="roleTagType[row.role]">{{ roleLabel[row.role] || row.role }}</el-tag> <el-tag :type="roleTagType[row.role]">{{ roleLabel[row.role] || row.role }}</el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="wecom_userid" label="企微 UserID" width="140"> <el-table-column prop="wecom_userid" label="企微 UserID" min-width="140">
<template #default="{ row }">{{ row.wecom_userid || '-' }}</template> <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>
<el-table-column label="操作" width="100" fixed="right"> <el-table-column label="操作" width="100" fixed="right">
<template #default="{ row }"> <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> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
</el-card> </el-card>
<el-dialog v-model="editDialogVisible" title="修改用户角色" width="420px"> <el-dialog v-model="editDialogVisible" title="编辑用户" width="440px">
<template v-if="editUser"> <template v-if="editUser">
<el-form label-position="top"> <el-form label-position="top">
<el-form-item label="用户"><el-input :value="editUser.name" disabled /></el-form-item> <el-form-item label="用户"><el-input :value="editUser.name" disabled /></el-form-item>
@@ -88,6 +110,10 @@ async function handleSave() {
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="部门(可选)"><el-input v-model="editDepartment" placeholder="如:XX支局" /></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> </el-form>
</template> </template>
<template #footer> <template #footer>
@@ -107,4 +133,6 @@ async function handleSave() {
color: var(--ink); letter-spacing: 0.06em; color: var(--ink); letter-spacing: 0.06em;
} }
.page-rule { width: 32px; height: 3px; background: var(--gold); margin-top: 12px; } .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> </style>