feat: add user center

This commit is contained in:
chansee97 2025-09-03 17:16:05 +08:00
parent 3e7fb6fa16
commit 7dd0301fb2
25 changed files with 459 additions and 676 deletions

2
.env
View File

@ -23,4 +23,4 @@ VITE_COPYRIGHT_INFO = Copyright © 2024 chansee97
VITE_AUTO_REFRESH_TOKEN = N
# 默认多语言 enUS | zhCN
VITE_DEFAULT_LANG = enUS
VITE_DEFAULT_LANG = zhCN

View File

@ -3,7 +3,6 @@
"mikestead.dotenv",
"usernamehw.errorlens",
"dbaeumer.vscode-eslint",
"eamodio.gitlens",
"mhutchie.git-graph",
"donjayamanne.githistory",
"lokalise.i18n-ally",

View File

@ -27,5 +27,5 @@ export function fetchRoleList() {
*/
export function fetchDictList(code?: string) {
const params = { code }
return request.Get<Api.Response<Entity.Dict[]>>('/dict/list', { params })
return request.Get<Api.Response<Entity.DictType[]>>('/dict/list', { params })
}

View File

@ -1,8 +1,4 @@
export * from './login'
export * from './demo'
export * from './test'
export * from './system/user'
export * from './system/menu'
export * from './system/role'
export * from './system/dict'
export * from './system/dept'
export * from './system'

View File

@ -1,6 +1,6 @@
import { request } from '../../utils/alova'
export type SearchQuery = Partial<Pick<Entity.Dept, 'deptName' | 'status'>>
export type DeptSearchQuery = Partial<Pick<Entity.Dept, 'deptName' | 'status'>>
/**
*
@ -14,7 +14,7 @@ export function createDept(data: Partial<Entity.Dept>) {
*
* GET /dept
*/
export function getDeptList(params?: SearchQuery) {
export function getDeptList(params?: DeptSearchQuery) {
return request.Get<Api.Response<Entity.Dept[]>>('/dept', { params })
}

5
src/api/system/index.ts Normal file
View File

@ -0,0 +1,5 @@
export * from './user'
export * from './menu'
export * from './role'
export * from './dict'
export * from './dept'

View File

@ -1,6 +1,6 @@
import { request } from '../../utils/alova'
export type SearchQuery = Partial<Pick<Entity.Menu, 'title' | 'status'>>
export type MenuSearchQuery = Partial<Pick<Entity.Menu, 'title' | 'status'>>
/**
*
* POST /menu
@ -13,7 +13,7 @@ export function createMenu(data: Partial<Entity.Menu>) {
*
* GET /menu
*/
export function getMenuList(params?: SearchQuery) {
export function getMenuList(params?: MenuSearchQuery) {
return request.Get<Api.Response<Entity.Menu[]>>('/menu', { params })
}

View File

@ -1,36 +0,0 @@
<script setup lang="ts">
interface Props {
count?: number
}
const {
count = 0,
} = defineProps<Props>()
const emit = defineEmits<{
change: [page: number, pageSize: number] //
}>()
const page = ref(1)
const pageSize = ref(10)
const displayOrder: Array<'pages' | 'size-picker' | 'quick-jumper'> = ['size-picker', 'pages']
function changePage() {
emit('change', page.value, pageSize.value)
}
</script>
<template>
<n-pagination
v-if="count > 0"
v-model:page="page"
v-model:page-size="pageSize"
:page-sizes="[10, 20, 30, 50]"
:item-count="count"
:display-order="displayOrder"
show-size-picker
@update-page="changePage"
@update-page-size="changePage"
/>
</template>
<style scoped></style>

72
src/hooks/useDict.ts Normal file
View File

@ -0,0 +1,72 @@
import { useDictStore } from '@/store/dict'
/**
*
*/
export interface DictUtils {
/** 原始字典数据 */
data: Entity.DictData[]
/** 枚举映射 { value: label } */
enum: Record<string, string>
/** 值映射 { value: dictData } */
valueMap: Record<string, Omit<Entity.DictData, 'value'>>
/** 标签映射 { label: dictData } */
labelMap: Record<string, Omit<Entity.DictData, 'name'>>
/** 选项数组 [{ label, value }] */
options: Array<{ label: string, value: string }>
}
/**
* 使 Hook
* @param dictType
* @returns
*/
export function useDict(dictType: string) {
const dictStore = useDictStore()
const dictData = ref<Entity.DictData[]>([])
// 获取字典数据
async function fetchDict() {
try {
dictData.value = await dictStore.getDict(dictType)
}
catch (err) {
console.error(`获取字典数据失败 [${dictType}]:`, err)
}
}
// 立即获取数据
fetchDict()
const enumMap = computed(() => {
return Object.fromEntries(
dictData.value.map(({ value, name }) => [value, name]),
)
})
const valueMap = computed(() => {
return Object.fromEntries(
dictData.value.map(({ value, ...data }) => [value, data]),
)
})
const labelMap = computed(() => {
return Object.fromEntries(
dictData.value.map(({ name, ...data }) => [name, data]),
)
})
const options = computed(() => {
return dictData.value.map(({ name, value }) => ({
label: name,
value,
}))
})
return {
rawData: dictData,
enumMap,
valueMap,
labelMap,
options,
}
}

View File

@ -1,35 +0,0 @@
import type { NDataTable } from 'naive-ui'
import { useDraggable } from 'vue-draggable-plus'
export function useTableDrag<T = unknown>(params: {
tableRef: Ref<InstanceType<typeof NDataTable> | undefined>
data: Ref<T[]>
onRowDrag: (rows: T[]) => void
}) {
const tableEl = computed(() => params.tableRef?.value?.$el as HTMLElement)
const tableBodyRef = ref<HTMLElement | undefined>(undefined)
const { start } = useDraggable(tableBodyRef, params.data, {
immediate: false,
animation: 150,
handle: '.drag-handle',
onEnd: (event) => {
const { oldIndex, newIndex } = event
const start = Math.min(oldIndex!, newIndex!)
const end = Math.max(oldIndex!, newIndex!) - start + 1
const changedRows = [...params.data.value].splice(start, end)
params.onRowDrag(unref([...changedRows]))
},
})
onMounted(async () => {
while (!tableBodyRef.value) {
tableBodyRef.value = tableEl.value?.querySelector('tbody') || undefined
await new Promise(resolve => setTimeout(resolve, 100))
}
})
watchOnce(() => tableBodyRef.value, (el) => {
el && start()
})
}

View File

@ -1,7 +1,7 @@
<script setup lang="ts">
import type { RouteLocationNormalized } from 'vue-router'
import { useAppStore, useTabStore } from '@/store'
import { useTabScroll } from '@/hooks/useTabScroll'
import { useTabScroll } from '@/layouts/hooks/useTabScroll'
import { useDraggable } from 'vue-draggable-plus'
import IconClose from '~icons/icon-park-outline/close'
import IconDelete from '~icons/icon-park-outline/delete-four'

View File

@ -1,5 +1,6 @@
import type { NScrollbar } from 'naive-ui'
import { ref, type Ref, watchEffect } from 'vue'
import { ref, watchEffect } from 'vue'
import type { Ref } from 'vue'
import { throttle } from 'radash'
export function useTabScroll(currentTabPath: Ref<string>) {

View File

@ -77,16 +77,6 @@ export const staticRoutes: AppRoute.RowRoute[] = [
id: 3,
parentId: null,
},
{
name: 'commonList',
path: '/list/common-list',
title: '常用列表',
requiresAuth: true,
icon: 'icon-park-outline:list-view',
component: '/demo/list/common-list/index.vue',
id: 301,
parentId: 3,
},
{
name: 'cardList',
path: '/list/card-list',

View File

@ -46,7 +46,6 @@ export const useAuthStore = defineStore('auth-store', {
clearAuthStorage() {
local.remove('accessToken')
local.remove('refreshToken')
local.remove('userInfo')
},
/* 用户登录 */
@ -62,10 +61,15 @@ export const useAuthStore = defineStore('auth-store', {
const { data } = await fetchLogin(loginData)
// 处理登录信息
await this.handleLoginInfo(data)
},
// 更新用户信息
await this.updataUserInfo()
},
async updataUserInfo() {
const { data } = await fetchUserInfo()
this.userInfo = data
},
/* 处理登录返回的数据 */
async handleLoginInfo(data: any) {
// 将token保存下来
@ -74,12 +78,9 @@ export const useAuthStore = defineStore('auth-store', {
local.set('refreshToken', data.refreshToken)
}
const res = await fetchUserInfo()
this.userInfo = res.data
// 添加路由和菜单
const routeStore = useRouteStore()
await routeStore.initAuthRoute()
const { initAuthRoute } = useRouteStore()
await initAuthRoute()
// 进行重定向跳转
const route = unref(router.currentRoute)

View File

@ -1,53 +1,70 @@
import { fetchDictList } from '@/api'
import { session } from '@/utils'
import { getDictDataByType } from '@/api'
// 字典缓存项接口
interface DictCacheItem {
data: Entity.DictData[]
timestamp: number
expireTime: number
}
// 默认缓存时间60分钟
const DICT_CACHE_TIME = 60 * 60 * 1000
export const useDictStore = defineStore('dict-store', {
state: () => {
return {
dictMap: {} as DictMap,
isInitDict: false,
dictMap: {} as Record<string, DictCacheItem>,
}
},
actions: {
async dict(code: string) {
// 调用前初始化
if (!this.dictMap) {
this.initDict()
async getDict(code: string): Promise<Entity.DictData[]> {
const cachedItem = this.dictMap[code]
const now = Date.now()
// 如果缓存存在且未过期,直接返回缓存数据
if (cachedItem && now < cachedItem.expireTime) {
return cachedItem.data
}
const targetDict = await this.getDict(code)
return {
data: () => targetDict,
enum: () => Object.fromEntries(targetDict.map(({ value, label }) => [value, label])),
valueMap: () => Object.fromEntries(targetDict.map(({ value, ...data }) => [value, data])),
labelMap: () => Object.fromEntries(targetDict.map(({ label, ...data }) => [label, data])),
}
},
async getDict(code: string) {
const isExist = Reflect.has(this.dictMap, code)
if (isExist) {
return this.dictMap[code]
}
else {
// 如果缓存不存在或已过期,尝试重新获取数据
try {
return await this.getDictByNet(code)
}
catch (error) {
// 如果有旧的缓存数据,返回旧数据
if (cachedItem) {
return cachedItem.data
}
// 如果没有缓存数据,抛出错误
throw error
}
},
async getDictByNet(code: string) {
const { data } = await fetchDictList(code)
Reflect.set(this.dictMap, code, data)
// 同步至session
session.set('dict', this.dictMap)
async getDictByNet(type: string): Promise<Entity.DictData[]> {
const { data } = await getDictDataByType(type)
const now = Date.now()
// 创建缓存项
const cacheItem: DictCacheItem = {
data,
timestamp: now,
expireTime: now + DICT_CACHE_TIME,
}
this.dictMap[type] = cacheItem
return data
},
initDict() {
const dict = session.get('dict')
if (dict) {
Object.assign(this.dictMap, dict)
}
this.isInitDict = true
// 清理字典缓存
cleanDict() {
this.dictMap = {}
},
// 移除指定字典缓存
removeDict(dictType: string) {
delete this.dictMap[dictType]
},
},
persist: {

View File

@ -31,5 +31,7 @@ namespace Entity {
remark?: string
/** 用户角色类型 */
roles: Entity.Role[]
/** 所属部门 */
dept: Entity.Dept
}
}

View File

@ -0,0 +1,132 @@
<script setup lang="ts">
import { reactive, ref, watch } from 'vue'
import { useMessage } from 'naive-ui'
import { useAuthStore } from '@/store'
import { updateUser } from '@/api/system/user'
const message = useMessage()
const authStore = useAuthStore()
const { userInfo } = authStore
//
const loading = ref(false)
//
const formData = reactive({
nickName: '',
email: '',
phone: '',
gender: 'unknown' as 'male' | 'female',
})
//
function initFormData() {
if (userInfo) {
formData.nickName = userInfo.nickName || ''
formData.email = userInfo.email || ''
formData.phone = userInfo.phone || ''
formData.gender = userInfo.gender
}
}
//
watch(() => userInfo, initFormData, { immediate: true })
//
async function updateUserInfo() {
if (!userInfo?.id) {
message.error('用户信息不完整')
return
}
loading.value = true
try {
await updateUser(userInfo.id, formData)
//
await authStore.updataUserInfo()
message.success('个人信息更新成功')
}
catch (error) {
console.error('更新用户信息失败:', error)
}
finally {
loading.value = false
}
}
</script>
<template>
<n-form
:model="formData"
label-placement="left"
label-width="80px"
class="max-w-400px"
>
<n-grid :cols="1">
<n-grid-item>
<n-form-item label="用户名">
{{ userInfo.username }}
</n-form-item>
</n-grid-item>
<n-grid-item>
<n-form-item label="昵称">
<n-input v-model:value="formData.nickName" placeholder="请输入昵称" />
</n-form-item>
</n-grid-item>
<n-grid-item>
<n-form-item label="性别">
<n-select
v-model:value="formData.gender"
:options="[
{ label: '男', value: 'male' },
{ label: '女', value: 'female' },
]"
/>
</n-form-item>
</n-grid-item>
<n-grid-item>
<n-form-item label="邮箱">
<n-input v-model:value="formData.email" placeholder="请输入邮箱" />
</n-form-item>
</n-grid-item>
<n-grid-item>
<n-form-item label="手机号">
<n-input v-model:value="formData.phone" placeholder="请输入手机号" />
</n-form-item>
</n-grid-item>
<n-grid-item>
<n-form-item label="所属部门">
{{ userInfo?.dept?.deptName || '-' }}
</n-form-item>
</n-grid-item>
</n-grid>
<n-form-item label="拥有角色">
<n-space>
<n-tag
v-for="(role, index) in userInfo?.roles"
:key="index"
type="primary"
size="medium"
>
{{ role?.roleName || role?.name || `角色${index + 1}` }}
</n-tag>
<n-tag v-if="!userInfo?.roles || userInfo?.roles.length === 0" type="default" size="medium">
暂无角色
</n-tag>
</n-space>
</n-form-item>
<!-- 更新按钮 -->
<n-form-item label=" ">
<n-button
type="primary"
:loading="loading"
@click="updateUserInfo"
>
更新个人信息
</n-button>
</n-form-item>
</n-form>
</template>

View File

@ -1,15 +1,13 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useAuthStore } from '@/store'
import PersonalInfo from './components/PersonalInfo.vue'
const authStore = useAuthStore()
const { userInfo } = authStore
const { userInfo } = storeToRefs(authStore)
//
const genderMap = {
male: '男',
female: '女',
unknown: '未知',
}
//
const activeTab = ref('profile')
//
function getGreeting() {
@ -28,62 +26,91 @@ function getGreeting() {
return '晚上好'
return '夜深了'
}
//
onMounted(async () => {
await authStore.updataUserInfo()
})
</script>
<template>
<n-space vertical>
<!-- 基本信息卡片 -->
<n-card :title="`${getGreeting()}${userInfo?.nickName || userInfo?.username || '用户'},欢迎使用个人中心`">
<n-flex gap="16px" align="center">
<!-- 欢迎标题 -->
<n-card>
<n-flex align="center" justify="space-between">
<div>
<n-h2>
{{ getGreeting() }}{{ userInfo?.nickName || userInfo.username }}
</n-h2>
<n-p class="text-sm opacity-90 m-0">
欢迎使用个人中心
</n-p>
</div>
<n-avatar
round
:size="128"
:size="64"
:src="userInfo?.avatar || `https://api.dicebear.com/9.x/adventurer-neutral/svg?seed=${userInfo!.username}`"
class="m-x-6"
/>
<n-descriptions
label-placement="left"
class="flex-1"
:column="2"
>
<n-descriptions-item label="用户名">
{{ userInfo?.username || '-' }}
</n-descriptions-item>
<n-descriptions-item label="昵称">
{{ userInfo?.nickName || '-' }}
</n-descriptions-item>
<n-descriptions-item label="性别">
<n-tag size="small">
{{ genderMap[userInfo?.gender || 'unknown'] }}
</n-tag>
</n-descriptions-item>
<n-descriptions-item label="邮箱">
{{ userInfo?.email || '-' }}
</n-descriptions-item>
<n-descriptions-item label="手机号">
{{ userInfo?.phone || '-' }}
</n-descriptions-item>
<n-descriptions-item label="部门ID">
{{ userInfo?.deptId || '-' }}
</n-descriptions-item>
<n-descriptions-item label="拥有角色" :span="2">
<n-space>
<n-tag
v-for="(role, index) in userInfo?.roles"
:key="index"
type="primary"
size="small"
>
{{ role?.roleName || role?.name || `角色${index + 1}` }}
</n-tag>
<n-tag v-if="!userInfo?.roles || userInfo?.roles.length === 0" type="default" size="small">
暂无角色
</n-tag>
</n-space>
</n-descriptions-item>
</n-descriptions>
</n-flex>
</n-card>
<!-- 主要内容区域 -->
<n-card>
<n-tabs
v-model:value="activeTab"
type="line"
placement="left"
tab-style="min-height: 30px;"
pane-style="padding-left: 1rem;"
>
<n-tab-pane name="profile">
<template #tab>
<icon-park-outline-user class="mr-2" />
个人信息
</template>
<n-h5>
个人信息
</n-h5>
<n-divider />
<PersonalInfo />
</n-tab-pane>
<n-tab-pane name="security">
<template #tab>
<icon-park-outline-lock class="mr-2" />
安全设置
</template>
<n-h5>
安全设置
</n-h5>
<n-divider />
<n-empty description="功能开发中..." />
</n-tab-pane>
<n-tab-pane name="notification">
<template #tab>
<icon-park-outline-remind class="mr-2" />
通知设置
</template>
<n-h5>
通知设置
</n-h5>
<n-divider />
<n-empty description="功能开发中..." />
</n-tab-pane>
<n-tab-pane name="preference">
<template #tab>
<icon-park-outline-setting-one class="mr-2" />
偏好设置
</template>
<n-h5>
偏好设置
</n-h5>
<n-divider />
<n-empty description="功能开发中..." />
</n-tab-pane>
</n-tabs>
</n-card>
</n-space>
</template>

View File

@ -1,117 +0,0 @@
<script setup lang="ts">
interface Props {
visible: boolean
type?: ModalType
modalData?: any
}
const {
visible,
type = 'add',
modalData = null,
} = defineProps<Props>()
const emit = defineEmits<Emits>()
const defaultFormModal: Entity.User = {
userName: '',
gender: 0,
email: '',
role: [],
}
const formModel = ref({ ...defaultFormModal })
interface Emits {
(e: 'update:visible', visible: boolean): void
}
const modalVisible = computed({
get() {
return visible
},
set(visible) {
closeModal(visible)
},
})
function closeModal(visible = false) {
emit('update:visible', visible)
}
type ModalType = 'add' | 'edit'
const title = computed(() => {
const titles: Record<ModalType, string> = {
add: '添加用户',
edit: '编辑用户',
}
return titles[type]
})
function UpdateFormModelByModalType() {
const handlers = {
add: () => {
formModel.value = { ...defaultFormModal }
},
edit: () => {
if (modalData)
formModel.value = { ...modalData }
},
}
handlers[type]()
}
watch(
() => visible,
(newValue) => {
if (newValue)
UpdateFormModelByModalType()
},
)
</script>
<template>
<n-modal
v-model:show="modalVisible"
:mask-closable="false"
preset="card"
:title="title"
class="w-700px"
:segmented="{
content: true,
action: true,
}"
>
<n-form label-placement="left" :model="formModel" label-align="left" :label-width="80">
<n-grid :cols="24" :x-gap="18">
<n-form-item-grid-item :span="12" label="用户名" path="name">
<n-input v-model:value="formModel.userName" />
</n-form-item-grid-item>
<n-form-item-grid-item :span="12" label="年龄" path="age">
<n-input-number v-model:value="formModel.gender" />
</n-form-item-grid-item>
<n-form-item-grid-item :span="12" label="性别" path="gender">
<n-radio-group v-model:value="formModel.gender">
<n-space>
<n-radio :value="1">
</n-radio>
<n-radio :value="0">
</n-radio>
</n-space>
</n-radio-group>
</n-form-item-grid-item>
<n-form-item-grid-item :span="12" label="邮箱" path="email">
<n-input v-model:value="formModel.email" />
</n-form-item-grid-item>
</n-grid>
</n-form>
<template #action>
<n-space justify="center">
<n-button @click="closeModal()">
取消
</n-button>
<n-button type="primary">
提交
</n-button>
</n-space>
</template>
</n-modal>
</template>
<style scoped></style>

View File

@ -1,209 +0,0 @@
<script setup lang="tsx">
import type { DataTableColumns, FormInst } from 'naive-ui'
import { Gender } from '@/constants'
import { useBoolean } from '@/hooks'
import { fetchUserPage } from '@/api'
import { NButton, NPopconfirm, NSpace, NSwitch, NTag } from 'naive-ui'
import TableModal from './components/TableModal.vue'
const { bool: loading, setTrue: startLoading, setFalse: endLoading } = useBoolean(false)
const { bool: visible, setTrue: openModal } = useBoolean(false)
const initialModel = {
condition_1: '',
condition_2: '',
condition_3: '',
condition_4: '',
}
const model = ref({ ...initialModel })
const formRef = ref<FormInst | null>()
function sendMail(id?: number) {
window.$message.success(`删除用户id:${id}`)
}
const columns: DataTableColumns<Entity.User> = [
{
title: '姓名',
align: 'center',
key: 'userName',
},
{
title: '年龄',
align: 'center',
key: 'age',
},
{
title: '性别',
align: 'center',
key: 'gender',
render: (row) => {
const tagType = {
0: 'primary',
1: 'success',
} as const
if (row.gender) {
return (
<NTag type={tagType[row.gender]}>
{Gender[row.gender]}
</NTag>
)
}
},
},
{
title: '邮箱',
align: 'center',
key: 'email',
},
{
title: '状态',
align: 'center',
key: 'status',
render: (row) => {
return (
<NSwitch
value={row.status}
checked-value={1}
unchecked-value={0}
onUpdateValue={(value: 0 | 1) =>
handleUpdateDisabled(value, row.id!)}
>
{{ checked: () => '启用', unchecked: () => '禁用' }}
</NSwitch>
)
},
},
{
title: '操作',
align: 'center',
key: 'actions',
render: (row) => {
return (
<NSpace justify="center">
<NButton
size="small"
onClick={() => handleEditTable(row)}
>
编辑
</NButton>
<NPopconfirm onPositiveClick={() => sendMail(row.id)}>
{{
default: () => '确认删除',
trigger: () => <NButton size="small">删除</NButton>,
}}
</NPopconfirm>
</NSpace>
)
},
},
]
const listData = ref<Entity.User[]>([])
function handleUpdateDisabled(value: 0 | 1, id: number) {
const index = listData.value.findIndex(item => item.id === id)
if (index > -1)
listData.value[index].status = value
}
onMounted(() => {
getUserList()
})
async function getUserList() {
startLoading()
await fetchUserPage().then((res: any) => {
listData.value = res.data.list
endLoading()
})
}
function changePage(page: number, size: number) {
window.$message.success(`分页器:${page},${size}`)
}
function handleResetSearch() {
model.value = { ...initialModel }
}
type ModalType = 'add' | 'edit'
const modalType = ref<ModalType>('add')
function setModalType(type: ModalType) {
modalType.value = type
}
const editData = ref<Entity.User | null>(null)
function setEditData(data: Entity.User | null) {
editData.value = data
}
function handleEditTable(row: Entity.User) {
setEditData(row)
setModalType('edit')
openModal()
}
function handleAddTable() {
openModal()
setModalType('add')
}
</script>
<template>
<NSpace vertical size="large">
<n-card>
<n-form ref="formRef" :model="model" label-placement="left" inline :show-feedback="false">
<n-flex>
<n-form-item label="姓名" path="condition_1">
<n-input v-model:value="model.condition_1" placeholder="请输入" />
</n-form-item>
<n-form-item label="年龄" path="condition_2">
<n-input v-model:value="model.condition_2" placeholder="请输入" />
</n-form-item>
<n-form-item label="性别" path="condition_3">
<n-input v-model:value="model.condition_3" placeholder="请输入" />
</n-form-item>
<n-form-item label="地址" path="condition_4">
<n-input v-model:value="model.condition_4" placeholder="请输入" />
</n-form-item>
<n-flex class="ml-auto">
<NButton type="primary" @click="getUserList">
<template #icon>
<icon-park-outline-search />
</template>
搜索
</NButton>
<NButton strong secondary @click="handleResetSearch">
<template #icon>
<icon-park-outline-redo />
</template>
重置
</NButton>
</n-flex>
</n-flex>
</n-form>
</n-card>
<n-card>
<NSpace vertical size="large">
<div class="flex gap-4">
<NButton type="primary" @click="handleAddTable">
<template #icon>
<icon-park-outline-add-one />
</template>
新增
</NButton>
<NButton strong secondary>
<template #icon>
<icon-park-outline-afferent />
</template>
批量导入
</NButton>
<NButton strong secondary class="ml-a">
<template #icon>
<icon-park-outline-download />
</template>
下载
</NButton>
</div>
<n-data-table :columns="columns" :data="listData" :loading="loading" />
<Pagination :count="100" @change="changePage" />
<TableModal v-model:visible="visible" :type="modalType" :modal-data="editData" />
</NSpace>
</n-card>
</NSpace>
</template>

View File

@ -1,170 +1,103 @@
<script setup lang="tsx">
import type { DataTableColumns, FormInst, NDataTable } from 'naive-ui'
import { Gender } from '@/constants'
import { useBoolean } from '@/hooks'
import { useTableDrag } from '@/hooks/useTableDrag'
import { fetchUserPage } from '@/api'
import { NButton, NPopconfirm, NSpace, NSwitch, NTag } from 'naive-ui'
import type { ProDataTableColumns, ProDataTableDragSortEnd } from 'pro-naive-ui'
import { AimOutlined } from '@vicons/antd'
import { NButton, NIcon } from 'naive-ui'
import { move } from 'pro-naive-ui'
import { onMounted, ref } from 'vue'
const { bool: loading, setTrue: startLoading, setFalse: endLoading } = useBoolean(false)
const initialModel = {
condition_1: '',
condition_2: '',
condition_3: '',
condition_4: '',
interface Song {
no: number
title: string
length: string
}
const model = ref({ ...initialModel })
const formRef = ref<FormInst | null>()
function sendMail(id?: number) {
window.$message.success(`删除用户id:${id}`)
const dragHandle = ref(true)
const loading = ref(false)
//
const data = ref<Song[]>([])
//
const songData: Song[] = [
{ no: 3, title: 'Wonderwall', length: '4:18' },
{ no: 4, title: 'Don\'t Look Back in Anger', length: '4:48' },
{ no: 12, title: 'Champagne Supernova', length: '7:27' },
{ no: 33, title: 'Wonderwall', length: '4:18' },
{ no: 44, title: 'Don\'t Look Back in Anger', length: '4:48' },
{ no: 122, title: 'Champagne Supernova', length: '7:27' },
]
//
function fetchData() {
loading.value = true
return new Promise<Song[]>((resolve) => {
setTimeout(() => {
resolve(songData)
loading.value = false
}, 1500) // 1.5
})
}
const columns: DataTableColumns<Entity.User> = [
onMounted(async () => {
data.value = await fetchData()
})
const columns: ProDataTableColumns<Song> = [
{
title: '姓名',
align: 'center',
key: 'userName',
path: 'dragSort',
},
{
title: '年龄',
align: 'center',
key: 'age',
title: '自定义排序名称',
path: 'dragSort',
width: 160,
},
{
title: '性别',
align: 'center',
key: 'gender',
render: (row) => {
const tagType = {
0: 'primary',
1: 'success',
} as const
if (row.gender) {
return (
<NTag type={tagType[row.gender]}>
{Gender[row.gender]}
</NTag>
)
}
},
},
{
title: '邮箱',
align: 'center',
key: 'email',
},
{
title: '状态',
align: 'center',
key: 'status',
render: (row) => {
title: '自定义渲染拖拽手柄',
path: 'dragSort',
width: 160,
render() {
return (
<NSwitch
value={row.status}
checked-value={1}
unchecked-value={0}
onUpdateValue={(value: 0 | 1) =>
handleUpdateDisabled(value, row.id!)}
>
{{ checked: () => '启用', unchecked: () => '禁用' }}
</NSwitch>
<NButton text={true} class="cursor-grab align-middle">
<NIcon size={16}>
<AimOutlined />
</NIcon>
</NButton>
)
},
},
{
title: '操作',
align: 'center',
key: 'actions',
render: (row) => {
return (
<NSpace justify="center">
<NPopconfirm onPositiveClick={() => sendMail(row.id)}>
{{
default: () => '确认删除',
trigger: () => <NButton size="small">删除</NButton>,
}}
</NPopconfirm>
</NSpace>
)
},
title: '不依赖手柄',
tooltip: '设置为 true 后再来拖拽',
path: 'length',
},
]
const listData = ref<Entity.User[]>([])
function handleUpdateDisabled(value: 0 | 1, id: number) {
const index = listData.value.findIndex(item => item.id === id)
if (index > -1)
listData.value[index].status = value
}
const tableRef = ref<InstanceType<typeof NDataTable>>()
useTableDrag({
tableRef,
data: listData,
onRowDrag(data) {
const target = data[data.length - 1]
window.$message.success(`拖拽数据 id: ${target.id} name: ${target.userName}`)
},
})
onMounted(() => {
getUserList()
})
async function getUserList() {
startLoading()
await fetchUserPage().then((res: any) => {
listData.value = res.data.list
endLoading()
})
}
function changePage(page: number, size: number) {
window.$message.success(`分页器:${page},${size}`)
}
function handleResetSearch() {
model.value = { ...initialModel }
/**
* 你需要在这里同步数据源
*/
function onDragSortEnd(event: Parameters<ProDataTableDragSortEnd>['0']) {
const { newIndex, oldIndex } = event
move(data.value, oldIndex, newIndex)
}
</script>
<template>
<NSpace vertical size="large">
<n-card>
<n-form ref="formRef" :model="model" label-placement="left" inline :show-feedback="false">
<n-flex>
<n-form-item label="姓名" path="condition_1">
<n-input v-model:value="model.condition_1" placeholder="请输入" />
</n-form-item>
<n-form-item label="年龄" path="condition_2">
<n-input v-model:value="model.condition_2" placeholder="请输入" />
</n-form-item>
<n-form-item label="性别" path="condition_3">
<n-input v-model:value="model.condition_3" placeholder="请输入" />
</n-form-item>
<n-form-item label="地址" path="condition_4">
<n-input v-model:value="model.condition_4" placeholder="请输入" />
</n-form-item>
<n-flex class="ml-auto">
<NButton type="primary" @click="getUserList">
<template #icon>
<icon-park-outline-search />
</template>
搜索
</NButton>
<NButton strong secondary @click="handleResetSearch">
<template #icon>
<icon-park-outline-redo />
</template>
重置
</NButton>
</n-flex>
</n-flex>
</n-form>
</n-card>
<n-card>
<NSpace vertical size="large">
<n-data-table ref="tableRef" row-class-name="drag-handle" :columns="columns" :data="listData" :loading="loading" />
<Pagination :count="100" @change="changePage" />
</NSpace>
</n-card>
</NSpace>
<pro-data-table
:data="data"
:loading
:columns="columns"
:drag-sort-options="{
columnPath: 'dragSort',
// false
handle: dragHandle ? undefined : false,
onEnd: onDragSortEnd,
}"
row-key="no"
>
<template #extra>
<div>
依赖手柄拖拽: <n-switch v-model:value="dragHandle" />
</div>
</template>
</pro-data-table>
</template>

View File

@ -1,7 +1,7 @@
<script setup lang="ts">
import { useBoolean } from '@/hooks'
import { deleteDept, getDeptList } from '@/api'
import type { SearchQuery } from '@/api'
import type { MenuSearchQuery } from '@/api'
import { createDeptColumns, deptSearchColumns } from './columns'
import DeptModal from './components/DeptModal.vue'
import arrayToTree from 'array-to-tree'
@ -12,7 +12,7 @@ const { bool: loading, setTrue: startLoading, setFalse: endLoading } = useBoolea
const deptModalRef = ref<InstanceType<typeof DeptModal>>()
//
const searchForm = createProSearchForm<SearchQuery>({
const searchForm = createProSearchForm<MenuSearchQuery>({
initialValues: {},
onSubmit: getAllDepts,
onReset: getAllDepts,
@ -40,7 +40,7 @@ const deptColumns = createDeptColumns({
const tableData = ref<Entity.Dept[]>([])
//
async function getAllDepts(params?: SearchQuery) {
async function getAllDepts(params?: MenuSearchQuery) {
startLoading()
try {
const { data } = await getDeptList(params)

View File

@ -3,7 +3,7 @@ import { NButton, NPopconfirm, NSpace, NTag } from 'naive-ui'
import { createIcon } from '@/utils'
import { renderProCopyableText } from 'pro-naive-ui'
import type { ProSearchFormColumns } from 'pro-naive-ui'
import type { SearchQuery } from '@/api'
import type { MenuSearchQuery } from '@/api'
// 菜单管理columns配置函数
interface MenuColumnActions {
@ -12,7 +12,7 @@ interface MenuColumnActions {
onAdd: (row: Entity.Menu) => void
}
export const searchColumns: ProSearchFormColumns<SearchQuery> = [
export const searchColumns: ProSearchFormColumns<MenuSearchQuery> = [
{
title: '菜单名称',
path: 'title',

View File

@ -1,7 +1,7 @@
<script setup lang="ts">
import { useBoolean } from '@/hooks'
import { deleteMenu, getMenuList } from '@/api'
import type { SearchQuery } from '@/api'
import type { MenuSearchQuery } from '@/api'
import { createMenuColumns, searchColumns } from './columns'
import MenuModal from './components/MenuModal.vue'
import arrayToTree from 'array-to-tree'
@ -12,7 +12,7 @@ const { bool: loading, setTrue: startLoading, setFalse: endLoading } = useBoolea
const menuModalRef = ref()
//
const searchForm = createProSearchForm<SearchQuery>({
const searchForm = createProSearchForm<MenuSearchQuery>({
initialValues: {},
onSubmit: getAllRoutes,
onReset: getAllRoutes,
@ -37,7 +37,7 @@ async function deleteData(id: number) {
}
const tableData = ref<Entity.Menu[]>([])
async function getAllRoutes(params?: SearchQuery) {
async function getAllRoutes(params?: MenuSearchQuery) {
startLoading()
try {
const { data } = await getMenuList(params)

View File

@ -6,12 +6,17 @@
"lib": ["ESNext", "DOM"],
"baseUrl": ".",
"module": "ESNext",
"moduleResolution": "node",
"moduleResolution": "bundler",
"paths": {
"@/*": ["src/*"]
},
"resolveJsonModule": true,
"types": ["node", "vite/client", "naive-ui/volar", "unplugin-icons/types/vue"],
"types": [
"node",
"vite/client",
"naive-ui/volar",
"unplugin-icons/types/vue"
],
"allowJs": true,
"strict": true,
"strictNullChecks": true,
@ -21,5 +26,5 @@
"isolatedModules": true,
"skipLibCheck": true
},
"exclude": ["node_modules", "dist", "eslint.config.js"]
"exclude": ["node_modules", "eslint.config.js"]
}