feat: 新增 H5 轨迹查询页并优化角色权限分配

- 新增 /track/query 移动端轨迹查询页,登录页 Hero 可跳转查询

- 角色权限分配支持父子级联勾选与已选计数修正

- nginx/vite 放行 H5 轨迹路由

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zzh 2026-07-04 17:59:20 +08:00
parent 2063621daf
commit 696adf7684
12 changed files with 567 additions and 32 deletions

View File

@ -35,6 +35,7 @@ server {
location ^~ /wechat/register { try_files /index.html =200; }
location ^~ /wechat/bind { try_files /index.html =200; }
location ^~ /track/query { try_files /index.html =200; }
location ^~ /operation/inboundScan { try_files /index.html =200; }
location ^~ /operation/outboundScan { try_files /index.html =200; }
location ^~ /operation/printLabel { try_files /index.html =200; }

View File

@ -0,0 +1,9 @@
import { postGetDetailTimeline } from '@/api/TrackOrder/trackOrder';
/** H5 公开轨迹查询(按单号) */
export async function queryPublicTrackTimeline(orderNumber: string) {
const no = orderNumber.trim();
if (!no) return [];
const list = await postGetDetailTimeline({ orderNumber: no });
return (list || []) as API.GetDetailTimelineOutput[];
}

View File

@ -1,6 +1,7 @@
import localeMessageBox from '@/components/message-box/locale/en-US';
import localeLogin from '@/views/login/locale/en-US';
import localeWechat from '@/views/wechat/locale/en-US';
import localeH5TrackQuery from '@/views/h5/track-query/locale/en-US';
import localeWorkplace from '@/views/home/workplace/locale/en-US';
@ -1060,6 +1061,7 @@ export default {
'system.role.tag.creating': 'Creating',
'system.role.tag.boundWarehouse': 'Warehouse bound',
'system.role.message.deleteSuccess': 'Delete successful',
'system.role.message.cannotDeleteSystemRole': 'Built-in system roles (e.g. Customer) cannot be deleted',
'system.role.message.updateSuccess': 'Update successful',
'system.role.message.createSuccess': 'Create successful',
@ -2958,6 +2960,7 @@ export default {
...localeMessageBox,
...localeLogin,
...localeWechat,
...localeH5TrackQuery,
...localeWorkplace,
...localeMonitor,

View File

@ -1,6 +1,7 @@
import localeMessageBox from '@/components/message-box/locale/zh-CN';
import localeLogin from '@/views/login/locale/zh-CN';
import localeWechat from '@/views/wechat/locale/zh-CN';
import localeH5TrackQuery from '@/views/h5/track-query/locale/zh-CN';
import localeWorkplace from '@/views/home/workplace/locale/zh-CN';
@ -946,6 +947,7 @@ export default {
'system.role.tag.creating': '新建中',
'system.role.tag.boundWarehouse': '已绑仓库',
'system.role.message.deleteSuccess': '删除成功',
'system.role.message.cannotDeleteSystemRole': '系统内置角色(如客户)不可删除',
'system.role.message.updateSuccess': '更新成功',
'system.role.message.createSuccess': '创建成功',
@ -2995,6 +2997,7 @@ export default {
...localeMessageBox,
...localeLogin,
...localeWechat,
...localeH5TrackQuery,
...localeWorkplace,
...localeMonitor,

View File

@ -51,6 +51,15 @@ const router = createRouter({
hideInMenu: true,
},
},
{
path: '/track/query',
name: 'H5TrackQuery',
component: () => import('@/views/h5/track-query/index.vue'),
meta: {
requiresAuth: false,
hideInMenu: true,
},
},
{
path: '/wechat/register',
name: 'WechatRegister',

View File

@ -111,3 +111,149 @@ export function collectPermissionCodesFromRows(rows: PermissionAssignRow[]): str
})
return Array.from(codes)
}
export function getAssignablePermissionCodeSet(rows: PermissionAssignRow[]): Set<string> {
return new Set(collectPermissionCodesFromRows(rows))
}
/** 仅统计分配树内的已选数量,并补全上级勾选 */
export function normalizeSelectedPermissions(
selected: readonly string[],
rows: PermissionAssignRow[]
): string[] {
const assignable = getAssignablePermissionCodeSet(rows)
if (assignable.size === 0) return []
const context = buildPermissionCascadeContext(rows)
const next = new Set<string>()
selected.forEach((code) => {
if (!assignable.has(code)) return
next.add(code)
for (const ancestor of context.ancestors.get(code) ?? []) {
if (assignable.has(ancestor)) {
next.add(ancestor)
}
}
})
return Array.from(next)
}
export function countSelectedAssignablePermissions(
selected: readonly string[],
rows: PermissionAssignRow[]
): number {
return normalizeSelectedPermissions(selected, rows).length
}
export interface PermissionCascadeContext {
/** 权限 code -> 上级 code 列表(由近到远:二级、一级) */
ancestors: Map<string, string[]>
/** 一级菜单 code -> 其下全部权限 code(含自身、二级、按钮) */
descendantsByLevel1: Map<string, string[]>
/** 二级菜单 code -> 其下全部权限 code(含自身、按钮) */
descendantsByLevel2: Map<string, string[]>
}
export function buildPermissionCascadeContext(
rows: PermissionAssignRow[]
): PermissionCascadeContext {
const ancestors = new Map<string, string[]>()
const descendantsByLevel1 = new Map<string, Set<string>>()
const descendantsByLevel2 = new Map<string, Set<string>>()
const ensureLevel1 = (code: string) => {
if (!descendantsByLevel1.has(code)) {
descendantsByLevel1.set(code, new Set())
}
return descendantsByLevel1.get(code)!
}
const ensureLevel2 = (code: string) => {
if (!descendantsByLevel2.has(code)) {
descendantsByLevel2.set(code, new Set())
}
return descendantsByLevel2.get(code)!
}
const addAncestors = (code: string, ancestorCodes: string[]) => {
if (!code || ancestorCodes.length === 0) return
const prev = ancestors.get(code) ?? []
ancestors.set(code, Array.from(new Set([...prev, ...ancestorCodes])))
}
rows.forEach((row) => {
const { level1Code, level2Code } = row
if (level1Code) {
ensureLevel1(level1Code).add(level1Code)
ancestors.set(level1Code, [])
}
if (level2Code) {
ensureLevel2(level2Code).add(level2Code)
addAncestors(level2Code, level1Code ? [level1Code] : [])
if (level1Code) {
ensureLevel1(level1Code).add(level2Code)
}
}
row.buttons.forEach((btn) => {
if (!btn.code) return
addAncestors(btn.code, [level2Code, level1Code].filter(Boolean) as string[])
if (level2Code) {
ensureLevel2(level2Code).add(btn.code)
}
if (level1Code) {
ensureLevel1(level1Code).add(btn.code)
}
})
})
const toArrayMap = (source: Map<string, Set<string>>) =>
new Map(Array.from(source.entries(), ([key, value]) => [key, Array.from(value)]))
return {
ancestors,
descendantsByLevel1: toArrayMap(descendantsByLevel1),
descendantsByLevel2: toArrayMap(descendantsByLevel2),
}
}
/** 勾选子级时联动勾选上级;取消一级时联动取消其下全部权限 */
export function togglePermissionWithCascade(
selected: readonly string[],
context: PermissionCascadeContext,
code: string,
enabled: boolean
): string[] {
if (!code) return [...selected]
const next = new Set(selected)
if (enabled) {
next.add(code)
for (const ancestor of context.ancestors.get(code) ?? []) {
next.add(ancestor)
}
return Array.from(next)
}
if (context.descendantsByLevel1.has(code)) {
for (const descendant of context.descendantsByLevel1.get(code)!) {
next.delete(descendant)
}
return Array.from(next)
}
if (context.descendantsByLevel2.has(code)) {
for (const descendant of context.descendantsByLevel2.get(code)!) {
next.delete(descendant)
}
return Array.from(next)
}
next.delete(code)
return Array.from(next)
}

View File

@ -0,0 +1,315 @@
<template>
<div class="track-h5-page">
<header class="track-h5-header">
<button type="button" class="track-h5-back" @click="goBack">
<icon-left />
</button>
<h1 class="track-h5-title">{{ t('h5.trackQuery.title') }}</h1>
</header>
<main class="track-h5-main" :class="{ 'track-h5-main--centered': !searched }">
<section class="track-h5-search-card">
<p class="track-h5-desc">{{ t('h5.trackQuery.desc') }}</p>
<form class="track-h5-form" @submit.prevent="handleSearch">
<a-input
v-model="orderNo"
size="large"
allow-clear
:placeholder="t('h5.trackQuery.placeholder')"
autocomplete="off"
/>
<a-button type="primary" size="large" html-type="submit" :loading="loading" long>
{{ t('h5.trackQuery.search') }}
</a-button>
</form>
</section>
<section v-if="searched" class="track-h5-result">
<div v-if="loading" class="track-h5-state">
<a-spin />
</div>
<a-empty v-else-if="timeline.length === 0" :description="t('h5.trackQuery.empty')" />
<div v-else class="track-h5-timeline">
<div class="track-h5-order-no">
{{ t('h5.trackQuery.orderNo') }}:{{ displayOrderNo }}
</div>
<div
v-for="(item, index) in timeline"
:key="`${item.trackCode}-${item.trackTime}-${index}`"
class="track-h5-timeline-item"
>
<div class="track-h5-timeline-axis">
<span
class="track-h5-timeline-dot"
:class="{ 'track-h5-timeline-dot--active': index === 0 || item.isHighlight }"
/>
<span v-if="index !== timeline.length - 1" class="track-h5-timeline-line" />
</div>
<div class="track-h5-timeline-body">
<div class="track-h5-timeline-content">{{ item.content || '—' }}</div>
<div v-if="item.trackPlace || formatPlace(item)" class="track-h5-timeline-place">
{{ item.trackPlace || formatPlace(item) }}
</div>
<div class="track-h5-timeline-time">
{{ formatTrackTime(item) }}
</div>
</div>
</div>
</div>
</section>
</main>
</div>
</template>
<script lang="ts" setup>
import { onMounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { Message } from '@arco-design/web-vue';
import { IconLeft } from '@arco-design/web-vue/es/icon';
import { queryPublicTrackTimeline } from '@/api/h5/publicTrack';
import { handleApiError } from '@/utils/rbac-error-handler';
import dayjs from 'dayjs';
defineOptions({ name: 'H5TrackQuery' });
const { t } = useI18n();
const route = useRoute();
const router = useRouter();
const orderNo = ref('');
const displayOrderNo = ref('');
const loading = ref(false);
const searched = ref(false);
const timeline = ref<API.GetDetailTimelineOutput[]>([]);
function formatPlace(item: API.GetDetailTimelineOutput) {
return [item.trackPlaceNation, item.trackPlaceProvince, item.trackPlaceCity]
.filter(Boolean)
.join(' ');
}
function formatTrackTime(item: API.GetDetailTimelineOutput) {
if (!item.trackTime) return '—';
const ts = item.trackTime > 1e12 ? item.trackTime : item.trackTime * 1000;
const prefix = item.trackTimePrefix ? `${item.trackTimePrefix} ` : '';
const zone = item.trackTimeZone ? ` (${item.trackTimeZone})` : '';
return `${prefix}${dayjs(ts).format('YYYY-MM-DD HH:mm:ss')}${zone}`;
}
async function handleSearch() {
const no = orderNo.value.trim();
if (!no) {
Message.info(t('h5.trackQuery.placeholder'));
return;
}
loading.value = true;
searched.value = true;
displayOrderNo.value = no;
router.replace({ path: '/track/query', query: { no } });
try {
timeline.value = await queryPublicTrackTimeline(no);
} catch (error) {
timeline.value = [];
handleApiError(error);
} finally {
loading.value = false;
}
}
function goBack() {
if (window.history.length > 1) {
router.back();
return;
}
router.push('/');
}
onMounted(() => {
const no = route.query.no;
if (typeof no === 'string' && no.trim()) {
orderNo.value = no.trim();
void handleSearch();
}
});
</script>
<style lang="less" scoped>
.track-h5-page {
min-height: 100vh;
background: #f0f3f4;
padding-bottom: 24px;
box-sizing: border-box;
}
.track-h5-header {
position: sticky;
top: 0;
z-index: 10;
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
background: var(--app-primary-color, #007979);
color: #fff;
}
.track-h5-back {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
border-radius: 4px;
background: transparent;
color: #fff;
cursor: pointer;
}
.track-h5-title {
margin: 0;
font-size: 18px;
font-weight: 600;
}
.track-h5-main {
display: flex;
flex-direction: column;
align-items: center;
padding: 16px;
box-sizing: border-box;
}
.track-h5-main--centered {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
min-height: calc(100vh - 56px);
}
.track-h5-search-card {
width: 100%;
max-width: 420px;
padding: 24px 20px;
background: #fff;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
text-align: center;
}
.track-h5-desc {
margin: 0 0 20px;
font-size: 14px;
line-height: 1.6;
color: #86909c;
}
.track-h5-form {
display: flex;
flex-direction: column;
gap: 12px;
width: 100%;
max-width: 320px;
margin: 0 auto;
}
.track-h5-form :deep(.arco-btn-primary) {
height: 40px;
border-radius: 4px;
background: var(--app-primary-color, #007979);
}
.track-h5-result {
width: 100%;
max-width: 420px;
margin-top: 16px;
}
.track-h5-state {
display: flex;
justify-content: center;
padding: 48px 0;
}
.track-h5-order-no {
margin-bottom: 16px;
padding: 12px 14px;
background: #fff;
border-radius: 8px;
font-size: 14px;
font-weight: 600;
color: #1d2129;
word-break: break-all;
}
.track-h5-timeline {
padding: 16px;
background: #fff;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
.track-h5-timeline-item {
display: flex;
gap: 12px;
}
.track-h5-timeline-axis {
display: flex;
flex-direction: column;
align-items: center;
width: 16px;
flex-shrink: 0;
}
.track-h5-timeline-dot {
width: 10px;
height: 10px;
margin-top: 6px;
border-radius: 50%;
background: #c9cdd4;
flex-shrink: 0;
}
.track-h5-timeline-dot--active {
background: var(--app-primary-color, #007979);
box-shadow: 0 0 0 4px rgba(0, 121, 121, 0.15);
}
.track-h5-timeline-line {
flex: 1;
width: 2px;
min-height: 24px;
margin: 4px 0;
background: #e5e6eb;
}
.track-h5-timeline-body {
flex: 1;
min-width: 0;
padding-bottom: 20px;
}
.track-h5-timeline-content {
font-size: 15px;
font-weight: 500;
line-height: 1.5;
color: #1d2129;
}
.track-h5-timeline-place {
margin-top: 4px;
font-size: 13px;
color: #4e5969;
}
.track-h5-timeline-time {
margin-top: 6px;
font-size: 12px;
color: #86909c;
}
</style>

View File

@ -0,0 +1,8 @@
export default {
'h5.trackQuery.title': 'Track Shipment',
'h5.trackQuery.desc': 'Enter your tracking number to view the latest updates',
'h5.trackQuery.placeholder': 'Enter tracking number',
'h5.trackQuery.search': 'Search',
'h5.trackQuery.empty': 'No tracking info found. Please check the number.',
'h5.trackQuery.orderNo': 'Tracking No.',
};

View File

@ -0,0 +1,8 @@
export default {
'h5.trackQuery.title': '物流轨迹查询',
'h5.trackQuery.desc': '输入运单号,查看包裹最新物流动态',
'h5.trackQuery.placeholder': '请输入运单号',
'h5.trackQuery.search': '查询',
'h5.trackQuery.empty': '暂无轨迹信息,请确认单号是否正确',
'h5.trackQuery.orderNo': '运单号',
};

View File

@ -31,6 +31,7 @@
<script lang="ts" setup>
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { Message } from '@arco-design/web-vue';
@ -41,14 +42,16 @@ defineProps<{
}>();
const { t } = useI18n();
const router = useRouter();
const trackingNo = ref('');
function onTrackSubmit() {
if (!trackingNo.value.trim()) {
const no = trackingNo.value.trim();
if (!no) {
Message.info(t('login.hero.trackPlaceholder'));
return;
}
Message.info(t('login.hero.trackPending'));
router.push({ path: '/track/query', query: { no } });
}
</script>
@ -160,6 +163,8 @@ function onTrackSubmit() {
width: 100%;
max-width: 420px;
height: auto;
margin-top: 200px;
margin-left: -200px;
transform-origin: 70% 50%;
animation: loginHeroPlane3D 3.6s linear 1 forwards;
will-change: transform, opacity;

View File

@ -48,7 +48,7 @@
<template #actions>
<a-space>
<a-button
v-if="hasPermission('Rbac.Roles.Update')"
v-if="hasPermission('Rbac.Roles.Update') && activeTab === 'info'"
type="primary"
:loading="modalLoading"
@click="handleSaveRole"
@ -57,6 +57,7 @@
</a-button>
<a-button
v-if="hasPermission('Rbac.Roles.AssignPermissions') && activeTab === 'permissions'"
type="primary"
:loading="permissionSaving"
@click="handleSavePermissions"
>
@ -112,7 +113,7 @@
<a-button size="small" @click="uncheckAllPermissions">{{ t('system.role.permission.uncheckAll') }}</a-button>
</a-space>
<span class="perm-summary">
{{ t('system.role.permission.selectedCount', { selected: selectedPermissions.length, total: totalPermissionCount }) }}
{{ t('system.role.permission.selectedCount', { selected: selectedPermissionCount, total: totalPermissionCount }) }}
</span>
</div>
<div class="section-card permission-list-card">
@ -157,19 +158,19 @@
</a-table-column>
<a-table-column :title="t('system.role.permission.col.level3')">
<template #cell="{ record }">
<a-checkbox-group
<div
v-if="record.level2HasChildren"
v-model="selectedPermissions"
class="permission-button-group"
>
<a-checkbox
v-for="btn in record.buttons"
:key="btn.code"
:value="btn.code"
:model-value="isPermissionChecked(btn.code)"
@change="(checked) => togglePermission(btn.code, checked)"
>
{{ btn.name }}
</a-checkbox>
</a-checkbox-group>
</div>
<span v-else class="permission-no-buttons">—</span>
</template>
</a-table-column>
@ -206,9 +207,13 @@ import { handleApiError } from '@/utils/rbac-error-handler'
import { buildFlatLeftMenuTree, findLeftMenuItemByKey } from '@/utils/rbacLeftMenuTree'
import {
buildPermissionAssignRows,
buildPermissionCascadeContext,
collectPermissionCodesFromRows,
computeLevel1RowSpans,
countSelectedAssignablePermissions,
filterPermissionAssignRows,
normalizeSelectedPermissions,
togglePermissionWithCascade,
} from '@/utils/rbacPermissionAssignRows'
import dayjs from 'dayjs'
@ -247,15 +252,8 @@ const permissionTree = ref<PermissionGroupDto[]>([])
const selectedPermissions = ref<string[]>([])
const permissionTableScrollY = 'calc(100vh - 340px)'
const internalRoles = computed(() =>
dataSource.value.filter((role) => {
const code = role.code || ''
return code !== 'Customer' && !code.startsWith('CustomerSub_')
})
)
const roleTreeData = computed(() =>
buildFlatLeftMenuTree('category-roles', t('system.role.pageTitle'), internalRoles.value, {
buildFlatLeftMenuTree('category-roles', t('system.role.pageTitle'), dataSource.value, {
titleKey: 'name',
codeKey: 'code',
})
@ -267,6 +265,10 @@ const selectedKeys = computed(() =>
const permissionRows = computed(() => buildPermissionAssignRows(permissionTree.value))
const permissionCascadeContext = computed(() =>
buildPermissionCascadeContext(permissionRows.value)
)
const filteredPermissionRows = computed(() =>
filterPermissionAssignRows(permissionRows.value, permissionKeyword.value)
)
@ -277,12 +279,23 @@ const totalPermissionCount = computed(() =>
collectPermissionCodesFromRows(permissionRows.value).length
)
const selectedPermissionCount = computed(() =>
countSelectedAssignablePermissions(selectedPermissions.value, permissionRows.value)
)
const normalizedSelectedPermissions = computed(() =>
normalizeSelectedPermissions(selectedPermissions.value, permissionRows.value)
)
const roleTags = computed((): RbacDetailTag[] => {
if (!selectedRole.value) return []
const tags: RbacDetailTag[] = []
if (isCreating.value) {
tags.push({ label: t('system.role.tag.creating'), color: 'orangered' })
}
if (isCustomerRole(selectedRole.value)) {
tags.push({ label: t('system.role.category.system'), color: 'arcoblue' })
}
if (formState.boundWarehouseId) {
tags.push({ label: t('system.role.tag.boundWarehouse'), color: 'green' })
}
@ -290,7 +303,7 @@ const roleTags = computed((): RbacDetailTag[] => {
})
const roleStats = computed(() => [
{ label: t('system.role.permission.selected'), value: selectedPermissions.value.length },
{ label: t('system.role.permission.selected'), value: selectedPermissionCount.value },
{ label: t('system.role.permission.total'), value: totalPermissionCount.value },
])
@ -300,7 +313,7 @@ const roleTabs = computed(() => {
tabs.push({
key: 'permissions',
label: t('system.role.modal.assignPermissions'),
badge: selectedPermissions.value.length || undefined,
badge: selectedPermissionCount.value || undefined,
})
}
return tabs
@ -323,13 +336,19 @@ function isPermissionChecked(code: string) {
return selectedPermissions.value.includes(code)
}
function isChangeChecked(checked: boolean | (string | number | boolean)) {
if (Array.isArray(checked)) return checked.length > 0
return checked === true
}
function togglePermission(code: string, checked: boolean | (string | number | boolean)) {
if (!code) return
const enabled = checked === true
const next = new Set(selectedPermissions.value)
if (enabled) next.add(code)
else next.delete(code)
selectedPermissions.value = Array.from(next)
selectedPermissions.value = togglePermissionWithCascade(
selectedPermissions.value,
permissionCascadeContext.value,
code,
isChangeChecked(checked)
)
}
function formatTime(value?: string | null) {
@ -342,7 +361,7 @@ function isCustomerRole(role?: RoleDto | null) {
}
function selectRoleFromKey(key: string) {
const role = findLeftMenuItemByKey(key, internalRoles.value)
const role = findLeftMenuItemByKey(key, dataSource.value)
if (role) {
isCreating.value = false
selectedRole.value = role
@ -376,10 +395,10 @@ async function loadData() {
)
dataSource.value = response?.items || []
if (selectedRole.value?.id) {
const stillExists = internalRoles.value.find((r) => r.id === selectedRole.value?.id)
selectedRole.value = stillExists || internalRoles.value[0] || null
} else if (!isCreating.value && internalRoles.value.length > 0) {
selectedRole.value = internalRoles.value[0]
const stillExists = dataSource.value.find((r) => r.id === selectedRole.value?.id)
selectedRole.value = stillExists || dataSource.value[0] || null
} else if (!isCreating.value && dataSource.value.length > 0) {
selectedRole.value = dataSource.value[0]
}
} catch (error) {
handleApiError(error)
@ -401,7 +420,8 @@ async function loadRolePermissions(roleId: string) {
permissionLoading.value = true
try {
await loadPermissionTree()
selectedPermissions.value = (await RoleService.getRbacRoleGetRolePermissions(roleId)) || []
const loaded = (await RoleService.getRbacRoleGetRolePermissions(roleId)) || []
selectedPermissions.value = normalizeSelectedPermissions(loaded, permissionRows.value)
} catch (error) {
handleApiError(error)
} finally {
@ -432,6 +452,11 @@ function handleAdd() {
async function handleDelete(id?: string) {
if (!id) return
const role = dataSource.value.find((r) => r.id === id)
if (isCustomerRole(role)) {
Message.warning(t('system.role.message.cannotDeleteSystemRole'))
return
}
Modal.confirm({
content: t('system.role.modal.deleteConfirmContent', { count: 1 }),
onOk: async () => {
@ -469,7 +494,7 @@ async function handleSaveRole() {
isCreating.value = false
await loadData()
if (newRoleId) {
selectedRole.value = internalRoles.value.find((r) => r.id === newRoleId) || null
selectedRole.value = dataSource.value.find((r) => r.id === newRoleId) || null
}
} else if (selectedRole.value?.id) {
const updateData: RoleUpdateDto = { name: formState.name }
@ -492,7 +517,10 @@ async function handleSavePermissions() {
if (!selectedRole.value?.id || isCreating.value) return
permissionSaving.value = true
try {
await RoleService.postRbacRoleAssignPermissions(selectedRole.value.id, selectedPermissions.value)
await RoleService.postRbacRoleAssignPermissions(
selectedRole.value.id,
normalizedSelectedPermissions.value
)
Message.success(t('system.role.permission.assignSuccess'))
} catch (error) {
handleApiError(error)
@ -505,7 +533,6 @@ watch(
() => selectedRole.value?.id,
async (roleId) => {
if (!roleId || isCreating.value) return
if (isCustomerRole(selectedRole.value)) return
syncFormFromRole(selectedRole.value!)
activeTab.value = 'info'
permissionKeyword.value = ''

View File

@ -274,6 +274,7 @@ export default defineConfig(({ mode }) => {
'/track/push',
'/track/kpiRegion',
'/track/kpiEarlyWarningConfig',
'/track/query',
// Operation 相关前端路由
'/operation/inboundScan',
'/operation/outboundScan',