feat: 用户管理(xionglin 为管理员)——服务端角色字段与 /api/admin/users 增删改查、客户端管理员专属入口

This commit is contained in:
root
2026-10-16 18:24:56 +08:00
parent cfc8e3c734
commit 3505e10961
10 changed files with 451 additions and 40 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -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};
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -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
View File
@@ -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" />
+2 -1
View File
@@ -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"/>')
}
+323
View File
@@ -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
View File
@@ -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' })
}