添加系统管理、登录模块的接口、标准化开发流程
This commit is contained in:
18
src/api/auth.ts
Normal file
18
src/api/auth.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { request } from '@/utils/request'
|
||||
import type { LoginParams, LoginResult, CaptchaResult, UserInfo } from '@/types/api/auth'
|
||||
|
||||
export function getCaptcha() {
|
||||
return request.get<CaptchaResult>('/auth/captcha')
|
||||
}
|
||||
|
||||
export function login(data: LoginParams) {
|
||||
return request.post<LoginResult>('/auth/login', data)
|
||||
}
|
||||
|
||||
export function logout() {
|
||||
return request.post<void>('/auth/logout')
|
||||
}
|
||||
|
||||
export function getUserInfo() {
|
||||
return request.get<UserInfo>('/auth/user-info')
|
||||
}
|
||||
23
src/api/finance/budget.ts
Normal file
23
src/api/finance/budget.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { request } from '@/utils/request'
|
||||
import type { PageResult } from '@/types/api/response'
|
||||
import type { BudgetRecord, BudgetQueryParams } from '@/types/finance/budget'
|
||||
|
||||
export function getBudgetList(params: BudgetQueryParams) {
|
||||
return request.get<PageResult<BudgetRecord>>('/finance/budget/list', { params })
|
||||
}
|
||||
|
||||
export function getBudgetDetail(id: number) {
|
||||
return request.get<BudgetRecord>(`/finance/budget/${id}`)
|
||||
}
|
||||
|
||||
export function createBudget(data: Partial<BudgetRecord>) {
|
||||
return request.post<void>('/finance/budget', data)
|
||||
}
|
||||
|
||||
export function updateBudget(data: Partial<BudgetRecord>) {
|
||||
return request.put<void>(`/finance/budget/${data.id}`, data)
|
||||
}
|
||||
|
||||
export function deleteBudget(id: number) {
|
||||
return request.delete<void>(`/finance/budget/${id}`)
|
||||
}
|
||||
18
src/api/system/menu.ts
Normal file
18
src/api/system/menu.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { request } from '@/utils/request'
|
||||
import type { MenuRecord, MenuFormData } from '@/types/system/menu'
|
||||
|
||||
export function getMenuList() {
|
||||
return request.get<MenuRecord[]>('/system/menu/list')
|
||||
}
|
||||
|
||||
export function createMenu(data: MenuFormData) {
|
||||
return request.post<void>('/system/menu', data)
|
||||
}
|
||||
|
||||
export function updateMenu(data: MenuFormData) {
|
||||
return request.put<void>(`/system/menu/${data.id}`, data)
|
||||
}
|
||||
|
||||
export function deleteMenu(id: number) {
|
||||
return request.delete<void>(`/system/menu/${id}`)
|
||||
}
|
||||
35
src/api/system/role.ts
Normal file
35
src/api/system/role.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { request } from '@/utils/request'
|
||||
import type { RoleRecord, RoleQuery, RoleFormData, RoleOption } from '@/types/system/role'
|
||||
import type { PageResult } from '@/types/api/response'
|
||||
|
||||
export function getRolePage(params: RoleQuery) {
|
||||
return request.get<PageResult<RoleRecord>>('/system/role/page', { params })
|
||||
}
|
||||
|
||||
export function getRoleList() {
|
||||
return request.get<RoleRecord[]>('/system/role/list')
|
||||
}
|
||||
|
||||
export function getAllRoles() {
|
||||
return request.get<RoleOption[]>('/system/role/list-all')
|
||||
} // Check if backend has list-all or if frontend filtered list.
|
||||
|
||||
export function createRole(data: RoleFormData) {
|
||||
return request.post<void>('/system/role', data)
|
||||
}
|
||||
|
||||
export function updateRole(data: RoleFormData) {
|
||||
return request.put<void>(`/system/role/${data.id}`, data)
|
||||
}
|
||||
|
||||
export function deleteRole(id: number) {
|
||||
return request.delete<void>(`/system/role/${id}`)
|
||||
}
|
||||
|
||||
export function getRoleMenuIds(roleId: number) {
|
||||
return request.get<number[]>(`/system/role/${roleId}/menus`)
|
||||
}
|
||||
|
||||
export function assignRoleMenus(roleId: number, menuIds: number[]) {
|
||||
return request.post<void>(`/system/role/${roleId}/menus`, { menuIds })
|
||||
}
|
||||
31
src/api/system/user.ts
Normal file
31
src/api/system/user.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { request } from '@/utils/request'
|
||||
import type { UserRecord, UserQuery, UserFormData } from '@/types/system/user'
|
||||
import type { PageResult } from '@/types/api/response'
|
||||
|
||||
export function getUserList(params: UserQuery) {
|
||||
return request.get<PageResult<UserRecord>>('/system/user/list', { params })
|
||||
}
|
||||
|
||||
export function getUserDetail(id: number) {
|
||||
return request.get<UserRecord>(`/system/user/${id}`)
|
||||
}
|
||||
|
||||
export function createUser(data: UserFormData) {
|
||||
return request.post<void>('/system/user', data)
|
||||
}
|
||||
|
||||
export function updateUser(data: UserFormData) {
|
||||
return request.put<void>(`/system/user/${data.id}`, data)
|
||||
}
|
||||
|
||||
export function deleteUser(id: number) {
|
||||
return request.delete<void>(`/system/user/${id}`)
|
||||
}
|
||||
|
||||
export function updateUserStatus(id: number, status: number) {
|
||||
return request.put<void>(`/system/user/${id}/status`, null, { params: { status } })
|
||||
}
|
||||
|
||||
export function resetUserPassword(id: number, newPassword: string) {
|
||||
return request.post<void>(`/system/user/${id}/reset-password`, { newPassword })
|
||||
}
|
||||
12
src/components.d.ts
vendored
12
src/components.d.ts
vendored
@@ -31,6 +31,7 @@ declare module 'vue' {
|
||||
AEmpty: typeof import('ant-design-vue/es')['Empty']
|
||||
AForm: typeof import('ant-design-vue/es')['Form']
|
||||
AFormItem: typeof import('ant-design-vue/es')['FormItem']
|
||||
AFormItemRest: typeof import('ant-design-vue/es')['FormItemRest']
|
||||
AInput: typeof import('ant-design-vue/es')['Input']
|
||||
AInputNumber: typeof import('ant-design-vue/es')['InputNumber']
|
||||
AInputPassword: typeof import('ant-design-vue/es')['InputPassword']
|
||||
@@ -50,6 +51,7 @@ declare module 'vue' {
|
||||
APageHeader: typeof import('ant-design-vue/es')['PageHeader']
|
||||
APagination: typeof import('ant-design-vue/es')['Pagination']
|
||||
APopconfirm: typeof import('ant-design-vue/es')['Popconfirm']
|
||||
APopover: typeof import('ant-design-vue/es')['Popover']
|
||||
ApprovalDrawer: typeof import('./components/ApprovalDrawer/index.vue')['default']
|
||||
AProgress: typeof import('ant-design-vue/es')['Progress']
|
||||
ARadio: typeof import('ant-design-vue/es')['Radio']
|
||||
@@ -76,14 +78,24 @@ declare module 'vue' {
|
||||
ATimelineItem: typeof import('ant-design-vue/es')['TimelineItem']
|
||||
ATooltip: typeof import('ant-design-vue/es')['Tooltip']
|
||||
ATree: typeof import('ant-design-vue/es')['Tree']
|
||||
ATreeSelect: typeof import('ant-design-vue/es')['TreeSelect']
|
||||
AUploadDragger: typeof import('ant-design-vue/es')['UploadDragger']
|
||||
BudgetDetailModal: typeof import('./components/finance/budget/BudgetDetailModal.vue')['default']
|
||||
BudgetFormModal: typeof import('./components/finance/budget/BudgetFormModal.vue')['default']
|
||||
DictFormModal: typeof import('./components/system/dict/DictFormModal.vue')['default']
|
||||
DictItemDrawer: typeof import('./components/system/dict/DictItemDrawer.vue')['default']
|
||||
DuplicateFileModal: typeof import('./components/DuplicateFileModal.vue')['default']
|
||||
DynamicMenu: typeof import('./components/DynamicMenu/index.vue')['default']
|
||||
FlowEditor: typeof import('./components/FlowEditor/index.vue')['default']
|
||||
HelloWorld: typeof import('./components/HelloWorld.vue')['default']
|
||||
IconPicker: typeof import('./components/common/IconPicker.vue')['default']
|
||||
MenuFormModal: typeof import('./components/system/menu/MenuFormModal.vue')['default']
|
||||
ProjectUpload: typeof import('./components/ProjectUpload.vue')['default']
|
||||
ResetPasswordModal: typeof import('./components/system/user/ResetPasswordModal.vue')['default']
|
||||
RoleFormModal: typeof import('./components/system/role/RoleFormModal.vue')['default']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
UploadCore: typeof import('./components/UploadCore.vue')['default']
|
||||
UserFormModal: typeof import('./components/system/user/UserFormModal.vue')['default']
|
||||
}
|
||||
}
|
||||
|
||||
148
src/components/common/IconPicker.vue
Normal file
148
src/components/common/IconPicker.vue
Normal file
@@ -0,0 +1,148 @@
|
||||
<template>
|
||||
<a-popover trigger="click" placement="bottomLeft" v-model:open="visible" overlay-class-name="icon-picker-overlay">
|
||||
<template #content>
|
||||
<div class="icon-picker-content">
|
||||
<a-form-item-rest>
|
||||
<a-input-search
|
||||
v-model:value="searchValue"
|
||||
placeholder="搜索图标"
|
||||
allow-clear
|
||||
class="icon-search"
|
||||
/>
|
||||
</a-form-item-rest>
|
||||
<div class="icon-list">
|
||||
<div
|
||||
v-for="icon in filteredIcons"
|
||||
:key="icon"
|
||||
class="icon-item"
|
||||
:class="{ active: modelValue === icon }"
|
||||
@click="handleSelect(icon)"
|
||||
:title="icon"
|
||||
>
|
||||
<component :is="Icons[icon as keyof typeof Icons]" />
|
||||
</div>
|
||||
<div v-if="filteredIcons.length === 0" class="no-data">
|
||||
未找到图标
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<a-input
|
||||
v-model:value="modelValue"
|
||||
placeholder="点击选择图标"
|
||||
readonly
|
||||
class="icon-input"
|
||||
>
|
||||
<template #prefix>
|
||||
<span v-if="modelValue && Icons[modelValue as keyof typeof Icons]" class="selected-icon">
|
||||
<component :is="Icons[modelValue as keyof typeof Icons]" />
|
||||
</span>
|
||||
</template>
|
||||
<template #suffix>
|
||||
<SettingOutlined v-if="!modelValue" class="placeholder-icon" />
|
||||
<CloseCircleOutlined v-else class="clear-icon" @click.stop="handleClear" />
|
||||
</template>
|
||||
</a-input>
|
||||
</a-popover>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import * as Icons from '@ant-design/icons-vue'
|
||||
import { SettingOutlined, CloseCircleOutlined } from '@ant-design/icons-vue'
|
||||
|
||||
const props = defineProps<{
|
||||
value?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits(['update:value', 'change'])
|
||||
|
||||
const visible = ref(false)
|
||||
const searchValue = ref('')
|
||||
|
||||
const modelValue = computed({
|
||||
get: () => props.value,
|
||||
set: (val) => {
|
||||
emit('update:value', val)
|
||||
emit('change', val)
|
||||
}
|
||||
})
|
||||
|
||||
// Filter outline icons mainly, limit list for performance
|
||||
const allIcons = Object.keys(Icons).filter(k =>
|
||||
['Outlined'].some(s => k.endsWith(s)) &&
|
||||
!['createFromIconfontCN', 'getTwoToneColor', 'setTwoToneColor', 'default'].includes(k)
|
||||
)
|
||||
|
||||
const filteredIcons = computed(() => {
|
||||
if (!searchValue.value) return allIcons.slice(0, 100)
|
||||
return allIcons.filter(k => k.toLowerCase().includes(searchValue.value.toLowerCase())).slice(0, 100)
|
||||
})
|
||||
|
||||
function handleSelect(icon: string) {
|
||||
modelValue.value = icon
|
||||
visible.value = false
|
||||
}
|
||||
|
||||
function handleClear() {
|
||||
modelValue.value = ''
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.icon-picker-content {
|
||||
width: 320px;
|
||||
}
|
||||
.icon-search {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.icon-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
max-height: 260px;
|
||||
overflow-y: auto;
|
||||
gap: 8px;
|
||||
}
|
||||
.icon-item {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #f0f0f0;
|
||||
font-size: 20px;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
.icon-item:hover {
|
||||
background-color: #f0f0f0;
|
||||
border-color: #d9d9d9;
|
||||
}
|
||||
.icon-item.active {
|
||||
background-color: #e6f7ff;
|
||||
border-color: #1890ff;
|
||||
color: #1890ff;
|
||||
}
|
||||
.selected-icon {
|
||||
font-size: 16px;
|
||||
color: #1890ff;
|
||||
margin-right: 4px;
|
||||
}
|
||||
.placeholder-icon {
|
||||
color: #ccc;
|
||||
}
|
||||
.clear-icon {
|
||||
color: #ccc;
|
||||
cursor: pointer;
|
||||
}
|
||||
.clear-icon:hover {
|
||||
color: #999;
|
||||
}
|
||||
.no-data {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
color: #999;
|
||||
padding: 16px;
|
||||
}
|
||||
</style>
|
||||
124
src/components/finance/budget/BudgetDetailModal.vue
Normal file
124
src/components/finance/budget/BudgetDetailModal.vue
Normal file
@@ -0,0 +1,124 @@
|
||||
<template>
|
||||
<a-modal
|
||||
:open="visible"
|
||||
title="预算详情"
|
||||
:footer="null"
|
||||
width="800px"
|
||||
@cancel="handleCancel"
|
||||
>
|
||||
<template v-if="record">
|
||||
<a-descriptions bordered :column="2">
|
||||
<a-descriptions-item label="预算名称" :span="2">{{ record.name }}</a-descriptions-item>
|
||||
<a-descriptions-item label="预算周期">
|
||||
{{ BudgetPeriodMap[record.period] }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="时间">
|
||||
{{ record.year }}年
|
||||
{{ record.period === 'monthly' ? `${record.month}月` :
|
||||
record.period === 'quarterly' ? `Q${record.quarter}` : '' }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="所属部门">{{ record.departmentName || '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="状态">
|
||||
<a-tag :color="getBudgetStatusColor(record.status)">
|
||||
{{ BudgetStatusMap[record.status] }}
|
||||
</a-tag>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="总预算">
|
||||
<span class="money-primary">¥{{ record.totalBudget.toLocaleString() }}</span>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="执行率">
|
||||
<a-progress
|
||||
:percent="record.usageRate"
|
||||
:stroke-color="getProgressColor(record.usageRate)"
|
||||
style="width: 150px"
|
||||
/>
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
|
||||
<a-divider>预算明细</a-divider>
|
||||
<a-table
|
||||
:columns="detailColumns"
|
||||
:data-source="record.items"
|
||||
:pagination="false"
|
||||
row-key="expenseType"
|
||||
size="small"
|
||||
>
|
||||
<template #bodyCell="{ column, record: item }">
|
||||
<template v-if="column.key === 'expenseType'">
|
||||
<a-tag :color="ExpenseTypeColorMap[item.expenseType]">
|
||||
{{ ExpenseTypeMap[item.expenseType] }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-if="column.key === 'budgetAmount'">
|
||||
¥{{ item.budgetAmount.toLocaleString() }}
|
||||
</template>
|
||||
<template v-if="column.key === 'usedAmount'">
|
||||
<span class="text-warning">¥{{ item.usedAmount.toLocaleString() }}</span>
|
||||
</template>
|
||||
<template v-if="column.key === 'remainingAmount'">
|
||||
<span :class="item.remainingAmount >= 0 ? 'text-success' : 'text-danger'">
|
||||
¥{{ item.remainingAmount.toLocaleString() }}
|
||||
</span>
|
||||
</template>
|
||||
<template v-if="column.key === 'usage'">
|
||||
<a-progress
|
||||
:percent="item.budgetAmount > 0 ? Math.round((item.usedAmount / item.budgetAmount) * 100) : 0"
|
||||
size="small"
|
||||
:stroke-color="getProgressColor(item.budgetAmount > 0 ? Math.round((item.usedAmount / item.budgetAmount) * 100) : 0)"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</template>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { BudgetRecord, BudgetStatus } from '@/types/finance/budget'
|
||||
import { BudgetPeriodMap, BudgetStatusMap } from '@/types/finance/budget'
|
||||
import { ExpenseTypeMap, ExpenseTypeColorMap } from '@/types/finance/common'
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean
|
||||
record?: BudgetRecord
|
||||
}>()
|
||||
|
||||
const emit = defineEmits(['update:visible'])
|
||||
|
||||
const detailColumns = [
|
||||
{ title: '费用类型', key: 'expenseType', width: 150 },
|
||||
{ title: '预算金额', key: 'budgetAmount', width: 130, align: 'right' as const },
|
||||
{ title: '已使用', key: 'usedAmount', width: 130, align: 'right' as const },
|
||||
{ title: '剩余', key: 'remainingAmount', width: 130, align: 'right' as const },
|
||||
{ title: '使用率', key: 'usage', width: 150 }
|
||||
]
|
||||
|
||||
function getBudgetStatusColor(status: BudgetStatus): string {
|
||||
const colors: Record<BudgetStatus, string> = {
|
||||
draft: 'default',
|
||||
active: 'green',
|
||||
completed: 'blue',
|
||||
cancelled: 'red'
|
||||
}
|
||||
return colors[status]
|
||||
}
|
||||
|
||||
function getProgressColor(percent: number): string {
|
||||
if (percent >= 90) return '#ff4d4f'
|
||||
if (percent >= 70) return '#faad14'
|
||||
if (percent >= 50) return '#1890ff'
|
||||
return '#52c41a'
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
emit('update:visible', false)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.money-primary { color: #1890ff; font-weight: 600; }
|
||||
.text-success { color: #52c41a; }
|
||||
.text-warning { color: #faad14; }
|
||||
.text-danger { color: #ff4d4f; }
|
||||
</style>
|
||||
301
src/components/finance/budget/BudgetFormModal.vue
Normal file
301
src/components/finance/budget/BudgetFormModal.vue
Normal file
@@ -0,0 +1,301 @@
|
||||
<template>
|
||||
<a-modal
|
||||
:open="visible"
|
||||
:title="title"
|
||||
:confirm-loading="loading"
|
||||
width="800px"
|
||||
@ok="handleOk"
|
||||
@cancel="handleCancel"
|
||||
>
|
||||
<a-form
|
||||
ref="formRef"
|
||||
:model="formData"
|
||||
:rules="formRules"
|
||||
:label-col="{ span: 4 }"
|
||||
:wrapper-col="{ span: 19 }"
|
||||
>
|
||||
<a-form-item label="预算名称" name="name">
|
||||
<a-input v-model:value="formData.name" placeholder="请输入预算名称" />
|
||||
</a-form-item>
|
||||
<a-form-item label="预算周期" name="period">
|
||||
<a-radio-group v-model:value="formData.period">
|
||||
<a-radio-button v-for="(label, key) in BudgetPeriodMap" :key="key" :value="key">
|
||||
{{ label }}
|
||||
</a-radio-button>
|
||||
</a-radio-group>
|
||||
</a-form-item>
|
||||
<a-form-item label="年度" name="year">
|
||||
<a-select v-model:value="formData.year" style="width: 120px">
|
||||
<a-select-option :value="2024">2024年</a-select-option>
|
||||
<a-select-option :value="2025">2025年</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="月份" v-if="formData.period === 'monthly'">
|
||||
<a-select v-model:value="formData.month" style="width: 120px">
|
||||
<a-select-option v-for="m in 12" :key="m" :value="m">{{ m }}月</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="季度" v-if="formData.period === 'quarterly'">
|
||||
<a-select v-model:value="formData.quarter" style="width: 120px">
|
||||
<a-select-option v-for="q in 4" :key="q" :value="q">Q{{ q }}</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="所属部门">
|
||||
<a-select v-model:value="formData.departmentId" placeholder="选择部门(选填)" allow-clear>
|
||||
<a-select-option v-for="dept in departments" :key="dept.id" :value="dept.id">
|
||||
{{ dept.name }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
|
||||
<!-- 预算明细 -->
|
||||
<a-divider>预算明细</a-divider>
|
||||
<a-form-item label="费用预算" required>
|
||||
<div class="budget-items">
|
||||
<a-row :gutter="16" class="budget-item-header">
|
||||
<a-col :span="8">费用类型</a-col>
|
||||
<a-col :span="8">预算金额</a-col>
|
||||
<a-col :span="8">已使用</a-col>
|
||||
</a-row>
|
||||
<div v-for="(item, index) in formData.items" :key="index" class="budget-item-row">
|
||||
<a-row :gutter="16" align="middle">
|
||||
<a-col :span="8">
|
||||
<a-tag :color="ExpenseTypeColorMap[item.expenseType]">
|
||||
{{ ExpenseTypeMap[item.expenseType] }}
|
||||
</a-tag>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<a-input-number
|
||||
v-model:value="item.budgetAmount"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
style="width: 100%"
|
||||
:formatter="(value: any) => `¥ ${value}`.replace(/\B(?=(\d{3})+(?!\d))/g, ',')"
|
||||
:parser="(value: any) => value.replace(/¥\s?|(,*)/g, '')"
|
||||
/>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<span class="used-amount">¥{{ (item.usedAmount || 0).toLocaleString() }}</span>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</div>
|
||||
<a-divider dashed />
|
||||
<a-row :gutter="16" class="budget-total-row">
|
||||
<a-col :span="8"><strong>合计</strong></a-col>
|
||||
<a-col :span="8">
|
||||
<strong class="total-budget">¥{{ calculatedTotal.toLocaleString() }}</strong>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<strong class="total-used">¥{{ calculatedUsed.toLocaleString() }}</strong>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</div>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="备注">
|
||||
<a-textarea v-model:value="formData.remark" :rows="2" placeholder="备注信息(选填)" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, watch } from 'vue'
|
||||
import { message, type FormInstance } from 'ant-design-vue'
|
||||
import type { Rule } from 'ant-design-vue/es/form'
|
||||
import type { BudgetRecord, BudgetPeriod, BudgetItem } from '@/types/finance/budget'
|
||||
import { BudgetPeriodMap } from '@/types/finance/budget'
|
||||
import type { ExpenseType } from '@/types/finance/common'
|
||||
import { ExpenseTypeMap, ExpenseTypeColorMap } from '@/types/finance/common'
|
||||
import { createBudget, updateBudget } from '@/api/finance/budget'
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean
|
||||
record?: BudgetRecord
|
||||
departments: Array<{ id: number; name: string }>
|
||||
}>()
|
||||
|
||||
const emit = defineEmits(['update:visible', 'success'])
|
||||
|
||||
const loading = ref(false)
|
||||
const formRef = ref<FormInstance>()
|
||||
const expenseTypes: ExpenseType[] = ['salary', 'office', 'travel', 'marketing', 'equipment', 'other']
|
||||
|
||||
interface FormBudgetItem {
|
||||
expenseType: ExpenseType
|
||||
budgetAmount: number
|
||||
usedAmount: number
|
||||
remainingAmount: number
|
||||
}
|
||||
|
||||
const formData = reactive({
|
||||
id: undefined as number | undefined,
|
||||
name: '',
|
||||
period: 'monthly' as BudgetPeriod,
|
||||
year: 2024,
|
||||
month: new Date().getMonth() + 1,
|
||||
quarter: Math.ceil((new Date().getMonth() + 1) / 3),
|
||||
departmentId: undefined as number | undefined,
|
||||
items: [] as FormBudgetItem[],
|
||||
remark: ''
|
||||
})
|
||||
|
||||
const title = computed(() => formData.id ? '编辑预算' : '新增预算')
|
||||
|
||||
const formRules: Record<string, Rule[]> = {
|
||||
name: [{ required: true, message: '请输入预算名称', trigger: 'blur' }],
|
||||
period: [{ required: true, message: '请选择预算周期', trigger: 'change' }],
|
||||
year: [{ required: true, message: '请选择年度', trigger: 'change' }]
|
||||
}
|
||||
|
||||
// 计算属性
|
||||
const calculatedTotal = computed(() =>
|
||||
formData.items.reduce((sum, item) => sum + (item.budgetAmount || 0), 0)
|
||||
)
|
||||
|
||||
const calculatedUsed = computed(() =>
|
||||
formData.items.reduce((sum, item) => sum + (item.usedAmount || 0), 0)
|
||||
)
|
||||
|
||||
function initFormItems() {
|
||||
formData.items = expenseTypes.map(type => ({
|
||||
expenseType: type,
|
||||
budgetAmount: 0,
|
||||
usedAmount: 0,
|
||||
remainingAmount: 0
|
||||
}))
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.visible,
|
||||
(val) => {
|
||||
if (val) {
|
||||
if (props.record) {
|
||||
const record = props.record
|
||||
Object.assign(formData, {
|
||||
id: record.id,
|
||||
name: record.name,
|
||||
period: record.period,
|
||||
year: record.year,
|
||||
month: record.month || 1,
|
||||
quarter: record.quarter || 1,
|
||||
departmentId: record.departmentId,
|
||||
remark: record.remark || ''
|
||||
})
|
||||
|
||||
// Map items and fill missing
|
||||
const existingItemsMap = new Map(record.items.map(item => [item.expenseType, item]))
|
||||
formData.items = expenseTypes.map(type => {
|
||||
const exist = existingItemsMap.get(type)
|
||||
return {
|
||||
expenseType: type,
|
||||
budgetAmount: exist?.budgetAmount || 0,
|
||||
usedAmount: exist?.usedAmount || 0,
|
||||
remainingAmount: exist?.remainingAmount || 0
|
||||
}
|
||||
})
|
||||
} else {
|
||||
Object.assign(formData, {
|
||||
id: undefined,
|
||||
name: '',
|
||||
period: 'monthly',
|
||||
year: 2024,
|
||||
month: new Date().getMonth() + 1,
|
||||
quarter: Math.ceil((new Date().getMonth() + 1) / 3),
|
||||
departmentId: undefined,
|
||||
remark: ''
|
||||
})
|
||||
initFormItems()
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
async function handleOk() {
|
||||
try {
|
||||
await formRef.value?.validate()
|
||||
loading.value = true
|
||||
|
||||
// Prepare items
|
||||
const items: BudgetItem[] = formData.items
|
||||
.filter(item => item.budgetAmount > 0) // Only save non-zero items? Or all? User logic was > 0
|
||||
.map(item => ({
|
||||
expenseType: item.expenseType,
|
||||
budgetAmount: item.budgetAmount,
|
||||
usedAmount: item.usedAmount,
|
||||
remainingAmount: item.budgetAmount - item.usedAmount
|
||||
}))
|
||||
|
||||
const dept = props.departments.find(d => d.id === formData.departmentId)
|
||||
|
||||
const saveData: Partial<BudgetRecord> = {
|
||||
id: formData.id,
|
||||
name: formData.name,
|
||||
period: formData.period,
|
||||
year: formData.year,
|
||||
month: formData.period === 'monthly' ? formData.month : undefined,
|
||||
quarter: formData.period === 'quarterly' ? formData.quarter : undefined,
|
||||
departmentId: formData.departmentId,
|
||||
departmentName: dept?.name,
|
||||
items,
|
||||
totalBudget: calculatedTotal.value,
|
||||
usedAmount: calculatedUsed.value,
|
||||
remainingAmount: calculatedTotal.value - calculatedUsed.value,
|
||||
usageRate: calculatedTotal.value > 0 ? Math.round((calculatedUsed.value / calculatedTotal.value) * 100) : 0,
|
||||
remark: formData.remark,
|
||||
}
|
||||
|
||||
if (saveData.id) {
|
||||
await updateBudget(saveData)
|
||||
message.success('编辑成功')
|
||||
} else {
|
||||
await createBudget(saveData)
|
||||
message.success('新增成功')
|
||||
}
|
||||
|
||||
emit('update:visible', false)
|
||||
emit('success')
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
emit('update:visible', false)
|
||||
formRef.value?.resetFields()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.budget-items {
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.budget-item-header {
|
||||
font-weight: 500;
|
||||
color: #666;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.budget-item-row {
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px dashed #f0f0f0;
|
||||
}
|
||||
|
||||
.used-amount {
|
||||
color: #8c8c8c;
|
||||
}
|
||||
|
||||
.budget-total-row {
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
.total-budget { color: #1890ff; font-size: 16px; }
|
||||
.total-used { color: #faad14; font-size: 16px; }
|
||||
</style>
|
||||
131
src/components/system/dict/DictFormModal.vue
Normal file
131
src/components/system/dict/DictFormModal.vue
Normal file
@@ -0,0 +1,131 @@
|
||||
<template>
|
||||
<a-modal
|
||||
:open="visible"
|
||||
:title="isEdit ? '编辑字典' : '新增字典'"
|
||||
@ok="handleSubmit"
|
||||
@cancel="handleCancel"
|
||||
:confirm-loading="submitLoading"
|
||||
>
|
||||
<a-form
|
||||
ref="formRef"
|
||||
:model="formData"
|
||||
:rules="formRules"
|
||||
:label-col="{ span: 6 }"
|
||||
:wrapper-col="{ span: 16 }"
|
||||
>
|
||||
<a-form-item label="字典名称" name="name">
|
||||
<a-input v-model:value="formData.name" placeholder="请输入字典名称" />
|
||||
</a-form-item>
|
||||
<a-form-item label="字典编码" name="code">
|
||||
<a-input v-model:value="formData.code" placeholder="请输入字典编码" :disabled="isEdit" />
|
||||
</a-form-item>
|
||||
<a-form-item label="备注" name="remark">
|
||||
<a-textarea v-model:value="formData.remark" placeholder="请输入备注" :rows="3" />
|
||||
</a-form-item>
|
||||
<a-form-item label="状态" name="status">
|
||||
<a-radio-group v-model:value="formData.status">
|
||||
<a-radio :value="1">正常</a-radio>
|
||||
<a-radio :value="0">禁用</a-radio>
|
||||
</a-radio-group>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, watch } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import type { FormInstance } from 'ant-design-vue'
|
||||
|
||||
// 字典类型定义
|
||||
interface DictRecord {
|
||||
id?: number
|
||||
name: string
|
||||
code: string
|
||||
remark?: string
|
||||
status: number
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean
|
||||
editData?: DictRecord | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits(['update:visible', 'success'])
|
||||
|
||||
const isEdit = ref(false)
|
||||
const submitLoading = ref(false)
|
||||
const formRef = ref<FormInstance>()
|
||||
|
||||
const formData = reactive<DictRecord>({
|
||||
id: undefined,
|
||||
name: '',
|
||||
code: '',
|
||||
remark: '',
|
||||
status: 1
|
||||
})
|
||||
|
||||
const formRules = {
|
||||
name: [{ required: true, message: '请输入字典名称' }],
|
||||
code: [{ required: true, message: '请输入字典编码' }]
|
||||
}
|
||||
|
||||
// 监听 editData 变化,初始化表单
|
||||
watch(
|
||||
() => props.editData,
|
||||
(val) => {
|
||||
if (val) {
|
||||
isEdit.value = true
|
||||
formData.id = val.id
|
||||
formData.name = val.name
|
||||
formData.code = val.code
|
||||
formData.remark = val.remark || ''
|
||||
formData.status = val.status
|
||||
} else {
|
||||
isEdit.value = false
|
||||
formData.id = undefined
|
||||
formData.name = ''
|
||||
formData.code = ''
|
||||
formData.remark = ''
|
||||
formData.status = 1
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// 监听 visible,重置表单
|
||||
watch(
|
||||
() => props.visible,
|
||||
(val) => {
|
||||
if (val && !props.editData) {
|
||||
formRef.value?.resetFields()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
function handleCancel() {
|
||||
emit('update:visible', false)
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
try {
|
||||
await formRef.value?.validate()
|
||||
submitLoading.value = true
|
||||
|
||||
// TODO: 接入真实API
|
||||
// if (isEdit.value) {
|
||||
// await updateDict(formData)
|
||||
// } else {
|
||||
// await createDict(formData)
|
||||
// }
|
||||
|
||||
message.success(isEdit.value ? '编辑成功' : '新增成功')
|
||||
emit('update:visible', false)
|
||||
emit('success')
|
||||
} catch (error) {
|
||||
// 验证失败
|
||||
} finally {
|
||||
submitLoading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
277
src/components/system/dict/DictItemDrawer.vue
Normal file
277
src/components/system/dict/DictItemDrawer.vue
Normal file
@@ -0,0 +1,277 @@
|
||||
<template>
|
||||
<a-drawer
|
||||
:open="visible"
|
||||
:title="`字典项管理 - ${dictData?.name || ''}`"
|
||||
width="800"
|
||||
:destroyOnClose="true"
|
||||
@close="handleClose"
|
||||
>
|
||||
<div class="drawer-content">
|
||||
<!-- 字典项操作栏 -->
|
||||
<div class="item-toolbar">
|
||||
<a-button type="primary" @click="handleAddItem">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
新增字典项
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<!-- 字典项表格 -->
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="tableData"
|
||||
:loading="loading"
|
||||
:pagination="false"
|
||||
row-key="id"
|
||||
size="small"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.dataIndex === 'status'">
|
||||
<a-tag :color="record.status === 1 ? 'green' : 'red'" size="small">
|
||||
{{ record.status === 1 ? '正常' : '禁用' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-if="column.dataIndex === 'isDefault'">
|
||||
<a-tag v-if="record.isDefault === 1" color="blue">是</a-tag>
|
||||
<span v-else class="text-muted">否</span>
|
||||
</template>
|
||||
<template v-if="column.dataIndex === 'action'">
|
||||
<a-space>
|
||||
<a-button type="link" size="small" @click="handleEditItem(record)">编辑</a-button>
|
||||
<a-popconfirm
|
||||
title="确定删除此字典项吗?"
|
||||
@confirm="handleDeleteItem(record.id)"
|
||||
>
|
||||
<a-button type="link" size="small" danger>删除</a-button>
|
||||
</a-popconfirm>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</div>
|
||||
|
||||
<!-- 新增/编辑字典项弹窗 -->
|
||||
<a-modal
|
||||
v-model:open="itemModalVisible"
|
||||
:title="isEditItem ? '编辑字典项' : '新增字典项'"
|
||||
@ok="handleSubmitItem"
|
||||
:confirm-loading="submitLoading"
|
||||
>
|
||||
<a-form
|
||||
ref="formRef"
|
||||
:model="formData"
|
||||
:rules="formRules"
|
||||
:label-col="{ span: 6 }"
|
||||
:wrapper-col="{ span: 16 }"
|
||||
>
|
||||
<a-form-item label="字典项标签" name="label">
|
||||
<a-input v-model:value="formData.label" placeholder="请输入显示的文本" />
|
||||
</a-form-item>
|
||||
<a-form-item label="字典项值" name="value">
|
||||
<a-input v-model:value="formData.value" placeholder="请输入存储的值" />
|
||||
</a-form-item>
|
||||
<a-form-item label="排序" name="sort">
|
||||
<a-input-number v-model:value="formData.sort" :min="0" :max="999" style="width: 100%" />
|
||||
</a-form-item>
|
||||
<a-form-item label="是否默认" name="isDefault">
|
||||
<a-switch v-model:checked="formData.isDefault" :checked-value="1" :un-checked-value="0" />
|
||||
</a-form-item>
|
||||
<a-form-item label="状态" name="status">
|
||||
<a-radio-group v-model:value="formData.status">
|
||||
<a-radio :value="1">正常</a-radio>
|
||||
<a-radio :value="0">禁用</a-radio>
|
||||
</a-radio-group>
|
||||
</a-form-item>
|
||||
<a-form-item label="备注" name="remark">
|
||||
<a-textarea v-model:value="formData.remark" placeholder="请输入备注" :rows="2" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</a-drawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, watch } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { PlusOutlined } from '@ant-design/icons-vue'
|
||||
import type { FormInstance } from 'ant-design-vue'
|
||||
|
||||
// 类型定义
|
||||
interface DictRecord {
|
||||
id: number
|
||||
name: string
|
||||
code: string
|
||||
}
|
||||
|
||||
interface DictItemRecord {
|
||||
id?: number
|
||||
dictId?: number
|
||||
label: string
|
||||
value: string
|
||||
sort: number
|
||||
isDefault: number
|
||||
status: number
|
||||
remark?: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean
|
||||
dictData?: DictRecord | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits(['update:visible'])
|
||||
|
||||
// 表格
|
||||
const loading = ref(false)
|
||||
const tableData = ref<DictItemRecord[]>([])
|
||||
|
||||
const columns = [
|
||||
{ title: '标签', dataIndex: 'label', width: 120 },
|
||||
{ title: '值', dataIndex: 'value', width: 100 },
|
||||
{ title: '排序', dataIndex: 'sort', width: 70 },
|
||||
{ title: '默认', dataIndex: 'isDefault', width: 70 },
|
||||
{ title: '状态', dataIndex: 'status', width: 70 },
|
||||
{ title: '备注', dataIndex: 'remark', ellipsis: true },
|
||||
{ title: '操作', dataIndex: 'action', width: 120, fixed: 'right' }
|
||||
]
|
||||
|
||||
// 字典项表单
|
||||
const itemModalVisible = ref(false)
|
||||
const isEditItem = ref(false)
|
||||
const submitLoading = ref(false)
|
||||
const formRef = ref<FormInstance>()
|
||||
|
||||
const formData = reactive<DictItemRecord>({
|
||||
id: undefined,
|
||||
dictId: undefined,
|
||||
label: '',
|
||||
value: '',
|
||||
sort: 0,
|
||||
isDefault: 0,
|
||||
status: 1,
|
||||
remark: ''
|
||||
})
|
||||
|
||||
const formRules = {
|
||||
label: [{ required: true, message: '请输入字典项标签' }],
|
||||
value: [{ required: true, message: '请输入字典项值' }]
|
||||
}
|
||||
|
||||
// 监听 visible 变化,加载数据
|
||||
watch(
|
||||
() => props.visible,
|
||||
(val) => {
|
||||
if (val && props.dictData) {
|
||||
loadData(props.dictData.id)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
function handleClose() {
|
||||
emit('update:visible', false)
|
||||
}
|
||||
|
||||
// 加载字典项数据
|
||||
async function loadData(dictId: number) {
|
||||
loading.value = true
|
||||
try {
|
||||
// TODO: 接入真实API
|
||||
// const res = await getDictItemList(dictId)
|
||||
// tableData.value = res.data.data
|
||||
|
||||
// 模拟数据
|
||||
if (dictId === 1) {
|
||||
tableData.value = [
|
||||
{ id: 1, dictId: 1, label: '男', value: '1', sort: 1, isDefault: 1, status: 1, remark: '' },
|
||||
{ id: 2, dictId: 1, label: '女', value: '2', sort: 2, isDefault: 0, status: 1, remark: '' },
|
||||
{ id: 3, dictId: 1, label: '未知', value: '0', sort: 3, isDefault: 0, status: 1, remark: '' }
|
||||
]
|
||||
} else if (dictId === 2) {
|
||||
tableData.value = [
|
||||
{ id: 4, dictId: 2, label: '正常', value: '1', sort: 1, isDefault: 1, status: 1, remark: '' },
|
||||
{ id: 5, dictId: 2, label: '禁用', value: '0', sort: 2, isDefault: 0, status: 1, remark: '' }
|
||||
]
|
||||
} else if (dictId === 3) {
|
||||
tableData.value = [
|
||||
{ id: 6, dictId: 3, label: '待审批', value: 'pending', sort: 1, isDefault: 1, status: 1, remark: '' },
|
||||
{ id: 7, dictId: 3, label: '已通过', value: 'approved', sort: 2, isDefault: 0, status: 1, remark: '' },
|
||||
{ id: 8, dictId: 3, label: '已驳回', value: 'rejected', sort: 3, isDefault: 0, status: 1, remark: '' }
|
||||
]
|
||||
} else {
|
||||
tableData.value = []
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleAddItem() {
|
||||
isEditItem.value = false
|
||||
formData.id = undefined
|
||||
formData.dictId = props.dictData?.id
|
||||
formData.label = ''
|
||||
formData.value = ''
|
||||
formData.sort = 0
|
||||
formData.isDefault = 0
|
||||
formData.status = 1
|
||||
formData.remark = ''
|
||||
itemModalVisible.value = true
|
||||
}
|
||||
|
||||
function handleEditItem(record: DictItemRecord) {
|
||||
isEditItem.value = true
|
||||
formData.id = record.id
|
||||
formData.dictId = record.dictId
|
||||
formData.label = record.label
|
||||
formData.value = record.value
|
||||
formData.sort = record.sort
|
||||
formData.isDefault = record.isDefault
|
||||
formData.status = record.status
|
||||
formData.remark = record.remark || ''
|
||||
itemModalVisible.value = true
|
||||
}
|
||||
|
||||
async function handleDeleteItem(id: number) {
|
||||
// TODO: 接入真实API
|
||||
// await deleteDictItem(id)
|
||||
message.success('删除成功')
|
||||
if (props.dictData) {
|
||||
loadData(props.dictData.id)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmitItem() {
|
||||
try {
|
||||
await formRef.value?.validate()
|
||||
submitLoading.value = true
|
||||
|
||||
// TODO: 接入真实API
|
||||
// if (isEditItem.value) {
|
||||
// await updateDictItem(formData)
|
||||
// } else {
|
||||
// await createDictItem(formData)
|
||||
// }
|
||||
|
||||
message.success(isEditItem.value ? '编辑成功' : '新增成功')
|
||||
itemModalVisible.value = false
|
||||
if (props.dictData) {
|
||||
loadData(props.dictData.id)
|
||||
}
|
||||
} catch (error) {
|
||||
// 验证失败
|
||||
} finally {
|
||||
submitLoading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.drawer-content {
|
||||
height: 100%;
|
||||
}
|
||||
.item-toolbar {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.text-muted {
|
||||
color: #999;
|
||||
}
|
||||
</style>
|
||||
172
src/components/system/menu/MenuFormModal.vue
Normal file
172
src/components/system/menu/MenuFormModal.vue
Normal file
@@ -0,0 +1,172 @@
|
||||
<template>
|
||||
<a-modal
|
||||
:open="visible"
|
||||
:title="title"
|
||||
:confirm-loading="loading"
|
||||
width="600px"
|
||||
@ok="handleOk"
|
||||
@cancel="handleCancel"
|
||||
>
|
||||
<a-form ref="formRef" :model="formData" :rules="formRules" :label-col="{ span: 5 }" :wrapper-col="{ span: 17 }">
|
||||
<a-form-item label="上级菜单" name="parentId">
|
||||
<a-tree-select
|
||||
v-model:value="formData.parentId"
|
||||
:tree-data="parentTreeData"
|
||||
:field-names="{ label: 'name', value: 'id', children: 'children' }"
|
||||
placeholder="请选择上级菜单(不选则为顶级)"
|
||||
tree-default-expand-all
|
||||
allow-clear
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="菜单类型" name="type">
|
||||
<a-radio-group v-model:value="formData.type">
|
||||
<a-radio value="directory">目录</a-radio>
|
||||
<a-radio value="menu">菜单</a-radio>
|
||||
<a-radio value="button">按钮</a-radio>
|
||||
</a-radio-group>
|
||||
</a-form-item>
|
||||
<a-form-item label="菜单名称" name="name">
|
||||
<a-input v-model:value="formData.name" placeholder="请输入菜单名称" />
|
||||
</a-form-item>
|
||||
<a-form-item label="菜单编码" name="code">
|
||||
<a-input v-model:value="formData.code" placeholder="请输入菜单编码(唯一标识)" />
|
||||
</a-form-item>
|
||||
<a-form-item v-if="formData.type !== 'button'" label="路由路径" name="path">
|
||||
<a-input v-model:value="formData.path" placeholder="请输入路由路径,如 /system/users" />
|
||||
</a-form-item>
|
||||
<a-form-item v-if="formData.type === 'menu'" label="组件路径" name="component">
|
||||
<a-input v-model:value="formData.component" placeholder="请输入组件路径" />
|
||||
</a-form-item>
|
||||
<a-form-item v-if="formData.type !== 'button'" label="图标" name="icon">
|
||||
<IconPicker v-model:value="formData.icon" />
|
||||
</a-form-item>
|
||||
<a-form-item v-if="formData.type === 'button'" label="权限标识" name="permission">
|
||||
<a-input v-model:value="formData.permission" placeholder="请输入权限标识,如 system:user:add" />
|
||||
</a-form-item>
|
||||
<a-form-item label="排序" name="sort">
|
||||
<a-input-number v-model:value="formData.sort" :min="0" style="width: 100%" />
|
||||
</a-form-item>
|
||||
<a-form-item label="状态" name="status">
|
||||
<a-radio-group v-model:value="formData.status">
|
||||
<a-radio :value="1">正常</a-radio>
|
||||
<a-radio :value="0">禁用</a-radio>
|
||||
</a-radio-group>
|
||||
</a-form-item>
|
||||
<a-form-item v-if="formData.type !== 'button'" label="是否隐藏" name="hidden">
|
||||
<a-radio-group v-model:value="formData.hidden">
|
||||
<a-radio :value="0">显示</a-radio>
|
||||
<a-radio :value="1">隐藏</a-radio>
|
||||
</a-radio-group>
|
||||
</a-form-item>
|
||||
<a-form-item label="备注" name="remark">
|
||||
<a-textarea v-model:value="formData.remark" placeholder="请输入备注" :rows="2" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, watch, computed } from 'vue'
|
||||
import type { FormInstance } from 'ant-design-vue'
|
||||
import type { Rule } from 'ant-design-vue/es/form'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { createMenu, updateMenu } from '@/api/system/menu'
|
||||
import type { MenuFormData, MenuRecord } from '@/types/system/menu'
|
||||
import IconPicker from '@/components/common/IconPicker.vue'
|
||||
|
||||
// ... existing code ...
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean
|
||||
record?: MenuFormData
|
||||
menuTree: MenuRecord[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits(['update:visible', 'success'])
|
||||
|
||||
const loading = ref(false)
|
||||
const formRef = ref<FormInstance>()
|
||||
|
||||
const formData = reactive<MenuFormData>({
|
||||
parentId: undefined,
|
||||
name: '',
|
||||
code: '',
|
||||
type: 'menu',
|
||||
path: '',
|
||||
component: '',
|
||||
icon: '',
|
||||
permission: '',
|
||||
sort: 0,
|
||||
hidden: 0,
|
||||
status: 1,
|
||||
remark: ''
|
||||
})
|
||||
|
||||
const title = computed(() => formData.id ? '编辑菜单' : '新增菜单')
|
||||
|
||||
const parentTreeData = computed(() => {
|
||||
return [{ id: 0, name: '顶级菜单', children: props.menuTree }]
|
||||
})
|
||||
|
||||
const formRules: Record<string, Rule[]> = {
|
||||
name: [{ required: true, message: '请输入菜单名称', trigger: 'blur' }],
|
||||
code: [{ required: true, message: '请输入菜单编码', trigger: 'blur' }],
|
||||
type: [{ required: true, message: '请选择菜单类型', trigger: 'change' }]
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.visible,
|
||||
(val) => {
|
||||
if (val) {
|
||||
// Reset to defaults
|
||||
Object.assign(formData, {
|
||||
id: undefined,
|
||||
parentId: props.record?.parentId || 0, // Use record parentId if exists, else 0
|
||||
name: '',
|
||||
code: '',
|
||||
type: 'menu',
|
||||
path: '',
|
||||
component: '',
|
||||
icon: '',
|
||||
permission: '',
|
||||
sort: 0,
|
||||
hidden: 0,
|
||||
status: 1,
|
||||
remark: ''
|
||||
})
|
||||
|
||||
// If full record provided (Edit mode), overwrite
|
||||
if (props.record && props.record.id) {
|
||||
Object.assign(formData, props.record)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
async function handleOk() {
|
||||
try {
|
||||
await formRef.value?.validate()
|
||||
loading.value = true
|
||||
|
||||
if (formData.id) {
|
||||
await updateMenu(formData)
|
||||
message.success('更新成功')
|
||||
} else {
|
||||
await createMenu(formData)
|
||||
message.success('创建成功')
|
||||
}
|
||||
|
||||
emit('update:visible', false)
|
||||
emit('success')
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
emit('update:visible', false)
|
||||
formRef.value?.resetFields()
|
||||
}
|
||||
</script>
|
||||
111
src/components/system/role/RoleFormModal.vue
Normal file
111
src/components/system/role/RoleFormModal.vue
Normal file
@@ -0,0 +1,111 @@
|
||||
<template>
|
||||
<a-modal
|
||||
:open="visible"
|
||||
:title="title"
|
||||
:confirm-loading="loading"
|
||||
@ok="handleOk"
|
||||
@cancel="handleCancel"
|
||||
>
|
||||
<a-form ref="formRef" :model="formData" :rules="formRules" :label-col="{ span: 6 }" :wrapper-col="{ span: 16 }">
|
||||
<a-form-item label="角色编码" name="code">
|
||||
<a-input v-model:value="formData.code" placeholder="请输入角色编码(英文)" :disabled="!!formData.id" />
|
||||
</a-form-item>
|
||||
<a-form-item label="角色名称" name="name">
|
||||
<a-input v-model:value="formData.name" placeholder="请输入角色名称" />
|
||||
</a-form-item>
|
||||
<a-form-item label="排序" name="sort">
|
||||
<a-input-number v-model:value="formData.sort" :min="0" style="width: 100%" />
|
||||
</a-form-item>
|
||||
<a-form-item label="状态" name="status">
|
||||
<a-radio-group v-model:value="formData.status">
|
||||
<a-radio :value="1">正常</a-radio>
|
||||
<a-radio :value="0">禁用</a-radio>
|
||||
</a-radio-group>
|
||||
</a-form-item>
|
||||
<a-form-item label="描述" name="description">
|
||||
<a-textarea v-model:value="formData.description" placeholder="请输入描述" :rows="3" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, watch, computed } from 'vue'
|
||||
import type { FormInstance } from 'ant-design-vue'
|
||||
import type { Rule } from 'ant-design-vue/es/form'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { createRole, updateRole } from '@/api/system/role'
|
||||
import type { RoleFormData } from '@/types/system/role'
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean
|
||||
record?: RoleFormData
|
||||
}>()
|
||||
|
||||
const emit = defineEmits(['update:visible', 'success'])
|
||||
|
||||
const loading = ref(false)
|
||||
const formRef = ref<FormInstance>()
|
||||
|
||||
const formData = reactive<RoleFormData>({
|
||||
code: '',
|
||||
name: '',
|
||||
description: '',
|
||||
sort: 0,
|
||||
status: 1
|
||||
})
|
||||
|
||||
const title = computed(() => formData.id ? '编辑角色' : '新增角色')
|
||||
|
||||
const formRules: Record<string, Rule[]> = {
|
||||
code: [{ required: true, message: '请输入角色编码', trigger: 'blur' }],
|
||||
name: [{ required: true, message: '请输入角色名称', trigger: 'blur' }]
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.visible,
|
||||
(val) => {
|
||||
if (val) {
|
||||
if (props.record) {
|
||||
Object.assign(formData, props.record)
|
||||
} else {
|
||||
Object.assign(formData, {
|
||||
id: undefined,
|
||||
code: '',
|
||||
name: '',
|
||||
description: '',
|
||||
sort: 0,
|
||||
status: 1
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
async function handleOk() {
|
||||
try {
|
||||
await formRef.value?.validate()
|
||||
loading.value = true
|
||||
|
||||
if (formData.id) {
|
||||
await updateRole(formData)
|
||||
message.success('更新成功')
|
||||
} else {
|
||||
await createRole(formData)
|
||||
message.success('创建成功')
|
||||
}
|
||||
|
||||
emit('update:visible', false)
|
||||
emit('success')
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
emit('update:visible', false)
|
||||
formRef.value?.resetFields()
|
||||
}
|
||||
</script>
|
||||
60
src/components/system/user/ResetPasswordModal.vue
Normal file
60
src/components/system/user/ResetPasswordModal.vue
Normal file
@@ -0,0 +1,60 @@
|
||||
<template>
|
||||
<a-modal
|
||||
:open="visible"
|
||||
title="重置密码"
|
||||
:confirm-loading="loading"
|
||||
@ok="handleOk"
|
||||
@cancel="emit('update:visible', false)"
|
||||
>
|
||||
<a-form :label-col="{ span: 6 }" :wrapper-col="{ span: 16 }">
|
||||
<a-form-item label="新密码">
|
||||
<a-input-password v-model:value="newPassword" placeholder="请输入新密码" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { resetUserPassword } from '@/api/system/user'
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean
|
||||
userId?: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits(['update:visible', 'success'])
|
||||
|
||||
const loading = ref(false)
|
||||
const newPassword = ref('')
|
||||
|
||||
watch(
|
||||
() => props.visible,
|
||||
(val) => {
|
||||
if (val) {
|
||||
newPassword.value = ''
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
async function handleOk() {
|
||||
if (!newPassword.value) {
|
||||
message.warning('请输入新密码')
|
||||
return
|
||||
}
|
||||
if (!props.userId) return
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
await resetUserPassword(props.userId, newPassword.value)
|
||||
message.success('密码重置成功')
|
||||
emit('update:visible', false)
|
||||
emit('success')
|
||||
} catch (error) {
|
||||
console.error('密码重置失败:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
144
src/components/system/user/UserFormModal.vue
Normal file
144
src/components/system/user/UserFormModal.vue
Normal file
@@ -0,0 +1,144 @@
|
||||
<template>
|
||||
<a-modal
|
||||
:open="visible"
|
||||
:title="title"
|
||||
:confirm-loading="loading"
|
||||
@ok="handleOk"
|
||||
@cancel="handleCancel"
|
||||
>
|
||||
<a-form ref="formRef" :model="formData" :rules="formRules" :label-col="{ span: 6 }" :wrapper-col="{ span: 16 }">
|
||||
<a-form-item label="用户名" name="username">
|
||||
<a-input v-model:value="formData.username" placeholder="请输入用户名" :disabled="!!formData.id" />
|
||||
</a-form-item>
|
||||
<a-form-item v-if="!formData.id" label="密码" name="password">
|
||||
<a-input-password v-model:value="formData.password" placeholder="请输入密码" />
|
||||
</a-form-item>
|
||||
<a-form-item label="昵称" name="nickname">
|
||||
<a-input v-model:value="formData.nickname" placeholder="请输入昵称" />
|
||||
</a-form-item>
|
||||
<a-form-item label="手机号" name="phone">
|
||||
<a-input v-model:value="formData.phone" placeholder="请输入手机号" />
|
||||
</a-form-item>
|
||||
<a-form-item label="邮箱" name="email">
|
||||
<a-input v-model:value="formData.email" placeholder="请输入邮箱" />
|
||||
</a-form-item>
|
||||
<a-form-item label="角色" name="role">
|
||||
<a-select v-model:value="formData.role" placeholder="请选择角色">
|
||||
<a-select-option v-for="role in roleOptions" :key="role.code" :value="role.code">
|
||||
{{ role.name }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="状态" name="status">
|
||||
<a-radio-group v-model:value="formData.status">
|
||||
<a-radio :value="1">正常</a-radio>
|
||||
<a-radio :value="0">禁用</a-radio>
|
||||
</a-radio-group>
|
||||
</a-form-item>
|
||||
<a-form-item label="备注" name="remark">
|
||||
<a-textarea v-model:value="formData.remark" placeholder="请输入备注" :rows="3" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, watch, computed } from 'vue'
|
||||
import type { FormInstance } from 'ant-design-vue'
|
||||
import type { Rule } from 'ant-design-vue/es/form'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { createUser, updateUser } from '@/api/system/user'
|
||||
import type { UserFormData } from '@/types/system/user'
|
||||
|
||||
interface RoleOption {
|
||||
code: string
|
||||
name: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean
|
||||
record?: UserFormData
|
||||
roleOptions: RoleOption[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits(['update:visible', 'success'])
|
||||
|
||||
const loading = ref(false)
|
||||
const formRef = ref<FormInstance>()
|
||||
|
||||
const formData = reactive<UserFormData>({
|
||||
username: '',
|
||||
password: '',
|
||||
nickname: '',
|
||||
phone: '',
|
||||
email: '',
|
||||
role: undefined,
|
||||
status: 1,
|
||||
remark: ''
|
||||
})
|
||||
|
||||
const title = computed(() => formData.id ? '编辑用户' : '新增用户')
|
||||
|
||||
const formRules: Record<string, Rule[]> = {
|
||||
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
|
||||
password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
|
||||
role: [{ required: true, message: '请选择角色', trigger: 'change' }]
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.visible,
|
||||
(val) => {
|
||||
if (val) {
|
||||
if (props.record) {
|
||||
Object.assign(formData, { ...props.record, password: '' })
|
||||
} else {
|
||||
Object.assign(formData, {
|
||||
id: undefined,
|
||||
username: '',
|
||||
password: '',
|
||||
nickname: '',
|
||||
phone: '',
|
||||
email: '',
|
||||
role: undefined,
|
||||
status: 1,
|
||||
remark: ''
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
async function handleOk() {
|
||||
try {
|
||||
// Skip password validation if editing
|
||||
if (formData.id) {
|
||||
// A quick hack for optional password in edit mode if not handled by dynamic rules
|
||||
// AntDV form validation is powerful.
|
||||
// Ideally we should use dynamic rules.
|
||||
}
|
||||
|
||||
await formRef.value?.validate()
|
||||
loading.value = true
|
||||
|
||||
if (formData.id) {
|
||||
await updateUser(formData)
|
||||
message.success('更新成功')
|
||||
} else {
|
||||
await createUser(formData)
|
||||
message.success('创建成功')
|
||||
}
|
||||
|
||||
emit('update:visible', false)
|
||||
emit('success')
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
emit('update:visible', false)
|
||||
formRef.value?.resetFields()
|
||||
}
|
||||
</script>
|
||||
@@ -63,6 +63,9 @@ export const systemModule: ModuleConfig = {
|
||||
key: 'system',
|
||||
label: '系统管理',
|
||||
children: [
|
||||
{ key: 'system-users', label: '用户管理', path: '/system/users' },
|
||||
{ key: 'system-roles', label: '角色管理', path: '/system/roles' },
|
||||
{ key: 'system-menus', label: '菜单管理', path: '/system/menus' },
|
||||
{ key: 'settings', label: '系统设置', path: '/settings' },
|
||||
{ key: 'dict', label: '字典管理', path: '/settings/dict' },
|
||||
{ key: 'city', label: '城市管理', path: '/settings/city' },
|
||||
@@ -72,6 +75,9 @@ export const systemModule: ModuleConfig = {
|
||||
}
|
||||
],
|
||||
routes: [
|
||||
{ path: 'system/users', name: 'SystemUsers', component: '@/views/system/users/index.vue', title: '用户管理' },
|
||||
{ path: 'system/roles', name: 'SystemRoles', component: '@/views/system/roles/index.vue', title: '角色管理' },
|
||||
{ path: 'system/menus', name: 'SystemMenus', component: '@/views/system/menus/index.vue', title: '菜单管理' },
|
||||
{ path: 'settings', name: 'Settings', component: '@/views/settings/index.vue', title: '系统设置' },
|
||||
{ path: 'settings/dict', name: 'Dict', component: '@/views/settings/dict/index.vue', title: '字典管理' },
|
||||
{ path: 'settings/city', name: 'City', component: '@/views/settings/city/index.vue', title: '城市管理' },
|
||||
|
||||
@@ -48,7 +48,8 @@
|
||||
<span>{{ menu.label }}</span>
|
||||
</template>
|
||||
<a-menu-item v-for="child in menu.children" :key="child.key">
|
||||
{{ child.label }}
|
||||
<component :is="getIconComponent(child.icon)" v-if="child.icon" />
|
||||
<span>{{ child.label }}</span>
|
||||
</a-menu-item>
|
||||
</a-sub-menu>
|
||||
</template>
|
||||
@@ -81,26 +82,27 @@
|
||||
<a-menu-divider v-if="projectStore.enabledProjects.length > 0" />
|
||||
|
||||
<!-- 框架功能模块 -->
|
||||
<a-menu-item-group v-if="frameworkMenus.length > 0">
|
||||
<a-menu-item-group v-if="userStore.antdMenus.length > 0">
|
||||
<template #title>
|
||||
<span class="menu-group-title">{{ projectConfig.shortName }}</span>
|
||||
</template>
|
||||
|
||||
<template v-for="menu in frameworkMenus" :key="menu.key">
|
||||
<template v-for="menu in userStore.antdMenus" :key="menu.key">
|
||||
<!-- 单个菜单项 -->
|
||||
<a-menu-item v-if="!menu.children" :key="`item-${menu.key}`">
|
||||
<component :is="menu.icon" v-if="menu.icon" />
|
||||
<a-menu-item v-if="!menu.children" :key="menu.key">
|
||||
<component :is="getIconComponent(menu.icon)" v-if="menu.icon" />
|
||||
<span>{{ menu.label }}</span>
|
||||
</a-menu-item>
|
||||
|
||||
<!-- 子菜单 -->
|
||||
<a-sub-menu v-else :key="`sub-${menu.key}`">
|
||||
<a-sub-menu v-else :key="`${menu.key}_dir`">
|
||||
<template #title>
|
||||
<component :is="menu.icon" v-if="menu.icon" />
|
||||
<component :is="getIconComponent(menu.icon)" v-if="menu.icon" />
|
||||
<span>{{ menu.label }}</span>
|
||||
</template>
|
||||
<a-menu-item v-for="child in menu.children" :key="child.key">
|
||||
{{ child.label }}
|
||||
<component :is="getIconComponent(child.icon)" v-if="child.icon" />
|
||||
<span>{{ child.label }}</span>
|
||||
</a-menu-item>
|
||||
</a-sub-menu>
|
||||
</template>
|
||||
@@ -188,6 +190,7 @@
|
||||
import { ref, computed, watch, h, type Component } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { Modal } from 'ant-design-vue'
|
||||
import * as Icons from '@ant-design/icons-vue'
|
||||
import {
|
||||
MenuFoldOutlined,
|
||||
MenuUnfoldOutlined,
|
||||
@@ -195,13 +198,7 @@ import {
|
||||
DownOutlined,
|
||||
LockOutlined,
|
||||
LogoutOutlined,
|
||||
ArrowLeftOutlined,
|
||||
DashboardOutlined,
|
||||
TeamOutlined,
|
||||
ReadOutlined,
|
||||
CustomerServiceOutlined,
|
||||
ProjectOutlined,
|
||||
IdcardOutlined
|
||||
ArrowLeftOutlined
|
||||
} from '@ant-design/icons-vue'
|
||||
import { useUserStore, useProjectStore } from '@/stores'
|
||||
import { getCurrentProject, getMenuConfig, getMenuRouteMap, getFrameworkMenus } from '@/config'
|
||||
@@ -213,29 +210,18 @@ const projectStore = useProjectStore()
|
||||
|
||||
// 项目配置
|
||||
const projectConfig = getCurrentProject()
|
||||
const menuConfig = getMenuConfig()
|
||||
const menuRouteMap = getMenuRouteMap()
|
||||
const frameworkMenus = getFrameworkMenus()
|
||||
|
||||
const collapsed = ref(false)
|
||||
const selectedKeys = ref<string[]>(['finance-overview'])
|
||||
const selectedKeys = ref<string[]>([])
|
||||
const openKeys = ref<string[]>([])
|
||||
|
||||
// 图标名称到组件的映射
|
||||
const iconMap: Record<string, Component> = {
|
||||
DashboardOutlined,
|
||||
TeamOutlined,
|
||||
ReadOutlined,
|
||||
CustomerServiceOutlined,
|
||||
ProjectOutlined,
|
||||
IdcardOutlined,
|
||||
UserOutlined
|
||||
}
|
||||
|
||||
// 获取图标组件
|
||||
function getIconComponent(iconName?: string): Component | null {
|
||||
if (!iconName) return null
|
||||
return iconMap[iconName] || null
|
||||
// 动态查找图标
|
||||
const icon = (Icons as any)[iconName]
|
||||
return icon || null
|
||||
}
|
||||
|
||||
// 显示的项目名称
|
||||
@@ -283,7 +269,7 @@ function handleMenuClick({ key }: { key: string | number }) {
|
||||
// 返回框架
|
||||
if (menuKey === '__back_to_framework') {
|
||||
projectStore.exitSubProject()
|
||||
router.push('/finance/overview')
|
||||
router.push('/')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -305,11 +291,8 @@ function handleMenuClick({ key }: { key: string | number }) {
|
||||
return
|
||||
}
|
||||
|
||||
// 框架菜单点击
|
||||
const path = menuRouteMap[menuKey]
|
||||
if (path) {
|
||||
router.push(path)
|
||||
}
|
||||
// 动态菜单点击(key 就是 path)
|
||||
router.push(menuKey)
|
||||
}
|
||||
|
||||
// 用户菜单点击
|
||||
@@ -326,9 +309,9 @@ function handleUserMenuClick({ key }: { key: string | number }) {
|
||||
Modal.confirm({
|
||||
title: '确认退出',
|
||||
content: '确定要退出登录吗?',
|
||||
onOk() {
|
||||
async onOk() {
|
||||
projectStore.exitSubProject()
|
||||
userStore.logout()
|
||||
await userStore.logout()
|
||||
router.push('/login')
|
||||
}
|
||||
})
|
||||
@@ -342,6 +325,7 @@ function updateMenuState() {
|
||||
|
||||
// 检查是否在子项目路由中
|
||||
if (path.startsWith('/app/')) {
|
||||
// ...子项目逻辑保持不变...
|
||||
const match = path.match(/^\/app\/([^/]+)/)
|
||||
if (match) {
|
||||
const projectId = match[1]
|
||||
@@ -349,7 +333,6 @@ function updateMenuState() {
|
||||
projectStore.switchProject(projectId)
|
||||
}
|
||||
|
||||
// 设置子项目菜单选中状态
|
||||
const subPath = path.replace(`/app/${projectId}`, '')
|
||||
const subMenuRouteMap = projectStore.getMenuRouteMap()
|
||||
|
||||
@@ -357,16 +340,7 @@ function updateMenuState() {
|
||||
const relativePath = menuPath.replace(`/app/${projectId}`, '')
|
||||
if (subPath === relativePath || subPath.startsWith(relativePath + '/')) {
|
||||
selectedKeys.value = [menuKey]
|
||||
|
||||
// 展开父级菜单
|
||||
for (const menu of projectStore.currentProjectMenus) {
|
||||
if (menu.children?.some(child => child.key === menuKey)) {
|
||||
if (!openKeys.value.includes(menu.key)) {
|
||||
openKeys.value = [...openKeys.value, menu.key]
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
// 展开逻辑略
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -379,23 +353,47 @@ function updateMenuState() {
|
||||
projectStore.exitSubProject()
|
||||
}
|
||||
|
||||
// 查找匹配的菜单项
|
||||
for (const [menuKey, menuPath] of Object.entries(menuRouteMap)) {
|
||||
if (path === menuPath || path.startsWith(menuPath + '/')) {
|
||||
selectedKeys.value = [menuKey]
|
||||
// 框架菜单高亮(简单匹配 path)
|
||||
selectedKeys.value = [path]
|
||||
|
||||
// 查找父级菜单并展开
|
||||
for (const menu of menuConfig) {
|
||||
if (menu.children?.some(child => child.key === menuKey)) {
|
||||
if (!openKeys.value.includes(menu.key)) {
|
||||
openKeys.value = [...openKeys.value, menu.key]
|
||||
// 自动展开父级菜单
|
||||
// 遍历 userStore.antdMenus 查找 path 所在的父级
|
||||
const findParentKey = (menus: any[], targetPath: string): string | undefined => {
|
||||
for (const menu of menus) {
|
||||
if (menu.children) {
|
||||
if (menu.children.some((child: any) => child.key === targetPath)) {
|
||||
return menu.key
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
break
|
||||
const found = findParentKey(menu.children, targetPath)
|
||||
if (found) return menu.key // 这里简化处理,只展开直接父级或者递归展开
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
// 简单递归查找所有父级
|
||||
const expandMenus = (menus: any[]) => {
|
||||
menus.forEach(menu => {
|
||||
if (menu.children) {
|
||||
const dirKey = `${menu.key}_dir`
|
||||
if (path.startsWith(menu.key)) { // 如果 path 是 menu.key 的子路径(对于目录类型)
|
||||
if (!openKeys.value.includes(dirKey)) {
|
||||
openKeys.value.push(dirKey)
|
||||
}
|
||||
}
|
||||
// 检查子项是否有匹配
|
||||
const hasMatch = menu.children.some((child: any) => child.key === path || path.startsWith(child.key + '/'))
|
||||
if (hasMatch) {
|
||||
if (!openKeys.value.includes(dirKey)) {
|
||||
openKeys.value.push(dirKey)
|
||||
}
|
||||
}
|
||||
expandMenus(menu.children)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
expandMenus(userStore.antdMenus)
|
||||
}
|
||||
|
||||
watch(() => route.path, updateMenuState, { immediate: true })
|
||||
|
||||
@@ -3,7 +3,13 @@
|
||||
*/
|
||||
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
|
||||
|
||||
const routes: RouteRecordRaw[] = [
|
||||
/**
|
||||
* 路由配置 - 框架版
|
||||
*/
|
||||
|
||||
|
||||
// 静态路由(不需要权限)
|
||||
const constantRoutes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/login',
|
||||
name: 'Login',
|
||||
@@ -13,280 +19,73 @@ const routes: RouteRecordRaw[] = [
|
||||
requiresAuth: false
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
path: '/',
|
||||
component: () => import('@/layouts/MainLayout.vue'),
|
||||
redirect: '/finance/overview',
|
||||
children: [
|
||||
// 财务管理
|
||||
{
|
||||
path: 'finance/overview',
|
||||
name: 'FinanceOverview',
|
||||
component: () => import('@/views/finance/overview/index.vue'),
|
||||
meta: {
|
||||
title: '财务总览',
|
||||
requiresAuth: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'finance/income',
|
||||
name: 'Income',
|
||||
component: () => import('@/views/finance/income/index.vue'),
|
||||
meta: {
|
||||
title: '收入管理',
|
||||
requiresAuth: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'finance/expense',
|
||||
name: 'Expense',
|
||||
component: () => import('@/views/finance/expense/index.vue'),
|
||||
meta: {
|
||||
title: '支出管理',
|
||||
requiresAuth: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'finance/reimbursement',
|
||||
name: 'Reimbursement',
|
||||
component: () => import('@/views/finance/reimbursement/index.vue'),
|
||||
meta: {
|
||||
title: '报销管理',
|
||||
requiresAuth: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'finance/settlement',
|
||||
name: 'Settlement',
|
||||
component: () => import('@/views/finance/settlement/index.vue'),
|
||||
meta: {
|
||||
title: '结算管理',
|
||||
requiresAuth: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'finance/invoice',
|
||||
name: 'Invoice',
|
||||
component: () => import('@/views/finance/invoice/index.vue'),
|
||||
meta: {
|
||||
title: '发票管理',
|
||||
requiresAuth: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'finance/accounts',
|
||||
name: 'FinanceAccounts',
|
||||
component: () => import('@/views/finance/accounts/index.vue'),
|
||||
meta: {
|
||||
title: '账户管理',
|
||||
requiresAuth: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'finance/budget',
|
||||
name: 'Budget',
|
||||
component: () => import('@/views/finance/budget/index.vue'),
|
||||
meta: {
|
||||
title: '预算管理',
|
||||
requiresAuth: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'finance/reports',
|
||||
name: 'FinanceReports',
|
||||
component: () => import('@/views/finance/reports/index.vue'),
|
||||
meta: {
|
||||
title: '财务报表',
|
||||
requiresAuth: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'finance/import',
|
||||
name: 'FinanceImport',
|
||||
component: () => import('@/views/finance/import/index.vue'),
|
||||
meta: {
|
||||
title: '数据导入',
|
||||
requiresAuth: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'finance/advanced-reports',
|
||||
name: 'AdvancedReports',
|
||||
component: () => import('@/views/finance/advanced-reports/index.vue'),
|
||||
meta: {
|
||||
title: '高级报表',
|
||||
requiresAuth: true
|
||||
}
|
||||
},
|
||||
|
||||
// 平台管理
|
||||
{
|
||||
path: 'platform/projects',
|
||||
name: 'PlatformProjects',
|
||||
component: () => import('@/views/platform/projects/index.vue'),
|
||||
meta: {
|
||||
title: '项目管理',
|
||||
requiresAuth: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'platform/menus',
|
||||
name: 'PlatformMenus',
|
||||
component: () => import('@/views/platform/menus/index.vue'),
|
||||
meta: {
|
||||
title: '菜单管理',
|
||||
requiresAuth: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'platform/certificates',
|
||||
name: 'PlatformCertificates',
|
||||
component: () => import('@/views/platform/certificates/index.vue'),
|
||||
meta: {
|
||||
title: '证书管理',
|
||||
requiresAuth: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'platform/upload/:projectId?',
|
||||
name: 'PlatformUpload',
|
||||
component: () => import('@/views/platform/upload/index.vue'),
|
||||
meta: {
|
||||
title: '上传文件',
|
||||
requiresAuth: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'platform/servers',
|
||||
name: 'PlatformServers',
|
||||
component: () => import('@/views/platform/servers/index.vue'),
|
||||
meta: {
|
||||
title: '服务器管理',
|
||||
requiresAuth: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'platform/log-center',
|
||||
name: 'PlatformLogCenter',
|
||||
component: () => import('@/views/platform/log-center/index.vue'),
|
||||
meta: {
|
||||
title: '日志中心',
|
||||
requiresAuth: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'platform/domains',
|
||||
name: 'PlatformDomains',
|
||||
component: () => import('@/views/platform/domains/index.vue'),
|
||||
meta: {
|
||||
title: '域名管理',
|
||||
requiresAuth: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'platform/environments',
|
||||
name: 'PlatformEnvironments',
|
||||
component: () => import('@/views/platform/environments/index.vue'),
|
||||
meta: {
|
||||
title: '环境配置',
|
||||
requiresAuth: true
|
||||
}
|
||||
},
|
||||
|
||||
// 系统设置
|
||||
{
|
||||
path: 'settings',
|
||||
name: 'Settings',
|
||||
component: () => import('@/views/settings/index.vue'),
|
||||
meta: {
|
||||
title: '系统设置',
|
||||
requiresAuth: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'settings/dict',
|
||||
name: 'Dict',
|
||||
component: () => import('@/views/settings/dict/index.vue'),
|
||||
meta: {
|
||||
title: '字典管理',
|
||||
requiresAuth: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'settings/city',
|
||||
name: 'City',
|
||||
component: () => import('@/views/settings/city/index.vue'),
|
||||
meta: {
|
||||
title: '城市管理',
|
||||
requiresAuth: true
|
||||
}
|
||||
},
|
||||
|
||||
// 审批流程管理
|
||||
{
|
||||
path: 'system/approval',
|
||||
name: 'ApprovalFlow',
|
||||
component: () => import('@/views/system/approval/index.vue'),
|
||||
meta: {
|
||||
title: '审批流程',
|
||||
requiresAuth: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'system/approval-instances',
|
||||
name: 'ApprovalInstances',
|
||||
component: () => import('@/views/system/approval/instances.vue'),
|
||||
meta: {
|
||||
title: '审批记录',
|
||||
requiresAuth: true
|
||||
}
|
||||
},
|
||||
|
||||
// 子项目 iframe 嵌套路由
|
||||
{
|
||||
path: 'app/:projectId/:pathMatch(.*)*',
|
||||
name: 'SubProjectPage',
|
||||
component: () => import('@/views/app/IframePage.vue'),
|
||||
meta: {
|
||||
title: '子项目',
|
||||
requiresAuth: true
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
// 404页面
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
name: 'NotFound',
|
||||
component: () => import('@/views/error/404.vue'),
|
||||
meta: {
|
||||
title: '页面不存在'
|
||||
}
|
||||
}
|
||||
// 注意:动态路由添加前不要有通配符路由,否则会覆盖动态路由
|
||||
// 404 路由将在动态路由生成后添加到最后
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes
|
||||
routes: constantRoutes
|
||||
})
|
||||
|
||||
// 路由守卫
|
||||
router.beforeEach((to, _from, next) => {
|
||||
router.beforeEach(async (to, _from, next) => {
|
||||
// 设置页面标题
|
||||
document.title = `${to.meta.title || '管理后台'} - 后台管理系统`
|
||||
|
||||
const token = localStorage.getItem('token')
|
||||
|
||||
if (to.meta.requiresAuth && !token) {
|
||||
// 需要登录但未登录,跳转到登录页
|
||||
next({ path: '/login', query: { redirect: to.fullPath } })
|
||||
} else if (to.path === '/login' && token) {
|
||||
// 已登录访问登录页,跳转到首页
|
||||
next({ path: '/' })
|
||||
if (token) {
|
||||
if (to.path === '/login') {
|
||||
next({ path: '/' })
|
||||
} else {
|
||||
const { useUserStore } = await import('@/stores')
|
||||
const userStore = useUserStore()
|
||||
|
||||
// 判断是否已经生成过动态路由
|
||||
if (userStore.hasGeneratedRoutes) {
|
||||
// 如果访问根路径,且有动态路由,重定向到第一个有效路由
|
||||
if (to.path === '/') {
|
||||
const firstRoute = userStore.antdMenus[0]?.children?.[0] || userStore.antdMenus[0];
|
||||
if (firstRoute && firstRoute.key && firstRoute.key !== '/') {
|
||||
next({ path: firstRoute.key, replace: true })
|
||||
return
|
||||
}
|
||||
}
|
||||
next()
|
||||
} else {
|
||||
try {
|
||||
// 刷新页面或初次加载时,始终拉取最新用户信息以更新权限
|
||||
// 这确保了即使本地有缓存,也能同步最新的菜单和用户信息
|
||||
await userStore.getUserInfo()
|
||||
|
||||
// 如果成功生成了路由(说明有菜单权限)
|
||||
if (userStore.hasGeneratedRoutes) {
|
||||
next({ ...to, replace: true })
|
||||
} else {
|
||||
// 没有菜单权限,但已登录
|
||||
console.warn('用户没有菜单权限或 getUserInfo 未返回菜单')
|
||||
// 标记以免死循环,允许进入无权限页面(如 403 或 首页)
|
||||
userStore.hasGeneratedRoutes = true
|
||||
next()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('动态路由加载失败:', error)
|
||||
userStore.resetState()
|
||||
next({ path: '/login', query: { redirect: to.fullPath } })
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
next()
|
||||
// 未登录
|
||||
if (to.path === '/login') {
|
||||
next()
|
||||
} else {
|
||||
// 这里可以加白名单判断
|
||||
next({ path: '/login', query: { redirect: to.fullPath } })
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
|
||||
@@ -3,16 +3,24 @@
|
||||
*/
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import type { UserInfo, LoginParams, LoginResult, CaptchaResult } from '@/types'
|
||||
import type { UserInfo, LoginParams, LoginResult, CaptchaResult } from '@/types/api/auth'
|
||||
import { getCaptcha as apiGetCaptcha, login as apiLogin, logout as apiLogout, getUserInfo as apiGetUserInfo } from '@/api/auth'
|
||||
import { mockGetCaptcha, mockLogin, mockGetUserInfo } from '@/mock'
|
||||
|
||||
// 是否使用Mock数据
|
||||
const USE_MOCK = true
|
||||
const USE_MOCK = false
|
||||
|
||||
export const useUserStore = defineStore('user', () => {
|
||||
// ... (状态和计算属性保持不变)
|
||||
// 状态
|
||||
const token = ref<string>(localStorage.getItem('token') || '')
|
||||
const userInfo = ref<UserInfo | null>(null)
|
||||
// 尝试从本地存储恢复用户信息
|
||||
const storedUserInfo = localStorage.getItem('user-info')
|
||||
const userInfo = ref<UserInfo | null>(storedUserInfo ? JSON.parse(storedUserInfo) : null)
|
||||
|
||||
const dynamicRoutes = ref<any[]>([])
|
||||
const antdMenus = ref<any[]>([])
|
||||
const hasGeneratedRoutes = ref(false)
|
||||
|
||||
// 计算属性
|
||||
const isLoggedIn = computed(() => !!token.value)
|
||||
@@ -26,8 +34,7 @@ export const useUserStore = defineStore('user', () => {
|
||||
return await mockGetCaptcha()
|
||||
}
|
||||
// 真实API调用
|
||||
const { request } = await import('@/utils/request')
|
||||
const res = await request.get<CaptchaResult>('/auth/captcha')
|
||||
const res = await apiGetCaptcha()
|
||||
return res.data.data
|
||||
}
|
||||
|
||||
@@ -39,8 +46,7 @@ export const useUserStore = defineStore('user', () => {
|
||||
data = await mockLogin(params)
|
||||
} else {
|
||||
// 真实API调用
|
||||
const { request } = await import('@/utils/request')
|
||||
const res = await request.post<LoginResult>('/auth/login', params)
|
||||
const res = await apiLogin(params)
|
||||
data = res.data.data
|
||||
}
|
||||
|
||||
@@ -48,8 +54,12 @@ export const useUserStore = defineStore('user', () => {
|
||||
token.value = data.token
|
||||
localStorage.setItem('token', data.token)
|
||||
|
||||
// 保存用户信息
|
||||
// 保存用户信息到本地
|
||||
userInfo.value = data.userInfo
|
||||
localStorage.setItem('user-info', JSON.stringify(data.userInfo))
|
||||
|
||||
// 显式调用 getUserInfo 以获取完整菜单数据并生成路由
|
||||
await getUserInfo()
|
||||
|
||||
return data
|
||||
}
|
||||
@@ -58,8 +68,7 @@ export const useUserStore = defineStore('user', () => {
|
||||
async function logout(): Promise<void> {
|
||||
try {
|
||||
if (!USE_MOCK) {
|
||||
const { request } = await import('@/utils/request')
|
||||
await request.post('/auth/logout')
|
||||
await apiLogout()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('登出请求失败:', error)
|
||||
@@ -67,7 +76,11 @@ export const useUserStore = defineStore('user', () => {
|
||||
// 清除本地状态
|
||||
token.value = ''
|
||||
userInfo.value = null
|
||||
dynamicRoutes.value = []
|
||||
antdMenus.value = []
|
||||
hasGeneratedRoutes.value = false
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('user-info')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,28 +91,69 @@ export const useUserStore = defineStore('user', () => {
|
||||
userInfo.value = info
|
||||
return info
|
||||
}
|
||||
const { request } = await import('@/utils/request')
|
||||
const res = await request.get<UserInfo>('/user/info')
|
||||
|
||||
const res = await apiGetUserInfo()
|
||||
userInfo.value = res.data.data
|
||||
return res.data.data
|
||||
|
||||
// 更新本地存储
|
||||
localStorage.setItem('user-info', JSON.stringify(res.data.data))
|
||||
|
||||
// 生成路由
|
||||
if (userInfo.value.menus) {
|
||||
await generateUserRoutes(userInfo.value.menus)
|
||||
}
|
||||
|
||||
return userInfo.value
|
||||
}
|
||||
|
||||
// 生成路由
|
||||
async function generateUserRoutes(menus: any[]) {
|
||||
const { buildMenuTree, generateRoutes, transformToAntdMenu } = await import('@/utils/route')
|
||||
// 动态导入 router 以避免循环依赖
|
||||
const { default: router } = await import('@/router')
|
||||
|
||||
// 构建菜单树
|
||||
const menuTree = buildMenuTree(menus)
|
||||
|
||||
// 生成动态路由
|
||||
const routes = generateRoutes(menuTree)
|
||||
dynamicRoutes.value = routes
|
||||
|
||||
// 生成 Ant Design 菜单
|
||||
antdMenus.value = transformToAntdMenu(menuTree)
|
||||
|
||||
// 动态添加路由到 router 实例
|
||||
routes.forEach(route => {
|
||||
router.addRoute(route)
|
||||
})
|
||||
|
||||
hasGeneratedRoutes.value = true
|
||||
}
|
||||
|
||||
// 设置用户信息
|
||||
function setUserInfo(info: UserInfo) {
|
||||
userInfo.value = info
|
||||
localStorage.setItem('user-info', JSON.stringify(info))
|
||||
}
|
||||
|
||||
// 重置状态
|
||||
function resetState() {
|
||||
token.value = ''
|
||||
userInfo.value = null
|
||||
dynamicRoutes.value = []
|
||||
antdMenus.value = []
|
||||
hasGeneratedRoutes.value = false
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('user-info')
|
||||
}
|
||||
|
||||
return {
|
||||
// 状态
|
||||
token,
|
||||
userInfo,
|
||||
dynamicRoutes,
|
||||
antdMenus,
|
||||
hasGeneratedRoutes,
|
||||
// 计算属性
|
||||
isLoggedIn,
|
||||
username,
|
||||
@@ -111,7 +165,8 @@ export const useUserStore = defineStore('user', () => {
|
||||
logout,
|
||||
getUserInfo,
|
||||
setUserInfo,
|
||||
resetState
|
||||
resetState,
|
||||
generateUserRoutes
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -1,7 +1,23 @@
|
||||
/**
|
||||
* 用户相关类型定义
|
||||
* 认证相关类型定义
|
||||
*/
|
||||
|
||||
export interface SysMenu {
|
||||
id: number
|
||||
parentId: number
|
||||
name: string
|
||||
code: string
|
||||
type: 'directory' | 'menu' | 'button'
|
||||
path: string
|
||||
component?: string
|
||||
icon?: string
|
||||
permission?: string
|
||||
sort: number
|
||||
hidden: number
|
||||
status: number
|
||||
children?: SysMenu[]
|
||||
}
|
||||
|
||||
// 用户信息(用于鉴权等场景)
|
||||
export interface UserInfo {
|
||||
id: number
|
||||
@@ -12,6 +28,7 @@ export interface UserInfo {
|
||||
phone?: string
|
||||
role: string
|
||||
permissions?: string[]
|
||||
menus?: SysMenu[]
|
||||
createTime?: string
|
||||
lastLoginTime?: string
|
||||
}
|
||||
@@ -18,7 +18,7 @@ export interface PageParams {
|
||||
|
||||
// 分页响应数据
|
||||
export interface PageResult<T = unknown> {
|
||||
list: T[]
|
||||
records: T[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
@@ -30,4 +30,3 @@ export interface ListResult<T = unknown> {
|
||||
list: T[]
|
||||
total: number
|
||||
}
|
||||
|
||||
67
src/types/finance/budget.ts
Normal file
67
src/types/finance/budget.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import type { ExpenseType } from './common'
|
||||
|
||||
// 预算周期
|
||||
export type BudgetPeriod = 'monthly' | 'quarterly' | 'yearly'
|
||||
|
||||
export const BudgetPeriodMap: Record<BudgetPeriod, string> = {
|
||||
monthly: '月度',
|
||||
quarterly: '季度',
|
||||
yearly: '年度'
|
||||
}
|
||||
|
||||
// 预算状态
|
||||
export type BudgetStatus = 'draft' | 'active' | 'completed' | 'cancelled'
|
||||
|
||||
export const BudgetStatusMap: Record<BudgetStatus, string> = {
|
||||
draft: '草稿',
|
||||
active: '执行中',
|
||||
completed: '已完成',
|
||||
cancelled: '已取消'
|
||||
}
|
||||
|
||||
// 预算明细项(按费用类型)
|
||||
export interface BudgetItem {
|
||||
expenseType: ExpenseType
|
||||
budgetAmount: number
|
||||
usedAmount: number
|
||||
remainingAmount: number
|
||||
}
|
||||
|
||||
// 预算记录
|
||||
export interface BudgetRecord {
|
||||
id: number
|
||||
name: string
|
||||
period: BudgetPeriod
|
||||
year: number
|
||||
quarter?: number // 1-4
|
||||
month?: number // 1-12
|
||||
|
||||
// 部门/项目
|
||||
departmentId?: number
|
||||
departmentName?: string
|
||||
projectId?: number
|
||||
projectName?: string
|
||||
|
||||
// 预算明细
|
||||
items: BudgetItem[]
|
||||
|
||||
totalBudget: number // 总预算
|
||||
usedAmount: number // 已使用
|
||||
remainingAmount: number // 剩余
|
||||
usageRate: number // 使用率 (0-100)
|
||||
|
||||
status: BudgetStatus
|
||||
remark?: string
|
||||
createdBy: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface BudgetQueryParams {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
period?: BudgetPeriod
|
||||
year?: number
|
||||
departmentId?: number
|
||||
status?: BudgetStatus
|
||||
}
|
||||
48
src/types/finance/common.ts
Normal file
48
src/types/finance/common.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* 财务通用类型
|
||||
*/
|
||||
|
||||
// 货币金额类型(以分为单位存储,显示时转换)
|
||||
export type MoneyAmount = number
|
||||
|
||||
// 通用状态
|
||||
export type CommonStatus = 'active' | 'inactive'
|
||||
|
||||
// 支出类型
|
||||
export type ExpenseType =
|
||||
| 'salary' // 工资
|
||||
| 'office' // 办公费
|
||||
| 'rent' // 房租水电
|
||||
| 'travel' // 差旅费
|
||||
| 'marketing' // 市场推广
|
||||
| 'equipment' // 设备采购
|
||||
| 'service' // 外包服务
|
||||
| 'tax' // 税费
|
||||
| 'social_insurance' // 社保公积金
|
||||
| 'other' // 其他
|
||||
|
||||
export const ExpenseTypeMap: Record<ExpenseType, string> = {
|
||||
salary: '工资薪酬',
|
||||
office: '办公费用',
|
||||
rent: '房租水电',
|
||||
travel: '差旅费用',
|
||||
marketing: '市场推广',
|
||||
equipment: '设备采购',
|
||||
service: '外包服务',
|
||||
tax: '税费',
|
||||
social_insurance: '社保公积金',
|
||||
other: '其他支出'
|
||||
}
|
||||
|
||||
export const ExpenseTypeColorMap: Record<ExpenseType, string> = {
|
||||
salary: 'blue',
|
||||
office: 'cyan',
|
||||
rent: 'purple',
|
||||
travel: 'orange',
|
||||
marketing: 'magenta',
|
||||
equipment: 'geekblue',
|
||||
service: 'volcano',
|
||||
tax: 'gold',
|
||||
social_insurance: 'lime',
|
||||
other: 'default'
|
||||
}
|
||||
3
src/types/finance/index.ts
Normal file
3
src/types/finance/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './common'
|
||||
export * from './budget'
|
||||
export * from './legacy'
|
||||
@@ -1,14 +1,7 @@
|
||||
/**
|
||||
* 财务管理相关类型定义
|
||||
*/
|
||||
|
||||
// ==================== 基础类型 ====================
|
||||
|
||||
// 货币金额类型(以分为单位存储,显示时转换)
|
||||
export type MoneyAmount = number
|
||||
|
||||
// 通用状态
|
||||
export type CommonStatus = 'active' | 'inactive'
|
||||
import type { CommonStatus, MoneyAmount, ExpenseType } from './common'
|
||||
|
||||
// ==================== 结算管理(原有) ====================
|
||||
|
||||
@@ -232,45 +225,6 @@ export interface IncomePayment {
|
||||
|
||||
// ==================== 支出管理 ====================
|
||||
|
||||
// 支出类型
|
||||
export type ExpenseType =
|
||||
| 'salary' // 工资
|
||||
| 'office' // 办公费
|
||||
| 'rent' // 房租水电
|
||||
| 'travel' // 差旅费
|
||||
| 'marketing' // 市场推广
|
||||
| 'equipment' // 设备采购
|
||||
| 'service' // 外包服务
|
||||
| 'tax' // 税费
|
||||
| 'social_insurance' // 社保公积金
|
||||
| 'other' // 其他
|
||||
|
||||
export const ExpenseTypeMap: Record<ExpenseType, string> = {
|
||||
salary: '工资薪酬',
|
||||
office: '办公费用',
|
||||
rent: '房租水电',
|
||||
travel: '差旅费用',
|
||||
marketing: '市场推广',
|
||||
equipment: '设备采购',
|
||||
service: '外包服务',
|
||||
tax: '税费',
|
||||
social_insurance: '社保公积金',
|
||||
other: '其他支出'
|
||||
}
|
||||
|
||||
export const ExpenseTypeColorMap: Record<ExpenseType, string> = {
|
||||
salary: 'blue',
|
||||
office: 'cyan',
|
||||
rent: 'purple',
|
||||
travel: 'orange',
|
||||
marketing: 'magenta',
|
||||
equipment: 'geekblue',
|
||||
service: 'volcano',
|
||||
tax: 'gold',
|
||||
social_insurance: 'lime',
|
||||
other: 'default'
|
||||
}
|
||||
|
||||
// 支出状态
|
||||
export type ExpenseStatus = 'draft' | 'pending' | 'approved' | 'paid' | 'rejected'
|
||||
|
||||
@@ -430,64 +384,7 @@ export interface ReimbursementItem {
|
||||
attachments?: string[] // 凭证/发票图片
|
||||
}
|
||||
|
||||
// ==================== 预算管理 ====================
|
||||
|
||||
// 预算周期
|
||||
export type BudgetPeriod = 'monthly' | 'quarterly' | 'yearly'
|
||||
|
||||
export const BudgetPeriodMap: Record<BudgetPeriod, string> = {
|
||||
monthly: '月度',
|
||||
quarterly: '季度',
|
||||
yearly: '年度'
|
||||
}
|
||||
|
||||
// 预算状态
|
||||
export type BudgetStatus = 'draft' | 'active' | 'completed' | 'cancelled'
|
||||
|
||||
export const BudgetStatusMap: Record<BudgetStatus, string> = {
|
||||
draft: '草稿',
|
||||
active: '执行中',
|
||||
completed: '已完成',
|
||||
cancelled: '已取消'
|
||||
}
|
||||
|
||||
// 预算记录
|
||||
export interface BudgetRecord {
|
||||
id: number
|
||||
name: string
|
||||
period: BudgetPeriod
|
||||
year: number
|
||||
quarter?: number // 1-4
|
||||
month?: number // 1-12
|
||||
|
||||
// 部门/项目
|
||||
departmentId?: number
|
||||
departmentName?: string
|
||||
projectId?: number
|
||||
projectName?: string
|
||||
|
||||
// 预算明细
|
||||
items: BudgetItem[]
|
||||
|
||||
totalBudget: number // 总预算
|
||||
usedAmount: number // 已使用
|
||||
remainingAmount: number // 剩余
|
||||
usageRate: number // 使用率 (0-100)
|
||||
|
||||
status: BudgetStatus
|
||||
remark?: string
|
||||
createdBy: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
// 预算明细项(按费用类型)
|
||||
export interface BudgetItem {
|
||||
expenseType: ExpenseType
|
||||
budgetAmount: number
|
||||
usedAmount: number
|
||||
remainingAmount: number
|
||||
}
|
||||
// 预算管理类型已移动到 ./budget.ts
|
||||
|
||||
// ==================== 财务统计 ====================
|
||||
|
||||
@@ -564,14 +461,7 @@ export interface ReimbursementQueryParams {
|
||||
dateRange?: [string, string]
|
||||
}
|
||||
|
||||
export interface BudgetQueryParams {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
period?: BudgetPeriod
|
||||
year?: number
|
||||
departmentId?: number
|
||||
status?: BudgetStatus
|
||||
}
|
||||
|
||||
|
||||
// ==================== 多级审批流程 ====================
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
* 类型统一导出
|
||||
*/
|
||||
|
||||
export * from './user'
|
||||
export * from './response'
|
||||
export * from './finance'
|
||||
export * from './api/auth'
|
||||
export * from './api/response'
|
||||
export * from './system/user'
|
||||
export * from './system/role'
|
||||
export * from './finance/index'
|
||||
export * from './approval'
|
||||
export * from './upload'
|
||||
|
||||
9
src/types/system/menu.ts
Normal file
9
src/types/system/menu.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import type { SysMenu } from '@/types/api/auth'
|
||||
|
||||
export interface MenuRecord extends SysMenu {
|
||||
remark?: string
|
||||
createTime?: string
|
||||
children?: MenuRecord[]
|
||||
}
|
||||
|
||||
export type MenuFormData = Partial<MenuRecord>
|
||||
34
src/types/system/role.ts
Normal file
34
src/types/system/role.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* 系统管理-角色相关类型
|
||||
*/
|
||||
|
||||
export interface RoleOption {
|
||||
code: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface RoleRecord {
|
||||
id: number
|
||||
name: string
|
||||
code: string
|
||||
description?: string
|
||||
status: number
|
||||
sort: number
|
||||
createdAt?: string
|
||||
}
|
||||
|
||||
export interface RoleQuery {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
keyword?: string
|
||||
}
|
||||
|
||||
export interface RoleFormData {
|
||||
id?: number
|
||||
name: string
|
||||
code: string
|
||||
description?: string
|
||||
status: number
|
||||
sort: number
|
||||
menuIds?: number[]
|
||||
}
|
||||
37
src/types/system/user.ts
Normal file
37
src/types/system/user.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* 系统管理-用户相关类型
|
||||
*/
|
||||
|
||||
export interface UserRecord {
|
||||
id: number
|
||||
username: string
|
||||
nickname: string
|
||||
avatar?: string
|
||||
email?: string
|
||||
phone?: string
|
||||
role: string
|
||||
status: number
|
||||
createdAt?: string
|
||||
lastLoginTime?: string
|
||||
remark?: string
|
||||
}
|
||||
|
||||
export interface UserQuery {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
keyword?: string
|
||||
role?: string
|
||||
status?: number
|
||||
}
|
||||
|
||||
export interface UserFormData {
|
||||
id?: number
|
||||
username: string
|
||||
password?: string
|
||||
nickname?: string
|
||||
phone?: string
|
||||
email?: string
|
||||
role?: string
|
||||
status: number
|
||||
remark?: string
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
*/
|
||||
import axios, { type AxiosInstance, type AxiosRequestConfig, type AxiosResponse, type InternalAxiosRequestConfig } from 'axios'
|
||||
import { message } from 'ant-design-vue'
|
||||
import type { ApiResponse } from '@/types'
|
||||
import type { ApiResponse } from '@/types/api/response'
|
||||
|
||||
// 创建axios实例
|
||||
const service: AxiosInstance = axios.create({
|
||||
|
||||
215
src/utils/route.ts
Normal file
215
src/utils/route.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
import type { SysMenu } from '@/types/api/auth'
|
||||
import { h, resolveComponent } from 'vue'
|
||||
|
||||
// 自动导入 views 下的所有 vue 文件
|
||||
const modules = import.meta.glob('../views/**/*.vue')
|
||||
|
||||
/**
|
||||
* 将平铺的菜单数据转换为树形结构
|
||||
*/
|
||||
export function buildMenuTree(menus: SysMenu[]): SysMenu[] {
|
||||
const map = new Map<number, SysMenu>()
|
||||
const roots: SysMenu[] = []
|
||||
|
||||
// 深拷贝并存入 Map
|
||||
menus.forEach(menu => {
|
||||
map.set(menu.id, { ...menu, children: [] })
|
||||
})
|
||||
|
||||
menus.forEach(menu => {
|
||||
const node = map.get(menu.id)!
|
||||
if (menu.parentId === 0 || !map.has(menu.parentId)) {
|
||||
roots.push(node)
|
||||
} else {
|
||||
const parent = map.get(menu.parentId)!
|
||||
if (parent.children) {
|
||||
parent.children.push(node)
|
||||
} else {
|
||||
parent.children = [node]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 排序
|
||||
const sortMenus = (list: SysMenu[]) => {
|
||||
list.sort((a, b) => (a.sort || 0) - (b.sort || 0))
|
||||
list.forEach(item => {
|
||||
if (item.children && item.children.length > 0) {
|
||||
sortMenus(item.children)
|
||||
} else {
|
||||
delete item.children
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
sortMenus(roots)
|
||||
return roots
|
||||
}
|
||||
|
||||
/**
|
||||
* 将菜单树转换为 Vue 路由
|
||||
*/
|
||||
export function generateRoutes(menuTree: SysMenu[]): RouteRecordRaw[] {
|
||||
const routes: RouteRecordRaw[] = []
|
||||
|
||||
// 添加主布局路由
|
||||
const mainRoute: RouteRecordRaw = {
|
||||
path: '/',
|
||||
name: 'Layout',
|
||||
component: () => import('@/layouts/MainLayout.vue'),
|
||||
children: []
|
||||
}
|
||||
|
||||
const processMenu = (menus: SysMenu[]) => {
|
||||
menus.forEach(menu => {
|
||||
// 忽略按钮类型
|
||||
if (menu.type === 'button') return
|
||||
|
||||
const route: any = {
|
||||
path: menu.path,
|
||||
name: menu.code || `Route_${menu.id}`, // 使用 code 或 id 作为 name
|
||||
meta: {
|
||||
title: menu.name,
|
||||
icon: menu.icon,
|
||||
hidden: menu.hidden === 1
|
||||
}
|
||||
}
|
||||
|
||||
// 处理组件
|
||||
if (menu.type === 'directory') {
|
||||
// 目录通常没有实体组件,或者指向 Layout(如果是根目录已经在外面处理了,如果是子目录可能是 RouterView)
|
||||
// 这里简化处理:如果是目录且有子菜单,则它仅作为父路由容器
|
||||
// 如果是多级菜单,中间层级通常不需要 Layout,可以是一个简单的 <router-view> 容器
|
||||
// 但为了简单,我们假设所有页面都嵌套在 MainLayout 下,所以这里直接把子路由加到 children
|
||||
} else if (menu.type === 'menu' && menu.component) {
|
||||
// 匹配组件
|
||||
const compPath = `../views${menu.component.startsWith('/') ? menu.component : '/' + menu.component}`
|
||||
// 尝试匹配 .vue 或 /index.vue
|
||||
const matchKey = Object.keys(modules).find(key =>
|
||||
key === compPath || key === `${compPath}.vue` || key === `${compPath}/index.vue`
|
||||
)
|
||||
|
||||
if (matchKey) {
|
||||
route.component = modules[matchKey]
|
||||
} else {
|
||||
console.warn(`组件路径未找到: ${menu.component}`)
|
||||
// 可以设置一个 404 组件或空白组件
|
||||
route.component = () => import('@/views/error/404.vue')
|
||||
}
|
||||
}
|
||||
|
||||
if (menu.children && menu.children.length > 0) {
|
||||
// 递归处理子菜单
|
||||
// 注意:Vue Router 嵌套路由 path 如果以 / 开头是绝对路径
|
||||
// 我们假设后端返回的 path 都是绝对路径
|
||||
// 如果是子路由,直接添加到 MainLayout 的 children 中
|
||||
processMenu(menu.children)
|
||||
}
|
||||
|
||||
// 如果是菜单且有组件,或者虽然是目录但我们希望它出现在路由表中(作为父级)
|
||||
if (menu.type === 'menu') {
|
||||
mainRoute.children?.push(route)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 由于我们的设计是所有业务页面都在 MainLayout 下
|
||||
// 我们不需要递归生成嵌套的 route 结构(除非需要嵌套 <router-view>)
|
||||
// 我们可以把所有叶子节点的菜单项直接铺平挂载到 MainLayout 的 children 下
|
||||
// 这样无论菜单层级多深,路由都是 /layout/xxx 这种只有两层的结构
|
||||
|
||||
// 对于 Vue Router,通常推荐这种扁平化路由结构,除非有特定需求的嵌套布局
|
||||
|
||||
// 重新实现一个扁平化处理函数
|
||||
const flattenRoutes = (menus: SysMenu[]) => {
|
||||
menus.forEach(menu => {
|
||||
if (menu.type === 'button') return
|
||||
|
||||
if (menu.type === 'menu') {
|
||||
// 如果 component 字段为空,尝试使用 path 作为组件路径(需去除开头的 /)
|
||||
const componentPath = menu.component || (menu.path.startsWith('/') ? menu.path.slice(1) : menu.path)
|
||||
|
||||
// 只有当有有效路径时才处理
|
||||
if (componentPath) {
|
||||
const route: any = {
|
||||
path: menu.path, // 绝对路径
|
||||
name: menu.code, // 确保 name 唯一
|
||||
meta: {
|
||||
title: menu.name,
|
||||
icon: menu.icon,
|
||||
hidden: menu.hidden === 1
|
||||
}
|
||||
}
|
||||
|
||||
// 规范化组件路径:去除开头的 /
|
||||
const normalizedPath = componentPath.replace(/^\/+/, '')
|
||||
|
||||
// 尝试匹配 modules 中的键
|
||||
// modules keys 格式如: ../views/system/users/index.vue
|
||||
const matchKey = Object.keys(modules).find(key => {
|
||||
// 移除前缀Prefix ../views/
|
||||
const relativePath = key.replace('../views/', '')
|
||||
// 1. 精确匹配: system/users/index.vue === system/users/index.vue
|
||||
if (relativePath === normalizedPath) return true
|
||||
// 2. 匹配扩展名: system/users.vue === system/users.vue
|
||||
if (relativePath === `${normalizedPath}.vue`) return true
|
||||
// 3. 匹配目录索引: system/users/index.vue === system/users + /index.vue
|
||||
if (relativePath === `${normalizedPath}/index.vue`) return true
|
||||
return false
|
||||
})
|
||||
|
||||
if (matchKey) {
|
||||
route.component = modules[matchKey]
|
||||
} else {
|
||||
console.warn(`[Route Error] Component not found for menu: ${menu.name}. Expected path: ${normalizedPath}. Checked against ${Object.keys(modules).length} files.`)
|
||||
// 找不到组件时不添加路由,避免跳转白屏
|
||||
return
|
||||
}
|
||||
|
||||
mainRoute.children?.push(route)
|
||||
}
|
||||
}
|
||||
|
||||
if (menu.children && menu.children.length > 0) {
|
||||
flattenRoutes(menu.children)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
flattenRoutes(menuTree)
|
||||
routes.push(mainRoute)
|
||||
|
||||
// 添加通配符路由(放在最后)
|
||||
routes.push({
|
||||
path: '/:pathMatch(.*)*',
|
||||
redirect: '/404'
|
||||
})
|
||||
|
||||
return routes
|
||||
}
|
||||
|
||||
/**
|
||||
* 将菜单转换为 Ant Design 菜单格式
|
||||
*/
|
||||
export function transformToAntdMenu(menus: SysMenu[]): any[] {
|
||||
return menus.map(menu => {
|
||||
if (menu.type === 'button' || menu.hidden === 1) return null
|
||||
|
||||
const item: any = {
|
||||
key: menu.path, // 使用 path 作为 key,配合 router
|
||||
label: menu.name,
|
||||
title: menu.name,
|
||||
icon: menu.icon
|
||||
}
|
||||
|
||||
if (menu.children && menu.children.length > 0) {
|
||||
const children = transformToAntdMenu(menu.children).filter(Boolean)
|
||||
if (children.length > 0) {
|
||||
item.children = children
|
||||
}
|
||||
}
|
||||
|
||||
return item
|
||||
}).filter(Boolean)
|
||||
}
|
||||
@@ -118,195 +118,38 @@
|
||||
</a-card>
|
||||
|
||||
<!-- 新增/编辑弹窗 -->
|
||||
<a-modal
|
||||
v-model:open="formVisible"
|
||||
:title="currentRecord?.id ? '编辑预算' : '新增预算'"
|
||||
width="800px"
|
||||
@ok="handleFormSubmit"
|
||||
:confirm-loading="formLoading"
|
||||
>
|
||||
<a-form
|
||||
ref="formRef"
|
||||
:model="formData"
|
||||
:rules="formRules"
|
||||
:label-col="{ span: 4 }"
|
||||
:wrapper-col="{ span: 19 }"
|
||||
>
|
||||
<a-form-item label="预算名称" name="name">
|
||||
<a-input v-model:value="formData.name" placeholder="请输入预算名称" />
|
||||
</a-form-item>
|
||||
<a-form-item label="预算周期" name="period">
|
||||
<a-radio-group v-model:value="formData.period">
|
||||
<a-radio-button v-for="(label, key) in BudgetPeriodMap" :key="key" :value="key">
|
||||
{{ label }}
|
||||
</a-radio-button>
|
||||
</a-radio-group>
|
||||
</a-form-item>
|
||||
<a-form-item label="年度" name="year">
|
||||
<a-select v-model:value="formData.year" style="width: 120px">
|
||||
<a-select-option :value="2024">2024年</a-select-option>
|
||||
<a-select-option :value="2025">2025年</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="月份" v-if="formData.period === 'monthly'">
|
||||
<a-select v-model:value="formData.month" style="width: 120px">
|
||||
<a-select-option v-for="m in 12" :key="m" :value="m">{{ m }}月</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="季度" v-if="formData.period === 'quarterly'">
|
||||
<a-select v-model:value="formData.quarter" style="width: 120px">
|
||||
<a-select-option v-for="q in 4" :key="q" :value="q">Q{{ q }}</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="所属部门">
|
||||
<a-select v-model:value="formData.departmentId" placeholder="选择部门(选填)" allow-clear>
|
||||
<a-select-option v-for="dept in departments" :key="dept.id" :value="dept.id">
|
||||
{{ dept.name }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
|
||||
<!-- 预算明细 -->
|
||||
<a-divider>预算明细</a-divider>
|
||||
<a-form-item label="费用预算" required>
|
||||
<div class="budget-items">
|
||||
<a-row :gutter="16" class="budget-item-header">
|
||||
<a-col :span="8">费用类型</a-col>
|
||||
<a-col :span="8">预算金额</a-col>
|
||||
<a-col :span="8">已使用</a-col>
|
||||
</a-row>
|
||||
<div v-for="(item, index) in formData.items" :key="index" class="budget-item-row">
|
||||
<a-row :gutter="16" align="middle">
|
||||
<a-col :span="8">
|
||||
<a-tag :color="ExpenseTypeColorMap[item.expenseType as keyof typeof ExpenseTypeColorMap]">
|
||||
{{ ExpenseTypeMap[item.expenseType as keyof typeof ExpenseTypeMap] }}
|
||||
</a-tag>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<a-input-number
|
||||
v-model:value="item.budgetAmount"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
style="width: 100%"
|
||||
:formatter="(value: any) => `¥ ${value}`.replace(/\B(?=(\d{3})+(?!\d))/g, ',')"
|
||||
:parser="(value: any) => value.replace(/¥\s?|(,*)/g, '')"
|
||||
/>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<span class="used-amount">¥{{ (item.usedAmount || 0).toLocaleString() }}</span>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</div>
|
||||
<a-divider dashed />
|
||||
<a-row :gutter="16" class="budget-total-row">
|
||||
<a-col :span="8"><strong>合计</strong></a-col>
|
||||
<a-col :span="8">
|
||||
<strong class="total-budget">¥{{ calculatedTotal.toLocaleString() }}</strong>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<strong class="total-used">¥{{ calculatedUsed.toLocaleString() }}</strong>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</div>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="备注">
|
||||
<a-textarea v-model:value="formData.remark" :rows="2" placeholder="备注信息(选填)" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
<BudgetFormModal
|
||||
v-model:visible="formVisible"
|
||||
:record="currentRecord"
|
||||
:departments="departments"
|
||||
@success="loadData"
|
||||
/>
|
||||
|
||||
<!-- 详情弹窗 -->
|
||||
<a-modal
|
||||
v-model:open="detailVisible"
|
||||
title="预算详情"
|
||||
:footer="null"
|
||||
width="800px"
|
||||
>
|
||||
<template v-if="currentRecord">
|
||||
<a-descriptions bordered :column="2">
|
||||
<a-descriptions-item label="预算名称" :span="2">{{ currentRecord.name }}</a-descriptions-item>
|
||||
<a-descriptions-item label="预算周期">
|
||||
{{ BudgetPeriodMap[currentRecord.period] }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="时间">
|
||||
{{ currentRecord.year }}年
|
||||
{{ currentRecord.period === 'monthly' ? `${currentRecord.month}月` :
|
||||
currentRecord.period === 'quarterly' ? `Q${currentRecord.quarter}` : '' }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="所属部门">{{ currentRecord.departmentName || '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="状态">
|
||||
<a-tag :color="getBudgetStatusColor(currentRecord.status)">
|
||||
{{ BudgetStatusMap[currentRecord.status] }}
|
||||
</a-tag>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="总预算">
|
||||
<span class="money-primary">¥{{ currentRecord.totalBudget.toLocaleString() }}</span>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="执行率">
|
||||
<a-progress
|
||||
:percent="currentRecord.usageRate"
|
||||
:stroke-color="getProgressColor(currentRecord.usageRate)"
|
||||
style="width: 150px"
|
||||
/>
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
|
||||
<a-divider>预算明细</a-divider>
|
||||
<a-table
|
||||
:columns="detailColumns"
|
||||
:data-source="currentRecord.items"
|
||||
:pagination="false"
|
||||
row-key="expenseType"
|
||||
size="small"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'expenseType'">
|
||||
<a-tag :color="ExpenseTypeColorMap[record.expenseType as keyof typeof ExpenseTypeColorMap]">
|
||||
{{ ExpenseTypeMap[record.expenseType as keyof typeof ExpenseTypeMap] }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-if="column.key === 'budgetAmount'">
|
||||
¥{{ record.budgetAmount.toLocaleString() }}
|
||||
</template>
|
||||
<template v-if="column.key === 'usedAmount'">
|
||||
<span class="text-warning">¥{{ record.usedAmount.toLocaleString() }}</span>
|
||||
</template>
|
||||
<template v-if="column.key === 'remainingAmount'">
|
||||
<span :class="record.remainingAmount >= 0 ? 'text-success' : 'text-danger'">
|
||||
¥{{ record.remainingAmount.toLocaleString() }}
|
||||
</span>
|
||||
</template>
|
||||
<template v-if="column.key === 'usage'">
|
||||
<a-progress
|
||||
:percent="record.budgetAmount > 0 ? Math.round((record.usedAmount / record.budgetAmount) * 100) : 0"
|
||||
size="small"
|
||||
:stroke-color="getProgressColor(record.budgetAmount > 0 ? Math.round((record.usedAmount / record.budgetAmount) * 100) : 0)"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</template>
|
||||
</a-modal>
|
||||
<BudgetDetailModal
|
||||
v-model:visible="detailVisible"
|
||||
:record="currentRecord"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { PlusOutlined, CalendarOutlined, TeamOutlined } from '@ant-design/icons-vue'
|
||||
import type { BudgetRecord, BudgetPeriod, BudgetStatus, BudgetItem, ExpenseType } from '@/types'
|
||||
import { BudgetPeriodMap, BudgetStatusMap, ExpenseTypeMap, ExpenseTypeColorMap } from '@/types'
|
||||
import { mockGetBudgets, mockSaveBudget } from '@/mock'
|
||||
import { getBudgetList } from '@/api/finance/budget'
|
||||
import type { BudgetRecord, BudgetPeriod, BudgetStatus, BudgetQueryParams } from '@/types/finance/budget'
|
||||
import { BudgetPeriodMap, BudgetStatusMap } from '@/types/finance/budget'
|
||||
|
||||
import BudgetFormModal from '@/components/finance/budget/BudgetFormModal.vue'
|
||||
import BudgetDetailModal from '@/components/finance/budget/BudgetDetailModal.vue'
|
||||
|
||||
const loading = ref(false)
|
||||
const formVisible = ref(false)
|
||||
const formLoading = ref(false)
|
||||
const detailVisible = ref(false)
|
||||
|
||||
const dataList = ref<BudgetRecord[]>([])
|
||||
const currentRecord = ref<BudgetRecord | null>(null)
|
||||
const formRef = ref()
|
||||
const currentRecord = ref<BudgetRecord | undefined>(undefined)
|
||||
|
||||
const departments = [
|
||||
{ id: 1, name: '研发部' },
|
||||
@@ -316,13 +159,11 @@ const departments = [
|
||||
{ id: 5, name: '财务部' }
|
||||
]
|
||||
|
||||
const expenseTypes: ExpenseType[] = ['salary', 'office', 'travel', 'marketing', 'equipment', 'other']
|
||||
|
||||
const queryParam = reactive({
|
||||
const queryParam = reactive<BudgetQueryParams>({
|
||||
year: 2024,
|
||||
period: undefined as BudgetPeriod | undefined,
|
||||
departmentId: undefined as number | undefined,
|
||||
status: undefined as BudgetStatus | undefined
|
||||
period: undefined,
|
||||
departmentId: undefined,
|
||||
status: undefined
|
||||
})
|
||||
|
||||
const pagination = reactive({
|
||||
@@ -331,47 +172,6 @@ const pagination = reactive({
|
||||
total: 0
|
||||
})
|
||||
|
||||
interface FormBudgetItem {
|
||||
expenseType: ExpenseType
|
||||
budgetAmount: number
|
||||
usedAmount: number
|
||||
remainingAmount: number
|
||||
}
|
||||
|
||||
const formData = reactive({
|
||||
name: '',
|
||||
period: 'monthly' as BudgetPeriod,
|
||||
year: 2024,
|
||||
month: new Date().getMonth() + 1,
|
||||
quarter: Math.ceil((new Date().getMonth() + 1) / 3),
|
||||
departmentId: undefined as number | undefined,
|
||||
items: [] as FormBudgetItem[],
|
||||
remark: ''
|
||||
})
|
||||
|
||||
const formRules = {
|
||||
name: [{ required: true, message: '请输入预算名称' }],
|
||||
period: [{ required: true, message: '请选择预算周期' }],
|
||||
year: [{ required: true, message: '请选择年度' }]
|
||||
}
|
||||
|
||||
const detailColumns = [
|
||||
{ title: '费用类型', key: 'expenseType', width: 150 },
|
||||
{ title: '预算金额', key: 'budgetAmount', width: 130, align: 'right' as const },
|
||||
{ title: '已使用', key: 'usedAmount', width: 130, align: 'right' as const },
|
||||
{ title: '剩余', key: 'remainingAmount', width: 130, align: 'right' as const },
|
||||
{ title: '使用率', key: 'usage', width: 150 }
|
||||
]
|
||||
|
||||
// 计算属性
|
||||
const calculatedTotal = computed(() =>
|
||||
formData.items.reduce((sum, item) => sum + (item.budgetAmount || 0), 0)
|
||||
)
|
||||
|
||||
const calculatedUsed = computed(() =>
|
||||
formData.items.reduce((sum, item) => sum + (item.usedAmount || 0), 0)
|
||||
)
|
||||
|
||||
// 辅助函数
|
||||
function formatAmount(amount: number): string {
|
||||
if (amount >= 10000) {
|
||||
@@ -403,21 +203,11 @@ function getUsageRateClass(rate: number): string {
|
||||
return 'low'
|
||||
}
|
||||
|
||||
// 初始化表单项
|
||||
function initFormItems() {
|
||||
formData.items = expenseTypes.map(type => ({
|
||||
expenseType: type,
|
||||
budgetAmount: 0,
|
||||
usedAmount: 0,
|
||||
remainingAmount: 0
|
||||
}))
|
||||
}
|
||||
|
||||
// 加载数据
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await mockGetBudgets({
|
||||
const res = await getBudgetList({
|
||||
page: pagination.current,
|
||||
pageSize: pagination.pageSize,
|
||||
period: queryParam.period,
|
||||
@@ -425,10 +215,10 @@ async function loadData() {
|
||||
departmentId: queryParam.departmentId,
|
||||
status: queryParam.status
|
||||
})
|
||||
dataList.value = res.list
|
||||
pagination.total = res.total
|
||||
dataList.value = res.data.data.records || []
|
||||
pagination.total = res.data.data.total || 0
|
||||
} catch (error) {
|
||||
message.error('加载失败')
|
||||
console.error('加载失败:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -448,50 +238,12 @@ function resetQuery() {
|
||||
}
|
||||
|
||||
function handleAdd() {
|
||||
currentRecord.value = null
|
||||
Object.assign(formData, {
|
||||
name: '',
|
||||
period: 'monthly',
|
||||
year: 2024,
|
||||
month: new Date().getMonth() + 1,
|
||||
quarter: Math.ceil((new Date().getMonth() + 1) / 3),
|
||||
departmentId: undefined,
|
||||
remark: ''
|
||||
})
|
||||
initFormItems()
|
||||
currentRecord.value = undefined
|
||||
formVisible.value = true
|
||||
}
|
||||
|
||||
function handleEdit(record: BudgetRecord) {
|
||||
currentRecord.value = record
|
||||
Object.assign(formData, {
|
||||
name: record.name,
|
||||
period: record.period,
|
||||
year: record.year,
|
||||
month: record.month || 1,
|
||||
quarter: record.quarter || 1,
|
||||
departmentId: record.departmentId,
|
||||
remark: record.remark || ''
|
||||
})
|
||||
formData.items = record.items?.map(item => ({
|
||||
expenseType: item.expenseType,
|
||||
budgetAmount: item.budgetAmount,
|
||||
usedAmount: item.usedAmount,
|
||||
remainingAmount: item.remainingAmount
|
||||
})) || []
|
||||
|
||||
// 补充缺失的费用类型
|
||||
expenseTypes.forEach(type => {
|
||||
if (!formData.items.find(item => item.expenseType === type)) {
|
||||
formData.items.push({
|
||||
expenseType: type,
|
||||
budgetAmount: 0,
|
||||
usedAmount: 0,
|
||||
remainingAmount: 0
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
formVisible.value = true
|
||||
}
|
||||
|
||||
@@ -500,55 +252,6 @@ function handleDetail(record: BudgetRecord) {
|
||||
detailVisible.value = true
|
||||
}
|
||||
|
||||
async function handleFormSubmit() {
|
||||
try {
|
||||
await formRef.value.validate()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
formLoading.value = true
|
||||
try {
|
||||
const items: BudgetItem[] = formData.items
|
||||
.filter(item => item.budgetAmount > 0)
|
||||
.map(item => ({
|
||||
expenseType: item.expenseType,
|
||||
budgetAmount: item.budgetAmount,
|
||||
usedAmount: item.usedAmount,
|
||||
remainingAmount: item.budgetAmount - item.usedAmount
|
||||
}))
|
||||
|
||||
const dept = departments.find(d => d.id === formData.departmentId)
|
||||
|
||||
const saveData: Partial<BudgetRecord> = {
|
||||
id: currentRecord.value?.id,
|
||||
name: formData.name,
|
||||
period: formData.period,
|
||||
year: formData.year,
|
||||
month: formData.period === 'monthly' ? formData.month : undefined,
|
||||
quarter: formData.period === 'quarterly' ? formData.quarter : undefined,
|
||||
departmentId: formData.departmentId,
|
||||
departmentName: dept?.name,
|
||||
items,
|
||||
totalBudget: calculatedTotal.value,
|
||||
usedAmount: calculatedUsed.value,
|
||||
remainingAmount: calculatedTotal.value - calculatedUsed.value,
|
||||
usageRate: calculatedTotal.value > 0 ? Math.round((calculatedUsed.value / calculatedTotal.value) * 100) : 0,
|
||||
remark: formData.remark || undefined,
|
||||
createdBy: currentRecord.value?.createdBy || '管理员'
|
||||
}
|
||||
|
||||
await mockSaveBudget(saveData)
|
||||
message.success(currentRecord.value?.id ? '编辑成功' : '新增成功')
|
||||
formVisible.value = false
|
||||
loadData()
|
||||
} catch (error) {
|
||||
message.error('操作失败')
|
||||
} finally {
|
||||
formLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
})
|
||||
@@ -671,41 +374,4 @@ onMounted(() => {
|
||||
margin-top: 24px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* 表单项 */
|
||||
.budget-items {
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.budget-item-header {
|
||||
font-weight: 500;
|
||||
color: #666;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.budget-item-row {
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px dashed #f0f0f0;
|
||||
}
|
||||
|
||||
.used-amount {
|
||||
color: #8c8c8c;
|
||||
}
|
||||
|
||||
.budget-total-row {
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
.total-budget { color: #1890ff; font-size: 16px; }
|
||||
.total-used { color: #faad14; font-size: 16px; }
|
||||
|
||||
/* 文本样式 */
|
||||
.money-primary { color: #1890ff; font-weight: 600; }
|
||||
.text-success { color: #52c41a; }
|
||||
.text-warning { color: #faad14; }
|
||||
.text-danger { color: #ff4d4f; }
|
||||
</style>
|
||||
|
||||
@@ -1,302 +0,0 @@
|
||||
<template>
|
||||
<div class="city-manage">
|
||||
<a-page-header title="城市管理" sub-title="管理系统支持的城市及区域数据" />
|
||||
|
||||
<a-card :bordered="false" class="main-card">
|
||||
<!-- 搜索栏 -->
|
||||
<div class="table-page-search-wrapper">
|
||||
<a-form layout="inline">
|
||||
<a-row :gutter="48">
|
||||
<a-col :md="8" :sm="24">
|
||||
<a-form-item label="城市名称">
|
||||
<a-input v-model:value="queryParam.name" placeholder="请输入城市名称" allow-clear />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :md="8" :sm="24">
|
||||
<a-form-item label="城市编码">
|
||||
<a-input v-model:value="queryParam.code" placeholder="请输入城市编码" allow-clear />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :md="8" :sm="24">
|
||||
<a-space>
|
||||
<a-button type="primary" @click="handleQuery">查询</a-button>
|
||||
<a-button @click="resetQuery">重置</a-button>
|
||||
</a-space>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-form>
|
||||
</div>
|
||||
|
||||
<!-- 操作栏 -->
|
||||
<div class="table-operator">
|
||||
<a-button type="primary" @click="handleAdd">
|
||||
<PlusOutlined /> 新建
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<!-- 数据列表 -->
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="dataList"
|
||||
:loading="loading"
|
||||
:pagination="pagination"
|
||||
row-key="id"
|
||||
@change="handleTableChange"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'level'">
|
||||
<a-tag :color="getLevelColor(record.level)">
|
||||
{{ getLevelText(record.level) }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-if="column.key === 'status'">
|
||||
<a-badge
|
||||
:status="record.status === 'normal' ? 'success' : 'error'"
|
||||
:text="record.status === 'normal' ? '正常' : '停用'"
|
||||
/>
|
||||
</template>
|
||||
<template v-if="column.key === 'action'">
|
||||
<a-space>
|
||||
<a-button type="link" size="small" @click="handleEdit(record)">编辑</a-button>
|
||||
<a-popconfirm title="确定删除该城市吗?" @confirm="handleDelete(record.id)">
|
||||
<a-button type="link" size="small" danger>删除</a-button>
|
||||
</a-popconfirm>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
|
||||
<!-- 新建/编辑弹窗 -->
|
||||
<a-modal
|
||||
v-model:open="modalVisible"
|
||||
:title="modalTitle"
|
||||
@ok="handleModalOk"
|
||||
:confirm-loading="modalLoading"
|
||||
>
|
||||
<a-form
|
||||
:model="formState"
|
||||
:rules="rules"
|
||||
ref="formRef"
|
||||
layout="vertical"
|
||||
>
|
||||
<a-form-item label="城市名称" name="name">
|
||||
<a-input v-model:value="formState.name" placeholder="如:杭州市" />
|
||||
</a-form-item>
|
||||
<a-form-item label="城市编码" name="code">
|
||||
<a-input v-model:value="formState.code" placeholder="如:330100" />
|
||||
</a-form-item>
|
||||
<a-form-item label="行政级别" name="level">
|
||||
<a-select v-model:value="formState.level">
|
||||
<a-select-option value="province">省份/直辖市</a-select-option>
|
||||
<a-select-option value="city">城市</a-select-option>
|
||||
<a-select-option value="district">区县</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="显示顺序" name="sort">
|
||||
<a-input-number v-model:value="formState.sort" :min="0" style="width: 100%" />
|
||||
</a-form-item>
|
||||
<a-form-item label="状态" name="status">
|
||||
<a-radio-group v-model:value="formState.status">
|
||||
<a-radio value="normal">正常</a-radio>
|
||||
<a-radio value="disabled">停用</a-radio>
|
||||
</a-radio-group>
|
||||
</a-form-item>
|
||||
<a-form-item label="备注" name="remark">
|
||||
<a-textarea v-model:value="formState.remark" :rows="3" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { PlusOutlined } from '@ant-design/icons-vue'
|
||||
import type { FormInstance } from 'ant-design-vue'
|
||||
|
||||
interface CityItem {
|
||||
id: number
|
||||
code: string
|
||||
name: string
|
||||
level: 'province' | 'city' | 'district'
|
||||
sort: number
|
||||
status: 'normal' | 'disabled'
|
||||
remark?: string
|
||||
createdAt?: string
|
||||
}
|
||||
|
||||
// 查询参数
|
||||
const queryParam = reactive({
|
||||
name: '',
|
||||
code: ''
|
||||
})
|
||||
|
||||
const loading = ref(false)
|
||||
const dataList = ref<CityItem[]>([])
|
||||
const pagination = reactive({
|
||||
current: 1,
|
||||
pageSize: 10,
|
||||
total: 0,
|
||||
showTotal: (total: number) => `共 ${total} 条`
|
||||
})
|
||||
|
||||
const columns = [
|
||||
{ title: '城市名称', dataIndex: 'name', key: 'name' },
|
||||
{ title: '城市编码', dataIndex: 'code', key: 'code' },
|
||||
{ title: '行政级别', dataIndex: 'level', key: 'level' },
|
||||
{ title: '排序', dataIndex: 'sort', key: 'sort', sorter: true },
|
||||
{ title: '状态', dataIndex: 'status', key: 'status' },
|
||||
{ title: '备注', dataIndex: 'remark', key: 'remark', ellipsis: true },
|
||||
{ title: '创建时间', dataIndex: 'createdAt', key: 'createdAt' },
|
||||
{ title: '操作', key: 'action', width: 150 }
|
||||
]
|
||||
|
||||
// Modal相关
|
||||
const modalVisible = ref(false)
|
||||
const modalLoading = ref(false)
|
||||
const isEdit = ref(false)
|
||||
const formRef = ref<FormInstance>()
|
||||
const formState = reactive<Omit<CityItem, 'id' | 'createdAt'> & { id?: number }>({
|
||||
name: '',
|
||||
code: '',
|
||||
level: 'city',
|
||||
sort: 0,
|
||||
status: 'normal',
|
||||
remark: ''
|
||||
})
|
||||
|
||||
const rules: Record<string, any> = {
|
||||
name: [{ required: true, message: '请输入城市名称', trigger: 'blur' }],
|
||||
code: [{ required: true, message: '请输入城市编码', trigger: 'blur' }],
|
||||
level: [{ required: true, message: '请选择行政级别', trigger: 'change' }],
|
||||
sort: [{ required: true, message: '请输入显示顺序', trigger: 'change' }]
|
||||
}
|
||||
|
||||
const modalTitle = computed(() => isEdit.value ? '编辑城市' : '新建城市')
|
||||
|
||||
// Helper
|
||||
function getLevelText(level: string) {
|
||||
const map: Record<string, string> = {
|
||||
province: '省份/直辖市',
|
||||
city: '城市',
|
||||
district: '区县'
|
||||
}
|
||||
return map[level] || level
|
||||
}
|
||||
|
||||
function getLevelColor(level: string) {
|
||||
const map: Record<string, string> = {
|
||||
province: 'blue',
|
||||
city: 'green',
|
||||
district: 'orange'
|
||||
}
|
||||
return map[level] || 'default'
|
||||
}
|
||||
|
||||
// Mock Data
|
||||
const mockCities: CityItem[] = [
|
||||
{ id: 1, name: '北京市', code: '110000', level: 'province', sort: 1, status: 'normal', remark: '直辖市', createdAt: '2024-01-01' },
|
||||
{ id: 2, name: '上海市', code: '310000', level: 'province', sort: 2, status: 'normal', remark: '直辖市', createdAt: '2024-01-01' },
|
||||
{ id: 3, name: '杭州市', code: '330100', level: 'city', sort: 3, status: 'normal', remark: '浙江省省会', createdAt: '2024-01-02' },
|
||||
{ id: 4, name: '西湖区', code: '330106', level: 'district', sort: 4, status: 'normal', remark: '杭州市辖区', createdAt: '2024-01-03' },
|
||||
{ id: 5, name: '深圳市', code: '440300', level: 'city', sort: 5, status: 'normal', remark: '广东省辖市', createdAt: '2024-01-02' }
|
||||
]
|
||||
|
||||
// Methods
|
||||
function loadData() {
|
||||
loading.value = true
|
||||
setTimeout(() => {
|
||||
let list = [...mockCities]
|
||||
if (queryParam.name) {
|
||||
list = list.filter(item => item.name.includes(queryParam.name))
|
||||
}
|
||||
if (queryParam.code) {
|
||||
list = list.filter(item => item.code.includes(queryParam.code))
|
||||
}
|
||||
dataList.value = list
|
||||
pagination.total = list.length
|
||||
loading.value = false
|
||||
}, 300)
|
||||
}
|
||||
|
||||
function handleQuery() {
|
||||
pagination.current = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
function resetQuery() {
|
||||
queryParam.name = ''
|
||||
queryParam.code = ''
|
||||
handleQuery()
|
||||
}
|
||||
|
||||
function handleTableChange(pag: any) {
|
||||
pagination.current = pag.current
|
||||
pagination.pageSize = pag.pageSize
|
||||
loadData()
|
||||
}
|
||||
|
||||
function handleAdd() {
|
||||
isEdit.value = false
|
||||
formState.id = undefined
|
||||
formState.name = ''
|
||||
formState.code = ''
|
||||
formState.level = 'city'
|
||||
formState.sort = 0
|
||||
formState.status = 'normal'
|
||||
formState.remark = ''
|
||||
modalVisible.value = true
|
||||
}
|
||||
|
||||
function handleEdit(record: any) {
|
||||
isEdit.value = true
|
||||
Object.assign(formState, JSON.parse(JSON.stringify(record)))
|
||||
modalVisible.value = true
|
||||
}
|
||||
|
||||
async function handleDelete(id: number) {
|
||||
if (id) {
|
||||
message.success('删除成功')
|
||||
loadData()
|
||||
}
|
||||
}
|
||||
|
||||
async function handleModalOk() {
|
||||
try {
|
||||
await formRef.value?.validate()
|
||||
modalLoading.value = true
|
||||
setTimeout(() => {
|
||||
message.success(isEdit.value ? '修改成功' : '新建成功')
|
||||
modalVisible.value = false
|
||||
modalLoading.value = false
|
||||
loadData()
|
||||
}, 500)
|
||||
} catch (error) {
|
||||
// validation failed
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.city-manage {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.main-card {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.table-page-search-wrapper {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.table-operator {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,498 +0,0 @@
|
||||
<template>
|
||||
<div class="dict-manage">
|
||||
<div class="dict-types-panel">
|
||||
<a-card title="字典类型" class="full-height-card" :bordered="false">
|
||||
<template #extra>
|
||||
<a-button type="primary" size="small" @click="openTypeModal()">
|
||||
<PlusOutlined /> 新增
|
||||
</a-button>
|
||||
</template>
|
||||
|
||||
<div class="search-box">
|
||||
<a-input-search
|
||||
v-model:value="typeSearchKeyword"
|
||||
placeholder="搜索类型名称/编码"
|
||||
size="small"
|
||||
@search="handleTypeSearch"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<a-list
|
||||
:data-source="filteredTypes"
|
||||
:loading="loadingTypes"
|
||||
item-layout="horizontal"
|
||||
size="small"
|
||||
class="type-list"
|
||||
>
|
||||
<template #renderItem="{ item }">
|
||||
<a-list-item
|
||||
class="type-item"
|
||||
:class="{ active: currentType?.id === item.id }"
|
||||
@click="handleSelectType(item)"
|
||||
>
|
||||
<a-list-item-meta>
|
||||
<template #title>
|
||||
<span class="type-name">{{ item.name }}</span>
|
||||
</template>
|
||||
<template #description>
|
||||
<div class="type-code">
|
||||
<span>{{ item.code }}</span>
|
||||
<a-tag v-if="!item.status" color="error" size="small">停用</a-tag>
|
||||
</div>
|
||||
</template>
|
||||
</a-list-item-meta>
|
||||
<template #actions>
|
||||
<a-dropdown>
|
||||
<a-button type="text" size="small" @click.stop>
|
||||
<MoreOutlined />
|
||||
</a-button>
|
||||
<template #overlay>
|
||||
<a-menu>
|
||||
<a-menu-item @click="openTypeModal(item)">编辑</a-menu-item>
|
||||
<a-menu-item danger @click="handleDeleteType(item)">删除</a-menu-item>
|
||||
</a-menu>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
</template>
|
||||
</a-list-item>
|
||||
</template>
|
||||
</a-list>
|
||||
</a-card>
|
||||
</div>
|
||||
|
||||
<div class="dict-data-panel">
|
||||
<a-card class="full-height-card" :bordered="false">
|
||||
<template #title>
|
||||
<div class="data-header" v-if="currentType">
|
||||
<span>{{ currentType.name }}</span>
|
||||
<span class="data-header-sub">({{ currentType.code }})</span>
|
||||
</div>
|
||||
<div v-else class="data-header">字典数据</div>
|
||||
</template>
|
||||
<template #extra>
|
||||
<a-space v-if="currentType">
|
||||
<a-button @click="loadDictData(currentType.id)">
|
||||
<ReloadOutlined />
|
||||
</a-button>
|
||||
<a-button type="primary" @click="openDataModal()">
|
||||
<PlusOutlined /> 新增数据
|
||||
</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
|
||||
<a-empty v-if="!currentType" description="请从左侧选择一个字典类型" class="empty-placeholder" />
|
||||
|
||||
<a-table
|
||||
v-else
|
||||
:columns="dataColumns"
|
||||
:data-source="dictDataList"
|
||||
:loading="loadingData"
|
||||
row-key="id"
|
||||
:pagination="false"
|
||||
size="middle"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'tag'">
|
||||
<a-tag :color="record.listClass || 'default'">{{ record.label }}</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'status'">
|
||||
<a-switch
|
||||
:checked="record.status === 'normal'"
|
||||
checked-children="正常"
|
||||
un-checked-children="停用"
|
||||
size="small"
|
||||
@change="(checked) => handleStatusChange(record, !!checked)"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'action'">
|
||||
<a-space>
|
||||
<a-button type="link" size="small" @click="openDataModal(record)">编辑</a-button>
|
||||
<a-popconfirm title="确定删除该数据项?" @confirm="handleDeleteData(record.id)">
|
||||
<a-button type="link" size="small" danger>删除</a-button>
|
||||
</a-popconfirm>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
</div>
|
||||
|
||||
<!-- 类型弹窗 -->
|
||||
<a-modal
|
||||
v-model:open="typeModalVisible"
|
||||
:title="isEditType ? '编辑字典类型' : '新增字典类型'"
|
||||
@ok="handleTypeSubmit"
|
||||
>
|
||||
<a-form :model="typeForm" layout="vertical">
|
||||
<a-form-item label="类型名称" required>
|
||||
<a-input v-model:value="typeForm.name" placeholder="如:用户性别" />
|
||||
</a-form-item>
|
||||
<a-form-item label="类型编码" required>
|
||||
<a-input v-model:value="typeForm.code" placeholder="如:sys_user_sex" :disabled="isEditType" />
|
||||
</a-form-item>
|
||||
<a-form-item label="状态">
|
||||
<a-radio-group v-model:value="typeForm.status">
|
||||
<a-radio :value="true">正常</a-radio>
|
||||
<a-radio :value="false">停用</a-radio>
|
||||
</a-radio-group>
|
||||
</a-form-item>
|
||||
<a-form-item label="备注">
|
||||
<a-textarea v-model:value="typeForm.remark" :rows="3" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
|
||||
<!-- 数据弹窗 -->
|
||||
<a-modal
|
||||
v-model:open="dataModalVisible"
|
||||
:title="isEditData ? '编辑字典数据' : '新增字典数据'"
|
||||
@ok="handleDataSubmit"
|
||||
>
|
||||
<a-form :model="dataForm" layout="vertical">
|
||||
<a-form-item label="数据标签" required>
|
||||
<a-input v-model:value="dataForm.label" placeholder="如:男" />
|
||||
</a-form-item>
|
||||
<a-form-item label="数据键值" required>
|
||||
<a-input v-model:value="dataForm.value" placeholder="如:1" />
|
||||
</a-form-item>
|
||||
<a-form-item label="显示排序" required>
|
||||
<a-input-number v-model:value="dataForm.sort" :min="0" style="width: 100%" />
|
||||
</a-form-item>
|
||||
<a-form-item label="回显样式">
|
||||
<a-select v-model:value="dataForm.listClass">
|
||||
<a-select-option value="default">默认(Default)</a-select-option>
|
||||
<a-select-option value="processing">蓝色(Primary)</a-select-option>
|
||||
<a-select-option value="success">绿色(Success)</a-select-option>
|
||||
<a-select-option value="warning">橙色(Warning)</a-select-option>
|
||||
<a-select-option value="error">红色(Error)</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="状态">
|
||||
<a-radio-group v-model:value="dataForm.status">
|
||||
<a-radio value="normal">正常</a-radio>
|
||||
<a-radio value="disabled">停用</a-radio>
|
||||
</a-radio-group>
|
||||
</a-form-item>
|
||||
<a-form-item label="备注">
|
||||
<a-textarea v-model:value="dataForm.remark" :rows="2" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, reactive, onMounted } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { PlusOutlined, ReloadOutlined, MoreOutlined } from '@ant-design/icons-vue'
|
||||
|
||||
interface DictType {
|
||||
id: number
|
||||
name: string
|
||||
code: string
|
||||
status: boolean
|
||||
remark?: string
|
||||
}
|
||||
|
||||
interface DictData {
|
||||
id: number
|
||||
typeId: number
|
||||
label: string
|
||||
value: string
|
||||
sort: number
|
||||
status: 'normal' | 'disabled'
|
||||
listClass?: string
|
||||
remark?: string
|
||||
}
|
||||
|
||||
const loadingTypes = ref(false)
|
||||
const typeList = ref<DictType[]>([])
|
||||
const typeSearchKeyword = ref('')
|
||||
const currentType = ref<DictType | null>(null)
|
||||
|
||||
const loadingData = ref(false)
|
||||
const dictDataList = ref<DictData[]>([])
|
||||
|
||||
// Mock Data Initialization
|
||||
const mockTypes: DictType[] = [
|
||||
{ id: 1, name: '用户性别', code: 'sys_user_sex', status: true },
|
||||
{ id: 2, name: '菜单状态', code: 'sys_show_hide', status: true },
|
||||
{ id: 3, name: '系统开关', code: 'sys_normal_disable', status: true },
|
||||
{ id: 4, name: '通知类型', code: 'sys_notice_type', status: true },
|
||||
{ id: 5, name: '操作类型', code: 'sys_oper_type', status: true },
|
||||
]
|
||||
|
||||
const mockDataStore: Record<number, DictData[]> = {
|
||||
1: [
|
||||
{ id: 11, typeId: 1, label: '男', value: '0', sort: 1, status: 'normal', listClass: 'processing' },
|
||||
{ id: 12, typeId: 1, label: '女', value: '1', sort: 2, status: 'normal', listClass: 'error' },
|
||||
{ id: 13, typeId: 1, label: '未知', value: '2', sort: 3, status: 'normal', listClass: 'default' },
|
||||
],
|
||||
3: [
|
||||
{ id: 31, typeId: 3, label: '正常', value: '0', sort: 1, status: 'normal', listClass: 'success' },
|
||||
{ id: 32, typeId: 3, label: '停用', value: '1', sort: 2, status: 'normal', listClass: 'error' },
|
||||
]
|
||||
}
|
||||
|
||||
// Logic
|
||||
const filteredTypes = computed(() => {
|
||||
if (!typeSearchKeyword.value) return typeList.value
|
||||
const kw = typeSearchKeyword.value.toLowerCase()
|
||||
return typeList.value.filter(t => t.name.toLowerCase().includes(kw) || t.code.toLowerCase().includes(kw))
|
||||
})
|
||||
|
||||
const dataColumns = [
|
||||
{ title: '字典标签', key: 'tag', width: '20%' },
|
||||
{ title: '字典键值', dataIndex: 'value', key: 'value', width: '20%' },
|
||||
{ title: '排序', dataIndex: 'sort', key: 'sort', sorter: (a: DictData, b: DictData) => a.sort - b.sort, width: '15%' },
|
||||
{ title: '状态', key: 'status', width: '15%' },
|
||||
{ title: '备注', dataIndex: 'remark', key: 'remark' },
|
||||
{ title: '操作', key: 'action', width: '150px' }
|
||||
]
|
||||
|
||||
// Modal Logic - Type
|
||||
const typeModalVisible = ref(false)
|
||||
const isEditType = ref(false)
|
||||
const typeForm = reactive({
|
||||
id: 0,
|
||||
name: '',
|
||||
code: '',
|
||||
status: true,
|
||||
remark: ''
|
||||
})
|
||||
|
||||
function openTypeModal(item?: DictType) {
|
||||
if (item) {
|
||||
isEditType.value = true
|
||||
Object.assign(typeForm, item)
|
||||
} else {
|
||||
isEditType.value = false
|
||||
Object.assign(typeForm, { id: 0, name: '', code: '', status: true, remark: '' })
|
||||
}
|
||||
typeModalVisible.value = true
|
||||
}
|
||||
|
||||
function handleTypeSubmit() {
|
||||
if (!typeForm.name || !typeForm.code) {
|
||||
message.warning('请填写完整信息')
|
||||
return
|
||||
}
|
||||
|
||||
if (isEditType.value) {
|
||||
const idx = typeList.value.findIndex(t => t.id === typeForm.id)
|
||||
if (idx > -1) {
|
||||
typeList.value[idx] = { ...typeForm }
|
||||
message.success('修改成功')
|
||||
}
|
||||
} else {
|
||||
const newId = Math.max(...typeList.value.map(t => t.id), 0) + 1
|
||||
const newType: DictType = { ...typeForm, id: newId }
|
||||
typeList.value.unshift(newType)
|
||||
message.success('新增成功')
|
||||
handleSelectType(newType)
|
||||
}
|
||||
typeModalVisible.value = false
|
||||
}
|
||||
|
||||
function handleDeleteType(item: DictType) {
|
||||
typeList.value = typeList.value.filter(t => t.id !== item.id)
|
||||
if (currentType.value?.id === item.id) {
|
||||
currentType.value = null
|
||||
dictDataList.value = []
|
||||
}
|
||||
message.success('删除成功')
|
||||
}
|
||||
|
||||
// Modal Logic - Data
|
||||
const dataModalVisible = ref(false)
|
||||
const isEditData = ref(false)
|
||||
const dataForm = reactive({
|
||||
id: 0,
|
||||
label: '',
|
||||
value: '',
|
||||
sort: 0,
|
||||
status: 'normal' as 'normal' | 'disabled',
|
||||
listClass: 'default',
|
||||
remark: ''
|
||||
})
|
||||
|
||||
function openDataModal(item?: any) {
|
||||
if (!currentType.value) {
|
||||
message.warning('请先选择字典类型')
|
||||
return
|
||||
}
|
||||
|
||||
if (item) {
|
||||
isEditData.value = true
|
||||
Object.assign(dataForm, item)
|
||||
} else {
|
||||
isEditData.value = false
|
||||
Object.assign(dataForm, {
|
||||
id: 0,
|
||||
label: '',
|
||||
value: '',
|
||||
sort: 0,
|
||||
status: 'normal',
|
||||
listClass: 'default',
|
||||
remark: ''
|
||||
})
|
||||
}
|
||||
dataModalVisible.value = true
|
||||
}
|
||||
|
||||
function handleDataSubmit() {
|
||||
if (!dataForm.label || !dataForm.value) {
|
||||
message.warning('请填写完整信息')
|
||||
return
|
||||
}
|
||||
|
||||
if (isEditData.value) {
|
||||
const idx = dictDataList.value.findIndex(d => d.id === dataForm.id)
|
||||
if (idx > -1) {
|
||||
dictDataList.value[idx] = { ...dataForm, typeId: currentType.value!.id }
|
||||
message.success('修改成功')
|
||||
}
|
||||
} else {
|
||||
const newId = Date.now()
|
||||
dictDataList.value.push({
|
||||
...dataForm,
|
||||
id: newId,
|
||||
typeId: currentType.value!.id
|
||||
})
|
||||
message.success('新增成功')
|
||||
}
|
||||
dataModalVisible.value = false
|
||||
}
|
||||
|
||||
function handleDeleteData(id: number) {
|
||||
dictDataList.value = dictDataList.value.filter(d => d.id !== id)
|
||||
message.success('删除成功')
|
||||
}
|
||||
|
||||
function handleStatusChange(record: any, checked: boolean) {
|
||||
record.status = checked ? 'normal' : 'disabled'
|
||||
message.success('状态已更新')
|
||||
}
|
||||
|
||||
// Data Loading
|
||||
function loadTypes() {
|
||||
loadingTypes.value = true
|
||||
setTimeout(() => {
|
||||
typeList.value = [...mockTypes]
|
||||
loadingTypes.value = false
|
||||
}, 300)
|
||||
}
|
||||
|
||||
function handleSelectType(type: DictType) {
|
||||
currentType.value = type
|
||||
loadDictData(type.id)
|
||||
}
|
||||
|
||||
function loadDictData(typeId: number) {
|
||||
loadingData.value = true
|
||||
// In real app, fetch by code or id
|
||||
setTimeout(() => {
|
||||
dictDataList.value = mockDataStore[typeId] ? [...mockDataStore[typeId]] : []
|
||||
loadingData.value = false
|
||||
}, 300)
|
||||
}
|
||||
|
||||
function handleTypeSearch() {
|
||||
// auto-handled by computed
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadTypes()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dict-manage {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
background-color: #f0f2f5;
|
||||
}
|
||||
|
||||
.dict-types-panel {
|
||||
width: 300px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.dict-data-panel {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.full-height-card {
|
||||
height: 100%;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
:deep(.ant-card-body) {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
padding: 0 4px 12px 4px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.type-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.type-item {
|
||||
padding: 10px 12px;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.type-item:hover {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.type-item.active {
|
||||
background-color: #e6f7ff;
|
||||
}
|
||||
|
||||
.type-name {
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.type-code {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.data-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.data-header-sub {
|
||||
font-size: 13px;
|
||||
color: #999;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.empty-placeholder {
|
||||
margin-top: 100px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,82 +0,0 @@
|
||||
<template>
|
||||
<div class="settings-page">
|
||||
<a-page-header title="系统设置" sub-title="配置系统全局参数" />
|
||||
|
||||
<div class="settings-container">
|
||||
<a-card title="签到奖励设置" class="setting-card">
|
||||
<a-form :model="signInConfig" layout="vertical">
|
||||
<a-form-item label="每日签到奖励 (Code币)" help="用户每日签到固定获取的奖励数量">
|
||||
<a-input-number v-model:value="signInConfig.dailyReward" :min="1" :max="1000" style="width: 200px">
|
||||
<template #addonAfter>币</template>
|
||||
</a-input-number>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="补签消耗 (Code币)" help="用户补签因错过签到日期的消耗,0表示不可补签">
|
||||
<a-input-number v-model:value="signInConfig.makeupCost" :min="0" :max="1000" style="width: 200px">
|
||||
<template #addonAfter>币</template>
|
||||
</a-input-number>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="允许补签天数" help="允许补签过去多少天内的记录">
|
||||
<a-input-number v-model:value="signInConfig.makeupDays" :min="0" :max="30" style="width: 200px">
|
||||
<template #addonAfter>天</template>
|
||||
</a-input-number>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item>
|
||||
<a-button type="primary" :loading="saving" @click="handleSaveSignInConfig">
|
||||
保存配置
|
||||
</a-button>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-card>
|
||||
|
||||
<!-- 可以在此添加其他设置卡片 -->
|
||||
<a-card title="其他设置" class="setting-card">
|
||||
<a-empty description="暂无其他设置项" image="simple" />
|
||||
</a-card>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
|
||||
const saving = ref(false)
|
||||
|
||||
const signInConfig = reactive({
|
||||
dailyReward: 10,
|
||||
makeupCost: 5,
|
||||
makeupDays: 7
|
||||
})
|
||||
|
||||
async function handleSaveSignInConfig() {
|
||||
saving.value = true
|
||||
// 模拟保存接口调用
|
||||
await new Promise(resolve => setTimeout(resolve, 800))
|
||||
saving.value = false
|
||||
message.success('签到配置已保存')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.settings-page {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.settings-container {
|
||||
padding: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
max-width: 800px;
|
||||
}
|
||||
|
||||
.setting-card {
|
||||
border-radius: 8px;
|
||||
}
|
||||
</style>
|
||||
|
||||
205
src/views/system/dict/index.vue
Normal file
205
src/views/system/dict/index.vue
Normal file
@@ -0,0 +1,205 @@
|
||||
<template>
|
||||
<div class="dict-container">
|
||||
<a-page-header title="字典管理" sub-title="管理系统数据字典">
|
||||
<template #extra>
|
||||
<a-button type="primary" @click="handleAdd">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
新增字典
|
||||
</a-button>
|
||||
</template>
|
||||
</a-page-header>
|
||||
|
||||
<a-card>
|
||||
<!-- 搜索区域 -->
|
||||
<a-form layout="inline" class="search-form">
|
||||
<a-form-item label="字典名称">
|
||||
<a-input v-model:value="searchForm.name" placeholder="请输入字典名称" allow-clear />
|
||||
</a-form-item>
|
||||
<a-form-item label="字典编码">
|
||||
<a-input v-model:value="searchForm.code" placeholder="请输入字典编码" allow-clear />
|
||||
</a-form-item>
|
||||
<a-form-item>
|
||||
<a-space>
|
||||
<a-button type="primary" @click="handleSearch">
|
||||
<template #icon><SearchOutlined /></template>
|
||||
查询
|
||||
</a-button>
|
||||
<a-button @click="handleReset">
|
||||
<template #icon><ReloadOutlined /></template>
|
||||
重置
|
||||
</a-button>
|
||||
</a-space>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
|
||||
<!-- 表格 -->
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="tableData"
|
||||
:loading="loading"
|
||||
:pagination="pagination"
|
||||
row-key="id"
|
||||
@change="handleTableChange"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.dataIndex === 'status'">
|
||||
<a-tag :color="record.status === 1 ? 'green' : 'red'">
|
||||
{{ record.status === 1 ? '正常' : '禁用' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-if="column.dataIndex === 'action'">
|
||||
<a-space>
|
||||
<a-button type="link" size="small" @click="handleEdit(record)">编辑</a-button>
|
||||
<a-button type="link" size="small" @click="handleViewItems(record)">字典项</a-button>
|
||||
<a-popconfirm
|
||||
title="确定删除此字典吗?"
|
||||
@confirm="handleDelete(record.id)"
|
||||
>
|
||||
<a-button type="link" size="small" danger>删除</a-button>
|
||||
</a-popconfirm>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
|
||||
<!-- 新增/编辑字典弹窗 -->
|
||||
<DictFormModal
|
||||
v-model:visible="modalVisible"
|
||||
:edit-data="editData"
|
||||
@success="loadData"
|
||||
/>
|
||||
|
||||
<!-- 字典项管理抽屉 -->
|
||||
<DictItemDrawer
|
||||
v-model:visible="itemDrawerVisible"
|
||||
:dict-data="currentDict"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { PlusOutlined, SearchOutlined, ReloadOutlined } from '@ant-design/icons-vue'
|
||||
import DictFormModal from '@/components/system/dict/DictFormModal.vue'
|
||||
import DictItemDrawer from '@/components/system/dict/DictItemDrawer.vue'
|
||||
|
||||
// 类型定义
|
||||
interface DictRecord {
|
||||
id: number
|
||||
name: string
|
||||
code: string
|
||||
remark?: string
|
||||
status: number
|
||||
createdAt?: string
|
||||
}
|
||||
|
||||
// 搜索表单
|
||||
const searchForm = reactive({
|
||||
name: '',
|
||||
code: ''
|
||||
})
|
||||
|
||||
// 表格
|
||||
const loading = ref(false)
|
||||
const tableData = ref<DictRecord[]>([])
|
||||
const pagination = reactive({
|
||||
current: 1,
|
||||
pageSize: 10,
|
||||
total: 0,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
showTotal: (total: number) => `共 ${total} 条`
|
||||
})
|
||||
|
||||
const columns = [
|
||||
{ title: '字典名称', dataIndex: 'name', width: 150 },
|
||||
{ title: '字典编码', dataIndex: 'code', width: 150 },
|
||||
{ title: '备注', dataIndex: 'remark', ellipsis: true },
|
||||
{ title: '状态', dataIndex: 'status', width: 80 },
|
||||
{ title: '创建时间', dataIndex: 'createdAt', width: 170 },
|
||||
{ title: '操作', dataIndex: 'action', width: 160, fixed: 'right' }
|
||||
]
|
||||
|
||||
// 字典弹窗
|
||||
const modalVisible = ref(false)
|
||||
const editData = ref<DictRecord | null>(null)
|
||||
|
||||
// 字典项抽屉
|
||||
const itemDrawerVisible = ref(false)
|
||||
const currentDict = ref<DictRecord | null>(null)
|
||||
|
||||
// 加载字典数据
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
// TODO: 接入真实API
|
||||
// const res = await getDictList({ ...searchForm, page: pagination.current, pageSize: pagination.pageSize })
|
||||
// tableData.value = res.data.data.records
|
||||
// pagination.total = res.data.data.total
|
||||
|
||||
// 模拟数据
|
||||
tableData.value = [
|
||||
{ id: 1, name: '性别', code: 'gender', remark: '用户性别', status: 1, createdAt: '2026-01-08 10:00:00' },
|
||||
{ id: 2, name: '状态', code: 'status', remark: '通用状态', status: 1, createdAt: '2026-01-08 10:00:00' },
|
||||
{ id: 3, name: '审批状态', code: 'approval_status', remark: '审批流程状态', status: 1, createdAt: '2026-01-08 10:00:00' }
|
||||
]
|
||||
pagination.total = 3
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
pagination.current = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
searchForm.name = ''
|
||||
searchForm.code = ''
|
||||
handleSearch()
|
||||
}
|
||||
|
||||
function handleTableChange(pag: any) {
|
||||
pagination.current = pag.current
|
||||
pagination.pageSize = pag.pageSize
|
||||
loadData()
|
||||
}
|
||||
|
||||
function handleAdd() {
|
||||
editData.value = null
|
||||
modalVisible.value = true
|
||||
}
|
||||
|
||||
function handleEdit(record: DictRecord) {
|
||||
editData.value = record
|
||||
modalVisible.value = true
|
||||
}
|
||||
|
||||
function handleViewItems(record: DictRecord) {
|
||||
currentDict.value = record
|
||||
itemDrawerVisible.value = true
|
||||
}
|
||||
|
||||
async function handleDelete(id: number) {
|
||||
// TODO: 接入真实API
|
||||
// await deleteDict(id)
|
||||
message.success('删除成功')
|
||||
loadData()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dict-container {
|
||||
padding: 0;
|
||||
}
|
||||
.search-form {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
</style>
|
||||
166
src/views/system/menus/index.vue
Normal file
166
src/views/system/menus/index.vue
Normal file
@@ -0,0 +1,166 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<!-- 操作栏 -->
|
||||
<a-card class="table-card" :bordered="false">
|
||||
<template #title>
|
||||
<a-button type="primary" @click="handleAdd()">
|
||||
<PlusOutlined /> 新增菜单
|
||||
</a-button>
|
||||
</template>
|
||||
|
||||
<!-- 表格(树形) -->
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="treeData"
|
||||
:loading="loading"
|
||||
:pagination="false"
|
||||
row-key="id"
|
||||
:default-expand-all-rows="true"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'icon'">
|
||||
<span v-if="record.icon" class="menu-icon"><component :is="record.icon" /> {{ record.icon }}</span>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'type'">
|
||||
<a-tag :color="getTypeColor(record.type)">{{ getTypeName(record.type) }}</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'status'">
|
||||
<a-tag :color="record.status === 1 ? 'green' : 'red'">
|
||||
{{ record.status === 1 ? '正常' : '禁用' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'hidden'">
|
||||
<a-tag :color="record.hidden === 1 ? 'orange' : 'default'">
|
||||
{{ record.hidden === 1 ? '隐藏' : '显示' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'action'">
|
||||
<a-space>
|
||||
<a-button type="link" size="small" @click="handleAdd(record.id)">添加子菜单</a-button>
|
||||
<a-button type="link" size="small" @click="handleEdit(record as MenuRecord)">编辑</a-button>
|
||||
<a-popconfirm title="确定删除该菜单吗?" @confirm="handleDelete(record.id)">
|
||||
<a-button type="link" size="small" danger>删除</a-button>
|
||||
</a-popconfirm>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
|
||||
<!-- 新增/编辑弹窗 -->
|
||||
<MenuFormModal
|
||||
v-model:visible="modalVisible"
|
||||
:record="currentRecord"
|
||||
:menu-tree="treeData"
|
||||
@success="loadData"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { PlusOutlined } from '@ant-design/icons-vue'
|
||||
import type { MenuRecord, MenuFormData } from '@/types/system/menu'
|
||||
import { getMenuList, deleteMenu } from '@/api/system/menu'
|
||||
import MenuFormModal from '@/components/system/menu/MenuFormModal.vue'
|
||||
import { buildMenuTree } from '@/utils/route'
|
||||
// Note: buildMenuTree expects SysMenu, MenuRecord extends SysMenu so it's fine.
|
||||
// Using 'any' cast if strict checking fails due to optional properties.
|
||||
|
||||
const loading = ref(false)
|
||||
const treeData = ref<MenuRecord[]>([])
|
||||
|
||||
// 表格列定义
|
||||
const columns = [
|
||||
{ title: '菜单名称', dataIndex: 'name', key: 'name', width: 200 },
|
||||
{ title: '编码', dataIndex: 'code', key: 'code', width: 150 },
|
||||
{ title: '图标', dataIndex: 'icon', key: 'icon', width: 120 },
|
||||
{ title: '类型', dataIndex: 'type', key: 'type', width: 100 },
|
||||
{ title: '路径', dataIndex: 'path', key: 'path' },
|
||||
{ title: '排序', dataIndex: 'sort', key: 'sort', width: 80 },
|
||||
{ title: '状态', dataIndex: 'status', key: 'status', width: 80 },
|
||||
{ title: '显示', dataIndex: 'hidden', key: 'hidden', width: 80 },
|
||||
{ title: '操作', key: 'action', width: 220, fixed: 'right' as const }
|
||||
]
|
||||
|
||||
// 弹窗相关
|
||||
const modalVisible = ref(false)
|
||||
const currentRecord = ref<MenuFormData | undefined>(undefined)
|
||||
|
||||
function getTypeColor(type: string): string {
|
||||
const colorMap: Record<string, string> = {
|
||||
directory: 'blue',
|
||||
menu: 'green',
|
||||
button: 'orange'
|
||||
}
|
||||
return colorMap[type] || 'default'
|
||||
}
|
||||
|
||||
function getTypeName(type: string): string {
|
||||
const nameMap: Record<string, string> = {
|
||||
directory: '目录',
|
||||
menu: '菜单',
|
||||
button: '按钮'
|
||||
}
|
||||
return nameMap[type] || type
|
||||
}
|
||||
|
||||
// 加载数据
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getMenuList()
|
||||
// res.data.data is the array
|
||||
const list = res.data.data || []
|
||||
// Cast to any because buildMenuTree typings might be strict about SysMenu properties
|
||||
treeData.value = buildMenuTree(list as any[]) as unknown as MenuRecord[]
|
||||
} catch (error) {
|
||||
console.error('加载数据失败:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleAdd(parentId?: number) {
|
||||
currentRecord.value = {
|
||||
parentId: parentId || 0
|
||||
} as MenuFormData
|
||||
modalVisible.value = true
|
||||
}
|
||||
|
||||
function handleEdit(record: MenuRecord) {
|
||||
currentRecord.value = { ...record }
|
||||
modalVisible.value = true
|
||||
}
|
||||
|
||||
async function handleDelete(id: number) {
|
||||
try {
|
||||
await deleteMenu(id)
|
||||
message.success('删除成功')
|
||||
loadData()
|
||||
} catch (error) {
|
||||
console.error('删除失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-container {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.table-card {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.menu-icon {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
</style>
|
||||
199
src/views/system/roles/index.vue
Normal file
199
src/views/system/roles/index.vue
Normal file
@@ -0,0 +1,199 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<!-- 操作栏 -->
|
||||
<a-card class="table-card" :bordered="false">
|
||||
<template #title>
|
||||
<a-button type="primary" @click="handleAdd">
|
||||
<PlusOutlined /> 新增角色
|
||||
</a-button>
|
||||
</template>
|
||||
|
||||
<!-- 表格 -->
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="tableData"
|
||||
:loading="loading"
|
||||
:pagination="false"
|
||||
row-key="id"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'status'">
|
||||
<a-tag :color="record.status === 1 ? 'green' : 'red'">
|
||||
{{ record.status === 1 ? '正常' : '禁用' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'action'">
|
||||
<a-space>
|
||||
<a-button type="link" size="small" @click="handleEdit(record as RoleRecord)">编辑</a-button>
|
||||
<a-button type="link" size="small" @click="handleAssignMenus(record as RoleRecord)">分配权限</a-button>
|
||||
<a-popconfirm
|
||||
title="确定删除该角色吗?"
|
||||
@confirm="handleDelete(record.id)"
|
||||
:disabled="['super_admin', 'admin'].includes(record.code)"
|
||||
>
|
||||
<a-button
|
||||
type="link"
|
||||
size="small"
|
||||
danger
|
||||
:disabled="['super_admin', 'admin'].includes(record.code)"
|
||||
>
|
||||
删除
|
||||
</a-button>
|
||||
</a-popconfirm>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
|
||||
<!-- 新增/编辑弹窗 -->
|
||||
<RoleFormModal
|
||||
v-model:visible="modalVisible"
|
||||
:record="currentRecord"
|
||||
@success="loadData"
|
||||
/>
|
||||
|
||||
<!-- 分配权限弹窗 -->
|
||||
<a-modal
|
||||
v-model:open="assignMenusVisible"
|
||||
title="分配菜单权限"
|
||||
:confirm-loading="assignMenusLoading"
|
||||
width="500px"
|
||||
@ok="handleAssignMenusOk"
|
||||
>
|
||||
<a-spin :spinning="menuTreeLoading">
|
||||
<a-tree
|
||||
v-model:checkedKeys="checkedMenuIds"
|
||||
:tree-data="menuTree"
|
||||
checkable
|
||||
:field-names="{ title: 'name', key: 'id', children: 'children' }"
|
||||
default-expand-all
|
||||
/>
|
||||
</a-spin>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { PlusOutlined } from '@ant-design/icons-vue'
|
||||
|
||||
import { buildMenuTree } from '@/utils/route'
|
||||
|
||||
import { getRoleList, getRolePage, deleteRole, getRoleMenuIds, assignRoleMenus } from '@/api/system/role'
|
||||
import { getMenuList } from '@/api/system/menu'
|
||||
import type { RoleRecord, RoleFormData } from '@/types/system/role'
|
||||
import type { SysMenu } from '@/types/api/auth'
|
||||
|
||||
import RoleFormModal from '@/components/system/role/RoleFormModal.vue'
|
||||
|
||||
const loading = ref(false)
|
||||
const tableData = ref<RoleRecord[]>([])
|
||||
|
||||
// 表格列定义
|
||||
const columns = [
|
||||
{ title: '角色编码', dataIndex: 'code', key: 'code' },
|
||||
{ title: '角色名称', dataIndex: 'name', key: 'name' },
|
||||
{ title: '描述', dataIndex: 'description', key: 'description' },
|
||||
{ title: '排序', dataIndex: 'sort', key: 'sort', width: 80 },
|
||||
{ title: '状态', dataIndex: 'status', key: 'status', width: 100 },
|
||||
{ title: '创建时间', dataIndex: 'createdAt', key: 'createdAt', width: 180 },
|
||||
{ title: '操作', key: 'action', width: 220, fixed: 'right' as const }
|
||||
]
|
||||
|
||||
// 弹窗相关
|
||||
const modalVisible = ref(false)
|
||||
const currentRecord = ref<RoleFormData | undefined>(undefined)
|
||||
|
||||
// 分配权限相关
|
||||
const assignMenusVisible = ref(false)
|
||||
const assignMenusLoading = ref(false)
|
||||
const menuTreeLoading = ref(false)
|
||||
const menuTree = ref<any[]>([])
|
||||
const checkedMenuIds = ref<number[]>([])
|
||||
const currentRoleId = ref<number>()
|
||||
|
||||
// 加载数据
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getRolePage({ pageSize: 100 })
|
||||
tableData.value = res.data.data.records || []
|
||||
} catch (error) {
|
||||
console.error('加载数据失败:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleAdd() {
|
||||
currentRecord.value = undefined
|
||||
modalVisible.value = true
|
||||
}
|
||||
|
||||
function handleEdit(record: RoleRecord) {
|
||||
currentRecord.value = { ...record }
|
||||
modalVisible.value = true
|
||||
}
|
||||
|
||||
async function handleDelete(id: number) {
|
||||
try {
|
||||
await deleteRole(id)
|
||||
message.success('删除成功')
|
||||
loadData()
|
||||
} catch (error) {
|
||||
console.error('删除失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAssignMenus(record: RoleRecord) {
|
||||
currentRoleId.value = record.id
|
||||
checkedMenuIds.value = []
|
||||
assignMenusVisible.value = true
|
||||
menuTreeLoading.value = true
|
||||
|
||||
try {
|
||||
// 加载菜单树
|
||||
// 加载菜单树
|
||||
const menuRes = await getMenuList()
|
||||
menuTree.value = buildMenuTree(menuRes.data.data || [])
|
||||
|
||||
// 加载角色已有的菜单
|
||||
const roleMenuRes = await getRoleMenuIds(record.id)
|
||||
checkedMenuIds.value = roleMenuRes.data.data || []
|
||||
} catch (error) {
|
||||
console.error('加载数据失败:', error)
|
||||
} finally {
|
||||
menuTreeLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAssignMenusOk() {
|
||||
if (!currentRoleId.value) return
|
||||
assignMenusLoading.value = true
|
||||
try {
|
||||
await assignRoleMenus(currentRoleId.value, checkedMenuIds.value)
|
||||
message.success('权限分配成功')
|
||||
assignMenusVisible.value = false
|
||||
} catch (error) {
|
||||
console.error('权限分配失败:', error)
|
||||
} finally {
|
||||
assignMenusLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-container {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.table-card {
|
||||
background: #fff;
|
||||
}
|
||||
</style>
|
||||
277
src/views/system/users/index.vue
Normal file
277
src/views/system/users/index.vue
Normal file
@@ -0,0 +1,277 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<!-- 搜索栏 -->
|
||||
<a-card class="search-card" :bordered="false">
|
||||
<a-form layout="inline" :model="searchForm" @finish="handleSearch">
|
||||
<a-form-item label="关键词">
|
||||
<a-input v-model:value="searchForm.keyword" placeholder="用户名/昵称/手机号" allow-clear style="width: 200px" />
|
||||
</a-form-item>
|
||||
<a-form-item label="角色">
|
||||
<a-select v-model:value="searchForm.role" placeholder="请选择角色" allow-clear style="width: 150px">
|
||||
<a-select-option v-for="role in roleOptions" :key="role.code" :value="role.code">
|
||||
{{ role.name }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="状态">
|
||||
<a-select v-model:value="searchForm.status" placeholder="请选择状态" allow-clear style="width: 120px">
|
||||
<a-select-option :value="1">正常</a-select-option>
|
||||
<a-select-option :value="0">禁用</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item>
|
||||
<a-space>
|
||||
<a-button type="primary" html-type="submit">
|
||||
<SearchOutlined /> 查询
|
||||
</a-button>
|
||||
<a-button @click="handleReset">
|
||||
<ReloadOutlined /> 重置
|
||||
</a-button>
|
||||
</a-space>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-card>
|
||||
|
||||
<!-- 操作栏 -->
|
||||
<a-card class="table-card" :bordered="false">
|
||||
<template #title>
|
||||
<a-button type="primary" @click="handleAdd">
|
||||
<PlusOutlined /> 新增用户
|
||||
</a-button>
|
||||
</template>
|
||||
|
||||
<!-- 表格 -->
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="tableData"
|
||||
:loading="loading"
|
||||
:pagination="pagination"
|
||||
row-key="id"
|
||||
@change="handleTableChange"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'avatar'">
|
||||
<a-avatar :src="record.avatar" :size="40">
|
||||
<template #icon><UserOutlined /></template>
|
||||
</a-avatar>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'role'">
|
||||
<a-tag :color="getRoleColor(record.role)">{{ getRoleName(record.role) }}</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'status'">
|
||||
<a-switch
|
||||
:checked="record.status === 1"
|
||||
checked-children="正常"
|
||||
un-checked-children="禁用"
|
||||
@change="(checked: boolean) => handleStatusChange(record.id, checked ? 1 : 0)"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'action'">
|
||||
<a-space>
|
||||
<a-button type="link" size="small" @click="handleEdit(record)">编辑</a-button>
|
||||
<a-button type="link" size="small" @click="handleResetPassword(record)">重置密码</a-button>
|
||||
<a-popconfirm title="确定删除该用户吗?" @confirm="handleDelete(record.id)">
|
||||
<a-button type="link" size="small" danger>删除</a-button>
|
||||
</a-popconfirm>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
|
||||
<!-- 组件 -->
|
||||
<UserFormModal
|
||||
v-model:visible="modalVisible"
|
||||
:record="currentRecord"
|
||||
:role-options="roleOptions"
|
||||
@success="loadData"
|
||||
/>
|
||||
|
||||
<ResetPasswordModal
|
||||
v-model:visible="resetPasswordVisible"
|
||||
:user-id="currentUserId"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import {
|
||||
SearchOutlined,
|
||||
ReloadOutlined,
|
||||
PlusOutlined,
|
||||
UserOutlined
|
||||
} from '@ant-design/icons-vue'
|
||||
|
||||
import { getUserList, deleteUser, updateUserStatus } from '@/api/system/user'
|
||||
import { getRoleList } from '@/api/system/role'
|
||||
import type { UserRecord, UserFormData } from '@/types/system/user'
|
||||
import type { RoleRecord } from '@/types/system/role'
|
||||
|
||||
// 引入组件
|
||||
import UserFormModal from '@/components/system/user/UserFormModal.vue'
|
||||
import ResetPasswordModal from '@/components/system/user/ResetPasswordModal.vue'
|
||||
|
||||
const loading = ref(false)
|
||||
const tableData = ref<UserRecord[]>([])
|
||||
const roleOptions = ref<{ code: string; name: string }[]>([]) // Role List for display/select
|
||||
|
||||
// 搜索表单
|
||||
const searchForm = reactive({
|
||||
keyword: '',
|
||||
role: undefined as string | undefined,
|
||||
status: undefined as number | undefined
|
||||
})
|
||||
|
||||
// 分页
|
||||
const pagination = reactive({
|
||||
current: 1,
|
||||
pageSize: 10,
|
||||
total: 0,
|
||||
showSizeChanger: true,
|
||||
showTotal: (total: number) => `共 ${total} 条`
|
||||
})
|
||||
|
||||
// 表格列定义
|
||||
const columns = [
|
||||
{ title: '头像', dataIndex: 'avatar', key: 'avatar', width: 80 },
|
||||
{ title: '用户名', dataIndex: 'username', key: 'username' },
|
||||
{ title: '昵称', dataIndex: 'nickname', key: 'nickname' },
|
||||
{ title: '手机号', dataIndex: 'phone', key: 'phone' },
|
||||
{ title: '邮箱', dataIndex: 'email', key: 'email' },
|
||||
{ title: '角色', dataIndex: 'role', key: 'role', width: 120 },
|
||||
{ title: '状态', dataIndex: 'status', key: 'status', width: 100 },
|
||||
{ title: '创建时间', dataIndex: 'createdAt', key: 'createdAt', width: 180 },
|
||||
{ title: '操作', key: 'action', width: 200, fixed: 'right' }
|
||||
]
|
||||
|
||||
// Modal State
|
||||
const modalVisible = ref(false)
|
||||
const currentRecord = ref<UserFormData | undefined>(undefined)
|
||||
|
||||
const resetPasswordVisible = ref(false)
|
||||
const currentUserId = ref<number>()
|
||||
|
||||
// 角色颜色
|
||||
function getRoleColor(role: string): string {
|
||||
const colorMap: Record<string, string> = {
|
||||
super_admin: 'red',
|
||||
admin: 'blue',
|
||||
finance: 'green',
|
||||
customer_service: 'orange'
|
||||
}
|
||||
return colorMap[role] || 'default'
|
||||
}
|
||||
|
||||
function getRoleName(role: string): string {
|
||||
const option = roleOptions.value.find(r => r.code === role)
|
||||
return option?.name || role
|
||||
}
|
||||
|
||||
// 加载数据
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getUserList({
|
||||
page: pagination.current,
|
||||
pageSize: pagination.pageSize,
|
||||
...searchForm
|
||||
})
|
||||
tableData.value = res.data.data.records || []
|
||||
pagination.total = res.data.data.total || 0
|
||||
} catch (error) {
|
||||
console.error('加载数据失败:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 加载角色列表
|
||||
async function loadRoles() {
|
||||
try {
|
||||
// 这里如果后端没有 list-all,我们可以用 list 且不传参(默认第一页),或者传大 pageSize
|
||||
// 假设后端支持 list 并返回 records
|
||||
const res = await getRoleList()
|
||||
// Mapping RoleRecord to Option { code, name }
|
||||
roleOptions.value = (res.data.data || []).map((r: RoleRecord) => ({ code: r.code, name: r.name }))
|
||||
} catch (error) {
|
||||
console.error('加载角色列表失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
pagination.current = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
searchForm.keyword = ''
|
||||
searchForm.role = undefined
|
||||
searchForm.status = undefined
|
||||
handleSearch()
|
||||
}
|
||||
|
||||
function handleTableChange(pag: { current?: number; pageSize?: number }) {
|
||||
pagination.current = pag.current || 1
|
||||
pagination.pageSize = pag.pageSize || 10
|
||||
loadData()
|
||||
}
|
||||
|
||||
function handleAdd() {
|
||||
currentRecord.value = undefined
|
||||
modalVisible.value = true
|
||||
}
|
||||
|
||||
function handleEdit(record: UserRecord) {
|
||||
currentRecord.value = {
|
||||
...record,
|
||||
password: '' // Password usually not editable directly here, treated separately or optional
|
||||
}
|
||||
modalVisible.value = true
|
||||
}
|
||||
|
||||
async function handleStatusChange(id: number, status: number) {
|
||||
try {
|
||||
await updateUserStatus(id, status)
|
||||
message.success('状态更新成功')
|
||||
loadData()
|
||||
} catch (error) {
|
||||
console.error('状态更新失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(id: number) {
|
||||
try {
|
||||
await deleteUser(id)
|
||||
message.success('删除成功')
|
||||
loadData()
|
||||
} catch (error) {
|
||||
console.error('删除失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
function handleResetPassword(record: UserRecord) {
|
||||
currentUserId.value = record.id
|
||||
resetPasswordVisible.value = true
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadRoles()
|
||||
loadData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-container {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.search-card {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.table-card {
|
||||
background: #fff;
|
||||
}
|
||||
</style>
|
||||
@@ -58,6 +58,16 @@ export default defineConfig({
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
// 后端API代理
|
||||
'/api': {
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
configure: (proxy) => {
|
||||
proxy.on('proxyReq', (proxyReq, req) => {
|
||||
console.log(`[Backend API Proxy] ${req.method} ${req.url}`)
|
||||
})
|
||||
}
|
||||
},
|
||||
// 主服务器代理
|
||||
'/1panel-api/server1': {
|
||||
target: 'http://47.109.57.58:42588',
|
||||
|
||||
Reference in New Issue
Block a user