Commit 4d16c20fe31c181344abe7646598007341354e33
1 parent
7d3ae82b
Refactor: Add substation role and user management features, extend API methods, …
…and update UI with new icons and routes.
Showing
10 changed files
with
1341 additions
and
5 deletions
src/api/index.ts
| ... | ... | @@ -24,6 +24,18 @@ export const systemRoleApi = { |
| 24 | 24 | request.post(`/api/platform/system/role/${roleId}/menus`, data), |
| 25 | 25 | } |
| 26 | 26 | |
| 27 | +export const adminRoleApi = { | |
| 28 | + list: () => request.get('/api/admin/system/role/list'), | |
| 29 | + add: (data: any) => request.post('/api/admin/system/role/add', data), | |
| 30 | + edit: (data: any) => request.put('/api/admin/system/role/edit', data), | |
| 31 | + ban: (id: number) => request.post('/api/admin/system/role/ban', null, { params: { id } }), | |
| 32 | + cancelBan: (id: number) => request.post('/api/admin/system/role/cancelBan', null, { params: { id } }), | |
| 33 | + del: (id: number) => request.delete('/api/admin/system/role/del', { params: { id } }), | |
| 34 | + menuTree: (roleId: number) => request.get(`/api/admin/system/role/${roleId}/menu-tree`), | |
| 35 | + assignMenus: (roleId: number, data: { menuIds: number[] }) => | |
| 36 | + request.post(`/api/admin/system/role/${roleId}/menus`, data), | |
| 37 | +} | |
| 38 | + | |
| 27 | 39 | // 城市管理 |
| 28 | 40 | export const cityApi = { |
| 29 | 41 | tree: () => request.get('/api/platform/city/tree'), |
| ... | ... | @@ -63,6 +75,17 @@ export const substationApi = { |
| 63 | 75 | request.post('/api/platform/substation/changePassword', data, { params: { id: data.id } }), |
| 64 | 76 | } |
| 65 | 77 | |
| 78 | +export const substationUserApi = { | |
| 79 | + list: (keyword?: string) => request.get('/api/admin/substation-user/list', { params: { keyword } }), | |
| 80 | + add: (data: any) => request.post('/api/admin/substation-user/add', data), | |
| 81 | + edit: (data: any) => request.put('/api/admin/substation-user/edit', data), | |
| 82 | + ban: (id: number) => request.post('/api/admin/substation-user/ban', null, { params: { id } }), | |
| 83 | + cancelBan: (id: number) => request.post('/api/admin/substation-user/cancelBan', null, { params: { id } }), | |
| 84 | + del: (id: number) => request.delete('/api/admin/substation-user/del', { params: { id } }), | |
| 85 | + changePassword: (data: { id: number; oldPassword: string; newPassword: string }) => | |
| 86 | + request.post('/api/admin/substation-user/changePassword', data, { params: { id: data.id } }), | |
| 87 | +} | |
| 88 | + | |
| 66 | 89 | export const adminUserApi = { |
| 67 | 90 | list: (keyword?: string) => request.get('/api/platform/admin-user/list', { params: { keyword } }), |
| 68 | 91 | add: (data: any) => request.post('/api/platform/admin-user/add', data), | ... | ... |
src/components/AppMenuTree.vue
| ... | ... | @@ -2,14 +2,14 @@ |
| 2 | 2 | <template v-for="menu in menus" :key="menu.code"> |
| 3 | 3 | <a-sub-menu v-if="menu.children?.length" :key="menu.code"> |
| 4 | 4 | <template #icon> |
| 5 | - <component :is="resolveIcon(menu.icon)" v-if="resolveIcon(menu.icon)" /> | |
| 5 | + <component :is="resolveIcon(menu.icon, menu.code)" v-if="resolveIcon(menu.icon, menu.code)" /> | |
| 6 | 6 | </template> |
| 7 | 7 | <template #title>{{ menu.name }}</template> |
| 8 | 8 | <AppMenuTree :menus="menu.children" /> |
| 9 | 9 | </a-sub-menu> |
| 10 | 10 | <a-menu-item v-else :key="menu.path || menu.code"> |
| 11 | 11 | <template #icon> |
| 12 | - <component :is="resolveIcon(menu.icon)" v-if="resolveIcon(menu.icon)" /> | |
| 12 | + <component :is="resolveIcon(menu.icon, menu.code)" v-if="resolveIcon(menu.icon, menu.code)" /> | |
| 13 | 13 | </template> |
| 14 | 14 | {{ menu.name }} |
| 15 | 15 | </a-menu-item> |
| ... | ... | @@ -25,8 +25,11 @@ import { |
| 25 | 25 | ControlOutlined, |
| 26 | 26 | GlobalOutlined, |
| 27 | 27 | HomeOutlined, |
| 28 | + SettingOutlined, | |
| 28 | 29 | ShopOutlined, |
| 29 | 30 | StarOutlined, |
| 31 | + TeamOutlined, | |
| 32 | + TrophyOutlined, | |
| 30 | 33 | UnorderedListOutlined, |
| 31 | 34 | UserOutlined, |
| 32 | 35 | } from '@ant-design/icons-vue' |
| ... | ... | @@ -45,10 +48,22 @@ const iconMap: Record<string, Component> = { |
| 45 | 48 | UnorderedListOutlined, |
| 46 | 49 | ControlOutlined, |
| 47 | 50 | ApiOutlined, |
| 51 | + TeamOutlined, | |
| 52 | + TrophyOutlined, | |
| 53 | + SettingOutlined, | |
| 48 | 54 | } |
| 49 | 55 | |
| 50 | -function resolveIcon(icon?: string) { | |
| 51 | - if (!icon) return null | |
| 52 | - return iconMap[icon] || null | |
| 56 | +const codeIconMap: Record<string, Component> = { | |
| 57 | + 'substation.user': TeamOutlined, | |
| 58 | + 'rider.level': TrophyOutlined, | |
| 59 | + 'system.sub_root': SettingOutlined, | |
| 60 | + 'system.sub_role': TeamOutlined, | |
| 61 | + 'system.sub_role_menu': SettingOutlined, | |
| 62 | +} | |
| 63 | + | |
| 64 | +function resolveIcon(icon?: string, code?: string) { | |
| 65 | + if (icon && iconMap[icon]) return iconMap[icon] | |
| 66 | + if (code && codeIconMap[code]) return codeIconMap[code] | |
| 67 | + return null | |
| 53 | 68 | } |
| 54 | 69 | </script> | ... | ... |
src/config/menu.ts
| ... | ... | @@ -16,6 +16,9 @@ export const iconRegistry: Record<string, string> = { |
| 16 | 16 | UnorderedListOutlined: 'unordered-list', |
| 17 | 17 | ControlOutlined: 'control', |
| 18 | 18 | ApiOutlined: 'api', |
| 19 | + TeamOutlined: 'team', | |
| 20 | + TrophyOutlined: 'trophy', | |
| 21 | + SettingOutlined: 'setting', | |
| 19 | 22 | } |
| 20 | 23 | |
| 21 | 24 | export const pinnedQuickLinks: QuickLinkItem[] = [ | ... | ... |
src/router/index.ts
| ... | ... | @@ -88,6 +88,12 @@ const router = createRouter({ |
| 88 | 88 | meta: { title: '骑手评价' }, |
| 89 | 89 | }, |
| 90 | 90 | { |
| 91 | + path: 'rider/level', | |
| 92 | + name: 'RiderLevel', | |
| 93 | + component: () => import('@/views/rider/RiderLevelList.vue'), | |
| 94 | + meta: { title: '骑手等级' }, | |
| 95 | + }, | |
| 96 | + { | |
| 91 | 97 | path: 'open', |
| 92 | 98 | name: 'OpenApp', |
| 93 | 99 | component: () => import('@/views/open/OpenAppList.vue'), |
| ... | ... | @@ -118,6 +124,24 @@ const router = createRouter({ |
| 118 | 124 | meta: { title: '角色菜单' }, |
| 119 | 125 | }, |
| 120 | 126 | { |
| 127 | + path: 'substation/user', | |
| 128 | + name: 'SubstationUser', | |
| 129 | + component: () => import('@/views/substation/SubstationUserList.vue'), | |
| 130 | + meta: { title: '分站账号' }, | |
| 131 | + }, | |
| 132 | + { | |
| 133 | + path: 'substation/role', | |
| 134 | + name: 'SubstationRole', | |
| 135 | + component: () => import('@/views/substation/SubstationRoleList.vue'), | |
| 136 | + meta: { title: '角色管理' }, | |
| 137 | + }, | |
| 138 | + { | |
| 139 | + path: 'substation/role-menu', | |
| 140 | + name: 'SubstationRoleMenu', | |
| 141 | + component: () => import('@/views/substation/SubstationRoleMenuAssign.vue'), | |
| 142 | + meta: { title: '角色菜单' }, | |
| 143 | + }, | |
| 144 | + { | |
| 121 | 145 | path: 'admin-user', |
| 122 | 146 | name: 'AdminUser', |
| 123 | 147 | component: () => import('@/views/admin/AdminUserList.vue'), | ... | ... |
src/views/rider/RiderLevelList.vue
0 → 100644
| 1 | +<template> | |
| 2 | + <div> | |
| 3 | + <a-card title="骑手等级" :bordered="false" class="list-table-card"> | |
| 4 | + <div class="list-toolbar"> | |
| 5 | + <div class="list-toolbar-left"> | |
| 6 | + <a-select | |
| 7 | + v-if="isAdmin" | |
| 8 | + v-model:value="selectedCityId" | |
| 9 | + placeholder="选择租户" | |
| 10 | + class="list-filter" | |
| 11 | + @change="loadList" | |
| 12 | + > | |
| 13 | + <a-select-option v-for="c in cityList" :key="c.id" :value="c.id">{{ c.name }}</a-select-option> | |
| 14 | + </a-select> | |
| 15 | + <div v-else class="managed-city-pill">当前租户:{{ currentCityName || `租户#${selectedCityId}` }}</div> | |
| 16 | + </div> | |
| 17 | + <div class="list-toolbar-right"> | |
| 18 | + <a-button type="primary" :disabled="!selectedCityId" @click="openAdd">新增等级</a-button> | |
| 19 | + </div> | |
| 20 | + </div> | |
| 21 | + | |
| 22 | + <template v-if="selectedCityId"> | |
| 23 | + <div class="soft-note-card page-note-card"> | |
| 24 | + <strong>骑手等级说明</strong> | |
| 25 | + <p>等级用于控制骑手收入规则和转单上限。平台管理员需先选择租户,分站管理员默认管理当前租户。</p> | |
| 26 | + </div> | |
| 27 | + | |
| 28 | + <a-table :dataSource="levelList" :columns="columns" :loading="loading" rowKey="id" :pagination="false"> | |
| 29 | + <template #bodyCell="{ column, record }"> | |
| 30 | + <template v-if="column.key === 'isDefault'"> | |
| 31 | + <a-tag :color="record.isDefault === 1 ? 'green' : 'default'"> | |
| 32 | + {{ record.isDefault === 1 ? '默认' : '普通' }} | |
| 33 | + </a-tag> | |
| 34 | + </template> | |
| 35 | + <template v-else-if="column.key === 'runFeeMode'"> | |
| 36 | + {{ runFeeModeMap[record.runFeeMode] || '-' }} | |
| 37 | + </template> | |
| 38 | + <template v-else-if="column.key === 'rule'"> | |
| 39 | + {{ formatLevelRule(record) }} | |
| 40 | + </template> | |
| 41 | + <template v-else-if="column.key === 'action'"> | |
| 42 | + <a-space> | |
| 43 | + <a @click="openEdit(record)">编辑</a> | |
| 44 | + <a v-if="record.isDefault !== 1" @click="handleSetDefault(record)">设为默认</a> | |
| 45 | + <a-popconfirm title="确认删除该等级?" @confirm="handleDelete(record)"> | |
| 46 | + <a style="color:red">删除</a> | |
| 47 | + </a-popconfirm> | |
| 48 | + </a-space> | |
| 49 | + </template> | |
| 50 | + </template> | |
| 51 | + </a-table> | |
| 52 | + </template> | |
| 53 | + | |
| 54 | + <a-empty v-else description="请先选择租户" /> | |
| 55 | + </a-card> | |
| 56 | + | |
| 57 | + <a-modal | |
| 58 | + v-model:open="modalVisible" | |
| 59 | + :title="editingId ? '编辑骑手等级' : '新增骑手等级'" | |
| 60 | + @ok="handleSave" | |
| 61 | + :confirmLoading="saving" | |
| 62 | + > | |
| 63 | + <a-form :model="form" layout="vertical"> | |
| 64 | + <a-form-item label="等级编号"> | |
| 65 | + <a-input-number v-model:value="form.levelId" :min="1" style="width:100%" /> | |
| 66 | + </a-form-item> | |
| 67 | + <a-form-item label="等级名称"> | |
| 68 | + <a-input v-model:value="form.name" placeholder="如:标准骑手 / 金牌骑手" /> | |
| 69 | + </a-form-item> | |
| 70 | + <a-form-item label="每日转单上限"> | |
| 71 | + <a-input-number v-model:value="form.transNums" :min="0" style="width:100%" /> | |
| 72 | + </a-form-item> | |
| 73 | + <a-form-item label="收入模式"> | |
| 74 | + <a-radio-group v-model:value="form.runFeeMode"> | |
| 75 | + <a-radio :value="1">固定金额</a-radio> | |
| 76 | + <a-radio :value="2">按比例</a-radio> | |
| 77 | + <a-radio :value="3">按距离</a-radio> | |
| 78 | + </a-radio-group> | |
| 79 | + </a-form-item> | |
| 80 | + <template v-if="form.runFeeMode === 1"> | |
| 81 | + <a-form-item label="固定收入(元)"> | |
| 82 | + <a-input-number v-model:value="form.runFixMoney" :min="0" :step="0.1" style="width:100%" /> | |
| 83 | + </a-form-item> | |
| 84 | + </template> | |
| 85 | + <template v-else-if="form.runFeeMode === 2"> | |
| 86 | + <a-form-item label="收入比例(%)"> | |
| 87 | + <a-input-number v-model:value="form.runRate" :min="0" :max="100" :step="0.1" style="width:100%" /> | |
| 88 | + </a-form-item> | |
| 89 | + </template> | |
| 90 | + <template v-else> | |
| 91 | + <a-row :gutter="16"> | |
| 92 | + <a-col :span="12"> | |
| 93 | + <a-form-item label="起始距离(米)"> | |
| 94 | + <a-input-number v-model:value="form.distanceBasic" :min="0" style="width:100%" /> | |
| 95 | + </a-form-item> | |
| 96 | + </a-col> | |
| 97 | + <a-col :span="12"> | |
| 98 | + <a-form-item label="基础收入(元)"> | |
| 99 | + <a-input-number v-model:value="form.distanceBasicMoney" :min="0" :step="0.1" style="width:100%" /> | |
| 100 | + </a-form-item> | |
| 101 | + </a-col> | |
| 102 | + </a-row> | |
| 103 | + <a-row :gutter="16"> | |
| 104 | + <a-col :span="12"> | |
| 105 | + <a-form-item label="超出每公里收入(元)"> | |
| 106 | + <a-input-number v-model:value="form.distanceMoreMoney" :min="0" :step="0.1" style="width:100%" /> | |
| 107 | + </a-form-item> | |
| 108 | + </a-col> | |
| 109 | + <a-col :span="12"> | |
| 110 | + <a-form-item label="最高收入上限(元)"> | |
| 111 | + <a-input-number v-model:value="form.distanceMaxMoney" :min="0" :step="0.1" style="width:100%" /> | |
| 112 | + </a-form-item> | |
| 113 | + </a-col> | |
| 114 | + </a-row> | |
| 115 | + </template> | |
| 116 | + </a-form> | |
| 117 | + </a-modal> | |
| 118 | + </div> | |
| 119 | +</template> | |
| 120 | + | |
| 121 | +<script setup lang="ts"> | |
| 122 | +import { onMounted, reactive, ref } from 'vue' | |
| 123 | +import { useRoute } from 'vue-router' | |
| 124 | +import { message } from 'ant-design-vue' | |
| 125 | +import { riderLevelApi } from '@/api' | |
| 126 | +import { useRoleSelectedCity } from '@/composables/useRoleSelectedCity' | |
| 127 | + | |
| 128 | +const route = useRoute() | |
| 129 | +const { isAdmin, cityList, selectedCityId, currentCityName, loadCities } = useRoleSelectedCity() | |
| 130 | + | |
| 131 | +const loading = ref(false) | |
| 132 | +const saving = ref(false) | |
| 133 | +const modalVisible = ref(false) | |
| 134 | +const editingId = ref<number | null>(null) | |
| 135 | +const levelList = ref<any[]>([]) | |
| 136 | +const form = reactive({ | |
| 137 | + cityId: 0, | |
| 138 | + levelId: 1, | |
| 139 | + name: '', | |
| 140 | + transNums: 0, | |
| 141 | + runFeeMode: 1, | |
| 142 | + runFixMoney: 0, | |
| 143 | + runRate: 0, | |
| 144 | + distanceBasic: 0, | |
| 145 | + distanceBasicMoney: 0, | |
| 146 | + distanceMoreMoney: 0, | |
| 147 | + distanceMaxMoney: 0, | |
| 148 | +}) | |
| 149 | + | |
| 150 | +const columns = [ | |
| 151 | + { title: '等级编号', dataIndex: 'levelId', width: 100 }, | |
| 152 | + { title: '等级名称', dataIndex: 'name' }, | |
| 153 | + { title: '默认', key: 'isDefault', width: 90 }, | |
| 154 | + { title: '转单上限', dataIndex: 'transNums', width: 100 }, | |
| 155 | + { title: '收入模式', key: 'runFeeMode', width: 110 }, | |
| 156 | + { title: '规则', key: 'rule' }, | |
| 157 | + { title: '操作', key: 'action', width: 220 }, | |
| 158 | +] | |
| 159 | + | |
| 160 | +const runFeeModeMap: Record<number, string> = { | |
| 161 | + 1: '固定金额', | |
| 162 | + 2: '按比例', | |
| 163 | + 3: '按距离', | |
| 164 | +} | |
| 165 | + | |
| 166 | +async function initPage() { | |
| 167 | + const queryCityId = Number(route.query.cityId || 0) || undefined | |
| 168 | + await loadCities(queryCityId) | |
| 169 | + if (selectedCityId.value) { | |
| 170 | + await loadList() | |
| 171 | + } | |
| 172 | +} | |
| 173 | + | |
| 174 | +async function loadList() { | |
| 175 | + if (!selectedCityId.value) { | |
| 176 | + levelList.value = [] | |
| 177 | + return | |
| 178 | + } | |
| 179 | + loading.value = true | |
| 180 | + try { | |
| 181 | + const res: any = await riderLevelApi.list(selectedCityId.value) | |
| 182 | + levelList.value = Array.isArray(res?.data) ? res.data : [] | |
| 183 | + } finally { | |
| 184 | + loading.value = false | |
| 185 | + } | |
| 186 | +} | |
| 187 | + | |
| 188 | +function openAdd() { | |
| 189 | + if (!selectedCityId.value) { | |
| 190 | + message.error('请先选择租户') | |
| 191 | + return | |
| 192 | + } | |
| 193 | + editingId.value = null | |
| 194 | + Object.assign(form, { | |
| 195 | + cityId: selectedCityId.value, | |
| 196 | + levelId: (levelList.value[levelList.value.length - 1]?.levelId || 0) + 1, | |
| 197 | + name: '', | |
| 198 | + transNums: 0, | |
| 199 | + runFeeMode: 1, | |
| 200 | + runFixMoney: 0, | |
| 201 | + runRate: 0, | |
| 202 | + distanceBasic: 0, | |
| 203 | + distanceBasicMoney: 0, | |
| 204 | + distanceMoreMoney: 0, | |
| 205 | + distanceMaxMoney: 0, | |
| 206 | + }) | |
| 207 | + modalVisible.value = true | |
| 208 | +} | |
| 209 | + | |
| 210 | +function openEdit(record: any) { | |
| 211 | + if (!selectedCityId.value) { | |
| 212 | + return | |
| 213 | + } | |
| 214 | + editingId.value = record.id | |
| 215 | + Object.assign(form, { | |
| 216 | + cityId: selectedCityId.value, | |
| 217 | + levelId: record.levelId, | |
| 218 | + name: record.name, | |
| 219 | + transNums: record.transNums, | |
| 220 | + runFeeMode: record.runFeeMode, | |
| 221 | + runFixMoney: record.runFixMoney ?? 0, | |
| 222 | + runRate: record.runRate ?? 0, | |
| 223 | + distanceBasic: record.distanceBasic ?? 0, | |
| 224 | + distanceBasicMoney: record.distanceBasicMoney ?? 0, | |
| 225 | + distanceMoreMoney: record.distanceMoreMoney ?? 0, | |
| 226 | + distanceMaxMoney: record.distanceMaxMoney ?? 0, | |
| 227 | + }) | |
| 228 | + modalVisible.value = true | |
| 229 | +} | |
| 230 | + | |
| 231 | +async function handleSave() { | |
| 232 | + if (!selectedCityId.value) { | |
| 233 | + message.error('请先选择租户') | |
| 234 | + return | |
| 235 | + } | |
| 236 | + if (!form.name.trim()) { | |
| 237 | + message.error('请填写等级名称') | |
| 238 | + return | |
| 239 | + } | |
| 240 | + saving.value = true | |
| 241 | + try { | |
| 242 | + const payload = { ...form, cityId: selectedCityId.value, id: editingId.value || undefined } | |
| 243 | + if (editingId.value) { | |
| 244 | + await riderLevelApi.edit(payload) | |
| 245 | + } else { | |
| 246 | + await riderLevelApi.add(payload) | |
| 247 | + } | |
| 248 | + message.success('保存成功') | |
| 249 | + modalVisible.value = false | |
| 250 | + await loadList() | |
| 251 | + } finally { | |
| 252 | + saving.value = false | |
| 253 | + } | |
| 254 | +} | |
| 255 | + | |
| 256 | +async function handleSetDefault(record: any) { | |
| 257 | + if (!selectedCityId.value) { | |
| 258 | + return | |
| 259 | + } | |
| 260 | + await riderLevelApi.setDefault(record.id, selectedCityId.value) | |
| 261 | + message.success('设置成功') | |
| 262 | + await loadList() | |
| 263 | +} | |
| 264 | + | |
| 265 | +async function handleDelete(record: any) { | |
| 266 | + if (!selectedCityId.value) { | |
| 267 | + return | |
| 268 | + } | |
| 269 | + await riderLevelApi.del(record.id, selectedCityId.value) | |
| 270 | + message.success('删除成功') | |
| 271 | + await loadList() | |
| 272 | +} | |
| 273 | + | |
| 274 | +function formatLevelRule(record: any) { | |
| 275 | + if (record.runFeeMode === 1) { | |
| 276 | + return `固定 ${record.runFixMoney ?? 0} 元` | |
| 277 | + } | |
| 278 | + if (record.runFeeMode === 2) { | |
| 279 | + return `按配送费 ${record.runRate ?? 0}%` | |
| 280 | + } | |
| 281 | + return `起始${record.distanceBasic ?? 0}米/${record.distanceBasicMoney ?? 0}元,超出每公里${record.distanceMoreMoney ?? 0}元,上限${record.distanceMaxMoney ?? 0}元` | |
| 282 | +} | |
| 283 | + | |
| 284 | +onMounted(initPage) | |
| 285 | +</script> | |
| 286 | + | |
| 287 | +<style scoped> | |
| 288 | +.managed-city-pill { | |
| 289 | + display: inline-flex; | |
| 290 | + align-items: center; | |
| 291 | + min-height: 36px; | |
| 292 | + padding: 0 14px; | |
| 293 | + border-radius: 999px; | |
| 294 | + border: 1px solid var(--line); | |
| 295 | + background: var(--panel-strong); | |
| 296 | + color: var(--text-soft); | |
| 297 | + font-size: 12px; | |
| 298 | +} | |
| 299 | + | |
| 300 | +.page-note-card { | |
| 301 | + margin-bottom: 16px; | |
| 302 | +} | |
| 303 | +</style> | ... | ... |
src/views/substation/SubstationRoleList.vue
0 → 100644
| 1 | +<template> | |
| 2 | + <div> | |
| 3 | + <a-card title="分站角色管理" :bordered="false" class="list-table-card"> | |
| 4 | + <div class="list-toolbar"> | |
| 5 | + <div class="list-toolbar-left"> | |
| 6 | + <a-input-search v-model:value="keyword" placeholder="搜索角色名称/编码" class="list-search" /> | |
| 7 | + </div> | |
| 8 | + <div class="list-toolbar-right"> | |
| 9 | + <a-button type="primary" @click="openAdd">新增角色</a-button> | |
| 10 | + </div> | |
| 11 | + </div> | |
| 12 | + | |
| 13 | + <a-table :dataSource="filteredList" :columns="columns" :loading="loading" rowKey="id" :pagination="false"> | |
| 14 | + <template #bodyCell="{ column, record }"> | |
| 15 | + <template v-if="column.key === 'name'"> | |
| 16 | + <div class="role-name-cell"> | |
| 17 | + <span>{{ record.name }}</span> | |
| 18 | + <a-tag v-if="record.builtIn" color="purple">内置</a-tag> | |
| 19 | + </div> | |
| 20 | + </template> | |
| 21 | + | |
| 22 | + <template v-else-if="column.key === 'roleScope'"> | |
| 23 | + <a-tag color="cyan">分站</a-tag> | |
| 24 | + </template> | |
| 25 | + | |
| 26 | + <template v-else-if="column.key === 'status'"> | |
| 27 | + <a-tag :color="record.status === 1 ? 'green' : 'red'"> | |
| 28 | + {{ record.status === 1 ? '正常' : '禁用' }} | |
| 29 | + </a-tag> | |
| 30 | + </template> | |
| 31 | + | |
| 32 | + <template v-else-if="column.key === 'binding'"> | |
| 33 | + <span class="binding-copy">{{ formatBinding(record) }}</span> | |
| 34 | + </template> | |
| 35 | + | |
| 36 | + <template v-else-if="column.key === 'action'"> | |
| 37 | + <a-space wrap> | |
| 38 | + <a v-if="!record.builtIn" @click="openEdit(record)">编辑</a> | |
| 39 | + <a-tooltip v-else title="内置角色不允许编辑"> | |
| 40 | + <span class="action-disabled">编辑</span> | |
| 41 | + </a-tooltip> | |
| 42 | + | |
| 43 | + <a v-if="record.status === 1" @click="goAssignMenus(record)">分配菜单</a> | |
| 44 | + <a-tooltip v-else title="请先启用角色再分配菜单"> | |
| 45 | + <span class="action-disabled">分配菜单</span> | |
| 46 | + </a-tooltip> | |
| 47 | + | |
| 48 | + <a-popconfirm | |
| 49 | + v-if="canToggleStatus(record)" | |
| 50 | + :title="record.status === 1 ? '确认禁用?' : '确认启用?'" | |
| 51 | + @confirm="toggleStatus(record)" | |
| 52 | + > | |
| 53 | + <a :style="record.status === 1 ? 'color:red' : ''"> | |
| 54 | + {{ record.status === 1 ? '禁用' : '启用' }} | |
| 55 | + </a> | |
| 56 | + </a-popconfirm> | |
| 57 | + <a-tooltip v-else :title="getToggleBlockedReason(record)"> | |
| 58 | + <span class="action-disabled">{{ record.status === 1 ? '禁用' : '启用' }}</span> | |
| 59 | + </a-tooltip> | |
| 60 | + | |
| 61 | + <a-popconfirm v-if="canDelete(record)" title="确认删除?" @confirm="handleDel(record.id)"> | |
| 62 | + <a style="color:red">删除</a> | |
| 63 | + </a-popconfirm> | |
| 64 | + <a-tooltip v-else :title="getDeleteBlockedReason(record)"> | |
| 65 | + <span class="action-disabled">删除</span> | |
| 66 | + </a-tooltip> | |
| 67 | + </a-space> | |
| 68 | + </template> | |
| 69 | + </template> | |
| 70 | + </a-table> | |
| 71 | + </a-card> | |
| 72 | + | |
| 73 | + <a-modal | |
| 74 | + v-model:open="modalVisible" | |
| 75 | + :title="editingId ? '编辑角色' : '新增角色'" | |
| 76 | + @ok="handleSave" | |
| 77 | + :confirmLoading="saving" | |
| 78 | + > | |
| 79 | + <div class="soft-page-stack"> | |
| 80 | + <div class="soft-note-card"> | |
| 81 | + <strong>分站角色说明</strong> | |
| 82 | + <p>分站角色仅作用于当前租户,角色范围固定为分站。创建后可继续进入“分配菜单”为角色配置可见菜单。</p> | |
| 83 | + </div> | |
| 84 | + <a-form :model="form" layout="vertical"> | |
| 85 | + <a-form-item label="角色名称"> | |
| 86 | + <a-input v-model:value="form.name" /> | |
| 87 | + </a-form-item> | |
| 88 | + <a-form-item label="角色编码"> | |
| 89 | + <a-input v-model:value="form.code" /> | |
| 90 | + </a-form-item> | |
| 91 | + <a-form-item label="角色范围"> | |
| 92 | + <a-input value="分站角色" disabled /> | |
| 93 | + </a-form-item> | |
| 94 | + </a-form> | |
| 95 | + </div> | |
| 96 | + </a-modal> | |
| 97 | + </div> | |
| 98 | +</template> | |
| 99 | + | |
| 100 | +<script setup lang="ts"> | |
| 101 | +import { computed, onMounted, reactive, ref } from 'vue' | |
| 102 | +import { message } from 'ant-design-vue' | |
| 103 | +import { useRouter } from 'vue-router' | |
| 104 | +import { adminRoleApi } from '@/api' | |
| 105 | + | |
| 106 | +interface RoleItem { | |
| 107 | + id: number | |
| 108 | + code: string | |
| 109 | + name: string | |
| 110 | + roleScope: string | |
| 111 | + status: number | |
| 112 | + builtIn?: boolean | |
| 113 | + adminUserCount?: number | |
| 114 | + substationCount?: number | |
| 115 | +} | |
| 116 | + | |
| 117 | +const router = useRouter() | |
| 118 | +const loading = ref(false) | |
| 119 | +const saving = ref(false) | |
| 120 | +const keyword = ref('') | |
| 121 | +const list = ref<RoleItem[]>([]) | |
| 122 | +const modalVisible = ref(false) | |
| 123 | +const editingId = ref<number | null>(null) | |
| 124 | +const form = reactive({ name: '', code: '', roleScope: 'SUBSTATION' }) | |
| 125 | + | |
| 126 | +const columns = [ | |
| 127 | + { title: 'ID', dataIndex: 'id', width: 80 }, | |
| 128 | + { title: '角色名称', key: 'name' }, | |
| 129 | + { title: '角色编码', dataIndex: 'code' }, | |
| 130 | + { title: '角色范围', key: 'roleScope', width: 110 }, | |
| 131 | + { title: '状态', key: 'status', width: 100 }, | |
| 132 | + { title: '绑定情况', key: 'binding' }, | |
| 133 | + { title: '操作', key: 'action', width: 320 }, | |
| 134 | +] | |
| 135 | + | |
| 136 | +const filteredList = computed(() => { | |
| 137 | + const value = keyword.value.trim().toLowerCase() | |
| 138 | + if (!value) return list.value | |
| 139 | + return list.value.filter(item => | |
| 140 | + item.name.toLowerCase().includes(value) || item.code.toLowerCase().includes(value), | |
| 141 | + ) | |
| 142 | +}) | |
| 143 | + | |
| 144 | +function formatBinding(record: RoleItem) { | |
| 145 | + const count = record.substationCount || 0 | |
| 146 | + return count > 0 ? `分站账号 ${count}` : '未绑定账号' | |
| 147 | +} | |
| 148 | + | |
| 149 | +function isBound(record: RoleItem) { | |
| 150 | + return (record.substationCount || 0) > 0 | |
| 151 | +} | |
| 152 | + | |
| 153 | +function canToggleStatus(record: RoleItem) { | |
| 154 | + if (record.builtIn) return false | |
| 155 | + if (record.status === 1 && isBound(record)) return false | |
| 156 | + return true | |
| 157 | +} | |
| 158 | + | |
| 159 | +function canDelete(record: RoleItem) { | |
| 160 | + return !record.builtIn && !isBound(record) | |
| 161 | +} | |
| 162 | + | |
| 163 | +function getToggleBlockedReason(record: RoleItem) { | |
| 164 | + if (record.builtIn) return '内置角色不允许禁用' | |
| 165 | + if (record.status === 1 && isBound(record)) return '角色已绑定账号,不能禁用' | |
| 166 | + return '当前角色不可操作' | |
| 167 | +} | |
| 168 | + | |
| 169 | +function getDeleteBlockedReason(record: RoleItem) { | |
| 170 | + if (record.builtIn) return '内置角色不允许删除' | |
| 171 | + if (isBound(record)) return '角色已绑定账号,不能删除' | |
| 172 | + return '当前角色不可删除' | |
| 173 | +} | |
| 174 | + | |
| 175 | +async function loadList() { | |
| 176 | + loading.value = true | |
| 177 | + try { | |
| 178 | + const res: any = await adminRoleApi.list() | |
| 179 | + list.value = Array.isArray(res?.data) ? res.data : [] | |
| 180 | + } finally { | |
| 181 | + loading.value = false | |
| 182 | + } | |
| 183 | +} | |
| 184 | + | |
| 185 | +function openAdd() { | |
| 186 | + editingId.value = null | |
| 187 | + Object.assign(form, { name: '', code: '', roleScope: 'SUBSTATION' }) | |
| 188 | + modalVisible.value = true | |
| 189 | +} | |
| 190 | + | |
| 191 | +function openEdit(record: RoleItem) { | |
| 192 | + editingId.value = record.id | |
| 193 | + Object.assign(form, { name: record.name, code: record.code, roleScope: 'SUBSTATION' }) | |
| 194 | + modalVisible.value = true | |
| 195 | +} | |
| 196 | + | |
| 197 | +async function handleSave() { | |
| 198 | + if (!form.name.trim() || !form.code.trim()) { | |
| 199 | + message.error('请填写角色名称和编码') | |
| 200 | + return | |
| 201 | + } | |
| 202 | + saving.value = true | |
| 203 | + try { | |
| 204 | + const payload = { name: form.name.trim(), code: form.code.trim(), roleScope: 'SUBSTATION' } | |
| 205 | + if (editingId.value) { | |
| 206 | + await adminRoleApi.edit({ id: editingId.value, ...payload }) | |
| 207 | + } else { | |
| 208 | + await adminRoleApi.add(payload) | |
| 209 | + } | |
| 210 | + message.success('保存成功') | |
| 211 | + modalVisible.value = false | |
| 212 | + await loadList() | |
| 213 | + } finally { | |
| 214 | + saving.value = false | |
| 215 | + } | |
| 216 | +} | |
| 217 | + | |
| 218 | +async function toggleStatus(record: RoleItem) { | |
| 219 | + if (record.status === 1) { | |
| 220 | + await adminRoleApi.ban(record.id) | |
| 221 | + } else { | |
| 222 | + await adminRoleApi.cancelBan(record.id) | |
| 223 | + } | |
| 224 | + message.success('操作成功') | |
| 225 | + await loadList() | |
| 226 | +} | |
| 227 | + | |
| 228 | +async function handleDel(id: number) { | |
| 229 | + await adminRoleApi.del(id) | |
| 230 | + message.success('删除成功') | |
| 231 | + await loadList() | |
| 232 | +} | |
| 233 | + | |
| 234 | +function goAssignMenus(record: RoleItem) { | |
| 235 | + router.push({ path: '/substation/role-menu', query: { roleId: String(record.id) } }) | |
| 236 | +} | |
| 237 | + | |
| 238 | +onMounted(loadList) | |
| 239 | +</script> | |
| 240 | + | |
| 241 | +<style scoped> | |
| 242 | +.role-name-cell { | |
| 243 | + display: inline-flex; | |
| 244 | + align-items: center; | |
| 245 | + gap: 8px; | |
| 246 | + flex-wrap: wrap; | |
| 247 | +} | |
| 248 | + | |
| 249 | +.binding-copy { | |
| 250 | + color: var(--text-soft); | |
| 251 | +} | |
| 252 | + | |
| 253 | +.action-disabled { | |
| 254 | + color: var(--text-soft); | |
| 255 | + cursor: not-allowed; | |
| 256 | +} | |
| 257 | +</style> | ... | ... |
src/views/substation/SubstationRoleMenuAssign.vue
0 → 100644
| 1 | +<template> | |
| 2 | + <div> | |
| 3 | + <a-card title="分站角色菜单分配" :bordered="false" class="list-table-card"> | |
| 4 | + <div class="plan-layout dispatch-layout"> | |
| 5 | + <div class="plan-sidebar"> | |
| 6 | + <div class="plan-sidebar-header"> | |
| 7 | + <div> | |
| 8 | + <div class="plan-sidebar-title">角色列表</div> | |
| 9 | + <div class="plan-sidebar-subtitle">选择角色后配置可见菜单</div> | |
| 10 | + </div> | |
| 11 | + </div> | |
| 12 | + <div class="plan-list"> | |
| 13 | + <button | |
| 14 | + v-for="role in roles" | |
| 15 | + :key="role.id" | |
| 16 | + type="button" | |
| 17 | + class="plan-item" | |
| 18 | + :class="{ active: role.id === selectedRoleId }" | |
| 19 | + @click="selectRole(role.id)" | |
| 20 | + > | |
| 21 | + <div class="plan-item-top"> | |
| 22 | + <span class="plan-item-name">{{ role.name }}</span> | |
| 23 | + <span class="mini-tag tag-scope substation">分站</span> | |
| 24 | + </div> | |
| 25 | + <div class="plan-item-bottom"> | |
| 26 | + <span class="role-code">@{{ role.code }}</span> | |
| 27 | + </div> | |
| 28 | + </button> | |
| 29 | + <a-empty v-if="!roles.length" description="暂无角色" /> | |
| 30 | + </div> | |
| 31 | + </div> | |
| 32 | + | |
| 33 | + <div class="plan-content"> | |
| 34 | + <template v-if="selectedRole"> | |
| 35 | + <div class="plan-content-top"> | |
| 36 | + <div class="soft-note-card plan-note-card"> | |
| 37 | + <div class="note-title"><InfoCircleOutlined /> 分配说明</div> | |
| 38 | + <p>这里只控制分站管理员侧菜单可见性,不做完整接口权限。保存后目标账号重新登录即可看到最新菜单。</p> | |
| 39 | + </div> | |
| 40 | + | |
| 41 | + <div class="plan-toolbar"> | |
| 42 | + <div class="plan-toolbar-meta"> | |
| 43 | + <span class="plan-toolbar-eyebrow">当前角色</span> | |
| 44 | + <strong>{{ selectedRole.name }}</strong> | |
| 45 | + </div> | |
| 46 | + <div class="plan-toolbar-submit"> | |
| 47 | + <span class="plan-toolbar-tip">这里只展示分站/通用菜单</span> | |
| 48 | + <a-button type="primary" class="plan-save-button" shape="round" :loading="saving" @click="handleSave">保存分配</a-button> | |
| 49 | + </div> | |
| 50 | + </div> | |
| 51 | + </div> | |
| 52 | + | |
| 53 | + <div class="plan-content-body"> | |
| 54 | + <a-spin :spinning="loadingTree"> | |
| 55 | + <div class="soft-note-card" style="margin-bottom: 12px"> | |
| 56 | + <div class="note-title"><InfoCircleOutlined /> 当前角色范围:分站</div> | |
| 57 | + <p style="margin: 6px 0 0">当前菜单树由后端按分站角色范围过滤,只返回允许分配的菜单节点。</p> | |
| 58 | + </div> | |
| 59 | + <a-tree | |
| 60 | + v-if="treeData.length" | |
| 61 | + checkable | |
| 62 | + block-node | |
| 63 | + check-strictly | |
| 64 | + default-expand-all | |
| 65 | + :show-line="{ showLeafIcon: false }" | |
| 66 | + :tree-data="treeData" | |
| 67 | + :field-names="fieldNames" | |
| 68 | + :checked-keys="checkedKeys" | |
| 69 | + @check="handleCheck" | |
| 70 | + > | |
| 71 | + <template #title="{ dataRef }"> | |
| 72 | + <span class="menu-node-name"> | |
| 73 | + <component :is="resolveIcon(dataRef.icon)" v-if="resolveIcon(dataRef.icon)" class="menu-icon" /> | |
| 74 | + <span class="menu-name-text">{{ dataRef.name }}</span> | |
| 75 | + </span> | |
| 76 | + <span class="menu-node-tags"> | |
| 77 | + <span class="mini-tag tag-scope" :class="dataRef.menuScope?.toLowerCase()">{{ formatScope(dataRef.menuScope) }}</span> | |
| 78 | + </span> | |
| 79 | + </template> | |
| 80 | + </a-tree> | |
| 81 | + <a-empty v-else description="当前角色暂无可分配菜单" /> | |
| 82 | + </a-spin> | |
| 83 | + </div> | |
| 84 | + </template> | |
| 85 | + | |
| 86 | + <a-empty v-else description="请选择左侧角色" /> | |
| 87 | + </div> | |
| 88 | + </div> | |
| 89 | + </a-card> | |
| 90 | + </div> | |
| 91 | +</template> | |
| 92 | + | |
| 93 | +<script setup lang="ts"> | |
| 94 | +import { computed, ref, watch } from 'vue' | |
| 95 | +import { useRoute } from 'vue-router' | |
| 96 | +import { message } from 'ant-design-vue' | |
| 97 | +import { adminRoleApi } from '@/api' | |
| 98 | +import { | |
| 99 | + ApiOutlined, | |
| 100 | + ApartmentOutlined, | |
| 101 | + ControlOutlined, | |
| 102 | + GlobalOutlined, | |
| 103 | + HomeOutlined, | |
| 104 | + SettingOutlined, | |
| 105 | + ShopOutlined, | |
| 106 | + StarOutlined, | |
| 107 | + TeamOutlined, | |
| 108 | + TrophyOutlined, | |
| 109 | + UnorderedListOutlined, | |
| 110 | + UserOutlined, | |
| 111 | + InfoCircleOutlined, | |
| 112 | +} from '@ant-design/icons-vue' | |
| 113 | + | |
| 114 | +interface RoleVO { | |
| 115 | + id: number | |
| 116 | + code: string | |
| 117 | + name: string | |
| 118 | + roleScope: string | |
| 119 | +} | |
| 120 | + | |
| 121 | +interface MenuTreeNode { | |
| 122 | + id: number | |
| 123 | + name: string | |
| 124 | + code: string | |
| 125 | + menuScope: string | |
| 126 | + checked: boolean | |
| 127 | + icon?: string | |
| 128 | + children: MenuTreeNode[] | |
| 129 | +} | |
| 130 | + | |
| 131 | +const iconMap: Record<string, any> = { | |
| 132 | + HomeOutlined, | |
| 133 | + GlobalOutlined, | |
| 134 | + ApartmentOutlined, | |
| 135 | + ShopOutlined, | |
| 136 | + UserOutlined, | |
| 137 | + StarOutlined, | |
| 138 | + UnorderedListOutlined, | |
| 139 | + ControlOutlined, | |
| 140 | + ApiOutlined, | |
| 141 | + TeamOutlined, | |
| 142 | + TrophyOutlined, | |
| 143 | + SettingOutlined, | |
| 144 | +} | |
| 145 | + | |
| 146 | +function resolveIcon(icon?: string) { | |
| 147 | + if (!icon) return null | |
| 148 | + return iconMap[icon] || null | |
| 149 | +} | |
| 150 | + | |
| 151 | +function formatScope(scope?: string) { | |
| 152 | + if (scope === 'PLATFORM') return '平台' | |
| 153 | + if (scope === 'SUBSTATION') return '分站' | |
| 154 | + return '通用' | |
| 155 | +} | |
| 156 | + | |
| 157 | +const route = useRoute() | |
| 158 | +const fieldNames = { title: 'name', key: 'id', children: 'children' } | |
| 159 | +const roles = ref<RoleVO[]>([]) | |
| 160 | +const selectedRoleId = ref<number>() | |
| 161 | +const loadingTree = ref(false) | |
| 162 | +const treeData = ref<MenuTreeNode[]>([]) | |
| 163 | +const checkedKeys = ref<number[]>([]) | |
| 164 | +const saving = ref(false) | |
| 165 | + | |
| 166 | +const selectedRole = computed(() => roles.value.find(item => item.id === selectedRoleId.value) || null) | |
| 167 | + | |
| 168 | +async function loadRoles() { | |
| 169 | + const res: any = await adminRoleApi.list() | |
| 170 | + roles.value = Array.isArray(res?.data) ? res.data : [] | |
| 171 | + if (!roles.value.length) { | |
| 172 | + selectedRoleId.value = undefined | |
| 173 | + treeData.value = [] | |
| 174 | + checkedKeys.value = [] | |
| 175 | + return | |
| 176 | + } | |
| 177 | + | |
| 178 | + const queryRoleId = Number(route.query.roleId) | |
| 179 | + const preferredRoleId = Number.isFinite(queryRoleId) && queryRoleId > 0 ? queryRoleId : selectedRoleId.value | |
| 180 | + const targetRole = roles.value.find(item => item.id === preferredRoleId) || roles.value[0] | |
| 181 | + await selectRole(targetRole.id) | |
| 182 | +} | |
| 183 | + | |
| 184 | +async function selectRole(roleId: number) { | |
| 185 | + selectedRoleId.value = roleId | |
| 186 | + loadingTree.value = true | |
| 187 | + try { | |
| 188 | + const res: any = await adminRoleApi.menuTree(roleId) | |
| 189 | + treeData.value = Array.isArray(res?.data) ? res.data : [] | |
| 190 | + checkedKeys.value = collectCheckedIds(treeData.value) | |
| 191 | + } finally { | |
| 192 | + loadingTree.value = false | |
| 193 | + } | |
| 194 | +} | |
| 195 | + | |
| 196 | +async function handleSave() { | |
| 197 | + if (!selectedRoleId.value) { | |
| 198 | + message.warning('请先选择角色') | |
| 199 | + return | |
| 200 | + } | |
| 201 | + saving.value = true | |
| 202 | + try { | |
| 203 | + await adminRoleApi.assignMenus(selectedRoleId.value, { menuIds: checkedKeys.value.map(Number) }) | |
| 204 | + message.success('保存成功') | |
| 205 | + await selectRole(selectedRoleId.value) | |
| 206 | + } finally { | |
| 207 | + saving.value = false | |
| 208 | + } | |
| 209 | +} | |
| 210 | + | |
| 211 | +function handleCheck(keys: any) { | |
| 212 | + checkedKeys.value = (Array.isArray(keys) ? keys : keys.checked).map(Number) | |
| 213 | +} | |
| 214 | + | |
| 215 | +function collectCheckedIds(nodes: MenuTreeNode[]): number[] { | |
| 216 | + const ids: number[] = [] | |
| 217 | + const walk = (items: MenuTreeNode[]) => { | |
| 218 | + for (const item of items) { | |
| 219 | + if (item.checked) ids.push(item.id) | |
| 220 | + if (item.children?.length) walk(item.children) | |
| 221 | + } | |
| 222 | + } | |
| 223 | + walk(nodes) | |
| 224 | + return ids | |
| 225 | +} | |
| 226 | + | |
| 227 | +watch(() => route.query.roleId, async (value) => { | |
| 228 | + const roleId = Number(value) | |
| 229 | + if (!Number.isFinite(roleId) || roleId < 1 || !roles.value.length) { | |
| 230 | + return | |
| 231 | + } | |
| 232 | + if (roles.value.some(item => item.id === roleId) && selectedRoleId.value !== roleId) { | |
| 233 | + await selectRole(roleId) | |
| 234 | + } | |
| 235 | +}) | |
| 236 | + | |
| 237 | +loadRoles() | |
| 238 | +</script> | |
| 239 | + | |
| 240 | +<style scoped> | |
| 241 | +.dispatch-layout { | |
| 242 | + margin-top: 8px; | |
| 243 | +} | |
| 244 | + | |
| 245 | +.plan-layout { | |
| 246 | + display: grid; | |
| 247 | + grid-template-columns: 280px minmax(0, 1fr); | |
| 248 | + gap: 18px; | |
| 249 | + min-height: 560px; | |
| 250 | +} | |
| 251 | + | |
| 252 | +.plan-sidebar, | |
| 253 | +.plan-content { | |
| 254 | + border-radius: 24px; | |
| 255 | + border: 1px solid var(--line); | |
| 256 | + background: var(--panel); | |
| 257 | + padding: 18px; | |
| 258 | +} | |
| 259 | + | |
| 260 | +.plan-content { | |
| 261 | + display: flex; | |
| 262 | + flex-direction: column; | |
| 263 | + gap: 18px; | |
| 264 | + min-width: 0; | |
| 265 | +} | |
| 266 | + | |
| 267 | +.plan-content-top, | |
| 268 | +.plan-content-body { | |
| 269 | + display: flex; | |
| 270 | + flex-direction: column; | |
| 271 | + gap: 12px; | |
| 272 | +} | |
| 273 | + | |
| 274 | +.plan-sidebar-header, | |
| 275 | +.plan-toolbar, | |
| 276 | +.plan-item-top, | |
| 277 | +.plan-item-bottom { | |
| 278 | + display: flex; | |
| 279 | + align-items: center; | |
| 280 | + justify-content: space-between; | |
| 281 | + gap: 12px; | |
| 282 | +} | |
| 283 | + | |
| 284 | +.plan-sidebar-title, | |
| 285 | +.plan-item-name { | |
| 286 | + color: var(--text-dark); | |
| 287 | + font-family: var(--font-display); | |
| 288 | +} | |
| 289 | + | |
| 290 | +.plan-sidebar-title { | |
| 291 | + font-size: 16px; | |
| 292 | + font-weight: 700; | |
| 293 | +} | |
| 294 | + | |
| 295 | +.plan-sidebar-subtitle, | |
| 296 | +.plan-item-bottom, | |
| 297 | +.plan-toolbar-eyebrow, | |
| 298 | +.plan-toolbar-tip { | |
| 299 | + color: var(--text-soft); | |
| 300 | + font-size: 12px; | |
| 301 | +} | |
| 302 | + | |
| 303 | +.plan-list { | |
| 304 | + display: flex; | |
| 305 | + flex-direction: column; | |
| 306 | + gap: 10px; | |
| 307 | + margin-top: 14px; | |
| 308 | +} | |
| 309 | + | |
| 310 | +.plan-item { | |
| 311 | + border: 1px solid var(--line); | |
| 312 | + background: var(--panel-strong); | |
| 313 | + border-radius: 18px; | |
| 314 | + padding: 14px; | |
| 315 | + text-align: left; | |
| 316 | + cursor: pointer; | |
| 317 | + color: inherit; | |
| 318 | + transition: all 0.3s; | |
| 319 | +} | |
| 320 | + | |
| 321 | +.plan-item:hover:not(.active) { | |
| 322 | + border-color: #d3b4fa; | |
| 323 | + background: var(--panel-tint); | |
| 324 | +} | |
| 325 | + | |
| 326 | +.plan-item.active { | |
| 327 | + border-color: #d3b4fa; | |
| 328 | + background: var(--primary-50, #f6f0ff); | |
| 329 | + box-shadow: none; | |
| 330 | +} | |
| 331 | + | |
| 332 | +.plan-item.active .plan-item-name { | |
| 333 | + color: var(--primary-color, #722ed1); | |
| 334 | +} | |
| 335 | + | |
| 336 | +.plan-item-name { | |
| 337 | + color: var(--text-dark); | |
| 338 | + font-family: var(--font-display); | |
| 339 | + transition: color 0.3s; | |
| 340 | +} | |
| 341 | + | |
| 342 | +.plan-toolbar { | |
| 343 | + flex-wrap: wrap; | |
| 344 | + align-items: center; | |
| 345 | + padding: 14px 16px; | |
| 346 | + border: 1px solid var(--line); | |
| 347 | + border-radius: 20px; | |
| 348 | + background: var(--panel-strong); | |
| 349 | + box-shadow: var(--shadow-sm); | |
| 350 | +} | |
| 351 | + | |
| 352 | +.plan-toolbar-meta, | |
| 353 | +.plan-toolbar-submit { | |
| 354 | + display: flex; | |
| 355 | + flex-direction: column; | |
| 356 | + gap: 4px; | |
| 357 | +} | |
| 358 | + | |
| 359 | +.plan-toolbar-meta strong { | |
| 360 | + color: var(--text-dark); | |
| 361 | + font-size: 15px; | |
| 362 | + line-height: 1.4; | |
| 363 | +} | |
| 364 | + | |
| 365 | +.plan-toolbar-submit { | |
| 366 | + margin-left: auto; | |
| 367 | + align-items: flex-end; | |
| 368 | +} | |
| 369 | + | |
| 370 | +.plan-save-button { | |
| 371 | + min-width: 108px; | |
| 372 | + height: 36px; | |
| 373 | +} | |
| 374 | + | |
| 375 | +.menu-node-name { | |
| 376 | + display: flex; | |
| 377 | + align-items: center; | |
| 378 | + min-width: 0; | |
| 379 | + font-size: 14px; | |
| 380 | +} | |
| 381 | + | |
| 382 | +.menu-name-text { | |
| 383 | + overflow: hidden; | |
| 384 | + text-overflow: ellipsis; | |
| 385 | + white-space: nowrap; | |
| 386 | +} | |
| 387 | + | |
| 388 | +.menu-node-tags { | |
| 389 | + display: flex; | |
| 390 | + align-items: center; | |
| 391 | + gap: 4px; | |
| 392 | + margin-left: 8px; | |
| 393 | + flex-shrink: 0; | |
| 394 | +} | |
| 395 | + | |
| 396 | +.mini-tag { | |
| 397 | + display: inline-flex; | |
| 398 | + align-items: center; | |
| 399 | + gap: 2px; | |
| 400 | + padding: 0 8px; | |
| 401 | + height: 22px; | |
| 402 | + border-radius: 999px; | |
| 403 | + font-size: 12px; | |
| 404 | + font-weight: 500; | |
| 405 | + line-height: 1; | |
| 406 | +} | |
| 407 | + | |
| 408 | +.tag-scope.platform { | |
| 409 | + background: #e6f4ff; | |
| 410 | + color: #1677ff; | |
| 411 | +} | |
| 412 | + | |
| 413 | +.tag-scope.substation { | |
| 414 | + background: #f6ffed; | |
| 415 | + color: #389e0d; | |
| 416 | +} | |
| 417 | + | |
| 418 | +.tag-scope.both { | |
| 419 | + background: #f9f0ff; | |
| 420 | + color: #722ed1; | |
| 421 | +} | |
| 422 | + | |
| 423 | +.note-title { | |
| 424 | + display: flex; | |
| 425 | + align-items: center; | |
| 426 | + gap: 6px; | |
| 427 | + font-weight: 600; | |
| 428 | + color: var(--text-dark); | |
| 429 | +} | |
| 430 | + | |
| 431 | +.role-code { | |
| 432 | + font-family: var(--font-mono, monospace); | |
| 433 | + opacity: 0.8; | |
| 434 | +} | |
| 435 | + | |
| 436 | +.menu-icon { | |
| 437 | + margin-right: 6px; | |
| 438 | + font-size: 14px; | |
| 439 | + color: var(--text-soft); | |
| 440 | + flex-shrink: 0; | |
| 441 | +} | |
| 442 | + | |
| 443 | +.plan-content > :deep(.ant-empty) { | |
| 444 | + margin: auto 0; | |
| 445 | +} | |
| 446 | + | |
| 447 | +:deep(.ant-tree) { | |
| 448 | + background: transparent; | |
| 449 | +} | |
| 450 | + | |
| 451 | +:deep(.ant-tree-node-content-wrapper) { | |
| 452 | + display: flex; | |
| 453 | + align-items: center; | |
| 454 | + border-radius: 8px; | |
| 455 | + padding: 4px 8px 4px 0; | |
| 456 | + transition: all 0.3s; | |
| 457 | +} | |
| 458 | + | |
| 459 | +:deep(.ant-tree-node-content-wrapper:hover) { | |
| 460 | + background-color: var(--panel-tint); | |
| 461 | +} | |
| 462 | + | |
| 463 | +:deep(.ant-tree-title) { | |
| 464 | + display: flex; | |
| 465 | + align-items: center; | |
| 466 | + width: 100%; | |
| 467 | +} | |
| 468 | + | |
| 469 | +@media (max-width: 960px) { | |
| 470 | + .plan-layout { | |
| 471 | + grid-template-columns: 1fr; | |
| 472 | + } | |
| 473 | + | |
| 474 | + .plan-toolbar-submit { | |
| 475 | + margin-left: 0; | |
| 476 | + align-items: flex-start; | |
| 477 | + } | |
| 478 | +} | |
| 479 | +</style> | ... | ... |
src/views/substation/SubstationUserList.vue
0 → 100644
| 1 | +<template> | |
| 2 | + <div> | |
| 3 | + <a-card title="分站账号管理" :bordered="false" class="list-table-card"> | |
| 4 | + <div class="list-toolbar"> | |
| 5 | + <div class="list-toolbar-left"> | |
| 6 | + <a-input-search v-model:value="keyword" placeholder="搜索账号/昵称/手机" @search="loadList" class="list-search" /> | |
| 7 | + </div> | |
| 8 | + <div class="list-toolbar-right"> | |
| 9 | + <a-button type="primary" @click="openAdd">新增分站账号</a-button> | |
| 10 | + </div> | |
| 11 | + </div> | |
| 12 | + <a-table :dataSource="list" :columns="columns" :loading="loading" rowKey="id" :pagination="false"> | |
| 13 | + <template #bodyCell="{ column, record }"> | |
| 14 | + <template v-if="column.key === 'roleId'"> | |
| 15 | + {{ getRoleName(record.roleId) }} | |
| 16 | + </template> | |
| 17 | + <template v-else-if="column.key === 'status'"> | |
| 18 | + <a-tag :color="record.userStatus === 1 ? 'green' : 'red'"> | |
| 19 | + {{ record.userStatus === 1 ? '正常' : '禁用' }} | |
| 20 | + </a-tag> | |
| 21 | + </template> | |
| 22 | + <template v-else-if="column.key === 'action'"> | |
| 23 | + <a-space> | |
| 24 | + <a @click="openEdit(record)">编辑</a> | |
| 25 | + <a @click="openChangePwd(record)">改密码</a> | |
| 26 | + <a-popconfirm | |
| 27 | + :title="record.userStatus === 1 ? '确认禁用?' : '确认启用?'" | |
| 28 | + @confirm="toggleBan(record)" | |
| 29 | + > | |
| 30 | + <a :style="record.userStatus === 1 ? 'color:red' : ''"> | |
| 31 | + {{ record.userStatus === 1 ? '禁用' : '启用' }} | |
| 32 | + </a> | |
| 33 | + </a-popconfirm> | |
| 34 | + <a-popconfirm title="确认删除?" @confirm="handleDel(record.id)"> | |
| 35 | + <a style="color:red">删除</a> | |
| 36 | + </a-popconfirm> | |
| 37 | + </a-space> | |
| 38 | + </template> | |
| 39 | + </template> | |
| 40 | + </a-table> | |
| 41 | + </a-card> | |
| 42 | + | |
| 43 | + <a-modal v-model:open="modalVisible" :title="editingId ? '编辑分站账号' : '新增分站账号'" @ok="handleSave" :confirmLoading="saving"> | |
| 44 | + <div class="soft-page-stack"> | |
| 45 | + <div class="soft-note-card"> | |
| 46 | + <strong>分站子账号说明</strong> | |
| 47 | + <p>这里只管理当前租户下的分站账号,不需要手动选择租户。编辑时登录账号保持不变,密码留空则不修改。</p> | |
| 48 | + </div> | |
| 49 | + <a-form :model="form" layout="vertical"> | |
| 50 | + <a-form-item label="菜单角色"> | |
| 51 | + <a-select v-model:value="form.roleId" placeholder="选择角色"> | |
| 52 | + <a-select-option v-for="item in roleOptions" :key="item.id" :value="item.id">{{ item.name }}</a-select-option> | |
| 53 | + </a-select> | |
| 54 | + </a-form-item> | |
| 55 | + <a-form-item label="登录账号"> | |
| 56 | + <a-input v-model:value="form.userLogin" :disabled="!!editingId" /> | |
| 57 | + </a-form-item> | |
| 58 | + <a-form-item label="昵称"> | |
| 59 | + <a-input v-model:value="form.userNickname" /> | |
| 60 | + </a-form-item> | |
| 61 | + <a-form-item label="手机号"> | |
| 62 | + <a-input v-model:value="form.mobile" /> | |
| 63 | + </a-form-item> | |
| 64 | + <a-form-item :label="editingId ? '新密码(不填不修改)' : '密码'"> | |
| 65 | + <a-input-password v-model:value="form.userPass" /> | |
| 66 | + </a-form-item> | |
| 67 | + </a-form> | |
| 68 | + </div> | |
| 69 | + </a-modal> | |
| 70 | + | |
| 71 | + <a-modal v-model:open="pwdVisible" title="修改密码" @ok="handleChangePwd" :confirmLoading="pwdSaving"> | |
| 72 | + <div class="soft-page-stack"> | |
| 73 | + <div class="soft-note-card"> | |
| 74 | + <strong>密码修改说明</strong> | |
| 75 | + <p>这里修改的是当前选中分站账号的登录密码,提交时会按目标账号执行。</p> | |
| 76 | + </div> | |
| 77 | + <a-form layout="vertical"> | |
| 78 | + <a-form-item label="原密码"> | |
| 79 | + <a-input-password v-model:value="pwdForm.oldPassword" /> | |
| 80 | + </a-form-item> | |
| 81 | + <a-form-item label="新密码"> | |
| 82 | + <a-input-password v-model:value="pwdForm.newPassword" /> | |
| 83 | + </a-form-item> | |
| 84 | + </a-form> | |
| 85 | + </div> | |
| 86 | + </a-modal> | |
| 87 | + </div> | |
| 88 | +</template> | |
| 89 | + | |
| 90 | +<script setup lang="ts"> | |
| 91 | +import { onMounted, reactive, ref } from 'vue' | |
| 92 | +import { message } from 'ant-design-vue' | |
| 93 | +import { adminRoleApi, substationUserApi } from '@/api' | |
| 94 | + | |
| 95 | +const loading = ref(false) | |
| 96 | +const saving = ref(false) | |
| 97 | +const list = ref<any[]>([]) | |
| 98 | +const roleOptions = ref<any[]>([]) | |
| 99 | +const keyword = ref('') | |
| 100 | +const modalVisible = ref(false) | |
| 101 | +const editingId = ref<number | null>(null) | |
| 102 | +const form = reactive({ roleId: undefined, userLogin: '', userNickname: '', mobile: '', userPass: '' }) | |
| 103 | + | |
| 104 | +const columns = [ | |
| 105 | + { title: 'ID', dataIndex: 'id', width: 80 }, | |
| 106 | + { title: '账号', dataIndex: 'userLogin' }, | |
| 107 | + { title: '昵称', dataIndex: 'userNickname' }, | |
| 108 | + { title: '手机', dataIndex: 'mobile' }, | |
| 109 | + { title: '角色', key: 'roleId' }, | |
| 110 | + { title: '状态', key: 'status' }, | |
| 111 | + { title: '操作', key: 'action' }, | |
| 112 | +] | |
| 113 | + | |
| 114 | +function getRoleName(roleId?: number) { | |
| 115 | + const role = roleOptions.value.find(item => item.id === roleId) | |
| 116 | + return role?.name || (roleId ? `角色#${roleId}` : '-') | |
| 117 | +} | |
| 118 | + | |
| 119 | +async function loadList() { | |
| 120 | + loading.value = true | |
| 121 | + try { | |
| 122 | + const res: any = await substationUserApi.list(keyword.value) | |
| 123 | + list.value = Array.isArray(res?.data) ? res.data : [] | |
| 124 | + } finally { | |
| 125 | + loading.value = false | |
| 126 | + } | |
| 127 | +} | |
| 128 | + | |
| 129 | +async function loadRoles() { | |
| 130 | + const res: any = await adminRoleApi.list() | |
| 131 | + roleOptions.value = Array.isArray(res?.data) ? res.data : [] | |
| 132 | +} | |
| 133 | + | |
| 134 | +function openAdd() { | |
| 135 | + editingId.value = null | |
| 136 | + Object.assign(form, { roleId: roleOptions.value[0]?.id, userLogin: '', userNickname: '', mobile: '', userPass: '' }) | |
| 137 | + modalVisible.value = true | |
| 138 | +} | |
| 139 | + | |
| 140 | +function openEdit(record: any) { | |
| 141 | + editingId.value = record.id | |
| 142 | + Object.assign(form, { | |
| 143 | + roleId: record.roleId, | |
| 144 | + userLogin: record.userLogin, | |
| 145 | + userNickname: record.userNickname, | |
| 146 | + mobile: record.mobile, | |
| 147 | + userPass: '', | |
| 148 | + }) | |
| 149 | + modalVisible.value = true | |
| 150 | +} | |
| 151 | + | |
| 152 | +async function handleSave() { | |
| 153 | + if (!form.roleId) { | |
| 154 | + message.error('请选择菜单角色') | |
| 155 | + return | |
| 156 | + } | |
| 157 | + saving.value = true | |
| 158 | + try { | |
| 159 | + if (editingId.value) { | |
| 160 | + await substationUserApi.edit({ ...form, id: editingId.value }) | |
| 161 | + } else { | |
| 162 | + await substationUserApi.add(form) | |
| 163 | + } | |
| 164 | + message.success('保存成功') | |
| 165 | + modalVisible.value = false | |
| 166 | + await Promise.all([loadList(), loadRoles()]) | |
| 167 | + } finally { | |
| 168 | + saving.value = false | |
| 169 | + } | |
| 170 | +} | |
| 171 | + | |
| 172 | +async function toggleBan(record: any) { | |
| 173 | + if (record.userStatus === 1) { | |
| 174 | + await substationUserApi.ban(record.id) | |
| 175 | + } else { | |
| 176 | + await substationUserApi.cancelBan(record.id) | |
| 177 | + } | |
| 178 | + message.success('操作成功') | |
| 179 | + await loadList() | |
| 180 | +} | |
| 181 | + | |
| 182 | +async function handleDel(id: number) { | |
| 183 | + await substationUserApi.del(id) | |
| 184 | + message.success('删除成功') | |
| 185 | + await loadList() | |
| 186 | +} | |
| 187 | + | |
| 188 | +const pwdVisible = ref(false) | |
| 189 | +const pwdSaving = ref(false) | |
| 190 | +const pwdForm = reactive({ oldPassword: '', newPassword: '' }) | |
| 191 | +const pwdTargetId = ref(0) | |
| 192 | + | |
| 193 | +function openChangePwd(record: any) { | |
| 194 | + pwdTargetId.value = record.id | |
| 195 | + Object.assign(pwdForm, { oldPassword: '', newPassword: '' }) | |
| 196 | + pwdVisible.value = true | |
| 197 | +} | |
| 198 | + | |
| 199 | +async function handleChangePwd() { | |
| 200 | + if (!pwdForm.oldPassword || !pwdForm.newPassword) { | |
| 201 | + message.error('请填写完整密码') | |
| 202 | + return | |
| 203 | + } | |
| 204 | + pwdSaving.value = true | |
| 205 | + try { | |
| 206 | + await substationUserApi.changePassword({ id: pwdTargetId.value, ...pwdForm }) | |
| 207 | + message.success('密码修改成功') | |
| 208 | + pwdVisible.value = false | |
| 209 | + } finally { | |
| 210 | + pwdSaving.value = false | |
| 211 | + } | |
| 212 | +} | |
| 213 | + | |
| 214 | +onMounted(async () => { | |
| 215 | + await Promise.all([loadList(), loadRoles()]) | |
| 216 | +}) | |
| 217 | +</script> | ... | ... |
src/views/system/MenuManage.vue
| ... | ... | @@ -213,8 +213,11 @@ import { |
| 213 | 213 | ControlOutlined, |
| 214 | 214 | GlobalOutlined, |
| 215 | 215 | HomeOutlined, |
| 216 | + SettingOutlined, | |
| 216 | 217 | ShopOutlined, |
| 217 | 218 | StarOutlined, |
| 219 | + TeamOutlined, | |
| 220 | + TrophyOutlined, | |
| 218 | 221 | UnorderedListOutlined, |
| 219 | 222 | UserOutlined, |
| 220 | 223 | EyeOutlined, |
| ... | ... | @@ -248,6 +251,9 @@ const iconOptions = [ |
| 248 | 251 | 'UnorderedListOutlined', |
| 249 | 252 | 'ControlOutlined', |
| 250 | 253 | 'ApiOutlined', |
| 254 | + 'TeamOutlined', | |
| 255 | + 'TrophyOutlined', | |
| 256 | + 'SettingOutlined', | |
| 251 | 257 | ] |
| 252 | 258 | |
| 253 | 259 | const iconMap: Record<string, any> = { |
| ... | ... | @@ -260,6 +266,9 @@ const iconMap: Record<string, any> = { |
| 260 | 266 | UnorderedListOutlined, |
| 261 | 267 | ControlOutlined, |
| 262 | 268 | ApiOutlined, |
| 269 | + TeamOutlined, | |
| 270 | + TrophyOutlined, | |
| 271 | + SettingOutlined, | |
| 263 | 272 | } |
| 264 | 273 | |
| 265 | 274 | function resolveIcon(icon?: string) { | ... | ... |
src/views/system/RoleMenuAssign.vue
| ... | ... | @@ -101,8 +101,11 @@ import { |
| 101 | 101 | ControlOutlined, |
| 102 | 102 | GlobalOutlined, |
| 103 | 103 | HomeOutlined, |
| 104 | + SettingOutlined, | |
| 104 | 105 | ShopOutlined, |
| 105 | 106 | StarOutlined, |
| 107 | + TeamOutlined, | |
| 108 | + TrophyOutlined, | |
| 106 | 109 | UnorderedListOutlined, |
| 107 | 110 | UserOutlined, |
| 108 | 111 | InfoCircleOutlined |
| ... | ... | @@ -135,6 +138,9 @@ const iconMap: Record<string, any> = { |
| 135 | 138 | UnorderedListOutlined, |
| 136 | 139 | ControlOutlined, |
| 137 | 140 | ApiOutlined, |
| 141 | + TeamOutlined, | |
| 142 | + TrophyOutlined, | |
| 143 | + SettingOutlined, | |
| 138 | 144 | } |
| 139 | 145 | |
| 140 | 146 | function resolveIcon(icon?: string) { | ... | ... |