feat: 新增 H5 轨迹查询页并优化角色权限分配
- 新增 /track/query 移动端轨迹查询页,登录页 Hero 可跳转查询 - 角色权限分配支持父子级联勾选与已选计数修正 - nginx/vite 放行 H5 轨迹路由 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
2063621daf
commit
696adf7684
@ -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; }
|
||||
|
||||
9
src/api/h5/publicTrack.ts
Normal file
9
src/api/h5/publicTrack.ts
Normal 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[];
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
315
src/views/h5/track-query/index.vue
Normal file
315
src/views/h5/track-query/index.vue
Normal 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>
|
||||
8
src/views/h5/track-query/locale/en-US.ts
Normal file
8
src/views/h5/track-query/locale/en-US.ts
Normal 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.',
|
||||
};
|
||||
8
src/views/h5/track-query/locale/zh-CN.ts
Normal file
8
src/views/h5/track-query/locale/zh-CN.ts
Normal 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': '运单号',
|
||||
};
|
||||
@ -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;
|
||||
|
||||
@ -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 = ''
|
||||
|
||||
@ -274,6 +274,7 @@ export default defineConfig(({ mode }) => {
|
||||
'/track/push',
|
||||
'/track/kpiRegion',
|
||||
'/track/kpiEarlyWarningConfig',
|
||||
'/track/query',
|
||||
// Operation 相关前端路由
|
||||
'/operation/inboundScan',
|
||||
'/operation/outboundScan',
|
||||
|
||||
Loading…
Reference in New Issue
Block a user