feat(android): add device activation flow and update dependencies
重构项目架构,引入启动页(SplashActivity)检查激活状态,未激活设备引导至激活页(ActivationActivity)完成provision+token流程。集成Retrofit+RxJava3网络层、Room数据库、Lifecycle组件、MMKV等依赖,升级OkHttp/Gson版本,并将构建配置从旧项目全面迁移至新项目结构。
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import request from './request';
|
||||
import request, { publicRequest } from './request';
|
||||
|
||||
export type DeviceType = 'CONTROLLER' | 'CONTROLLED' | null;
|
||||
|
||||
@@ -43,6 +43,54 @@ export interface ServerConfig {
|
||||
serverPort: number;
|
||||
}
|
||||
|
||||
// ==================== 管理员(独立体系) ====================
|
||||
|
||||
export interface AdminAccountView {
|
||||
adminId: string;
|
||||
username: string;
|
||||
displayName: string | null;
|
||||
status: 'ACTIVE' | 'DISABLED';
|
||||
createdAt: number;
|
||||
lastLoginAt: number | null;
|
||||
}
|
||||
|
||||
export interface AdminLoginResult {
|
||||
token: string;
|
||||
tokenType: string;
|
||||
expiresIn: number;
|
||||
adminId: string;
|
||||
username: string;
|
||||
displayName: string | null;
|
||||
}
|
||||
|
||||
export function adminLogin(username: string, password: string): Promise<AdminLoginResult> {
|
||||
return publicRequest.post('/api/admin/login', { username, password }) as unknown as Promise<AdminLoginResult>;
|
||||
}
|
||||
|
||||
export function getAdmins(): Promise<AdminAccountView[]> {
|
||||
return request.get('/api/admin/admins') as unknown as Promise<AdminAccountView[]>;
|
||||
}
|
||||
|
||||
export function createAdmin(username: string, password: string, displayName?: string): Promise<{ success: boolean; admin: AdminAccountView }> {
|
||||
return request.post('/api/admin/admins', { username, password, displayName }) as unknown as Promise<{ success: boolean; admin: AdminAccountView }>;
|
||||
}
|
||||
|
||||
export function disableAdmin(adminId: string): Promise<{ success: boolean }> {
|
||||
return request.post(`/api/admin/admins/${adminId}/disable`) as unknown as Promise<{ success: boolean }>;
|
||||
}
|
||||
|
||||
export function enableAdmin(adminId: string): Promise<{ success: boolean }> {
|
||||
return request.post(`/api/admin/admins/${adminId}/enable`) as unknown as Promise<{ success: boolean }>;
|
||||
}
|
||||
|
||||
export function deleteAdmin(adminId: string): Promise<{ success: boolean }> {
|
||||
return request.delete(`/api/admin/admins/${adminId}`) as unknown as Promise<{ success: boolean }>;
|
||||
}
|
||||
|
||||
export function resetAdminPassword(adminId: string, password: string): Promise<{ success: boolean }> {
|
||||
return request.post(`/api/admin/admins/${adminId}/reset-password`, { password }) as unknown as Promise<{ success: boolean }>;
|
||||
}
|
||||
|
||||
export function getDashboard(): Promise<DashboardData> {
|
||||
return request.get('/api/admin/dashboard') as unknown as Promise<DashboardData>;
|
||||
}
|
||||
@@ -65,7 +113,188 @@ export function getHealth(): Promise<{ status: string }> {
|
||||
return request.get('/api/admin/health') as unknown as Promise<{ status: string }>;
|
||||
}
|
||||
|
||||
/** 校验令牌有效性(用于登录时验证)。 */
|
||||
/** 校验管理员令牌有效性(调用 /me 校验)。 */
|
||||
export function verifyToken(token: string): Promise<unknown> {
|
||||
return request.get('/api/admin/health', { headers: { 'X-Admin-Token': token } });
|
||||
return request.get('/api/admin/me', {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
}) as Promise<unknown>;
|
||||
}
|
||||
|
||||
// ==================== 用户账号管理 ====================
|
||||
|
||||
export type AccountStatus = 'ACTIVE' | 'SUSPENDED' | 'BANNED';
|
||||
|
||||
export interface UserAccountView {
|
||||
userId: string;
|
||||
username: string;
|
||||
status: AccountStatus;
|
||||
statusReason: string | null;
|
||||
statusUntil: number | null;
|
||||
admin: boolean;
|
||||
totpEnabled: boolean;
|
||||
createdAt: number;
|
||||
lastLoginAt: number | null;
|
||||
activeSessions: number;
|
||||
}
|
||||
|
||||
export interface UserSessionView {
|
||||
sessionId: string;
|
||||
createdAt: number;
|
||||
lastSeenAt: number | null;
|
||||
expiresAt: number | null;
|
||||
ip: string | null;
|
||||
userAgent: string | null;
|
||||
}
|
||||
|
||||
export interface UserQuery {
|
||||
keyword?: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export function getUsers(query: UserQuery = {}): Promise<UserAccountView[]> {
|
||||
return request.get('/api/admin/users', { params: query }) as unknown as Promise<UserAccountView[]>;
|
||||
}
|
||||
|
||||
export function createUser(payload: {
|
||||
username: string;
|
||||
password: string;
|
||||
admin?: boolean;
|
||||
}): Promise<{ success: boolean; message?: string; user?: UserAccountView }> {
|
||||
return request.post('/api/admin/users', payload) as unknown as Promise<{
|
||||
success: boolean;
|
||||
message?: string;
|
||||
user?: UserAccountView;
|
||||
}>;
|
||||
}
|
||||
|
||||
export function deleteUser(userId: string): Promise<{ success: boolean }> {
|
||||
return request.delete(`/api/admin/users/${encodeURIComponent(userId)}`) as unknown as Promise<{
|
||||
success: boolean;
|
||||
}>;
|
||||
}
|
||||
|
||||
export function resetUserPassword(
|
||||
userId: string,
|
||||
password: string,
|
||||
): Promise<{ success: boolean; message?: string }> {
|
||||
return request.post(`/api/admin/users/${encodeURIComponent(userId)}/reset-password`, {
|
||||
password,
|
||||
}) as unknown as Promise<{ success: boolean; message?: string }>;
|
||||
}
|
||||
|
||||
export function setUserAdmin(userId: string, admin: boolean): Promise<{ success: boolean }> {
|
||||
return request.post(`/api/admin/users/${encodeURIComponent(userId)}/admin`, {
|
||||
admin,
|
||||
}) as unknown as Promise<{ success: boolean }>;
|
||||
}
|
||||
|
||||
/** 封禁账号,durationSeconds 省略或 <=0 表示永久。 */
|
||||
export function banUser(
|
||||
userId: string,
|
||||
reason: string,
|
||||
durationSeconds?: number,
|
||||
): Promise<{ success: boolean }> {
|
||||
return request.post(`/api/admin/users/${encodeURIComponent(userId)}/ban`, {
|
||||
reason,
|
||||
durationSeconds,
|
||||
}) as unknown as Promise<{ success: boolean }>;
|
||||
}
|
||||
|
||||
export function unbanUser(userId: string): Promise<{ success: boolean }> {
|
||||
return request.post(
|
||||
`/api/admin/users/${encodeURIComponent(userId)}/unban`,
|
||||
) as unknown as Promise<{ success: boolean }>;
|
||||
}
|
||||
|
||||
export function kickUser(userId: string, reason?: string): Promise<{ success: boolean }> {
|
||||
return request.post(`/api/admin/users/${encodeURIComponent(userId)}/kick`, {
|
||||
reason,
|
||||
}) as unknown as Promise<{ success: boolean }>;
|
||||
}
|
||||
|
||||
export function getUserSessions(userId: string): Promise<UserSessionView[]> {
|
||||
return request.get(
|
||||
`/api/admin/users/${encodeURIComponent(userId)}/sessions`,
|
||||
) as unknown as Promise<UserSessionView[]>;
|
||||
}
|
||||
|
||||
export function kickSession(sessionId: string): Promise<{ success: boolean }> {
|
||||
return request.post(
|
||||
`/api/admin/sessions/${encodeURIComponent(sessionId)}/kick`,
|
||||
) as unknown as Promise<{ success: boolean }>;
|
||||
}
|
||||
|
||||
// ==================== 设备账号管理 ====================
|
||||
|
||||
export interface DeviceAccountView {
|
||||
deviceUid: string;
|
||||
sn: string;
|
||||
model: string | null;
|
||||
status: AccountStatus;
|
||||
statusReason: string | null;
|
||||
statusUntil: number | null;
|
||||
provisionedAt: number;
|
||||
lastOnlineAt: number | null;
|
||||
online: boolean;
|
||||
}
|
||||
|
||||
export interface DeviceQuery {
|
||||
keyword?: string;
|
||||
status?: string;
|
||||
online?: boolean;
|
||||
}
|
||||
|
||||
export function getDeviceAccounts(query: DeviceQuery = {}): Promise<DeviceAccountView[]> {
|
||||
return request.get('/api/admin/device-accounts', { params: query }) as unknown as Promise<
|
||||
DeviceAccountView[]
|
||||
>;
|
||||
}
|
||||
|
||||
export function disableDevice(deviceUid: string, reason: string): Promise<{ success: boolean }> {
|
||||
return request.post(`/api/admin/device-accounts/${encodeURIComponent(deviceUid)}/disable`, {
|
||||
reason,
|
||||
}) as unknown as Promise<{ success: boolean }>;
|
||||
}
|
||||
|
||||
export function enableDevice(deviceUid: string): Promise<{ success: boolean }> {
|
||||
return request.post(
|
||||
`/api/admin/device-accounts/${encodeURIComponent(deviceUid)}/enable`,
|
||||
) as unknown as Promise<{ success: boolean }>;
|
||||
}
|
||||
|
||||
export function kickDevice(deviceUid: string, reason?: string): Promise<{ success: boolean }> {
|
||||
return request.post(`/api/admin/device-accounts/${encodeURIComponent(deviceUid)}/kick`, {
|
||||
reason,
|
||||
}) as unknown as Promise<{ success: boolean }>;
|
||||
}
|
||||
|
||||
export function deleteDevice(deviceUid: string): Promise<{ success: boolean }> {
|
||||
return request.delete(
|
||||
`/api/admin/device-accounts/${encodeURIComponent(deviceUid)}`,
|
||||
) as unknown as Promise<{ success: boolean }>;
|
||||
}
|
||||
|
||||
// ==================== SN 白名单 ====================
|
||||
|
||||
export function getAllowlist(): Promise<{ total: number; sns: string[] }> {
|
||||
return request.get('/api/admin/device-allowlist') as unknown as Promise<{
|
||||
total: number;
|
||||
sns: string[];
|
||||
}>;
|
||||
}
|
||||
|
||||
export function importAllowlist(
|
||||
sns: string[],
|
||||
): Promise<{ success: boolean; added: number; total: number }> {
|
||||
return request.post('/api/admin/device-allowlist', { sns }) as unknown as Promise<{
|
||||
success: boolean;
|
||||
added: number;
|
||||
total: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export function removeAllowlist(sn: string): Promise<{ success: boolean; total: number }> {
|
||||
return request.delete(
|
||||
`/api/admin/device-allowlist/${encodeURIComponent(sn)}`,
|
||||
) as unknown as Promise<{ success: boolean; total: number }>;
|
||||
}
|
||||
|
||||
@@ -10,11 +10,37 @@ const request = axios.create({
|
||||
request.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('admin_token');
|
||||
if (token) {
|
||||
config.headers['X-Admin-Token'] = token;
|
||||
config.headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
/**
|
||||
* 不携带任何鉴权头的请求实例,专用于登录等公开接口,
|
||||
* 避免把本地可能过期/失效的令牌误带上去造成 401 / 鉴权混乱。
|
||||
*/
|
||||
export const publicRequest = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_BASE || 'http://localhost:8080',
|
||||
timeout: 15000,
|
||||
});
|
||||
|
||||
publicRequest.interceptors.response.use(
|
||||
(resp) => resp.data,
|
||||
(error) => {
|
||||
const status = error.response?.status;
|
||||
if (status === 401) {
|
||||
localStorage.removeItem('admin_token');
|
||||
ElMessage.error('登录失败,请检查账号或密码');
|
||||
} else {
|
||||
ElMessage.error(error.response?.data?.message || error.message || '请求失败');
|
||||
}
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
|
||||
export default request;
|
||||
|
||||
|
||||
request.interceptors.response.use(
|
||||
(resp) => resp.data,
|
||||
(error) => {
|
||||
@@ -30,4 +56,3 @@ request.interceptors.response.use(
|
||||
},
|
||||
);
|
||||
|
||||
export default request;
|
||||
|
||||
1
WebRTCSignalServerWeb/src/env.d.ts
vendored
1
WebRTCSignalServerWeb/src/env.d.ts
vendored
@@ -8,7 +8,6 @@ declare module '*.vue' {
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_BASE: string;
|
||||
readonly VITE_ADMIN_TOKEN: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
|
||||
@@ -33,7 +33,10 @@ const activeMenu = computed(() => route.path);
|
||||
|
||||
const menus = [
|
||||
{ path: '/dashboard', title: '仪表盘', icon: 'Odometer' },
|
||||
{ path: '/devices', title: '设备管理', icon: 'Monitor' },
|
||||
{ path: '/admins', title: '管理员管理', icon: 'Avatar' },
|
||||
{ path: '/users', title: '终端用户管理', icon: 'User' },
|
||||
{ path: '/device-accounts', title: '终端设备管理', icon: 'Iphone' },
|
||||
{ path: '/devices', title: '在线设备', icon: 'Monitor' },
|
||||
{ path: '/connections', title: '连接请求', icon: 'Connection' },
|
||||
{ path: '/system/info', title: '服务器信息', icon: 'Cpu' },
|
||||
{ path: '/system/config', title: '系统配置', icon: 'Setting' },
|
||||
|
||||
@@ -21,11 +21,29 @@ const routes: RouteRecordRaw[] = [
|
||||
component: () => import('@/views/dashboard/Dashboard.vue'),
|
||||
meta: { title: '仪表盘', icon: 'Odometer' },
|
||||
},
|
||||
{
|
||||
path: 'users',
|
||||
name: 'Users',
|
||||
component: () => import('@/views/users/UserList.vue'),
|
||||
meta: { title: '终端用户管理', icon: 'User' },
|
||||
},
|
||||
{
|
||||
path: 'admins',
|
||||
name: 'Admins',
|
||||
component: () => import('@/views/admins/AdminList.vue'),
|
||||
meta: { title: '管理员管理', icon: 'Avatar' },
|
||||
},
|
||||
{
|
||||
path: 'device-accounts',
|
||||
name: 'DeviceAccounts',
|
||||
component: () => import('@/views/devices/DeviceAccountList.vue'),
|
||||
meta: { title: '终端设备管理', icon: 'Iphone' },
|
||||
},
|
||||
{
|
||||
path: 'devices',
|
||||
name: 'Devices',
|
||||
component: () => import('@/views/devices/DeviceList.vue'),
|
||||
meta: { title: '设备管理', icon: 'Monitor' },
|
||||
meta: { title: '在线设备', icon: 'Monitor' },
|
||||
},
|
||||
{
|
||||
path: 'connections',
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref } from 'vue';
|
||||
import { verifyToken } from '@/api/admin';
|
||||
import { adminLogin, type AdminLoginResult } from '@/api/admin';
|
||||
|
||||
const TOKEN_KEY = 'admin_token';
|
||||
const USER_KEY = 'admin_user';
|
||||
|
||||
export const useUserStore = defineStore('user', () => {
|
||||
const token = ref<string>(localStorage.getItem(TOKEN_KEY) || '');
|
||||
const username = ref<string>(localStorage.getItem(USER_KEY) || 'admin');
|
||||
const username = ref<string>(localStorage.getItem(USER_KEY) || '');
|
||||
|
||||
function setToken(value: string) {
|
||||
if (!value) return;
|
||||
token.value = value;
|
||||
localStorage.setItem(TOKEN_KEY, value);
|
||||
}
|
||||
@@ -19,15 +20,19 @@ export const useUserStore = defineStore('user', () => {
|
||||
localStorage.setItem(USER_KEY, value);
|
||||
}
|
||||
|
||||
/** 登录:先用令牌请求 /health 校验有效性,成功后再保存。 */
|
||||
async function login(value: string) {
|
||||
await verifyToken(value);
|
||||
setToken(value);
|
||||
/** 登录:使用管理员账号密码,成功后将返回的 admt_ 令牌保存到本地。 */
|
||||
async function login(usernameValue: string, password: string) {
|
||||
const result: AdminLoginResult = await adminLogin(usernameValue, password);
|
||||
setToken(result.token);
|
||||
setUser(result.username);
|
||||
return result;
|
||||
}
|
||||
|
||||
function logout() {
|
||||
token.value = '';
|
||||
username.value = '';
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
localStorage.removeItem(USER_KEY);
|
||||
}
|
||||
|
||||
return { token, username, setToken, setUser, login, logout };
|
||||
|
||||
258
WebRTCSignalServerWeb/src/views/admins/AdminList.vue
Normal file
258
WebRTCSignalServerWeb/src/views/admins/AdminList.vue
Normal file
@@ -0,0 +1,258 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<el-card shadow="hover">
|
||||
<div class="toolbar">
|
||||
<div>
|
||||
<h2 class="page-title">后台管理员</h2>
|
||||
<span class="text-muted">共 {{ admins.length }} 个管理员(独立于普通用户体系)</span>
|
||||
</div>
|
||||
<div class="toolbar-actions">
|
||||
<el-button :icon="'Refresh'" :loading="loading" @click="load">刷新</el-button>
|
||||
<el-button type="primary" :icon="'Plus'" @click="openCreate">添加管理员</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-table v-loading="loading" :data="admins" stripe>
|
||||
<el-table-column prop="username" label="账号" min-width="160" />
|
||||
<el-table-column prop="displayName" label="显示名" min-width="140">
|
||||
<template #default="{ row }">{{ row.displayName || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 'ACTIVE' ? 'success' : 'danger'" effect="dark" size="small">
|
||||
{{ row.status === 'ACTIVE' ? '启用' : '禁用' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="创建时间" min-width="170">
|
||||
<template #default="{ row }">{{ formatTime(row.createdAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="最后登录" min-width="170">
|
||||
<template #default="{ row }">{{ row.lastLoginAt ? formatTime(row.lastLoginAt) : '从未登录' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" min-width="240" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
v-if="row.status === 'ACTIVE'"
|
||||
link
|
||||
type="warning"
|
||||
@click="disable(row)"
|
||||
>禁用</el-button>
|
||||
<el-button
|
||||
v-else
|
||||
link
|
||||
type="success"
|
||||
@click="enable(row)"
|
||||
>启用</el-button>
|
||||
<el-button link type="primary" @click="openReset(row)">重置密码</el-button>
|
||||
<el-button link type="danger" @click="remove(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<!-- 新增管理员 -->
|
||||
<el-dialog v-model="createVisible" title="添加管理员" width="420px">
|
||||
<el-form :model="createForm" label-width="80px">
|
||||
<el-form-item label="账号">
|
||||
<el-input v-model="createForm.username" placeholder="3-32 位字母/数字/下划线/点/连字符" />
|
||||
</el-form-item>
|
||||
<el-form-item label="显示名">
|
||||
<el-input v-model="createForm.displayName" placeholder="可选" />
|
||||
</el-form-item>
|
||||
<el-form-item label="密码">
|
||||
<el-input
|
||||
v-model="createForm.password"
|
||||
type="password"
|
||||
show-password
|
||||
placeholder="8-128 位,含两类字符"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="createVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="submitCreate">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 重置密码 -->
|
||||
<el-dialog v-model="resetVisible" title="重置密码" width="420px">
|
||||
<el-form :model="resetForm" label-width="80px">
|
||||
<el-form-item label="账号">
|
||||
<span>{{ resetForm.username }}</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="新密码">
|
||||
<el-input
|
||||
v-model="resetForm.password"
|
||||
type="password"
|
||||
show-password
|
||||
placeholder="8-128 位,含两类字符"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="resetVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="submitReset">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import {
|
||||
getAdmins,
|
||||
createAdmin,
|
||||
disableAdmin,
|
||||
enableAdmin,
|
||||
deleteAdmin,
|
||||
resetAdminPassword,
|
||||
type AdminAccountView,
|
||||
} from '@/api/admin';
|
||||
|
||||
const admins = ref<AdminAccountView[]>([]);
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
|
||||
const createVisible = ref(false);
|
||||
const createForm = ref({ username: '', displayName: '', password: '' });
|
||||
|
||||
const resetVisible = ref(false);
|
||||
const resetForm = ref({ adminId: '', username: '', password: '' });
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
admins.value = await getAdmins();
|
||||
} catch (e: unknown) {
|
||||
const err = e as { response?: { data?: { message?: string } } };
|
||||
ElMessage.error(err?.response?.data?.message || '加载失败');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
createForm.value = { username: '', displayName: '', password: '' };
|
||||
createVisible.value = true;
|
||||
}
|
||||
|
||||
async function submitCreate() {
|
||||
const { username, password, displayName } = createForm.value;
|
||||
if (!username.trim()) {
|
||||
ElMessage.warning('请输入账号');
|
||||
return;
|
||||
}
|
||||
if (!password) {
|
||||
ElMessage.warning('请输入密码');
|
||||
return;
|
||||
}
|
||||
saving.value = true;
|
||||
try {
|
||||
await createAdmin(username.trim(), password, displayName.trim() || undefined);
|
||||
ElMessage.success('管理员已添加');
|
||||
createVisible.value = false;
|
||||
await load();
|
||||
} catch (e: unknown) {
|
||||
const err = e as { response?: { data?: { message?: string } } };
|
||||
ElMessage.error(err?.response?.data?.message || '添加失败');
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function disable(row: AdminAccountView) {
|
||||
await ElMessageBox.confirm(`确定禁用管理员「${row.username}」?`, '提示', { type: 'warning' });
|
||||
try {
|
||||
await disableAdmin(row.adminId);
|
||||
ElMessage.success('已禁用');
|
||||
await load();
|
||||
} catch (e: unknown) {
|
||||
const err = e as { response?: { data?: { message?: string } } };
|
||||
ElMessage.error(err?.response?.data?.message || '操作失败');
|
||||
}
|
||||
}
|
||||
|
||||
async function enable(row: AdminAccountView) {
|
||||
try {
|
||||
await enableAdmin(row.adminId);
|
||||
ElMessage.success('已启用');
|
||||
await load();
|
||||
} catch (e: unknown) {
|
||||
const err = e as { response?: { data?: { message?: string } } };
|
||||
ElMessage.error(err?.response?.data?.message || '操作失败');
|
||||
}
|
||||
}
|
||||
|
||||
function openReset(row: AdminAccountView) {
|
||||
resetForm.value = { adminId: row.adminId, username: row.username, password: '' };
|
||||
resetVisible.value = true;
|
||||
}
|
||||
|
||||
async function submitReset() {
|
||||
if (!resetForm.value.password) {
|
||||
ElMessage.warning('请输入新密码');
|
||||
return;
|
||||
}
|
||||
saving.value = true;
|
||||
try {
|
||||
await resetAdminPassword(resetForm.value.adminId, resetForm.value.password);
|
||||
ElMessage.success('密码已重置');
|
||||
resetVisible.value = false;
|
||||
} catch (e: unknown) {
|
||||
const err = e as { response?: { data?: { message?: string } } };
|
||||
ElMessage.error(err?.response?.data?.message || '重置失败');
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(row: AdminAccountView) {
|
||||
await ElMessageBox.confirm(
|
||||
`确定删除管理员「${row.username}」?该操作不可恢复。`,
|
||||
'危险操作',
|
||||
{ type: 'error' },
|
||||
);
|
||||
try {
|
||||
await deleteAdmin(row.adminId);
|
||||
ElMessage.success('已删除');
|
||||
await load();
|
||||
} catch (e: unknown) {
|
||||
const err = e as { response?: { data?: { message?: string } } };
|
||||
ElMessage.error(err?.response?.data?.message || '删除失败');
|
||||
}
|
||||
}
|
||||
|
||||
function formatTime(ts: number): string {
|
||||
if (!ts) return '-';
|
||||
const d = new Date(ts);
|
||||
return d.toLocaleString('zh-CN', { hour12: false });
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-container {
|
||||
padding: 16px;
|
||||
}
|
||||
.toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.page-title {
|
||||
margin: 0 0 4px;
|
||||
font-size: 18px;
|
||||
}
|
||||
.text-muted {
|
||||
color: #909399;
|
||||
font-size: 13px;
|
||||
}
|
||||
.toolbar-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
</style>
|
||||
336
WebRTCSignalServerWeb/src/views/devices/DeviceAccountList.vue
Normal file
336
WebRTCSignalServerWeb/src/views/devices/DeviceAccountList.vue
Normal file
@@ -0,0 +1,336 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<el-card shadow="hover">
|
||||
<el-tabs v-model="activeTab">
|
||||
<!-- ============ 设备账号 ============ -->
|
||||
<el-tab-pane label="设备账号" name="devices">
|
||||
<div class="toolbar">
|
||||
<div>
|
||||
<span class="text-muted">
|
||||
共 {{ devices.length }} 台已激活设备 · 在线 {{ onlineCount }} 台
|
||||
</span>
|
||||
</div>
|
||||
<div class="toolbar-actions">
|
||||
<el-input
|
||||
v-model="keyword"
|
||||
placeholder="搜索设备 UID / 型号"
|
||||
clearable
|
||||
style="width: 200px"
|
||||
@keyup.enter="loadDevices"
|
||||
@clear="loadDevices"
|
||||
/>
|
||||
<el-select
|
||||
v-model="statusFilter"
|
||||
placeholder="全部状态"
|
||||
clearable
|
||||
style="width: 130px"
|
||||
>
|
||||
<el-option label="正常" value="ACTIVE" />
|
||||
<el-option label="停用" value="SUSPENDED" />
|
||||
<el-option label="封禁" value="BANNED" />
|
||||
</el-select>
|
||||
<el-select
|
||||
v-model="onlineFilter"
|
||||
placeholder="全部"
|
||||
clearable
|
||||
style="width: 120px"
|
||||
>
|
||||
<el-option label="在线" :value="true" />
|
||||
<el-option label="离线" :value="false" />
|
||||
</el-select>
|
||||
<el-button :icon="'Refresh'" :loading="loading" @click="loadDevices">刷新</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-table v-loading="loading" :data="devices" stripe>
|
||||
<el-table-column prop="deviceUid" label="设备 UID" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column prop="sn" label="序列号" min-width="140" />
|
||||
<el-table-column prop="model" label="型号" min-width="130">
|
||||
<template #default="{ row }">{{ row.model || '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="140">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="statusTagType(row.status)" effect="dark" size="small">
|
||||
{{ statusText(row.status) }}
|
||||
</el-tag>
|
||||
<el-tooltip v-if="row.statusReason" :content="row.statusReason" placement="top">
|
||||
<el-icon class="reason-icon"><InfoFilled /></el-icon>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="连接" width="90">
|
||||
<template #default="{ row }">
|
||||
<span class="status-dot" :class="row.online ? 'online' : 'offline'"></span>
|
||||
{{ row.online ? '在线' : '离线' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="最后在线" min-width="170">
|
||||
<template #default="{ row }">{{ fmtTime(row.lastOnlineAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="激活时间" min-width="170">
|
||||
<template #default="{ row }">{{ fmtTime(row.provisionedAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="220" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
v-if="row.status === 'ACTIVE'"
|
||||
link
|
||||
type="warning"
|
||||
@click="handleDisable(row)"
|
||||
>
|
||||
禁用
|
||||
</el-button>
|
||||
<el-button v-else link type="success" @click="handleEnable(row)">启用</el-button>
|
||||
<el-button link type="primary" :disabled="!row.online" @click="handleKick(row)">
|
||||
下线
|
||||
</el-button>
|
||||
<el-button link type="danger" @click="handleDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- ============ SN 白名单 ============ -->
|
||||
<el-tab-pane label="SN 白名单" name="allowlist">
|
||||
<div class="toolbar">
|
||||
<div>
|
||||
<span class="text-muted">共 {{ allowlist.length }} 条白名单记录</span>
|
||||
</div>
|
||||
<div class="toolbar-actions">
|
||||
<el-input
|
||||
v-model="allowKeyword"
|
||||
placeholder="搜索 SN"
|
||||
clearable
|
||||
style="width: 200px"
|
||||
/>
|
||||
<el-button :icon="'Refresh'" :loading="allowLoading" @click="loadAllowlist">
|
||||
刷新
|
||||
</el-button>
|
||||
<el-button type="primary" :icon="'Plus'" @click="importVisible = true">
|
||||
批量导入
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-table v-loading="allowLoading" :data="filteredAllowlist" stripe>
|
||||
<el-table-column type="index" label="#" width="70" />
|
||||
<el-table-column prop="sn" label="序列号" min-width="240" />
|
||||
<el-table-column label="操作" width="100" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="danger" @click="handleRemoveAllow(row.sn)">移除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</el-card>
|
||||
|
||||
<!-- 批量导入白名单 -->
|
||||
<el-dialog v-model="importVisible" title="批量导入 SN 白名单" width="480px">
|
||||
<el-input
|
||||
v-model="importText"
|
||||
type="textarea"
|
||||
:rows="10"
|
||||
placeholder="每行一个 SN,例如: SN0001 SN0002"
|
||||
/>
|
||||
<template #footer>
|
||||
<el-button @click="importVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="submitImport">导入</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch } from 'vue';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import { InfoFilled } from '@element-plus/icons-vue';
|
||||
import {
|
||||
getDeviceAccounts,
|
||||
disableDevice,
|
||||
enableDevice,
|
||||
kickDevice,
|
||||
deleteDevice,
|
||||
getAllowlist,
|
||||
importAllowlist,
|
||||
removeAllowlist,
|
||||
} from '@/api/admin';
|
||||
import type { AccountStatus, DeviceAccountView } from '@/api/admin';
|
||||
|
||||
const activeTab = ref('devices');
|
||||
|
||||
// ---------- 设备账号 ----------
|
||||
const devices = ref<DeviceAccountView[]>([]);
|
||||
const loading = ref(false);
|
||||
const submitting = ref(false);
|
||||
const keyword = ref('');
|
||||
const statusFilter = ref('');
|
||||
const onlineFilter = ref<boolean | ''>('');
|
||||
|
||||
const onlineCount = computed(() => devices.value.filter((d) => d.online).length);
|
||||
|
||||
async function loadDevices() {
|
||||
loading.value = true;
|
||||
try {
|
||||
devices.value = await getDeviceAccounts({
|
||||
keyword: keyword.value || undefined,
|
||||
status: statusFilter.value || undefined,
|
||||
online: onlineFilter.value === '' ? undefined : onlineFilter.value,
|
||||
});
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
watch([statusFilter, onlineFilter], loadDevices);
|
||||
|
||||
async function handleDisable(row: DeviceAccountView) {
|
||||
const input = await ElMessageBox.prompt('请输入禁用原因', `禁用设备「${row.deviceUid}」`, {
|
||||
inputPlaceholder: '例如:设备遗失',
|
||||
confirmButtonText: '确定禁用',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
}).catch(() => null);
|
||||
if (!input) return;
|
||||
await disableDevice(row.deviceUid, input.value || '管理员操作');
|
||||
ElMessage.success('设备已禁用');
|
||||
await loadDevices();
|
||||
}
|
||||
|
||||
async function handleEnable(row: DeviceAccountView) {
|
||||
await enableDevice(row.deviceUid);
|
||||
ElMessage.success('设备已启用');
|
||||
await loadDevices();
|
||||
}
|
||||
|
||||
async function handleKick(row: DeviceAccountView) {
|
||||
const ok = await ElMessageBox.confirm(
|
||||
`确定强制设备「${row.deviceUid}」下线?其访问令牌将立即失效。`,
|
||||
'强制下线',
|
||||
{ type: 'warning', confirmButtonText: '确定', cancelButtonText: '取消' },
|
||||
).catch(() => false);
|
||||
if (!ok) return;
|
||||
await kickDevice(row.deviceUid, '管理员强制下线');
|
||||
ElMessage.success('已强制下线');
|
||||
await loadDevices();
|
||||
}
|
||||
|
||||
async function handleDelete(row: DeviceAccountView) {
|
||||
const ok = await ElMessageBox.confirm(
|
||||
`确定删除设备「${row.deviceUid}」?该操作不可恢复,设备需重新激活。`,
|
||||
'删除设备',
|
||||
{ type: 'error', confirmButtonText: '确定删除', cancelButtonText: '取消' },
|
||||
).catch(() => false);
|
||||
if (!ok) return;
|
||||
await deleteDevice(row.deviceUid);
|
||||
ElMessage.success('设备已删除');
|
||||
await loadDevices();
|
||||
}
|
||||
|
||||
// ---------- SN 白名单 ----------
|
||||
const allowlist = ref<{ sn: string }[]>([]);
|
||||
const allowLoading = ref(false);
|
||||
const allowKeyword = ref('');
|
||||
const importVisible = ref(false);
|
||||
const importText = ref('');
|
||||
|
||||
const filteredAllowlist = computed(() => {
|
||||
const kw = allowKeyword.value.trim().toLowerCase();
|
||||
return kw ? allowlist.value.filter((i) => i.sn.toLowerCase().includes(kw)) : allowlist.value;
|
||||
});
|
||||
|
||||
async function loadAllowlist() {
|
||||
allowLoading.value = true;
|
||||
try {
|
||||
const resp = await getAllowlist();
|
||||
allowlist.value = (resp.sns || []).map((sn) => ({ sn }));
|
||||
} finally {
|
||||
allowLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function submitImport() {
|
||||
const sns = importText.value
|
||||
.split('\n')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
if (!sns.length) {
|
||||
ElMessage.warning('请输入至少一个 SN');
|
||||
return;
|
||||
}
|
||||
submitting.value = true;
|
||||
try {
|
||||
const resp = await importAllowlist(sns);
|
||||
ElMessage.success(`导入成功,新增 ${resp.added} 条,当前共 ${resp.total} 条`);
|
||||
importVisible.value = false;
|
||||
importText.value = '';
|
||||
await loadAllowlist();
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRemoveAllow(sn: string) {
|
||||
const ok = await ElMessageBox.confirm(`确定将「${sn}」移出白名单?`, '移除白名单', {
|
||||
type: 'warning',
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
}).catch(() => false);
|
||||
if (!ok) return;
|
||||
await removeAllowlist(sn);
|
||||
ElMessage.success('已移除');
|
||||
await loadAllowlist();
|
||||
}
|
||||
|
||||
watch(activeTab, (tab) => {
|
||||
if (tab === 'allowlist' && !allowlist.value.length) loadAllowlist();
|
||||
});
|
||||
|
||||
function statusText(status: AccountStatus) {
|
||||
return { ACTIVE: '正常', SUSPENDED: '停用', BANNED: '封禁' }[status] || status;
|
||||
}
|
||||
|
||||
function statusTagType(status: AccountStatus) {
|
||||
return { ACTIVE: 'success', SUSPENDED: 'warning', BANNED: 'danger' }[status] || 'info';
|
||||
}
|
||||
|
||||
function fmtTime(ts: number | null) {
|
||||
return ts ? new Date(ts).toLocaleString('zh-CN') : '—';
|
||||
}
|
||||
|
||||
onMounted(loadDevices);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
.toolbar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.reason-icon {
|
||||
margin-left: 6px;
|
||||
color: var(--el-color-warning);
|
||||
vertical-align: middle;
|
||||
}
|
||||
.status-dot {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
margin-right: 6px;
|
||||
}
|
||||
.status-dot.online {
|
||||
background: #67c23a;
|
||||
}
|
||||
.status-dot.offline {
|
||||
background: #f56c6c;
|
||||
}
|
||||
</style>
|
||||
@@ -3,7 +3,7 @@
|
||||
<el-card shadow="hover">
|
||||
<div class="toolbar">
|
||||
<div>
|
||||
<h2 class="page-title">设备管理</h2>
|
||||
<h2 class="page-title">终端设备管理</h2>
|
||||
<span class="text-muted">共 {{ devices.length }} 台已注册设备</span>
|
||||
</div>
|
||||
<div class="toolbar-actions">
|
||||
|
||||
@@ -9,10 +9,21 @@
|
||||
<el-form @submit.prevent="onSubmit">
|
||||
<el-form-item>
|
||||
<el-input
|
||||
v-model="token"
|
||||
v-model="username"
|
||||
size="large"
|
||||
placeholder="请输入管理令牌 (X-Admin-Token)"
|
||||
:prefix-icon="'Key'"
|
||||
placeholder="管理员账号"
|
||||
:prefix-icon="'User'"
|
||||
@keyup.enter="onSubmit"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-input
|
||||
v-model="password"
|
||||
size="large"
|
||||
type="password"
|
||||
show-password
|
||||
placeholder="密码"
|
||||
:prefix-icon="'Lock'"
|
||||
@keyup.enter="onSubmit"
|
||||
/>
|
||||
</el-form-item>
|
||||
@@ -26,8 +37,9 @@
|
||||
登 录
|
||||
</el-button>
|
||||
<p class="hint">
|
||||
默认令牌与服务器 <code>application.yml</code> 中的
|
||||
<code>admin.token</code> 一致(当前默认:<code>webrtc-admin-token</code>)。
|
||||
默认管理员账号/密码与服务器 <code>application.yml</code> 中的
|
||||
<code>admin.bootstrap-username</code> / <code>admin.bootstrap-password</code> 一致
|
||||
(当前默认:<code>admin</code> / <code>admin123456</code>)。首次登录后请及时修改密码。
|
||||
</p>
|
||||
</el-form>
|
||||
</div>
|
||||
@@ -43,23 +55,24 @@ import { ElMessage } from 'element-plus';
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const user = useUserStore();
|
||||
const token = ref(import.meta.env.VITE_ADMIN_TOKEN || 'webrtc-admin-token');
|
||||
const username = ref('');
|
||||
const password = ref('');
|
||||
const loading = ref(false);
|
||||
|
||||
async function onSubmit() {
|
||||
if (!token.value.trim()) {
|
||||
ElMessage.warning('请输入管理令牌');
|
||||
if (!username.value.trim() || !password.value) {
|
||||
ElMessage.warning('请输入账号和密码');
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
try {
|
||||
await user.login(token.value.trim());
|
||||
await user.login(username.value.trim(), password.value);
|
||||
ElMessage.success('登录成功');
|
||||
const redirect = (route.query.redirect as string) || '/dashboard';
|
||||
router.replace(redirect);
|
||||
} catch (e: unknown) {
|
||||
const err = e as { response?: { data?: { message?: string } } };
|
||||
ElMessage.error(err?.response?.data?.message || '令牌校验失败');
|
||||
ElMessage.error(err?.response?.data?.message || '账号或密码错误');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
@@ -17,19 +17,22 @@
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="hover" header="管理令牌 (admin.token)" class="info-card">
|
||||
<el-card shadow="hover" header="管理员鉴权" class="info-card">
|
||||
<el-alert type="info" :closable="false" show-icon>
|
||||
<template #title>如何修改后台登录令牌</template>
|
||||
<template #title>后台登录机制</template>
|
||||
<p>
|
||||
管理接口受 <code>X-Admin-Token</code> 头保护,令牌由服务器
|
||||
<code>application.yml</code> 中的 <code>admin.token</code> 配置,
|
||||
也可通过环境变量 <code>ADMIN_TOKEN</code> 覆盖。
|
||||
后台使用<strong>独立的管理员账号密码</strong>体系,与普通用户完全隔离,
|
||||
数据存储在独立的 <code>admin_account</code> 表中,登录后签发独立的管理员令牌
|
||||
(<code>admt_</code> 前缀 JWT,默认 1 小时有效)。
|
||||
</p>
|
||||
<p>当前前端默认令牌取自 <code>.env</code> 的 <code>VITE_ADMIN_TOKEN</code>,需与服务器保持一致。</p>
|
||||
<p>示例:</p>
|
||||
<pre>admin:
|
||||
token: ${ADMIN_TOKEN:webrtc-admin-token}</pre>
|
||||
<p class="text-muted">修改后重启信令服务器即可生效。</p>
|
||||
<p>
|
||||
首个管理员在管理员表为空时,由服务器
|
||||
<code>application.yml</code> 的
|
||||
<code>admin.bootstrap-username</code> / <code>admin.bootstrap-password</code>
|
||||
自动创建(也可通过环境变量 <code>ADMIN_BOOTSTRAP_USERNAME</code> /
|
||||
<code>ADMIN_BOOTSTRAP_PASSWORD</code> 覆盖)。
|
||||
</p>
|
||||
<p class="text-muted">建议首次登录后,在「管理员管理」中修改密码并新增专用管理员账号。</p>
|
||||
</el-alert>
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
408
WebRTCSignalServerWeb/src/views/users/UserList.vue
Normal file
408
WebRTCSignalServerWeb/src/views/users/UserList.vue
Normal file
@@ -0,0 +1,408 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<el-card shadow="hover">
|
||||
<div class="toolbar">
|
||||
<div>
|
||||
<h2 class="page-title">终端用户管理</h2>
|
||||
<span class="text-muted">
|
||||
共 {{ users.length }} 个账号 · 在线会话 {{ totalSessions }} 个
|
||||
</span>
|
||||
</div>
|
||||
<div class="toolbar-actions">
|
||||
<el-input
|
||||
v-model="keyword"
|
||||
placeholder="搜索用户名"
|
||||
clearable
|
||||
style="width: 180px"
|
||||
@keyup.enter="load"
|
||||
@clear="load"
|
||||
/>
|
||||
<el-select v-model="statusFilter" placeholder="全部状态" clearable style="width: 140px">
|
||||
<el-option label="正常" value="ACTIVE" />
|
||||
<el-option label="停用" value="SUSPENDED" />
|
||||
<el-option label="封禁" value="BANNED" />
|
||||
</el-select>
|
||||
<el-button :icon="'Refresh'" :loading="loading" @click="load">刷新</el-button>
|
||||
<el-button type="primary" :icon="'Plus'" @click="openCreate">添加用户</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-table v-loading="loading" :data="users" stripe>
|
||||
<el-table-column prop="username" label="用户名" min-width="150">
|
||||
<template #default="{ row }">
|
||||
<span>{{ row.username }}</span>
|
||||
<el-tag v-if="row.admin" type="danger" size="small" style="margin-left: 6px">
|
||||
管理员
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="userId" label="用户 ID" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column label="状态" width="140">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="statusTagType(row.status)" effect="dark" size="small">
|
||||
{{ statusText(row.status) }}
|
||||
</el-tag>
|
||||
<el-tooltip v-if="row.statusReason" :content="row.statusReason" placement="top">
|
||||
<el-icon class="reason-icon"><InfoFilled /></el-icon>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="双因子" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.totpEnabled ? 'success' : 'info'" size="small">
|
||||
{{ row.totpEnabled ? '已启用' : '未启用' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="在线会话" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
v-if="row.activeSessions > 0"
|
||||
link
|
||||
type="primary"
|
||||
@click="openSessions(row)"
|
||||
>
|
||||
{{ row.activeSessions }}
|
||||
</el-button>
|
||||
<span v-else class="text-muted">0</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="最后登录" min-width="170">
|
||||
<template #default="{ row }">{{ fmtTime(row.lastLoginAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="创建时间" min-width="170">
|
||||
<template #default="{ row }">{{ fmtTime(row.createdAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="230" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<span class="action-group">
|
||||
<el-button
|
||||
v-if="row.status === 'ACTIVE'"
|
||||
link
|
||||
type="warning"
|
||||
@click="handleBan(row)"
|
||||
>
|
||||
禁用
|
||||
</el-button>
|
||||
<el-button v-else link type="success" @click="handleUnban(row)">启用</el-button>
|
||||
<el-button link type="primary" @click="handleKick(row)">下线</el-button>
|
||||
<el-dropdown trigger="click" @command="(cmd: string) => handleCommand(cmd, row)">
|
||||
<el-button link type="info">更多</el-button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="sessions">查看会话</el-dropdown-item>
|
||||
<el-dropdown-item command="reset">重置密码</el-dropdown-item>
|
||||
<el-dropdown-item command="admin">
|
||||
{{ row.admin ? '取消管理员' : '设为管理员' }}
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item command="delete" divided>删除账号</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<!-- 添加用户 -->
|
||||
<el-dialog v-model="createVisible" title="添加用户" width="440px">
|
||||
<el-form ref="createFormRef" :model="createForm" :rules="createRules" label-width="80px">
|
||||
<el-form-item label="用户名" prop="username">
|
||||
<el-input v-model="createForm.username" placeholder="3-32 位字母数字下划线" />
|
||||
</el-form-item>
|
||||
<el-form-item label="密码" prop="password">
|
||||
<el-input v-model="createForm.password" type="password" show-password />
|
||||
</el-form-item>
|
||||
<el-form-item label="管理员">
|
||||
<el-switch v-model="createForm.admin" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="createVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="submitCreate">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 重置密码 -->
|
||||
<el-dialog v-model="resetVisible" title="重置密码" width="420px">
|
||||
<el-alert
|
||||
:title="`将重置账号「${current?.username}」的密码,该账号所有登录会话会被强制下线`"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
style="margin-bottom: 16px"
|
||||
/>
|
||||
<el-input v-model="newPassword" type="password" placeholder="请输入新密码" show-password />
|
||||
<template #footer>
|
||||
<el-button @click="resetVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="submitReset">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 会话列表 -->
|
||||
<el-dialog v-model="sessionsVisible" :title="`「${current?.username}」的登录会话`" width="720px">
|
||||
<el-table v-loading="sessionLoading" :data="sessions" stripe size="small">
|
||||
<el-table-column prop="sessionId" label="会话 ID" min-width="180" show-overflow-tooltip />
|
||||
<el-table-column prop="ip" label="IP" width="130">
|
||||
<template #default="{ row }">{{ row.ip || '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="登录时间" min-width="160">
|
||||
<template #default="{ row }">{{ fmtTime(row.createdAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="最后活跃" min-width="160">
|
||||
<template #default="{ row }">{{ fmtTime(row.lastSeenAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="80" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="danger" @click="handleKickSession(row)">踢出</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch } from 'vue';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import { InfoFilled } from '@element-plus/icons-vue';
|
||||
import type { FormInstance, FormRules } from 'element-plus';
|
||||
import {
|
||||
getUsers,
|
||||
createUser,
|
||||
deleteUser,
|
||||
resetUserPassword,
|
||||
setUserAdmin,
|
||||
banUser,
|
||||
unbanUser,
|
||||
kickUser,
|
||||
getUserSessions,
|
||||
kickSession,
|
||||
} from '@/api/admin';
|
||||
import type { AccountStatus, UserAccountView, UserSessionView } from '@/api/admin';
|
||||
|
||||
const users = ref<UserAccountView[]>([]);
|
||||
const loading = ref(false);
|
||||
const submitting = ref(false);
|
||||
const keyword = ref('');
|
||||
const statusFilter = ref('');
|
||||
const current = ref<UserAccountView | null>(null);
|
||||
|
||||
const totalSessions = computed(() =>
|
||||
users.value.reduce((sum, u) => sum + (u.activeSessions || 0), 0),
|
||||
);
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
users.value = await getUsers({
|
||||
keyword: keyword.value || undefined,
|
||||
status: statusFilter.value || undefined,
|
||||
});
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
watch(statusFilter, load);
|
||||
|
||||
// ---------- 添加用户 ----------
|
||||
const createVisible = ref(false);
|
||||
const createFormRef = ref<FormInstance>();
|
||||
const createForm = ref({ username: '', password: '', admin: false });
|
||||
const createRules: FormRules = {
|
||||
username: [
|
||||
{ required: true, message: '请输入用户名', trigger: 'blur' },
|
||||
{ min: 3, max: 32, message: '长度需为 3-32 位', trigger: 'blur' },
|
||||
],
|
||||
password: [
|
||||
{ required: true, message: '请输入密码', trigger: 'blur' },
|
||||
{ min: 8, message: '密码至少 8 位', trigger: 'blur' },
|
||||
],
|
||||
};
|
||||
|
||||
function openCreate() {
|
||||
createForm.value = { username: '', password: '', admin: false };
|
||||
createVisible.value = true;
|
||||
}
|
||||
|
||||
async function submitCreate() {
|
||||
const valid = await createFormRef.value?.validate().catch(() => false);
|
||||
if (!valid) return;
|
||||
submitting.value = true;
|
||||
try {
|
||||
const resp = await createUser(createForm.value);
|
||||
if (resp.success) {
|
||||
ElMessage.success('账号创建成功');
|
||||
createVisible.value = false;
|
||||
await load();
|
||||
} else {
|
||||
ElMessage.error(resp.message || '创建失败');
|
||||
}
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 禁用 / 启用 / 下线 ----------
|
||||
async function handleBan(row: UserAccountView) {
|
||||
const input = await ElMessageBox.prompt('请输入禁用原因', `禁用账号「${row.username}」`, {
|
||||
inputPlaceholder: '例如:违规操作',
|
||||
confirmButtonText: '确定禁用',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
}).catch(() => null);
|
||||
if (!input) return;
|
||||
await banUser(row.userId, input.value || '管理员操作');
|
||||
ElMessage.success('账号已禁用');
|
||||
await load();
|
||||
}
|
||||
|
||||
async function handleUnban(row: UserAccountView) {
|
||||
await unbanUser(row.userId);
|
||||
ElMessage.success('账号已启用');
|
||||
await load();
|
||||
}
|
||||
|
||||
async function handleKick(row: UserAccountView) {
|
||||
const ok = await ElMessageBox.confirm(
|
||||
`确定强制账号「${row.username}」全端下线?该账号需要重新登录。`,
|
||||
'强制下线',
|
||||
{ type: 'warning', confirmButtonText: '确定', cancelButtonText: '取消' },
|
||||
).catch(() => false);
|
||||
if (!ok) return;
|
||||
await kickUser(row.userId, '管理员强制下线');
|
||||
ElMessage.success('已强制下线');
|
||||
await load();
|
||||
}
|
||||
|
||||
async function handleDelete(row: UserAccountView) {
|
||||
const ok = await ElMessageBox.confirm(
|
||||
`确定删除账号「${row.username}」?该操作不可恢复,其全部会话将被清除。`,
|
||||
'删除账号',
|
||||
{ type: 'error', confirmButtonText: '确定删除', cancelButtonText: '取消' },
|
||||
).catch(() => false);
|
||||
if (!ok) return;
|
||||
await deleteUser(row.userId);
|
||||
ElMessage.success('账号已删除');
|
||||
await load();
|
||||
}
|
||||
|
||||
async function handleToggleAdmin(row: UserAccountView) {
|
||||
const next = !row.admin;
|
||||
const ok = await ElMessageBox.confirm(
|
||||
`确定${next ? '授予' : '取消'}「${row.username}」的管理员权限?`,
|
||||
'权限调整',
|
||||
{ type: 'warning', confirmButtonText: '确定', cancelButtonText: '取消' },
|
||||
).catch(() => false);
|
||||
if (!ok) return;
|
||||
await setUserAdmin(row.userId, next);
|
||||
ElMessage.success('权限已更新');
|
||||
await load();
|
||||
}
|
||||
|
||||
// ---------- 重置密码 ----------
|
||||
const resetVisible = ref(false);
|
||||
const newPassword = ref('');
|
||||
|
||||
function openReset(row: UserAccountView) {
|
||||
current.value = row;
|
||||
newPassword.value = '';
|
||||
resetVisible.value = true;
|
||||
}
|
||||
|
||||
async function submitReset() {
|
||||
if (!newPassword.value || newPassword.value.length < 8) {
|
||||
ElMessage.warning('密码至少 8 位');
|
||||
return;
|
||||
}
|
||||
submitting.value = true;
|
||||
try {
|
||||
const resp = await resetUserPassword(current.value!.userId, newPassword.value);
|
||||
if (resp.success) {
|
||||
ElMessage.success('密码已重置');
|
||||
resetVisible.value = false;
|
||||
await load();
|
||||
} else {
|
||||
ElMessage.error(resp.message || '重置失败');
|
||||
}
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 会话 ----------
|
||||
const sessionsVisible = ref(false);
|
||||
const sessions = ref<UserSessionView[]>([]);
|
||||
const sessionLoading = ref(false);
|
||||
|
||||
async function openSessions(row: UserAccountView) {
|
||||
current.value = row;
|
||||
sessionsVisible.value = true;
|
||||
sessionLoading.value = true;
|
||||
try {
|
||||
sessions.value = await getUserSessions(row.userId);
|
||||
} finally {
|
||||
sessionLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleKickSession(row: UserSessionView) {
|
||||
await kickSession(row.sessionId);
|
||||
ElMessage.success('会话已踢出');
|
||||
if (current.value) await openSessions(current.value);
|
||||
await load();
|
||||
}
|
||||
|
||||
function handleCommand(cmd: string, row: UserAccountView) {
|
||||
if (cmd === 'sessions') openSessions(row);
|
||||
else if (cmd === 'reset') openReset(row);
|
||||
else if (cmd === 'admin') handleToggleAdmin(row);
|
||||
else if (cmd === 'delete') handleDelete(row);
|
||||
}
|
||||
|
||||
function statusText(status: AccountStatus) {
|
||||
return { ACTIVE: '正常', SUSPENDED: '停用', BANNED: '封禁' }[status] || status;
|
||||
}
|
||||
|
||||
function statusTagType(status: AccountStatus) {
|
||||
return { ACTIVE: 'success', SUSPENDED: 'warning', BANNED: 'danger' }[status] || 'info';
|
||||
}
|
||||
|
||||
function fmtTime(ts: number | null) {
|
||||
return ts ? new Date(ts).toLocaleString('zh-CN') : '—';
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.action-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.page-title {
|
||||
margin: 0 0 4px;
|
||||
font-size: 20px;
|
||||
}
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
.toolbar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.reason-icon {
|
||||
margin-left: 6px;
|
||||
color: var(--el-color-warning);
|
||||
vertical-align: middle;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user