Commit 4d16c20fe31c181344abe7646598007341354e33

Authored by shaofan
1 parent 7d3ae82b

Refactor: Add substation role and user management features, extend API methods, …

…and update UI with new icons and routes.
src/api/index.ts
@@ -24,6 +24,18 @@ export const systemRoleApi = { @@ -24,6 +24,18 @@ export const systemRoleApi = {
24 request.post(`/api/platform/system/role/${roleId}/menus`, data), 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 export const cityApi = { 40 export const cityApi = {
29 tree: () => request.get('/api/platform/city/tree'), 41 tree: () => request.get('/api/platform/city/tree'),
@@ -63,6 +75,17 @@ export const substationApi = { @@ -63,6 +75,17 @@ export const substationApi = {
63 request.post('/api/platform/substation/changePassword', data, { params: { id: data.id } }), 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 export const adminUserApi = { 89 export const adminUserApi = {
67 list: (keyword?: string) => request.get('/api/platform/admin-user/list', { params: { keyword } }), 90 list: (keyword?: string) => request.get('/api/platform/admin-user/list', { params: { keyword } }),
68 add: (data: any) => request.post('/api/platform/admin-user/add', data), 91 add: (data: any) => request.post('/api/platform/admin-user/add', data),
src/components/AppMenuTree.vue
@@ -2,14 +2,14 @@ @@ -2,14 +2,14 @@
2 <template v-for="menu in menus" :key="menu.code"> 2 <template v-for="menu in menus" :key="menu.code">
3 <a-sub-menu v-if="menu.children?.length" :key="menu.code"> 3 <a-sub-menu v-if="menu.children?.length" :key="menu.code">
4 <template #icon> 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 </template> 6 </template>
7 <template #title>{{ menu.name }}</template> 7 <template #title>{{ menu.name }}</template>
8 <AppMenuTree :menus="menu.children" /> 8 <AppMenuTree :menus="menu.children" />
9 </a-sub-menu> 9 </a-sub-menu>
10 <a-menu-item v-else :key="menu.path || menu.code"> 10 <a-menu-item v-else :key="menu.path || menu.code">
11 <template #icon> 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 </template> 13 </template>
14 {{ menu.name }} 14 {{ menu.name }}
15 </a-menu-item> 15 </a-menu-item>
@@ -25,8 +25,11 @@ import { @@ -25,8 +25,11 @@ import {
25 ControlOutlined, 25 ControlOutlined,
26 GlobalOutlined, 26 GlobalOutlined,
27 HomeOutlined, 27 HomeOutlined,
  28 + SettingOutlined,
28 ShopOutlined, 29 ShopOutlined,
29 StarOutlined, 30 StarOutlined,
  31 + TeamOutlined,
  32 + TrophyOutlined,
30 UnorderedListOutlined, 33 UnorderedListOutlined,
31 UserOutlined, 34 UserOutlined,
32 } from '@ant-design/icons-vue' 35 } from '@ant-design/icons-vue'
@@ -45,10 +48,22 @@ const iconMap: Record&lt;string, Component&gt; = { @@ -45,10 +48,22 @@ const iconMap: Record&lt;string, Component&gt; = {
45 UnorderedListOutlined, 48 UnorderedListOutlined,
46 ControlOutlined, 49 ControlOutlined,
47 ApiOutlined, 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 </script> 69 </script>
src/config/menu.ts
@@ -16,6 +16,9 @@ export const iconRegistry: Record&lt;string, string&gt; = { @@ -16,6 +16,9 @@ export const iconRegistry: Record&lt;string, string&gt; = {
16 UnorderedListOutlined: 'unordered-list', 16 UnorderedListOutlined: 'unordered-list',
17 ControlOutlined: 'control', 17 ControlOutlined: 'control',
18 ApiOutlined: 'api', 18 ApiOutlined: 'api',
  19 + TeamOutlined: 'team',
  20 + TrophyOutlined: 'trophy',
  21 + SettingOutlined: 'setting',
19 } 22 }
20 23
21 export const pinnedQuickLinks: QuickLinkItem[] = [ 24 export const pinnedQuickLinks: QuickLinkItem[] = [
src/router/index.ts
@@ -88,6 +88,12 @@ const router = createRouter({ @@ -88,6 +88,12 @@ const router = createRouter({
88 meta: { title: '骑手评价' }, 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 path: 'open', 97 path: 'open',
92 name: 'OpenApp', 98 name: 'OpenApp',
93 component: () => import('@/views/open/OpenAppList.vue'), 99 component: () => import('@/views/open/OpenAppList.vue'),
@@ -118,6 +124,24 @@ const router = createRouter({ @@ -118,6 +124,24 @@ const router = createRouter({
118 meta: { title: '角色菜单' }, 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 path: 'admin-user', 145 path: 'admin-user',
122 name: 'AdminUser', 146 name: 'AdminUser',
123 component: () => import('@/views/admin/AdminUserList.vue'), 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,8 +213,11 @@ import {
213 ControlOutlined, 213 ControlOutlined,
214 GlobalOutlined, 214 GlobalOutlined,
215 HomeOutlined, 215 HomeOutlined,
  216 + SettingOutlined,
216 ShopOutlined, 217 ShopOutlined,
217 StarOutlined, 218 StarOutlined,
  219 + TeamOutlined,
  220 + TrophyOutlined,
218 UnorderedListOutlined, 221 UnorderedListOutlined,
219 UserOutlined, 222 UserOutlined,
220 EyeOutlined, 223 EyeOutlined,
@@ -248,6 +251,9 @@ const iconOptions = [ @@ -248,6 +251,9 @@ const iconOptions = [
248 'UnorderedListOutlined', 251 'UnorderedListOutlined',
249 'ControlOutlined', 252 'ControlOutlined',
250 'ApiOutlined', 253 'ApiOutlined',
  254 + 'TeamOutlined',
  255 + 'TrophyOutlined',
  256 + 'SettingOutlined',
251 ] 257 ]
252 258
253 const iconMap: Record<string, any> = { 259 const iconMap: Record<string, any> = {
@@ -260,6 +266,9 @@ const iconMap: Record&lt;string, any&gt; = { @@ -260,6 +266,9 @@ const iconMap: Record&lt;string, any&gt; = {
260 UnorderedListOutlined, 266 UnorderedListOutlined,
261 ControlOutlined, 267 ControlOutlined,
262 ApiOutlined, 268 ApiOutlined,
  269 + TeamOutlined,
  270 + TrophyOutlined,
  271 + SettingOutlined,
263 } 272 }
264 273
265 function resolveIcon(icon?: string) { 274 function resolveIcon(icon?: string) {
src/views/system/RoleMenuAssign.vue
@@ -101,8 +101,11 @@ import { @@ -101,8 +101,11 @@ import {
101 ControlOutlined, 101 ControlOutlined,
102 GlobalOutlined, 102 GlobalOutlined,
103 HomeOutlined, 103 HomeOutlined,
  104 + SettingOutlined,
104 ShopOutlined, 105 ShopOutlined,
105 StarOutlined, 106 StarOutlined,
  107 + TeamOutlined,
  108 + TrophyOutlined,
106 UnorderedListOutlined, 109 UnorderedListOutlined,
107 UserOutlined, 110 UserOutlined,
108 InfoCircleOutlined 111 InfoCircleOutlined
@@ -135,6 +138,9 @@ const iconMap: Record&lt;string, any&gt; = { @@ -135,6 +138,9 @@ const iconMap: Record&lt;string, any&gt; = {
135 UnorderedListOutlined, 138 UnorderedListOutlined,
136 ControlOutlined, 139 ControlOutlined,
137 ApiOutlined, 140 ApiOutlined,
  141 + TeamOutlined,
  142 + TrophyOutlined,
  143 + SettingOutlined,
138 } 144 }
139 145
140 function resolveIcon(icon?: string) { 146 function resolveIcon(icon?: string) {