feat: 用户管理(xionglin 为管理员)——服务端角色字段与 /api/admin/users 增删改查、客户端管理员专属入口
This commit is contained in:
+1
-1
File diff suppressed because one or more lines are too long
-26
File diff suppressed because one or more lines are too long
+26
File diff suppressed because one or more lines are too long
+2
-2
@@ -1,2 +1,2 @@
|
||||
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["./web-XtLEyZTQ.js","./index-BMAz2lN3.js","./index-DUPSAEmu.css"])))=>i.map(i=>d[i]);
|
||||
import{r as s,_ as i}from"./index-BMAz2lN3.js";var t;(function(e){e.Documents="DOCUMENTS",e.Data="DATA",e.Library="LIBRARY",e.Cache="CACHE",e.External="EXTERNAL",e.ExternalStorage="EXTERNAL_STORAGE"})(t||(t={}));var a;(function(e){e.UTF8="utf8",e.ASCII="ascii",e.UTF16="utf16"})(a||(a={}));const E=s("Filesystem",{web:()=>i(()=>import("./web-XtLEyZTQ.js"),__vite__mapDeps([0,1,2]),import.meta.url).then(e=>new e.FilesystemWeb)});export{t as Directory,a as Encoding,E as Filesystem};
|
||||
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["./web-D6Z8f91T.js","./index-CnCk6ytG.js","./index-2EGKD3r-.css"])))=>i.map(i=>d[i]);
|
||||
import{r as s,_ as i}from"./index-CnCk6ytG.js";var t;(function(e){e.Documents="DOCUMENTS",e.Data="DATA",e.Library="LIBRARY",e.Cache="CACHE",e.External="EXTERNAL",e.ExternalStorage="EXTERNAL_STORAGE"})(t||(t={}));var a;(function(e){e.UTF8="utf8",e.ASCII="ascii",e.UTF16="utf16"})(a||(a={}));const E=s("Filesystem",{web:()=>i(()=>import("./web-D6Z8f91T.js"),__vite__mapDeps([0,1,2]),import.meta.url).then(e=>new e.FilesystemWeb)});export{t as Directory,a as Encoding,E as Filesystem};
|
||||
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+2
-2
@@ -8,8 +8,8 @@
|
||||
<link rel="icon" href="./icons/icon.svg" type="image/svg+xml" />
|
||||
<link rel="apple-touch-icon" href="./icons/icon-192.png" />
|
||||
<title>实习工作日志</title>
|
||||
<script type="module" crossorigin src="./assets/index-BMAz2lN3.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-DUPSAEmu.css">
|
||||
<script type="module" crossorigin src="./assets/index-CnCk6ytG.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-2EGKD3r-.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
+21
-6
@@ -9,6 +9,7 @@ import LedgerView from './views/LedgerView.vue'
|
||||
import MemoView from './views/MemoView.vue'
|
||||
import VaultView from './views/VaultView.vue'
|
||||
import SettingsView from './views/SettingsView.vue'
|
||||
import UserAdminView from './views/UserAdminView.vue'
|
||||
import LoginView from './views/LoginView.vue'
|
||||
import { getSession, logoutUser, openUserDatabase, type Session } from './db/auth'
|
||||
import { autoSyncNow, enableRealtimeSync, ensureCloudBinding, stopRealtime } from './sync/engine'
|
||||
@@ -116,23 +117,36 @@ const VIEWS = [
|
||||
{ key: 'ledger', name: '记账', icon: 'wallet' },
|
||||
{ key: 'memo', name: '备忘录', icon: 'memo' },
|
||||
{ key: 'vault', name: '密码库', icon: 'lock' },
|
||||
{ key: 'settings', name: '设置', icon: 'settings' }
|
||||
{ key: 'settings', name: '设置', icon: 'settings' },
|
||||
{ key: 'admin', name: '用户管理', icon: 'shield' }
|
||||
]
|
||||
|
||||
function readRoute(): string {
|
||||
const h = (location.hash || '').replace('#/', '')
|
||||
return VIEWS.some(v => v.key === h) ? h : 'calendar'
|
||||
if (!VIEWS.some(v => v.key === h)) return 'calendar'
|
||||
if (h === 'admin' && (getSession()?.username || '') !== 'xionglin') return 'calendar'
|
||||
return h
|
||||
}
|
||||
const route = ref(readRoute())
|
||||
window.addEventListener('hashchange', () => { route.value = readRoute() })
|
||||
function nav(key: string) { location.hash = '#/' + key }
|
||||
const current = computed(() => VIEWS.find(v => v.key === route.value) || VIEWS[0])
|
||||
|
||||
// ---- 用户管理:仅管理员 xionglin 可见 ----
|
||||
const isAdminUser = computed(() => (session.value?.username || '') === 'xionglin')
|
||||
const shownViews = computed(() => isAdminUser.value ? VIEWS : VIEWS.filter(v => v.key !== 'admin'))
|
||||
const moreViews = computed(() => {
|
||||
const list = VIEWS.filter(v => !TAB_KEYS.includes(v.key))
|
||||
return isAdminUser.value ? list : list.filter(v => v.key !== 'admin')
|
||||
})
|
||||
const isMoreActive = computed(() => moreViews.value.some(v => v.key === route.value))
|
||||
watch([isAdminUser, route], () => {
|
||||
if (route.value === 'admin' && !isAdminUser.value) nav('calendar')
|
||||
}, { immediate: true })
|
||||
|
||||
// 手机端底部标签页:4 个主功能 + 「更多」弹层(统计/总结/密码库/设置/退出)
|
||||
const TAB_KEYS = ['calendar', 'today', 'ledger', 'memo']
|
||||
const TABS = VIEWS.filter(v => TAB_KEYS.includes(v.key))
|
||||
const MORE_VIEWS = VIEWS.filter(v => !TAB_KEYS.includes(v.key))
|
||||
const isMoreActive = computed(() => MORE_VIEWS.some(v => v.key === route.value))
|
||||
const moreOpen = ref(false)
|
||||
function navFromSheet(key: string) { moreOpen.value = false; nav(key) }
|
||||
|
||||
@@ -220,7 +234,7 @@ onMounted(async () => {
|
||||
<div v-else class="app">
|
||||
<nav class="side">
|
||||
<button
|
||||
v-for="v in VIEWS" :key="v.key"
|
||||
v-for="v in shownViews" :key="v.key"
|
||||
class="nav-btn" :class="{ active: route === v.key }"
|
||||
:data-tip="v.name" @click="nav(v.key)"
|
||||
>
|
||||
@@ -280,6 +294,7 @@ onMounted(async () => {
|
||||
<LedgerView v-else-if="route === 'ledger'" />
|
||||
<MemoView v-else-if="route === 'memo'" />
|
||||
<VaultView v-else-if="route === 'vault'" />
|
||||
<UserAdminView v-else-if="route === 'admin'" />
|
||||
<SettingsView v-else />
|
||||
</div>
|
||||
</main>
|
||||
@@ -304,7 +319,7 @@ onMounted(async () => {
|
||||
<div v-if="moreOpen" class="sheet-mask" @click="moreOpen = false">
|
||||
<div class="more-sheet" @click.stop>
|
||||
<div class="sheet-user num">{{ session?.username }}</div>
|
||||
<button v-for="v in MORE_VIEWS" :key="v.key" class="sheet-row" @click="navFromSheet(v.key)">
|
||||
<button v-for="v in moreViews" :key="v.key" class="sheet-row" @click="navFromSheet(v.key)">
|
||||
<Icon :name="v.icon" :size="18" />
|
||||
<span class="sheet-name">{{ v.name }}</span>
|
||||
<Icon name="chevron-right" :size="14" class="sheet-go" />
|
||||
|
||||
@@ -23,5 +23,6 @@ export const ICONS: Record<string, string> = {
|
||||
memo: svg('<path d="M6.5 3.5h7.5L18.5 8v11a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1v-15a1 1 0 0 1 1-1z"/><path d="M14 3.5V8h4.5M9.5 12h5M9.5 15.5h5"/>'),
|
||||
lock: svg('<rect x="5.5" y="10.5" width="13" height="9.5" rx="1.5"/><path d="M8.5 10.5V8a3.5 3.5 0 0 1 7 0v2.5"/>'),
|
||||
wallet: svg('<rect x="3.5" y="6" width="17" height="12.5" rx="2"/><path d="M3.5 10h17"/><circle cx="16.4" cy="14.2" r="1.1"/>'),
|
||||
more: svg('<circle cx="5.4" cy="12" r="1.7"/><circle cx="12" cy="12" r="1.7"/><circle cx="18.6" cy="12" r="1.7"/>')
|
||||
more: svg('<circle cx="5.4" cy="12" r="1.7"/><circle cx="12" cy="12" r="1.7"/><circle cx="18.6" cy="12" r="1.7"/>'),
|
||||
shield: svg('<path d="M12 3.5l7.2 2.7v5.1c0 4.7-3.1 7.8-7.2 9.4-4.1-1.6-7.2-4.7-7.2-9.4V6.2z"/><path d="M9.3 12.2l1.9 1.9 3.6-4"/>')
|
||||
}
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import Icon from '../components/Icon.vue'
|
||||
import ConfirmDialog from '../components/ConfirmDialog.vue'
|
||||
import { showToast } from '../lib/toast'
|
||||
import { getSyncBase, getToken, setToken } from '../lib/server'
|
||||
import { getCloudCreds } from '../db/auth'
|
||||
|
||||
interface AdminUser {
|
||||
id: string
|
||||
username: string
|
||||
role: string
|
||||
created_at: string
|
||||
data_bytes: number
|
||||
}
|
||||
|
||||
const users = ref<AdminUser[]>([])
|
||||
const meId = ref('')
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
|
||||
|
||||
// ---- 请求辅助:携带令牌;401 自动用云端凭据续期一次 ----
|
||||
async function request(path: string, body?: unknown): Promise<{ ok: boolean; status: number; data: any }> {
|
||||
const base = await getSyncBase()
|
||||
let token = getToken()
|
||||
if (!token) {
|
||||
const creds = getCloudCreds()
|
||||
const lr = await fetch(base + '/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username: creds?.username, password: creds?.password })
|
||||
})
|
||||
if (lr.ok) {
|
||||
const j = await lr.json()
|
||||
token = String(j.token || '')
|
||||
setToken(token)
|
||||
}
|
||||
}
|
||||
const r = await fetch(base + path, {
|
||||
method: body ? 'POST' : 'GET',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + token },
|
||||
body: body ? JSON.stringify(body) : undefined
|
||||
})
|
||||
if (r.status === 401 && (getCloudCreds())) {
|
||||
const creds = getCloudCreds()
|
||||
const lr = await fetch(base + '/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username: creds?.username, password: creds?.password })
|
||||
})
|
||||
if (lr.ok) {
|
||||
const j = await lr.json()
|
||||
token = String(j.token || '')
|
||||
setToken(token)
|
||||
const r2 = await fetch(base + path, {
|
||||
method: body ? 'POST' : 'GET',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + token },
|
||||
body: body ? JSON.stringify(body) : undefined
|
||||
})
|
||||
return { ok: r2.ok, status: r2.status, data: await r2.json().catch(() => ({})) }
|
||||
}
|
||||
}
|
||||
return { ok: r.ok, status: r.status, data: await r.json().catch(() => ({})) }
|
||||
}
|
||||
|
||||
// ---- 列表 ----
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
const res = await request('/api/admin/users')
|
||||
if (!res.ok) {
|
||||
loading.value = false
|
||||
error.value = (res.data && res.data.error) || ('加载失败 ' + res.status)
|
||||
return
|
||||
}
|
||||
users.value = res.data.users || []
|
||||
meId.value = res.data.me || ''
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
function fmtBytes(n: number): string {
|
||||
if (n <= 0) return '--'
|
||||
if (n < 1024) return n + ' B'
|
||||
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB'
|
||||
return (n / 1024 / 1024).toFixed(2) + ' MB'
|
||||
}
|
||||
function fmtDate(s: string): string {
|
||||
return (s || '').slice(0, 10)
|
||||
}
|
||||
|
||||
// ---- 新增用户 ----
|
||||
const createOpen = ref(false)
|
||||
const newName = ref('')
|
||||
const newPwd = ref('')
|
||||
const newPwd2 = ref('')
|
||||
const createError = ref('')
|
||||
const creating = ref(false)
|
||||
function openCreate() {
|
||||
createOpen.value = true
|
||||
createError.value = ''
|
||||
newName.value = ''
|
||||
newPwd.value = ''
|
||||
newPwd2.value = ''
|
||||
}
|
||||
async function doCreate() {
|
||||
const name = newName.value.trim()
|
||||
if (newPwd.value !== newPwd2.value) { createError.value = '两次输入的密码不一致'; return }
|
||||
if (newPwd.value.length < 6) { createError.value = '密码至少 6 位'; return }
|
||||
creating.value = true
|
||||
const res = await request('/api/admin/users/create', { username: name, password: newPwd.value })
|
||||
creating.value = false
|
||||
if (!res.ok) { createError.value = (res.data && res.data.error) || ('创建失败 ' + res.status); return }
|
||||
showToast('用户「' + name + '」已创建')
|
||||
createOpen.value = false
|
||||
await load()
|
||||
}
|
||||
|
||||
// ---- 重置密码 ----
|
||||
const resetTarget = ref<AdminUser | null>(null)
|
||||
const resetPwd = ref('')
|
||||
const resetError = ref('')
|
||||
const resetting = ref(false)
|
||||
function openReset(u: AdminUser) {
|
||||
resetTarget.value = u
|
||||
resetPwd.value = ''
|
||||
resetError.value = ''
|
||||
}
|
||||
async function doReset() {
|
||||
if (!resetTarget.value) return
|
||||
if (resetPwd.value.length < 6) { resetError.value = '密码至少 6 位'; return }
|
||||
resetting.value = true
|
||||
const res = await request('/api/admin/users/password', { userId: resetTarget.value.id, newPassword: resetPwd.value })
|
||||
resetting.value = false
|
||||
if (!res.ok) { resetError.value = (res.data && res.data.error) || ('重置失败 ' + res.status); return }
|
||||
showToast('已重置「' + resetTarget.value.username + '」的登录密码')
|
||||
resetTarget.value = null
|
||||
}
|
||||
|
||||
// ---- 删除用户 ----
|
||||
const deleteTarget = ref<AdminUser | null>(null)
|
||||
async function doDelete() {
|
||||
if (!deleteTarget.value) return
|
||||
const res = await request('/api/admin/users/delete', { userId: deleteTarget.value.id })
|
||||
if (!res.ok) { deleteTarget.value = null; showToast((res.data && res.data.error) || '删除失败') ; return }
|
||||
showToast('已删除账号「' + deleteTarget.value.username + '」及其全部云端数据')
|
||||
deleteTarget.value = null
|
||||
await load()
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="admin-page">
|
||||
<div class="card sec">
|
||||
<div class="toolbar">
|
||||
<div class="head-wrap">
|
||||
<div class="sec-title">用户管理</div>
|
||||
<div class="muted head-sub">云端账号统一管理 · 管理员:xionglin</div>
|
||||
</div>
|
||||
<button class="btn btn-primary btn-sm" @click="openCreate">
|
||||
<Icon name="plus" :size="14" />
|
||||
新增用户
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="error" class="form-error">{{ error }}</div>
|
||||
</div>
|
||||
|
||||
<div class="card table-card admin-table-card">
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>用户名</th><th>角色</th><th>创建时间</th><th>数据量</th><th class="th-ops">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="u in users" :key="u.id">
|
||||
<td class="td-name" data-label="用户名">{{ u.username }}</td>
|
||||
<td data-label="角色">
|
||||
<span v-if="u.role === 'admin'" class="role-badge">管理员</span>
|
||||
<span v-else class="muted">普通用户</span>
|
||||
</td>
|
||||
<td class="muted num" data-label="创建时间">{{ fmtDate(u.created_at) }}</td>
|
||||
<td class="muted num" data-label="数据量">{{ fmtBytes(u.data_bytes) }}</td>
|
||||
<td class="td-ops">
|
||||
<div class="ops">
|
||||
<button class="btn btn-xs" @click="openReset(u)">重置密码</button>
|
||||
<button class="btn btn-xs btn-danger" :disabled="u.id === meId" @click="deleteTarget = u">删除</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!loading && !users.length">
|
||||
<td colspan="5" class="empty muted">加载结果为空</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div v-if="loading" class="muted load-line">加载中</div>
|
||||
</div>
|
||||
|
||||
<div class="muted admin-note">重置密码立即生效;删除账号将移除其全部云端数据,不可恢复。自己的账号不能删除。</div>
|
||||
|
||||
<!-- 新增用户弹窗 -->
|
||||
<teleport to="body">
|
||||
<div v-if="createOpen" class="fixed-mask" @click.self="createOpen = false">
|
||||
<div class="dialog create-dialog" role="dialog" aria-label="新增用户">
|
||||
<div class="dialog-head">
|
||||
<span class="dialog-title">新增用户</span>
|
||||
<button class="icon-btn" @click="createOpen = false"><Icon name="close" :size="16" /></button>
|
||||
</div>
|
||||
<div class="dialog-body">
|
||||
<div class="form-row">
|
||||
<label>用户名(2 到 20 个字符)</label>
|
||||
<input class="input" v-model="newName" placeholder="如:xiaowang" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>初始登录密码(至少 6 位)</label>
|
||||
<input class="input" type="password" v-model="newPwd" placeholder="转发给使用者,登录后可自行修改" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>确认密码</label>
|
||||
<input class="input" type="password" v-model="newPwd2" @keydown.enter="doCreate" />
|
||||
</div>
|
||||
<div v-if="createError" class="form-error">{{ createError }}</div>
|
||||
</div>
|
||||
<div class="dialog-btns">
|
||||
<button class="btn btn-sm" @click="createOpen = false">取消</button>
|
||||
<button class="btn btn-primary btn-sm" :disabled="creating || !newName.trim()" @click="doCreate">{{ creating ? '创建中' : '创建' }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</teleport>
|
||||
|
||||
<!-- 重置密码弹窗 -->
|
||||
<teleport to="body">
|
||||
<div v-if="resetTarget" class="fixed-mask" @click.self="resetTarget = null">
|
||||
<div class="dialog create-dialog" role="dialog" aria-label="重置密码">
|
||||
<div class="dialog-head">
|
||||
<span class="dialog-title">重置「{{ resetTarget.username }}」的登录密码</span>
|
||||
<button class="icon-btn" @click="resetTarget = null"><Icon name="close" :size="16" /></button>
|
||||
</div>
|
||||
<div class="dialog-body">
|
||||
<div class="form-row">
|
||||
<label>新登录密码(至少 6 位)</label>
|
||||
<input class="input" type="password" v-model="resetPwd" @keydown.enter="doReset" />
|
||||
</div>
|
||||
<div v-if="resetError" class="form-error">{{ resetError }}</div>
|
||||
</div>
|
||||
<div class="dialog-btns">
|
||||
<button class="btn btn-sm" @click="resetTarget = null">取消</button>
|
||||
<button class="btn btn-primary btn-sm" :disabled="resetting" @click="doReset">{{ resetting ? '提交中' : '重置' }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</teleport>
|
||||
|
||||
<ConfirmDialog
|
||||
:open="!!deleteTarget"
|
||||
title="删除用户确认"
|
||||
:message="deleteTarget ? '将永久删除云端账号「' + deleteTarget.username + '」及其服务器上的全部数据,不可恢复。请确认。' : ''"
|
||||
confirm-text="永久删除" danger
|
||||
@cancel="deleteTarget = null"
|
||||
@confirm="doDelete"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.admin-page { display: flex; flex-direction: column; gap: 12px; max-width: 860px; }
|
||||
|
||||
.toolbar { display: flex; align-items: center; gap: 12px; }
|
||||
.head-wrap { flex: 1; min-width: 0; }
|
||||
.sec-title { font-weight: 600; font-size: 13px; }
|
||||
.head-sub { font-size: 12px; margin-top: 2px; }
|
||||
.sec { padding: 14px 16px; }
|
||||
|
||||
.admin-table-card { overflow-x: auto; }
|
||||
.admin-table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||
.admin-table th { text-align: left; font-weight: 500; color: var(--text-3); font-size: 12px; padding: 8px 12px; border-bottom: 1px solid var(--border); }
|
||||
.admin-table td { padding: 9px 12px; border-bottom: 1px solid var(--border); white-space: nowrap; }
|
||||
.admin-table td:last-child { border-bottom: none; }
|
||||
.td-name { font-weight: 500; }
|
||||
.role-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border: 1px solid var(--accent);
|
||||
color: var(--accent);
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
padding: 1px 8px;
|
||||
}
|
||||
.load-line { padding: 12px; font-size: 12px; }
|
||||
.admin-note { font-size: 12px; padding: 0 2px; }
|
||||
.ops { display: inline-flex; gap: 6px; }
|
||||
.btn-danger { color: var(--danger); }
|
||||
|
||||
.form-row { display: flex; flex-direction: column; gap: 5px; margin-top: 10px; }
|
||||
.form-row label { font-size: 12px; color: var(--text-2); }
|
||||
.fixed-mask {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 80;
|
||||
background: rgba(29, 33, 41, 0.35);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
}
|
||||
.dialog { background: var(--bg-page); border-radius: 12px; width: 420px; max-width: 100%; }
|
||||
.dialog-head { display: flex; align-items: center; justify-content: space-between; padding: 14px 16px 0; }
|
||||
.dialog-title { font-weight: 600; font-size: 14px; }
|
||||
.dialog-body { padding: 6px 16px 4px; }
|
||||
.dialog-btns { display: flex; justify-content: flex-end; gap: 10px; padding: 12px 16px 16px; }
|
||||
.btn-danger { color: var(--danger); }
|
||||
|
||||
.empty { text-align: center; }
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.toolbar { flex-wrap: wrap; row-gap: 8px; }
|
||||
.admin-table td, .admin-table th { white-space: normal; }
|
||||
}
|
||||
</style>
|
||||
+73
-1
@@ -46,6 +46,20 @@ const SECRET = readJson('secret.key', { secret: 'fallback-secret' }).secret
|
||||
|
||||
const registryFile = 'registry.json'
|
||||
let registry = readJson(registryFile, { users: [] })
|
||||
// 管理员:xionglin 固定为管理员(存量注册表自动补齐 role 字段)
|
||||
let adminDirty = false
|
||||
for (const u of registry.users) {
|
||||
if (u.username === 'xionglin' && u.role !== 'admin') { u.role = 'admin'; adminDirty = true }
|
||||
else if (u.username !== 'xionglin' && !u.role) { u.role = 'user'; adminDirty = true }
|
||||
}
|
||||
if (adminDirty) saveRegistry()
|
||||
function isAdminToken(token) {
|
||||
const auth = verifyToken(token)
|
||||
if (!auth) return null
|
||||
const rec = registry.users.find(u => u.id === auth.userId)
|
||||
if (!rec || rec.role !== 'admin') return null
|
||||
return rec
|
||||
}
|
||||
function saveRegistry() { writeJson(registryFile, registry); try { doBackup() } catch { /* 忽略 */ } }
|
||||
|
||||
function userFile(userId) { return path.join(DATA_DIR, 'users', userId + '.json') }
|
||||
@@ -235,7 +249,7 @@ async function handle(req, res) {
|
||||
if (registry.users.find(u => u.username === username)) { json(res, 409, { error: '用户名已存在' }); return }
|
||||
const salt = crypto.randomBytes(16).toString('hex')
|
||||
const userId = 'cu_' + crypto.randomBytes(6).toString('hex')
|
||||
registry.users.push({ id: userId, username, pwd_hash: pbkdf2Hash(password, salt), pwd_salt: salt, created_at: new Date().toISOString() })
|
||||
registry.users.push({ id: userId, username, role: username === 'xionglin' ? 'admin' : 'user', pwd_hash: pbkdf2Hash(password, salt), pwd_salt: salt, created_at: new Date().toISOString() })
|
||||
saveRegistry()
|
||||
json(res, 200, { userId, username, token: makeToken(userId) })
|
||||
return
|
||||
@@ -346,6 +360,64 @@ async function handle(req, res) {
|
||||
return
|
||||
}
|
||||
|
||||
// ---------- 用户管理(仅管理员 xionglin) ----------
|
||||
if (route.startsWith('/api/admin/users')) {
|
||||
const admin = isAdminToken(String(req.headers['authorization'] || '').replace(/^Bearer\s+/i, '').trim())
|
||||
if (!admin) { json(res, 403, { error: '需要管理员权限' }); return }
|
||||
|
||||
if (req.method === 'GET' && route === '/api/admin/users') {
|
||||
const list = registry.users.map(u => {
|
||||
let size = 0
|
||||
try { size = fs.statSync(userFile(u.id)).size } catch { /* 无数据文件 */ }
|
||||
return { id: u.id, username: u.username, role: u.role || 'user', created_at: u.created_at, data_bytes: size }
|
||||
}).sort((x, y) => (x.role === 'admin' ? -1 : 1) - (y.role === 'admin' ? -1 : 1))
|
||||
json(res, 200, { users: list, me: admin.id })
|
||||
return
|
||||
}
|
||||
|
||||
if (req.method === 'POST' && route === '/api/admin/users/create') {
|
||||
const body = await readBody(req)
|
||||
const username = String(body.username || '').trim()
|
||||
const password = String(body.password || '')
|
||||
if (username.length < 2 || username.length > 20) { json(res, 400, { error: '用户名需 2 到 20 个字符' }); return }
|
||||
if (password.length < 6) { json(res, 400, { error: '密码至少 6 位' }); return }
|
||||
if (registry.users.find(u => u.username === username)) { json(res, 409, { error: '用户名已存在' }); return }
|
||||
const salt = crypto.randomBytes(16).toString('hex')
|
||||
const userId = 'cu_' + crypto.randomBytes(6).toString('hex')
|
||||
registry.users.push({ id: userId, username, role: username === 'xionglin' ? 'admin' : 'user', pwd_hash: pbkdf2Hash(password, salt), pwd_salt: salt, created_at: new Date().toISOString() })
|
||||
saveRegistry()
|
||||
json(res, 200, { ok: true, userId, username })
|
||||
return
|
||||
}
|
||||
|
||||
if (req.method === 'POST' && route === '/api/admin/users/password') {
|
||||
const body = await readBody(req)
|
||||
const rec = registry.users.find(u => u.id === body.userId)
|
||||
if (!rec) { json(res, 404, { error: '用户不存在' }); return }
|
||||
if (String(body.newPassword || '').length < 6) { json(res, 400, { error: '密码至少 6 位' }); return }
|
||||
rec.pwd_salt = crypto.randomBytes(16).toString('hex')
|
||||
rec.pwd_hash = pbkdf2Hash(String(body.newPassword), rec.pwd_salt)
|
||||
rec.pwd_changed_at = new Date().toISOString()
|
||||
saveRegistry()
|
||||
json(res, 200, { ok: true })
|
||||
return
|
||||
}
|
||||
|
||||
if (req.method === 'POST' && route === '/api/admin/users/delete') {
|
||||
const body = await readBody(req)
|
||||
if (body.userId === admin.id) { json(res, 400, { error: '不能删除自己的账号' }); return }
|
||||
const idx = registry.users.findIndex(u => u.id === body.userId)
|
||||
if (idx < 0) { json(res, 404, { error: '用户不存在' }); return }
|
||||
registry.users.splice(idx, 1)
|
||||
saveRegistry()
|
||||
try { fs.rmSync(userFile(body.userId), { force: true }) } catch { /* 忽略 */ }
|
||||
for (const sseRes of (sseClients.get(body.userId) || [])) { try { sseRes.end() } catch { /* 忽略 */ } }
|
||||
sseClients.delete(body.userId)
|
||||
json(res, 200, { ok: true })
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
json(res, 404, { error: 'not found' })
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user