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:
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