feat: User Management

This commit is contained in:
fit2cloud-chenyw
2025-05-06 18:15:35 +08:00
parent 0179b3d371
commit 2de7ece73b
12 changed files with 530 additions and 18 deletions
+20 -2
View File
@@ -1,9 +1,27 @@
from fastapi import APIRouter, Depends, Request
from common.core.deps import CurrentUser
from apps.system.models.user import user_grid
from common.core.deps import CurrentUser, SessionDep
from common.core.pagination import Paginator
from common.core.schemas import PaginatedResponse, PaginationParams
router = APIRouter(tags=["user"], prefix="/user")
@router.get("/info")
async def user_info(current_user: CurrentUser):
return current_user.to_dict()
return current_user.to_dict()
@router.get("/pager/{pageNum}/{pageSize}", response_model=PaginatedResponse[user_grid])
async def pager(
session: SessionDep,
pageNum: int,
pageSize: int
):
pagination = PaginationParams(page=pageNum, size=pageSize)
paginator = Paginator(session)
filters = {}
return await paginator.get_paginated_response(
model=user_grid,
pagination=pagination,
**filters)
+5 -3
View File
@@ -1,12 +1,14 @@
from sqlmodel import Session, select
from ..models.user import sys_user
from ..models.user import sys_user, user_grid
from common.core.security import verify_md5pwd
def get_user_by_account(*, session: Session, account: str) -> sys_user | None:
statement = select(sys_user).where(sys_user.account == account)
#statement = select(sys_user).where(sys_user.account == account)
statement = select(user_grid.id, user_grid.account, user_grid.oid, user_grid.password).where(user_grid.account == account)
session_user = session.exec(statement).first()
return session_user
result_user = sys_user.model_validate(session_user)
return result_user
def authenticate(*, session: Session, account: str, password: str) -> sys_user | None:
db_user = get_user_by_account(session=session, account=account)
+11 -2
View File
@@ -1,7 +1,8 @@
from typing import Optional
from sqlmodel import SQLModel, Field
class sys_user(SQLModel, table=True):
class sys_user(SQLModel):
id: int = Field(primary_key=True, index=True)
account: str = Field(max_length=255, unique=True)
password: str = Field(max_length=255)
@@ -12,4 +13,12 @@ class sys_user(SQLModel, table=True):
"id": self.id,
"account": self.account,
"oid": self.oid
}
}
class user_grid(sys_user, table=True):
__tablename__ = "sys_user"
name: str
email: str
status: int
create_time: int
+7 -3
View File
@@ -5,12 +5,12 @@ from fastapi import Depends, HTTPException, status
# from fastapi.security import OAuth2PasswordBearer
from jwt.exceptions import InvalidTokenError
from pydantic import BaseModel, ValidationError
from sqlmodel import Session
from sqlmodel import Session, select
from common.core.schemas import TokenPayload, XOAuth2PasswordBearer
from common.core import security
from common.core.config import settings
from common.core.db import get_session
from apps.system.models.user import sys_user
from apps.system.models.user import sys_user, user_grid
reusable_oauth2 = XOAuth2PasswordBearer(
tokenUrl=f"{settings.API_V1_STR}/login/access-token"
)
@@ -32,7 +32,11 @@ async def get_current_user(session: SessionDep, token: TokenDep) -> sys_user:
status_code=status.HTTP_403_FORBIDDEN,
detail="Could not validate credentials",
)
user = session.get(sys_user, token_data.id)
statement = select(user_grid.id, user_grid.account, user_grid.oid, user_grid.password).where(user_grid.id == token_data.id)
session_user = session.exec(statement).first()
if not session_user:
raise HTTPException(status_code=404, detail="User not found")
user = sys_user.model_validate(session_user)
if not user:
raise HTTPException(status_code=404, detail="User not found")
""" if not user.is_active:
+9
View File
@@ -0,0 +1,9 @@
import { request } from '@/utils/request'
export const userApi = {
pager: (pageNumber: number, pageSize: number) => request.get(`/user/pager/${pageNumber}/${pageSize}`),
add: (data: any) => request.post('/settings/terminology', data),
edit: (data: any) => request.put('/settings/terminology', data),
delete: (id: number) => request.delete(`/settings/terminology/${id}`),
query: (id: number) => request.get(`/settings/terminology/${id}`)
}
+56 -6
View File
@@ -45,7 +45,7 @@
<h1>{{ currentPageTitle }}</h1>
<div class="header-actions">
<el-tooltip content="System manage" placement="bottom">
<div class="header-icon-btn">
<div class="header-icon-btn" @click="toSystem">
<el-icon><iconsystem /></el-icon>
<span>System manage</span>
</div>
@@ -77,8 +77,27 @@
</div>
</div>
<div v-if="sysRouterList.length && showHead" class="sys-setting-container">
<el-menu
:default-active="activeMenu"
class="el-menu-demo"
mode="horizontal"
>
<el-menu-item v-for="item in sysRouterList" :key="item.path" :index="item.path" @click="menuSelect">
<el-icon v-if="item.meta.icon">
<component :is="resolveIcon(item.meta.icon)" />
</el-icon>
<span>{{ item.meta.title }}</span>
</el-menu-item>
</el-menu>
</div>
<div class="page-content">
<div v-if="sysRouterList.length && showHead" class="sys-page-content">
<div class="sys-inner-container">
<router-view />
</div>
</div>
<div v-else class="page-content">
<router-view />
</div>
</div>
@@ -105,19 +124,31 @@ const route = useRoute()
const userStore = useUserStore()
const name = ref('admin')
const activeMenu = computed(() => route.path)
const routerList = computed(() => {
return router.getRoutes().filter(route => {
return route.path !== '/login' && !route.redirect && route.path !== '/:pathMatch(.*)*'
return route.path !== '/login' && !route.path.includes('/system') && !route.redirect && route.path !== '/:pathMatch(.*)*'
})
})
const sysRouterList = computed(() => {
return router.getRoutes().filter(route => route.path.includes('/system'))
})
const showHead = computed(() => {
return route.path.includes('/system')
})
const workspace = ref('1')
const options = [
{ value: '1', label: 'Default workspace' },
{ value: '2', label: 'Workspace 2' },
{ value: '3', label: 'Workspace 3' }
]
const currentPageTitle = computed(() => route.meta.title || 'Dashboard')
const currentPageTitle = computed(() => {
if (route.path.includes('/system')) {
return 'System Settings'
}
return route.meta.title || 'Dashboard'
})
const resolveIcon = (iconName: any) => {
const icons: Record<string, any> = {
'ds': ds,
@@ -129,13 +160,15 @@ const resolveIcon = (iconName: any) => {
}
const menuSelect = (e: any) => {
console.log(routerList.value)
router.push(e.index)
}
const logout = () => {
userStore.logout()
router.push('/login')
}
const toSystem = () => {
router.push('/system')
}
</script>
<style lang="less" scoped>
@@ -260,6 +293,23 @@ const logout = () => {
flex: 1;
overflow-y: auto;
}
.sys-page-content {
background-color: var(--white);
border-radius: var(--border-radius);
padding: 24px;
box-shadow: var(--shadow);
margin-top: 24px;
.sys-inner-container {
background: #fff;
border-radius: 8px;
padding: 20px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
}
.sys-setting-container {
overflow: hidden;
border-radius: 8px;
}
}
}
</style>
+19
View File
@@ -66,6 +66,25 @@ const router = createRouter({
meta: { title: 'Setting', icon: 'setting' }
}
]
},
{
path: '/system',
component: Layout,
redirect: '/system/user',
children: [
{
path: 'user',
name: 'user',
component: () => import('@/views/system/user/index.vue'),
meta: { title: 'User Management', icon: 'setting' }
},
{
path: 'model',
name: 'model',
component: () => import('@/views/system/model/index.vue'),
meta: { title: 'AI Model Configuration', icon: 'setting' }
}
]
}
]
})
+32
View File
@@ -0,0 +1,32 @@
export function formatTimestamp(timestamp: number, format: string = 'YYYY-MM-DD'): string {
if (!timestamp) {
return '-'
}
const date = new Date(timestamp)
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
const seconds = String(date.getSeconds()).padStart(2, '0')
return format.replace(/YYYY|MM|DD|HH|mm|ss/g, (match) => {
switch (match) {
case 'YYYY':
return String(year)
case 'MM':
return month
case 'DD':
return day
case 'HH':
return hours
case 'mm':
return minutes
case 'ss':
return seconds
default:
return match
}
})
}
+1 -1
View File
@@ -71,7 +71,7 @@ class HttpService {
}
// Request logging
console.log(`[Request] ${config.method?.toUpperCase()} ${config.url}`)
// console.log(`[Request] ${config.method?.toUpperCase()} ${config.url}`)
return config
},
+1 -1
View File
@@ -11,7 +11,7 @@
<div class="login-form">
<el-card class="login-card">
<h2 class="login-title">Login</h2>
<el-form :model="loginForm" :rules="rules" ref="loginFormRef">
<el-form :model="loginForm" :rules="rules" ref="loginFormRef" @keyup.enter="submitForm">
<el-form-item prop="username">
<el-input v-model="loginForm.username" placeholder="username" prefix-icon="user"></el-input>
</el-form-item>
@@ -0,0 +1,3 @@
<template>
<h2>this is ai model page</h2>
</template>
+366
View File
@@ -0,0 +1,366 @@
<template>
<div class="sqlbot-table-container professional-container">
<div class="sqlbot-tool">
<div class="tool-left">
<el-input
v-model="keyword"
class="sqlbot-search"
placeholder="Search"
prefix-icon="el-icon-search"
@keyup.enter.native="handleSearch"
>
<template #prefix>
<el-icon class="el-input__icon"><search /></el-icon>
</template>
</el-input>
</div>
<!-- <div class="tool-right">
<div class="tool-btn secondary-btn">
<el-icon><IconOpeUpload /></el-icon>
<span>Import Terminology</span>
</div>
<div class="tool-btn secondary-btn">
<el-icon><IconOpeDownload /></el-icon>
<span>Export Terminology</span>
</div>
<div class="tool-btn primary-btn" @click="openDialog">
<el-icon><IconOpeAdd /></el-icon>
<span>Add Terminology</span>
</div>
</div> -->
</div>
<div class="sqlbot-table">
<el-table :data="state.tableData" style="width: 100%">
<el-table-column prop="account" label="Account" width="280" />
<el-table-column prop="name" label="Name" width="280" />
<el-table-column prop="email" label="Email" width="280" />
<el-table-column prop="status" label="Status" width="180">
<template #default="scope">
<div class="user-status-container" :class="[ scope.row.status ? 'active' : 'disabled' ]">
<el-icon>
<SuccessFilled v-if="scope.row.status"/>
<CircleCloseFilled v-else />
</el-icon>
<span>{{ scope.row.status ? 'Activated' : 'Disabled' }}</span>
</div>
</template>
</el-table-column>
<el-table-column prop="create_time" label="Create time">
<template #default="scope">
<span>{{ formatTimestamp(scope.row.create_time) }}</span>
</template>
</el-table-column>
<el-table-column prop="id" label="Actions" width="120" fixed="right">
<template #default="scope">
<div class="table-operate">
<div class="opt-btn" @click="editHandler(scope.row.id)">
<el-icon><IconOpeEdit /></el-icon>
</div>
<div class="opt-btn" @click="deleteHandler(scope.row.id)">
<el-icon><IconOpeDelete /></el-icon>
</div>
</div>
</template>
</el-table-column>
</el-table>
</div>
</div>
<el-dialog v-model="dialogFormVisible" title="Add Terminology" destroy-on-close width="500" @close="onFormClose" class="sqlbot-dialog">
<el-form :model="state.form"
label-width="180px"
label-position="top"
class="sqlbot-form"
size="large"
ref="termFormRef"
>
<el-form-item label="Account" >
<el-input v-model="state.form.term" autocomplete="off" />
</el-form-item>
<el-form-item label="Definition" >
<el-input type="textarea" v-model="state.form.definition" />
</el-form-item>
<el-form-item label="Domain">
<el-select v-model="state.form.domain" placeholder="Please select a domain">
<el-option label="domain1" value="domain1" />
<el-option label="domain2" value="domain2" />
<el-option label="domain3" value="domain3" />
</el-select>
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button @click="closeForm">Cancel</el-button>
<el-button type="primary" @click="saveHandler">
save
</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue';
/* import IconOpeUpload from '@/assets/svg/operate/ope-upload.svg';
import IconOpeDownload from '@/assets/svg/operate/ope-download.svg';
import IconOpeAdd from '@/assets/svg/operate/ope-add.svg'; */
import { SuccessFilled, CircleCloseFilled } from '@element-plus/icons-vue'
import IconOpeEdit from '@/assets/svg/operate/ope-edit.svg';
import IconOpeDelete from '@/assets/svg/operate/ope-delete.svg';
import { Search } from '@element-plus/icons-vue'
import { userApi } from '@/api/auth'
import { formatTimestamp } from '@/utils/date'
const keyword = ref('')
const dialogFormVisible = ref(false)
const termFormRef = ref()
const state = reactive({
tableData: [
],
form: {
id: '',
term: '',
definition: '',
domain: ''
},
})
const handleSearch = (e: any) => {
console.log('search', e);
}
const editHandler = (id: any) => {
/* userApi.query(id).then((res: any) => {
console.log('term detail', res)
state.form = res
dialogFormVisible.value = true
}) */
}
const deleteHandler = (id: any) => {
ElMessageBox.confirm(
'Are you sure to delete?',
'Warning',
{
confirmButtonText: 'OK',
cancelButtonText: 'Cancel',
type: 'warning',
}
)
.then(() => {
/* userApi.delete(id).then(() => {
ElMessage({
type: 'success',
message: 'Delete completed',
})
search()
}) */
})
.catch(() => {
ElMessage({
type: 'info',
message: 'Delete canceled',
})
})
}
const openDialog = () => {
dialogFormVisible.value = true
}
const resetForm = () => {
if (!termFormRef) return
Object.keys(state.form).forEach((key) => {
state.form[key as keyof typeof state.form] = ''
})
}
const closeForm = () => {
dialogFormVisible.value = false
}
const onFormClose = () => {
resetForm()
dialogFormVisible.value = false
}
const search = () => {
userApi.pager(1, 20).then((res: any) => {
state.tableData = res.items
})
}
const addTerm = () => {
userApi.add(state.form).then(() => {
dialogFormVisible.value = false
search()
ElMessage({
type: 'success',
message: 'Add completed',
})
})
}
const editTerm = () => {
userApi.edit(state.form).then((res: any) => {
console.log('edit term', res)
dialogFormVisible.value = false
search()
ElMessage({
type: 'success',
message: 'Edit completed',
})
})
}
const saveHandler = () => {
if (state.form.id) {
editTerm()
} else {
addTerm()
}
}
onMounted(() => {
search()
})
</script>
<style lang="less" scoped>
.sqlbot-table-container {
width: 100%;
height: 100%;
.sqlbot-tool {
height: 42px;
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
.tool-left {
display: flex;
align-items: center;
.sqlbot-search {
width: 300px;
--el-input-inner-height: 40px !important;
--el-input-border-radius: 24px;
--el-input-bg-color: #f5f7fa;
--el-input-border-color: #e5e8ed;
}
}
.tool-right {
display: flex;
align-items: center;
column-gap: 12px;
height: 35px;
.secondary-btn {
background: #f5f5f5;
color: #333;
border: 1px solid #ddd;
}
.primary-btn {
background: #1890ff;
color: white;
}
.tool-btn {
display: flex;
align-items: center;
padding: 6px 16px;
border-radius: 4px;
cursor: pointer;
outline: none;
font-weight: 500;
transition: all 0.3s;
font-size: 14px;
gap: 6px;
&:hover {
background-color: #f1f3f4;
}
&.default {
color: #2d2e31;
font-weight: 500;
font-size: 14px;
i {
width: 20px;
height: 20px;
}
}
}
}
}
.sqlbot-table {
border-radius: 6px;
:deep(.el-table) {
--el-table-header-bg-color: #f5f7fa;
--el-table-border-color: #ebeef5;
--el-table-header-text-color: #606266;
th {
font-weight: 600;
height: 48px;
}
td {
height: 52px;
}
}
.table-operate {
display: flex;
justify-content: flex-end;
gap: 8px;
.opt-btn {
width: 36px;
height: 36px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: #5f6368;
cursor: pointer;
transition: all 0.2s;
background-color: #f1f3f4;
border: none;
font-size: 18px;
&:hover {
background-color: #e8f0fe;
color: #4285f4;
}
}
}
}
}
.sqlbot-form {
width: 100%;
.el-form-item {
margin-bottom: 16px;
text-align: left;
label {
font-size: 14px;
color: #606266;
}
}
}
.sqlbot-dialog {
width: 500px;
:deep(.el-dialog__header) {
text-align: left !important;
font-size: 16px;
background-color: #f5f7fa;
border-bottom: 1px solid #ebeef5;
color: #606266;
font-size: 16px;
}
}
.user-status-container {
display: flex;
align-items: center;
column-gap: 8px;
font-size: 14px;
color: #606266;
padding: 4px 8px;
width: fit-content;
border-radius: 4px;
&.active {
background-color: #e6f7ee;
color: #389e0d;
}
&.disabled {
background-color: #fff1f0;
color: #cf1322;
}
}
</style>