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/register { try_files /index.html =200; }
|
||||||
location ^~ /wechat/bind { 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/inboundScan { try_files /index.html =200; }
|
||||||
location ^~ /operation/outboundScan { try_files /index.html =200; }
|
location ^~ /operation/outboundScan { try_files /index.html =200; }
|
||||||
location ^~ /operation/printLabel { 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 localeMessageBox from '@/components/message-box/locale/en-US';
|
||||||
import localeLogin from '@/views/login/locale/en-US';
|
import localeLogin from '@/views/login/locale/en-US';
|
||||||
import localeWechat from '@/views/wechat/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';
|
import localeWorkplace from '@/views/home/workplace/locale/en-US';
|
||||||
|
|
||||||
@ -1060,6 +1061,7 @@ export default {
|
|||||||
'system.role.tag.creating': 'Creating',
|
'system.role.tag.creating': 'Creating',
|
||||||
'system.role.tag.boundWarehouse': 'Warehouse bound',
|
'system.role.tag.boundWarehouse': 'Warehouse bound',
|
||||||
'system.role.message.deleteSuccess': 'Delete successful',
|
'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.updateSuccess': 'Update successful',
|
||||||
'system.role.message.createSuccess': 'Create successful',
|
'system.role.message.createSuccess': 'Create successful',
|
||||||
|
|
||||||
@ -2958,6 +2960,7 @@ export default {
|
|||||||
...localeMessageBox,
|
...localeMessageBox,
|
||||||
...localeLogin,
|
...localeLogin,
|
||||||
...localeWechat,
|
...localeWechat,
|
||||||
|
...localeH5TrackQuery,
|
||||||
...localeWorkplace,
|
...localeWorkplace,
|
||||||
|
|
||||||
...localeMonitor,
|
...localeMonitor,
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import localeMessageBox from '@/components/message-box/locale/zh-CN';
|
import localeMessageBox from '@/components/message-box/locale/zh-CN';
|
||||||
import localeLogin from '@/views/login/locale/zh-CN';
|
import localeLogin from '@/views/login/locale/zh-CN';
|
||||||
import localeWechat from '@/views/wechat/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';
|
import localeWorkplace from '@/views/home/workplace/locale/zh-CN';
|
||||||
|
|
||||||
@ -946,6 +947,7 @@ export default {
|
|||||||
'system.role.tag.creating': '新建中',
|
'system.role.tag.creating': '新建中',
|
||||||
'system.role.tag.boundWarehouse': '已绑仓库',
|
'system.role.tag.boundWarehouse': '已绑仓库',
|
||||||
'system.role.message.deleteSuccess': '删除成功',
|
'system.role.message.deleteSuccess': '删除成功',
|
||||||
|
'system.role.message.cannotDeleteSystemRole': '系统内置角色(如客户)不可删除',
|
||||||
'system.role.message.updateSuccess': '更新成功',
|
'system.role.message.updateSuccess': '更新成功',
|
||||||
'system.role.message.createSuccess': '创建成功',
|
'system.role.message.createSuccess': '创建成功',
|
||||||
|
|
||||||
@ -2995,6 +2997,7 @@ export default {
|
|||||||
...localeMessageBox,
|
...localeMessageBox,
|
||||||
...localeLogin,
|
...localeLogin,
|
||||||
...localeWechat,
|
...localeWechat,
|
||||||
|
...localeH5TrackQuery,
|
||||||
...localeWorkplace,
|
...localeWorkplace,
|
||||||
|
|
||||||
...localeMonitor,
|
...localeMonitor,
|
||||||
|
|||||||
@ -51,6 +51,15 @@ const router = createRouter({
|
|||||||
hideInMenu: true,
|
hideInMenu: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/track/query',
|
||||||
|
name: 'H5TrackQuery',
|
||||||
|
component: () => import('@/views/h5/track-query/index.vue'),
|
||||||
|
meta: {
|
||||||
|
requiresAuth: false,
|
||||||
|
hideInMenu: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/wechat/register',
|
path: '/wechat/register',
|
||||||
name: 'WechatRegister',
|
name: 'WechatRegister',
|
||||||
|
|||||||
@ -111,3 +111,149 @@ export function collectPermissionCodesFromRows(rows: PermissionAssignRow[]): str
|
|||||||
})
|
})
|
||||||
return Array.from(codes)
|
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>
|
<script lang="ts" setup>
|
||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { Message } from '@arco-design/web-vue';
|
import { Message } from '@arco-design/web-vue';
|
||||||
|
|
||||||
@ -41,14 +42,16 @@ defineProps<{
|
|||||||
}>();
|
}>();
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
const router = useRouter();
|
||||||
const trackingNo = ref('');
|
const trackingNo = ref('');
|
||||||
|
|
||||||
function onTrackSubmit() {
|
function onTrackSubmit() {
|
||||||
if (!trackingNo.value.trim()) {
|
const no = trackingNo.value.trim();
|
||||||
|
if (!no) {
|
||||||
Message.info(t('login.hero.trackPlaceholder'));
|
Message.info(t('login.hero.trackPlaceholder'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
Message.info(t('login.hero.trackPending'));
|
router.push({ path: '/track/query', query: { no } });
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@ -160,6 +163,8 @@ function onTrackSubmit() {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 420px;
|
max-width: 420px;
|
||||||
height: auto;
|
height: auto;
|
||||||
|
margin-top: 200px;
|
||||||
|
margin-left: -200px;
|
||||||
transform-origin: 70% 50%;
|
transform-origin: 70% 50%;
|
||||||
animation: loginHeroPlane3D 3.6s linear 1 forwards;
|
animation: loginHeroPlane3D 3.6s linear 1 forwards;
|
||||||
will-change: transform, opacity;
|
will-change: transform, opacity;
|
||||||
|
|||||||
@ -48,7 +48,7 @@
|
|||||||
<template #actions>
|
<template #actions>
|
||||||
<a-space>
|
<a-space>
|
||||||
<a-button
|
<a-button
|
||||||
v-if="hasPermission('Rbac.Roles.Update')"
|
v-if="hasPermission('Rbac.Roles.Update') && activeTab === 'info'"
|
||||||
type="primary"
|
type="primary"
|
||||||
:loading="modalLoading"
|
:loading="modalLoading"
|
||||||
@click="handleSaveRole"
|
@click="handleSaveRole"
|
||||||
@ -57,6 +57,7 @@
|
|||||||
</a-button>
|
</a-button>
|
||||||
<a-button
|
<a-button
|
||||||
v-if="hasPermission('Rbac.Roles.AssignPermissions') && activeTab === 'permissions'"
|
v-if="hasPermission('Rbac.Roles.AssignPermissions') && activeTab === 'permissions'"
|
||||||
|
type="primary"
|
||||||
:loading="permissionSaving"
|
:loading="permissionSaving"
|
||||||
@click="handleSavePermissions"
|
@click="handleSavePermissions"
|
||||||
>
|
>
|
||||||
@ -112,7 +113,7 @@
|
|||||||
<a-button size="small" @click="uncheckAllPermissions">{{ t('system.role.permission.uncheckAll') }}</a-button>
|
<a-button size="small" @click="uncheckAllPermissions">{{ t('system.role.permission.uncheckAll') }}</a-button>
|
||||||
</a-space>
|
</a-space>
|
||||||
<span class="perm-summary">
|
<span class="perm-summary">
|
||||||
{{ t('system.role.permission.selectedCount', { selected: selectedPermissions.length, total: totalPermissionCount }) }}
|
{{ t('system.role.permission.selectedCount', { selected: selectedPermissionCount, total: totalPermissionCount }) }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="section-card permission-list-card">
|
<div class="section-card permission-list-card">
|
||||||
@ -157,19 +158,19 @@
|
|||||||
</a-table-column>
|
</a-table-column>
|
||||||
<a-table-column :title="t('system.role.permission.col.level3')">
|
<a-table-column :title="t('system.role.permission.col.level3')">
|
||||||
<template #cell="{ record }">
|
<template #cell="{ record }">
|
||||||
<a-checkbox-group
|
<div
|
||||||
v-if="record.level2HasChildren"
|
v-if="record.level2HasChildren"
|
||||||
v-model="selectedPermissions"
|
|
||||||
class="permission-button-group"
|
class="permission-button-group"
|
||||||
>
|
>
|
||||||
<a-checkbox
|
<a-checkbox
|
||||||
v-for="btn in record.buttons"
|
v-for="btn in record.buttons"
|
||||||
:key="btn.code"
|
:key="btn.code"
|
||||||
:value="btn.code"
|
:model-value="isPermissionChecked(btn.code)"
|
||||||
|
@change="(checked) => togglePermission(btn.code, checked)"
|
||||||
>
|
>
|
||||||
{{ btn.name }}
|
{{ btn.name }}
|
||||||
</a-checkbox>
|
</a-checkbox>
|
||||||
</a-checkbox-group>
|
</div>
|
||||||
<span v-else class="permission-no-buttons">—</span>
|
<span v-else class="permission-no-buttons">—</span>
|
||||||
</template>
|
</template>
|
||||||
</a-table-column>
|
</a-table-column>
|
||||||
@ -206,9 +207,13 @@ import { handleApiError } from '@/utils/rbac-error-handler'
|
|||||||
import { buildFlatLeftMenuTree, findLeftMenuItemByKey } from '@/utils/rbacLeftMenuTree'
|
import { buildFlatLeftMenuTree, findLeftMenuItemByKey } from '@/utils/rbacLeftMenuTree'
|
||||||
import {
|
import {
|
||||||
buildPermissionAssignRows,
|
buildPermissionAssignRows,
|
||||||
|
buildPermissionCascadeContext,
|
||||||
collectPermissionCodesFromRows,
|
collectPermissionCodesFromRows,
|
||||||
computeLevel1RowSpans,
|
computeLevel1RowSpans,
|
||||||
|
countSelectedAssignablePermissions,
|
||||||
filterPermissionAssignRows,
|
filterPermissionAssignRows,
|
||||||
|
normalizeSelectedPermissions,
|
||||||
|
togglePermissionWithCascade,
|
||||||
} from '@/utils/rbacPermissionAssignRows'
|
} from '@/utils/rbacPermissionAssignRows'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
|
|
||||||
@ -247,15 +252,8 @@ const permissionTree = ref<PermissionGroupDto[]>([])
|
|||||||
const selectedPermissions = ref<string[]>([])
|
const selectedPermissions = ref<string[]>([])
|
||||||
const permissionTableScrollY = 'calc(100vh - 340px)'
|
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(() =>
|
const roleTreeData = computed(() =>
|
||||||
buildFlatLeftMenuTree('category-roles', t('system.role.pageTitle'), internalRoles.value, {
|
buildFlatLeftMenuTree('category-roles', t('system.role.pageTitle'), dataSource.value, {
|
||||||
titleKey: 'name',
|
titleKey: 'name',
|
||||||
codeKey: 'code',
|
codeKey: 'code',
|
||||||
})
|
})
|
||||||
@ -267,6 +265,10 @@ const selectedKeys = computed(() =>
|
|||||||
|
|
||||||
const permissionRows = computed(() => buildPermissionAssignRows(permissionTree.value))
|
const permissionRows = computed(() => buildPermissionAssignRows(permissionTree.value))
|
||||||
|
|
||||||
|
const permissionCascadeContext = computed(() =>
|
||||||
|
buildPermissionCascadeContext(permissionRows.value)
|
||||||
|
)
|
||||||
|
|
||||||
const filteredPermissionRows = computed(() =>
|
const filteredPermissionRows = computed(() =>
|
||||||
filterPermissionAssignRows(permissionRows.value, permissionKeyword.value)
|
filterPermissionAssignRows(permissionRows.value, permissionKeyword.value)
|
||||||
)
|
)
|
||||||
@ -277,12 +279,23 @@ const totalPermissionCount = computed(() =>
|
|||||||
collectPermissionCodesFromRows(permissionRows.value).length
|
collectPermissionCodesFromRows(permissionRows.value).length
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const selectedPermissionCount = computed(() =>
|
||||||
|
countSelectedAssignablePermissions(selectedPermissions.value, permissionRows.value)
|
||||||
|
)
|
||||||
|
|
||||||
|
const normalizedSelectedPermissions = computed(() =>
|
||||||
|
normalizeSelectedPermissions(selectedPermissions.value, permissionRows.value)
|
||||||
|
)
|
||||||
|
|
||||||
const roleTags = computed((): RbacDetailTag[] => {
|
const roleTags = computed((): RbacDetailTag[] => {
|
||||||
if (!selectedRole.value) return []
|
if (!selectedRole.value) return []
|
||||||
const tags: RbacDetailTag[] = []
|
const tags: RbacDetailTag[] = []
|
||||||
if (isCreating.value) {
|
if (isCreating.value) {
|
||||||
tags.push({ label: t('system.role.tag.creating'), color: 'orangered' })
|
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) {
|
if (formState.boundWarehouseId) {
|
||||||
tags.push({ label: t('system.role.tag.boundWarehouse'), color: 'green' })
|
tags.push({ label: t('system.role.tag.boundWarehouse'), color: 'green' })
|
||||||
}
|
}
|
||||||
@ -290,7 +303,7 @@ const roleTags = computed((): RbacDetailTag[] => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const roleStats = computed(() => [
|
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 },
|
{ label: t('system.role.permission.total'), value: totalPermissionCount.value },
|
||||||
])
|
])
|
||||||
|
|
||||||
@ -300,7 +313,7 @@ const roleTabs = computed(() => {
|
|||||||
tabs.push({
|
tabs.push({
|
||||||
key: 'permissions',
|
key: 'permissions',
|
||||||
label: t('system.role.modal.assignPermissions'),
|
label: t('system.role.modal.assignPermissions'),
|
||||||
badge: selectedPermissions.value.length || undefined,
|
badge: selectedPermissionCount.value || undefined,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return tabs
|
return tabs
|
||||||
@ -323,13 +336,19 @@ function isPermissionChecked(code: string) {
|
|||||||
return selectedPermissions.value.includes(code)
|
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)) {
|
function togglePermission(code: string, checked: boolean | (string | number | boolean)) {
|
||||||
if (!code) return
|
if (!code) return
|
||||||
const enabled = checked === true
|
selectedPermissions.value = togglePermissionWithCascade(
|
||||||
const next = new Set(selectedPermissions.value)
|
selectedPermissions.value,
|
||||||
if (enabled) next.add(code)
|
permissionCascadeContext.value,
|
||||||
else next.delete(code)
|
code,
|
||||||
selectedPermissions.value = Array.from(next)
|
isChangeChecked(checked)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatTime(value?: string | null) {
|
function formatTime(value?: string | null) {
|
||||||
@ -342,7 +361,7 @@ function isCustomerRole(role?: RoleDto | null) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function selectRoleFromKey(key: string) {
|
function selectRoleFromKey(key: string) {
|
||||||
const role = findLeftMenuItemByKey(key, internalRoles.value)
|
const role = findLeftMenuItemByKey(key, dataSource.value)
|
||||||
if (role) {
|
if (role) {
|
||||||
isCreating.value = false
|
isCreating.value = false
|
||||||
selectedRole.value = role
|
selectedRole.value = role
|
||||||
@ -376,10 +395,10 @@ async function loadData() {
|
|||||||
)
|
)
|
||||||
dataSource.value = response?.items || []
|
dataSource.value = response?.items || []
|
||||||
if (selectedRole.value?.id) {
|
if (selectedRole.value?.id) {
|
||||||
const stillExists = internalRoles.value.find((r) => r.id === selectedRole.value?.id)
|
const stillExists = dataSource.value.find((r) => r.id === selectedRole.value?.id)
|
||||||
selectedRole.value = stillExists || internalRoles.value[0] || null
|
selectedRole.value = stillExists || dataSource.value[0] || null
|
||||||
} else if (!isCreating.value && internalRoles.value.length > 0) {
|
} else if (!isCreating.value && dataSource.value.length > 0) {
|
||||||
selectedRole.value = internalRoles.value[0]
|
selectedRole.value = dataSource.value[0]
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleApiError(error)
|
handleApiError(error)
|
||||||
@ -401,7 +420,8 @@ async function loadRolePermissions(roleId: string) {
|
|||||||
permissionLoading.value = true
|
permissionLoading.value = true
|
||||||
try {
|
try {
|
||||||
await loadPermissionTree()
|
await loadPermissionTree()
|
||||||
selectedPermissions.value = (await RoleService.getRbacRoleGetRolePermissions(roleId)) || []
|
const loaded = (await RoleService.getRbacRoleGetRolePermissions(roleId)) || []
|
||||||
|
selectedPermissions.value = normalizeSelectedPermissions(loaded, permissionRows.value)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleApiError(error)
|
handleApiError(error)
|
||||||
} finally {
|
} finally {
|
||||||
@ -432,6 +452,11 @@ function handleAdd() {
|
|||||||
|
|
||||||
async function handleDelete(id?: string) {
|
async function handleDelete(id?: string) {
|
||||||
if (!id) return
|
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({
|
Modal.confirm({
|
||||||
content: t('system.role.modal.deleteConfirmContent', { count: 1 }),
|
content: t('system.role.modal.deleteConfirmContent', { count: 1 }),
|
||||||
onOk: async () => {
|
onOk: async () => {
|
||||||
@ -469,7 +494,7 @@ async function handleSaveRole() {
|
|||||||
isCreating.value = false
|
isCreating.value = false
|
||||||
await loadData()
|
await loadData()
|
||||||
if (newRoleId) {
|
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) {
|
} else if (selectedRole.value?.id) {
|
||||||
const updateData: RoleUpdateDto = { name: formState.name }
|
const updateData: RoleUpdateDto = { name: formState.name }
|
||||||
@ -492,7 +517,10 @@ async function handleSavePermissions() {
|
|||||||
if (!selectedRole.value?.id || isCreating.value) return
|
if (!selectedRole.value?.id || isCreating.value) return
|
||||||
permissionSaving.value = true
|
permissionSaving.value = true
|
||||||
try {
|
try {
|
||||||
await RoleService.postRbacRoleAssignPermissions(selectedRole.value.id, selectedPermissions.value)
|
await RoleService.postRbacRoleAssignPermissions(
|
||||||
|
selectedRole.value.id,
|
||||||
|
normalizedSelectedPermissions.value
|
||||||
|
)
|
||||||
Message.success(t('system.role.permission.assignSuccess'))
|
Message.success(t('system.role.permission.assignSuccess'))
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleApiError(error)
|
handleApiError(error)
|
||||||
@ -505,7 +533,6 @@ watch(
|
|||||||
() => selectedRole.value?.id,
|
() => selectedRole.value?.id,
|
||||||
async (roleId) => {
|
async (roleId) => {
|
||||||
if (!roleId || isCreating.value) return
|
if (!roleId || isCreating.value) return
|
||||||
if (isCustomerRole(selectedRole.value)) return
|
|
||||||
syncFormFromRole(selectedRole.value!)
|
syncFormFromRole(selectedRole.value!)
|
||||||
activeTab.value = 'info'
|
activeTab.value = 'info'
|
||||||
permissionKeyword.value = ''
|
permissionKeyword.value = ''
|
||||||
|
|||||||
@ -274,6 +274,7 @@ export default defineConfig(({ mode }) => {
|
|||||||
'/track/push',
|
'/track/push',
|
||||||
'/track/kpiRegion',
|
'/track/kpiRegion',
|
||||||
'/track/kpiEarlyWarningConfig',
|
'/track/kpiEarlyWarningConfig',
|
||||||
|
'/track/query',
|
||||||
// Operation 相关前端路由
|
// Operation 相关前端路由
|
||||||
'/operation/inboundScan',
|
'/operation/inboundScan',
|
||||||
'/operation/outboundScan',
|
'/operation/outboundScan',
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user