feat(controlled, controller): 新增配对码生成与兑换功能

被控端:新增生成一次性配对码并弹窗展示,支持倒计时与复制;控制端:新增输入配对码兑换绑定,刷新设备列表,并在设备列表中显示在线状态和点击连接。同时添加统一 401 拦截器,令牌失效时触发重新激活。
This commit is contained in:
TongTongStudio
2026-08-03 01:57:42 +08:00
parent ded6fe5ab7
commit e689b999e6
29 changed files with 1096 additions and 86 deletions

View File

@@ -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[]>;
}

View File

@@ -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);
},
);

View File

@@ -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;