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:
TongTongStudio
2026-08-03 00:25:51 +08:00
parent 61398ff4ff
commit ded6fe5ab7
105 changed files with 5363 additions and 1503 deletions

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

View 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例如&#10;SN0001&#10;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>

View File

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

View File

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

View File

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

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