feat(controlled, controller): 新增配对码生成与兑换功能
被控端:新增生成一次性配对码并弹窗展示,支持倒计时与复制;控制端:新增输入配对码兑换绑定,刷新设备列表,并在设备列表中显示在线状态和点击连接。同时添加统一 401 拦截器,令牌失效时触发重新激活。
This commit is contained in:
@@ -298,3 +298,26 @@ export function removeAllowlist(sn: string): Promise<{ success: boolean; total:
|
||||
`/api/admin/device-allowlist/${encodeURIComponent(sn)}`,
|
||||
) as unknown as Promise<{ success: boolean; total: number }>;
|
||||
}
|
||||
|
||||
// ==================== 绑定关系 ====================
|
||||
|
||||
export type BindingRole = 'OWNER' | 'MEMBER';
|
||||
export type BindingStatus = 'ACTIVE' | 'REVOKED';
|
||||
|
||||
export interface UserBindingView {
|
||||
bindingId: string;
|
||||
deviceUid: string;
|
||||
userId: string;
|
||||
role: BindingRole;
|
||||
alias: string | null;
|
||||
status: BindingStatus;
|
||||
online: boolean;
|
||||
boundAt: number | null;
|
||||
}
|
||||
|
||||
/** 获取某用户绑定的全部终端设备(含在线状态)。 */
|
||||
export function getUserBindings(userId: string): Promise<UserBindingView[]> {
|
||||
return request.get(
|
||||
`/api/admin/users/${encodeURIComponent(userId)}/bindings`,
|
||||
) as unknown as Promise<UserBindingView[]>;
|
||||
}
|
||||
|
||||
@@ -2,8 +2,10 @@ import axios from 'axios';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import router from '@/router';
|
||||
|
||||
const BASE_URL = import.meta.env.VITE_API_BASE || 'http://localhost:8080';
|
||||
|
||||
const request = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_BASE || 'http://localhost:8080',
|
||||
baseURL: BASE_URL,
|
||||
timeout: 15000,
|
||||
});
|
||||
|
||||
@@ -15,12 +17,34 @@ request.interceptors.request.use((config) => {
|
||||
return config;
|
||||
});
|
||||
|
||||
/**
|
||||
* 统一响应拦截:除 401 外所有异常都给出错误提示。
|
||||
* 401 表示令牌失效/未登录,清除本地令牌并跳转登录页。
|
||||
*/
|
||||
request.interceptors.response.use(
|
||||
(resp) => resp.data,
|
||||
(error) => {
|
||||
const status = error.response?.status;
|
||||
if (status === 401) {
|
||||
localStorage.removeItem('admin_token');
|
||||
ElMessage.error('登录已失效,请重新登录');
|
||||
if (router.currentRoute.value.name !== 'Login') {
|
||||
router.replace({ name: 'Login' });
|
||||
}
|
||||
} else {
|
||||
ElMessage.error(error.response?.data?.message || error.message || '请求失败');
|
||||
}
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* 不携带任何鉴权头的请求实例,专用于登录等公开接口,
|
||||
* 避免把本地可能过期/失效的令牌误带上去造成 401 / 鉴权混乱。
|
||||
* 公开接口的 401 表示账号或密码错误,直接提示,不跳转(已在登录页)。
|
||||
*/
|
||||
export const publicRequest = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_BASE || 'http://localhost:8080',
|
||||
baseURL: BASE_URL,
|
||||
timeout: 15000,
|
||||
});
|
||||
|
||||
@@ -29,7 +53,6 @@ publicRequest.interceptors.response.use(
|
||||
(error) => {
|
||||
const status = error.response?.status;
|
||||
if (status === 401) {
|
||||
localStorage.removeItem('admin_token');
|
||||
ElMessage.error('登录失败,请检查账号或密码');
|
||||
} else {
|
||||
ElMessage.error(error.response?.data?.message || error.message || '请求失败');
|
||||
@@ -40,19 +63,3 @@ publicRequest.interceptors.response.use(
|
||||
|
||||
export default request;
|
||||
|
||||
|
||||
request.interceptors.response.use(
|
||||
(resp) => resp.data,
|
||||
(error) => {
|
||||
const status = error.response?.status;
|
||||
if (status === 401) {
|
||||
localStorage.removeItem('admin_token');
|
||||
ElMessage.error('登录已失效,请重新登录');
|
||||
router.replace({ name: 'Login' });
|
||||
} else {
|
||||
ElMessage.error(error.response?.data?.message || error.message || '请求失败');
|
||||
}
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -30,10 +30,13 @@
|
||||
<el-table v-loading="loading" :data="users" stripe>
|
||||
<el-table-column prop="username" label="用户名" min-width="150">
|
||||
<template #default="{ row }">
|
||||
<span>{{ row.username }}</span>
|
||||
<span class="link-user" @click="openBindings(row)">{{ row.username }}</span>
|
||||
<el-tag v-if="row.admin" type="danger" size="small" style="margin-left: 6px">
|
||||
管理员
|
||||
</el-tag>
|
||||
<el-tag v-if="row.deviceCount > 0" type="info" size="small" style="margin-left: 6px">
|
||||
{{ row.deviceCount }} 台设备
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="userId" label="用户 ID" min-width="200" show-overflow-tooltip />
|
||||
@@ -90,6 +93,7 @@
|
||||
<el-button link type="info">更多</el-button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="bindings">查看绑定设备</el-dropdown-item>
|
||||
<el-dropdown-item command="sessions">查看会话</el-dropdown-item>
|
||||
<el-dropdown-item command="reset">重置密码</el-dropdown-item>
|
||||
<el-dropdown-item command="admin">
|
||||
@@ -160,6 +164,40 @@
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 绑定关系(点击用户名查看该用户绑定的终端设备) -->
|
||||
<el-dialog v-model="bindingsVisible" :title="`「${bindingUser?.username}」绑定的终端设备`" width="760px">
|
||||
<el-table v-loading="bindingLoading" :data="userBindings" stripe size="small" empty-text="该用户暂未绑定任何终端设备">
|
||||
<el-table-column prop="deviceUid" label="设备 ID" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column label="别名" min-width="140">
|
||||
<template #default="{ row }">{{ row.alias || '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="角色" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.role === 'OWNER' ? 'primary' : 'info'" size="small">
|
||||
{{ row.role === 'OWNER' ? '所有者' : '成员' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 'ACTIVE' ? 'success' : 'danger'" size="small">
|
||||
{{ row.status === 'ACTIVE' ? '有效' : '已撤销' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="在线" width="90" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.online ? 'success' : 'info'" size="small" effect="dark">
|
||||
{{ row.online ? '在线' : '离线' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="绑定时间" min-width="170">
|
||||
<template #default="{ row }">{{ fmtTime(row.boundAt) }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -178,9 +216,10 @@ import {
|
||||
unbanUser,
|
||||
kickUser,
|
||||
getUserSessions,
|
||||
getUserBindings,
|
||||
kickSession,
|
||||
} from '@/api/admin';
|
||||
import type { AccountStatus, UserAccountView, UserSessionView } from '@/api/admin';
|
||||
import type { AccountStatus, UserAccountView, UserSessionView, UserBindingView } from '@/api/admin';
|
||||
|
||||
const users = ref<UserAccountView[]>([]);
|
||||
const loading = ref(false);
|
||||
@@ -348,6 +387,26 @@ async function openSessions(row: UserAccountView) {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 绑定关系(点击用户名查看该用户绑定的终端设备) ----------
|
||||
const bindingsVisible = ref(false);
|
||||
const bindingUser = ref<UserAccountView | null>(null);
|
||||
const userBindings = ref<UserBindingView[]>([]);
|
||||
const bindingLoading = ref(false);
|
||||
|
||||
async function openBindings(row: UserAccountView) {
|
||||
bindingUser.value = row;
|
||||
bindingsVisible.value = true;
|
||||
bindingLoading.value = true;
|
||||
try {
|
||||
userBindings.value = await getUserBindings(row.userId);
|
||||
} catch {
|
||||
userBindings.value = [];
|
||||
ElMessage.error('加载绑定关系失败');
|
||||
} finally {
|
||||
bindingLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleKickSession(row: UserSessionView) {
|
||||
await kickSession(row.sessionId);
|
||||
ElMessage.success('会话已踢出');
|
||||
@@ -356,7 +415,8 @@ async function handleKickSession(row: UserSessionView) {
|
||||
}
|
||||
|
||||
function handleCommand(cmd: string, row: UserAccountView) {
|
||||
if (cmd === 'sessions') openSessions(row);
|
||||
if (cmd === 'bindings') openBindings(row);
|
||||
else if (cmd === 'sessions') openSessions(row);
|
||||
else if (cmd === 'reset') openReset(row);
|
||||
else if (cmd === 'admin') handleToggleAdmin(row);
|
||||
else if (cmd === 'delete') handleDelete(row);
|
||||
@@ -383,6 +443,14 @@ onMounted(load);
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.link-user {
|
||||
color: var(--el-color-primary);
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
}
|
||||
.link-user:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.page-title {
|
||||
margin: 0 0 4px;
|
||||
font-size: 20px;
|
||||
|
||||
Reference in New Issue
Block a user