refactor: migrate frontend admin page
This commit is contained in:
@@ -21,6 +21,18 @@ div.paperbox-item-words {
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
// 多行卡片样式(作用于管理员用户列表项、会话令牌项等内部需要纵向堆叠多个字段的卡片)
|
||||
div.paperbox-item-multiline-words {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
align-items: flex-start;
|
||||
|
||||
flex-grow: 1;
|
||||
flex-basis: 0;
|
||||
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
div.paperbox-item-icon {
|
||||
margin-left: 0.75rem;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
<script setup lang="ts">
|
||||
const props = defineProps({
|
||||
uuid: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
ua: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
ip: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
expireOn: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
isMe: {
|
||||
type: Boolean,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'delete', uuid: string): void
|
||||
}>()
|
||||
|
||||
const deleteItem = () => {
|
||||
emit('delete', props.uuid);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="paperbox-item card">
|
||||
<div class="paperbox-item-multiline-words">
|
||||
<b>{{ uuid }}</b>
|
||||
<p>
|
||||
<span>User Agent: </span>
|
||||
<span>{{ ua }}</span>
|
||||
</p>
|
||||
<p>
|
||||
<span>IP: </span>
|
||||
<span>{{ ip }}</span>
|
||||
</p>
|
||||
<p>
|
||||
<span>Expire On: </span>
|
||||
<span>{{ expireOn }}</span>
|
||||
</p>
|
||||
<p v-if="isMe">
|
||||
<span class="icon is-small"><font-awesome-icon
|
||||
icon="fas fa-exclamation-triangle"></font-awesome-icon></span>
|
||||
<span>This is the login credentials you are currently using.</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="paperbox-item-icon control" @click="deleteItem">
|
||||
<a class="button"><span class="icon is-small"><font-awesome-icon
|
||||
icon="fas fa-sign-out-alt"></font-awesome-icon></span></a>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -0,0 +1,95 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
username: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
isAdmin: {
|
||||
type: Boolean,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'delete', username: string): void
|
||||
(e: 'update', username: string, newPassword: string, newIsAdmin: boolean): void
|
||||
}>()
|
||||
|
||||
const isEditing = ref<boolean>(false);
|
||||
const editingPassword = ref<string>("");
|
||||
const editingIsAdmin = ref<boolean>(false);
|
||||
|
||||
const editItem = () => {
|
||||
// copy isAdmin to checkbox and clean password box
|
||||
editingIsAdmin.value = props.isAdmin;
|
||||
editingPassword.value = "";
|
||||
// switch to edit mode
|
||||
isEditing.value = true;
|
||||
}
|
||||
|
||||
const deleteItem = () => {
|
||||
emit('delete', props.username);
|
||||
}
|
||||
|
||||
const updateItem = () => {
|
||||
const new_password = editingPassword.value;
|
||||
const new_isAdmin = editingIsAdmin.value;
|
||||
// clean editing state
|
||||
editingPassword.value = "";
|
||||
// switch to normal mode
|
||||
isEditing.value = false;
|
||||
emit('update', props.username, new_password, new_isAdmin);
|
||||
}
|
||||
|
||||
const cancelUpdateItem = () => {
|
||||
// clean editing state
|
||||
editingPassword.value = "";
|
||||
// switch to normal mode
|
||||
isEditing.value = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="paperbox-item card">
|
||||
<div class="paperbox-item-multiline-words">
|
||||
<div class="control" style="display: flex; flex-flow: row; align-items: center;">
|
||||
<div v-show="isAdmin" class="icon is-small" style="margin-right: 1rem;">
|
||||
<font-awesome-icon icon="fas fa-wrench"></font-awesome-icon>
|
||||
</div>
|
||||
<div>{{ username }}</div>
|
||||
</div>
|
||||
<div v-show="isEditing" class="field">
|
||||
<label class="label">New Password</label>
|
||||
<div class="control">
|
||||
<input v-model="editingPassword" class="input" type="password">
|
||||
</div>
|
||||
</div>
|
||||
<div v-show="isEditing" class="field">
|
||||
<label class="checkbox">
|
||||
<input v-model="editingIsAdmin" type="checkbox"><span>Is Admin</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="paperbox-item-icon control" v-show="!isEditing" @click="editItem">
|
||||
<a class="button"><span class="icon is-small"><font-awesome-icon icon="fas fa-pen"></font-awesome-icon></span></a>
|
||||
</div>
|
||||
<div class="paperbox-item-icon control" v-show="!isEditing" @click="deleteItem">
|
||||
<a class="button"><span class="icon is-small"><font-awesome-icon
|
||||
icon="fas fa-trash"></font-awesome-icon></span></a>
|
||||
</div>
|
||||
|
||||
<div class="paperbox-item-icon control" v-show="isEditing" @click="updateItem">
|
||||
<a class="button"><span class="icon is-small"><font-awesome-icon
|
||||
icon="fas fa-check"></font-awesome-icon></span></a>
|
||||
</div>
|
||||
<div class="paperbox-item-icon control" v-show="isEditing" @click="cancelUpdateItem">
|
||||
<a class="button"><span class="icon is-small"><font-awesome-icon
|
||||
icon="fas fa-times"></font-awesome-icon></span></a>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -3,7 +3,7 @@ import { createPinia } from 'pinia'
|
||||
|
||||
import { library } from '@fortawesome/fontawesome-svg-core'
|
||||
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
|
||||
import { faUser, faLock, faPen, faShare, faTrash, faCheck, faTimes, faPlus, faSync, faChevronCircleLeft, faChevronCircleRight, faEye, faEyeSlash, faRetweet, faGlobe } from '@fortawesome/free-solid-svg-icons'
|
||||
import { faUser, faLock, faPen, faShare, faTrash, faCheck, faTimes, faPlus, faSync, faChevronCircleLeft, faChevronCircleRight, faEye, faEyeSlash, faRetweet, faGlobe, faKey, faSignOutAlt, faExclamationTriangle, faWrench } from '@fortawesome/free-solid-svg-icons'
|
||||
|
||||
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
|
||||
|
||||
@@ -19,7 +19,7 @@ const app = createApp(App);
|
||||
app.use(pinia);
|
||||
app.use(router);
|
||||
|
||||
library.add(faUser, faLock, faPen, faShare, faTrash, faCheck, faTimes, faPlus, faSync, faChevronCircleLeft, faChevronCircleRight, faEye, faEyeSlash, faRetweet, faGlobe);
|
||||
library.add(faUser, faLock, faPen, faShare, faTrash, faCheck, faTimes, faPlus, faSync, faChevronCircleLeft, faChevronCircleRight, faEye, faEyeSlash, faRetweet, faGlobe, faKey, faSignOutAlt, faExclamationTriangle, faWrench);
|
||||
app.component('font-awesome-icon', FontAwesomeIcon);
|
||||
|
||||
app.mount('#app');
|
||||
|
||||
@@ -1,8 +1,238 @@
|
||||
<script setup lang="ts"></script>
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { useTokenStore } from '@/stores/token';
|
||||
import MessageBox from '@/components/MessageBox.vue';
|
||||
import {
|
||||
isAdmin as apiProfileIsAdmin,
|
||||
changePassword as apiProfileChangePassword,
|
||||
getToken as apiProfileGetToken,
|
||||
deleteToken as apiProfileDeleteToken,
|
||||
type TokenRow,
|
||||
} from '@/api/profile';
|
||||
import {
|
||||
get as apiAdminGet,
|
||||
add as apiAdminAdd,
|
||||
update as apiAdminUpdate,
|
||||
del as apiAdminDel,
|
||||
type AdminUserRow,
|
||||
} from '@/api/admin';
|
||||
import TokenItem from '@/components/admin/TokenItem.vue';
|
||||
import UserItem from '@/components/admin/UserItem.vue';
|
||||
|
||||
const token = useTokenStore();
|
||||
const messagebox = ref<InstanceType<typeof MessageBox>>();
|
||||
|
||||
// tab control state
|
||||
const activeTab = ref<number>(1);
|
||||
const showUserTab = ref<boolean>(false);
|
||||
|
||||
// profile tab state
|
||||
const profilePendingPassword = ref<string>("");
|
||||
|
||||
// token tab state
|
||||
const tokenItems = ref<Map<string, TokenRow>>(new Map());
|
||||
|
||||
// user tab state
|
||||
const userItems = ref<Map<string, AdminUserRow>>(new Map());
|
||||
const pendingUsername = ref<string>("");
|
||||
|
||||
onMounted(async () => {
|
||||
// check admin status for user tab visibility
|
||||
showUserTab.value = await apiProfileIsAdmin(token.currentToken);
|
||||
// auto-refresh both lists
|
||||
await refreshTokenList();
|
||||
if (showUserTab.value) {
|
||||
await refreshUserList();
|
||||
}
|
||||
})
|
||||
|
||||
// region: Profile Tab
|
||||
|
||||
const changePassword = async () => {
|
||||
const newpassword = profilePendingPassword.value;
|
||||
if (newpassword === "") return;
|
||||
|
||||
const rv = await apiProfileChangePassword(token.currentToken, newpassword);
|
||||
if (rv) {
|
||||
messagebox.value?.show("Operation OK.");
|
||||
profilePendingPassword.value = "";
|
||||
} else {
|
||||
messagebox.value?.show("An update operation failed. It may caused by wrong arguments or lost target. Refreshing page may fix system problem. Before refreshing page, please backup all your unsaved data.");
|
||||
}
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region: Token Tab
|
||||
|
||||
const refreshTokenList = async () => {
|
||||
// clean token items
|
||||
tokenItems.value.clear();
|
||||
|
||||
// fetch data
|
||||
const rv = await apiProfileGetToken(token.currentToken);
|
||||
if (typeof rv === 'undefined') {
|
||||
console.error("fail to fetch token list");
|
||||
return;
|
||||
}
|
||||
|
||||
// add into dict for rendering
|
||||
for (const item of rv) {
|
||||
tokenItems.value.set(item[1], item);
|
||||
}
|
||||
}
|
||||
|
||||
const deleteTokenItem = async (uuid: string) => {
|
||||
const rv = await apiProfileDeleteToken(token.currentToken, uuid);
|
||||
if (!rv) {
|
||||
messagebox.value?.show("A delete operation failed. It may caused by no matched item. Refreshing page may fix system problem. Before refreshing page, please backup all your unsaved data.");
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO: revoking the current session token (the isMe case) is allowed here,
|
||||
// but the local tokenStore is not cleared afterwards. The client is left in
|
||||
// an inconsistent state until the next failed API call or page reload.
|
||||
// Faithfully preserved from legacy for now; revisit later.
|
||||
tokenItems.value.delete(uuid);
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region: User List Tab
|
||||
|
||||
const refreshUserList = async () => {
|
||||
// clean user items
|
||||
userItems.value.clear();
|
||||
|
||||
// fetch data
|
||||
const rv = await apiAdminGet(token.currentToken);
|
||||
if (typeof rv === 'undefined') {
|
||||
console.error("fail to fetch user list");
|
||||
return;
|
||||
}
|
||||
|
||||
// add into dict for rendering
|
||||
for (const item of rv) {
|
||||
userItems.value.set(item[0], item);
|
||||
}
|
||||
}
|
||||
|
||||
const addUser = async () => {
|
||||
const username = pendingUsername.value;
|
||||
if (username === "") return;
|
||||
|
||||
const rv = await apiAdminAdd(token.currentToken, username);
|
||||
if (typeof rv === 'undefined') {
|
||||
messagebox.value?.show("An add operation failed. It may caused by wrong arguments. Refreshing page may fix system problem. Before refreshing page, please backup all your unsaved data.");
|
||||
return;
|
||||
}
|
||||
|
||||
userItems.value.set(rv[0], rv);
|
||||
}
|
||||
|
||||
const deleteUser = async (username: string) => {
|
||||
const rv = await apiAdminDel(token.currentToken, username);
|
||||
if (!rv) {
|
||||
messagebox.value?.show("A delete operation failed. It may caused by no matched item. Refreshing page may fix system problem. Before refreshing page, please backup all your unsaved data.");
|
||||
return;
|
||||
}
|
||||
|
||||
userItems.value.delete(username);
|
||||
}
|
||||
|
||||
const updateUser = async (username: string, newPassword: string, newIsAdmin: boolean) => {
|
||||
const savedIsAdmin = userItems.value.get(username)![1];
|
||||
const passwordArg = newPassword === "" ? undefined : newPassword;
|
||||
const isAdminArg = newIsAdmin === savedIsAdmin ? undefined : newIsAdmin;
|
||||
|
||||
const rv = await apiAdminUpdate(token.currentToken, username, passwordArg, isAdminArg);
|
||||
if (!rv) {
|
||||
messagebox.value?.show("An update operation failed. It may caused by wrong arguments or lost target. Refreshing page may fix system problem. Before refreshing page, please backup all your unsaved data.");
|
||||
return;
|
||||
}
|
||||
|
||||
// safely update data
|
||||
userItems.value.get(username)![1] = newIsAdmin;
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<h1>Congratulations</h1>
|
||||
<p>This is admin.</p>
|
||||
<div class="container" style="margin-top: 20px;">
|
||||
<div class="tabs">
|
||||
<ul>
|
||||
<li :class="{ 'is-active': activeTab === 1 }" @click="activeTab = 1"><a>My Profile</a></li>
|
||||
<li :class="{ 'is-active': activeTab === 2 }" @click="activeTab = 2"><a>Manage Multi-login</a></li>
|
||||
<li v-if="showUserTab" :class="{ 'is-active': activeTab === 3 }" @click="activeTab = 3"><a>Manager User</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-show="activeTab === 1" class="container" style="margin-top: 20px;">
|
||||
<h1 class="title">Change Password</h1>
|
||||
<div class="field has-addons">
|
||||
<div class="control">
|
||||
<input v-model="profilePendingPassword" class="input" type="password">
|
||||
</div>
|
||||
<div class="control" @click="changePassword">
|
||||
<a class="button is-primary">
|
||||
<span class="icon is-small"><font-awesome-icon icon="fas fa-key"></font-awesome-icon></span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-show="activeTab === 2" class="container" style="margin-top: 20px;">
|
||||
<h1 class="title">Manage multi-login</h1>
|
||||
<h2 class="subtitle">Manage the multi-login of the current account. You can forced logout some login in
|
||||
there.</h2>
|
||||
<div class="control" @click="refreshTokenList">
|
||||
<a class="button is-primary">
|
||||
<span class="icon is-small"><font-awesome-icon icon="fas fa-sync"></font-awesome-icon></span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; flex-flow: column; margin-top: 1.25rem;">
|
||||
<div v-for="item in tokenItems.values()" :key="item[1]"
|
||||
style="display: flex; flex-flow: column; margin-top: 1.25rem;">
|
||||
<TokenItem :uuid="item[1]" :ua="item[3]" :ip="item[4]"
|
||||
:expire-on="new Date(item[2] * 1000).toLocaleString()" :is-me="token.currentToken === item[1]"
|
||||
@delete="deleteTokenItem" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-show="activeTab === 3" class="container" style="margin-top: 20px;">
|
||||
<h1 class="title">User List</h1>
|
||||
<div class="button-list">
|
||||
<div class="field has-addons">
|
||||
<div class="control">
|
||||
<input v-model="pendingUsername" class="input" type="text">
|
||||
</div>
|
||||
<div class="control" @click="addUser">
|
||||
<a class="button is-primary">
|
||||
<span class="icon is-small"><font-awesome-icon icon="fas fa-plus"></font-awesome-icon></span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="control" @click="refreshUserList">
|
||||
<a class="button is-primary">
|
||||
<span class="icon is-small"><font-awesome-icon icon="fas fa-sync"></font-awesome-icon></span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; flex-flow: column; margin-top: 1.25rem;">
|
||||
<div v-for="item in userItems.values()" :key="item[0]"
|
||||
style="display: flex; flex-flow: column; margin-top: 1.25rem;">
|
||||
<UserItem :username="item[0]" :is-admin="item[1]" @delete="deleteUser" @update="updateUser" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MessageBox ref="messagebox" />
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
|
||||
Reference in New Issue
Block a user