添加系统管理、登录模块的接口、标准化开发流程

This commit is contained in:
super
2026-01-08 20:49:42 +08:00
parent fef12b01e2
commit 8fa07e4952
40 changed files with 3126 additions and 1701 deletions

18
src/api/auth.ts Normal file
View 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
View 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
View 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
View 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
View 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
View File

@@ -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']
}
}

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View File

@@ -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: '城市管理' },

View File

@@ -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]
// 查找父级菜单并展开
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]
// 框架菜单高亮(简单匹配 path
selectedKeys.value = [path]
// 自动展开父级菜单
// 遍历 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 })

View File

@@ -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

View File

@@ -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
}
})

View File

@@ -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
}

View File

@@ -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
}

View 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
}

View 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'
}

View File

@@ -0,0 +1,3 @@
export * from './common'
export * from './budget'
export * from './legacy'

View File

@@ -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
}
// ==================== 多级审批流程 ====================

View File

@@ -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
View 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
View 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
View 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
}

View File

@@ -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
View 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)
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View 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>

View 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>

View 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>

View 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>

View File

@@ -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',