align(菜单管理): 同级拖拽排序接线(手柄列+类型/父级分组+reorder带menu_type)、列表拉全量类型、类型文案后台/软件、弹窗补菜单类型下拉、父菜单候选按类型过滤(对齐 admin.js loadColumns/bindColumnDragSort/persistColumnOrder)
This commit is contained in:
@@ -2,15 +2,19 @@
|
||||
import { formatDateTime } from '@/utils/datetime'
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { createMenu, deleteMenu, loadMenuManageTree, updateMenu } from './menu-manage-api'
|
||||
import { createMenu, deleteMenu, loadMenuManageTree, reorderMenus, updateMenu } from './menu-manage-api'
|
||||
import {
|
||||
MENU_TYPE_OPTIONS,
|
||||
createMenuForm,
|
||||
flattenMenuNodes,
|
||||
menuDeleteReason,
|
||||
menuFormFromNode,
|
||||
menuParentCandidates,
|
||||
menuParentChangeReason,
|
||||
menuSiblingGroupKey,
|
||||
menuTypeLabel,
|
||||
nextSiblingSort,
|
||||
siblingOrderAfterDrop,
|
||||
validateMenuManageForm,
|
||||
type MenuManageForm,
|
||||
type MenuManageNode,
|
||||
@@ -22,7 +26,7 @@ const createVisible = ref(false)
|
||||
const saving = ref(false)
|
||||
const editingNode = ref<MenuManageNode | null>(null)
|
||||
const menuForm = reactive<MenuManageForm>(createMenuForm(null, 0))
|
||||
const parentOptions = computed(() => flattenMenuNodes(rows.value))
|
||||
const parentOptions = computed(() => menuParentCandidates(rows.value, menuForm.menuType))
|
||||
const dialogTitle = computed(() => (editingNode.value ? '编辑菜单' : '新增菜单'))
|
||||
/** 节点 id -> 名称,供「上级菜单」列展示。 */
|
||||
const nameById = computed(() => {
|
||||
@@ -31,8 +35,11 @@ const nameById = computed(() => {
|
||||
return map
|
||||
})
|
||||
|
||||
/** 拖拽状态:同"菜单类型+父级"分组内才可落位(对齐 admin.js columnDragState)。 */
|
||||
const dragState = ref<{ id: number; groupKey: string; menuType: string } | null>(null)
|
||||
|
||||
function parentNameOf(node: MenuManageNode): string {
|
||||
if (node.parentId == null) return '—'
|
||||
if (node.parentId == null) return '-'
|
||||
return nameById.value.get(node.parentId) ?? `#${node.parentId}`
|
||||
}
|
||||
|
||||
@@ -59,6 +66,58 @@ function openEdit(node: MenuManageNode) {
|
||||
createVisible.value = true
|
||||
}
|
||||
|
||||
/** 菜单类型切换时:当前父菜单不属于新类型则重置为根。 */
|
||||
function onMenuTypeChange(): void {
|
||||
const candidates = menuParentCandidates(rows.value, menuForm.menuType)
|
||||
if (menuForm.parentId != null && !candidates.some((node) => node.id === menuForm.parentId)) {
|
||||
menuForm.parentId = null
|
||||
}
|
||||
}
|
||||
|
||||
function onDragStart(event: DragEvent, node: MenuManageNode): void {
|
||||
dragState.value = { id: node.id, groupKey: menuSiblingGroupKey(node), menuType: node.menuType }
|
||||
if (event.dataTransfer) {
|
||||
event.dataTransfer.effectAllowed = 'move'
|
||||
try {
|
||||
event.dataTransfer.setData('text/plain', String(node.id))
|
||||
} catch {
|
||||
// 某些浏览器 setData 受限,忽略即可。
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onDragEnd(): void {
|
||||
dragState.value = null
|
||||
}
|
||||
|
||||
function onDragOver(event: DragEvent, node: MenuManageNode): void {
|
||||
const state = dragState.value
|
||||
if (!state || state.groupKey !== menuSiblingGroupKey(node) || state.id === node.id) return
|
||||
event.preventDefault()
|
||||
if (event.dataTransfer) event.dataTransfer.dropEffect = 'move'
|
||||
}
|
||||
|
||||
async function onDrop(event: DragEvent, target: MenuManageNode): Promise<void> {
|
||||
const state = dragState.value
|
||||
dragState.value = null
|
||||
if (!state || state.groupKey !== menuSiblingGroupKey(target) || state.id === target.id) return
|
||||
event.preventDefault()
|
||||
const rect = (event.currentTarget as HTMLElement).getBoundingClientRect()
|
||||
const placeAfter = event.clientY - rect.top > rect.height / 2
|
||||
const groupIds = flattenMenuNodes(rows.value)
|
||||
.filter((node) => menuSiblingGroupKey(node) === state.groupKey)
|
||||
.map((node) => node.id)
|
||||
const next = siblingOrderAfterDrop(groupIds, state.id, target.id, placeAfter)
|
||||
if (next.join(',') === groupIds.join(',')) return
|
||||
try {
|
||||
await reorderMenus(state.menuType, next)
|
||||
ElMessage.success('排序已保存')
|
||||
await loadMenus()
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '排序保存失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function saveMenu() {
|
||||
if (editingNode.value) {
|
||||
const reason = menuParentChangeReason(editingNode.value, menuForm.parentId)
|
||||
@@ -121,27 +180,41 @@ onMounted(loadMenus)
|
||||
<el-button type="primary" @click="openCreate">新增菜单</el-button>
|
||||
</div>
|
||||
<el-card shadow="never">
|
||||
<p class="menu-drag-tip">拖动每行左侧的手柄可调整同级菜单的显示顺序,松开后自动保存。</p>
|
||||
<el-table v-loading="loading" :data="rows" row-key="id" default-expand-all stripe>
|
||||
<el-table-column label="排序" width="64">
|
||||
<template #default="{ row }">
|
||||
<span
|
||||
class="menu-drag-handle"
|
||||
draggable="true"
|
||||
role="button"
|
||||
:title="`拖动调整“${(row as MenuManageNode).name}”的顺序`"
|
||||
@dragstart="onDragStart($event, row as MenuManageNode)"
|
||||
@dragend="onDragEnd"
|
||||
@dragover="onDragOver($event, row as MenuManageNode)"
|
||||
@drop="onDrop($event, row as MenuManageNode)"
|
||||
>⠿</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="id" label="ID" width="70" />
|
||||
<el-table-column prop="name" label="菜单名称" min-width="180" />
|
||||
<el-table-column label="菜单类型" width="110">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" :type="row.menuType === 'app' ? 'info' : 'primary'">{{ menuTypeLabel(row.menuType) }}</el-tag>
|
||||
<el-tag size="small" :type="(row as MenuManageNode).menuType === 'app' ? 'info' : 'primary'">{{ menuTypeLabel((row as MenuManageNode).menuType) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="上级菜单" min-width="120">
|
||||
<template #default="{ row }">
|
||||
{{ parentNameOf(row) }}
|
||||
{{ parentNameOf(row as MenuManageNode) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="createdAt" label="创建时间" min-width="150">
|
||||
<template #default="{ row }">{{ formatDateTime(row.createdAt) }}</template>
|
||||
<template #default="{ row }">{{ formatDateTime((row as MenuManageNode).createdAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="sortOrder" label="排序" width="90" />
|
||||
<el-table-column label="操作" width="160" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="openEdit(row)">编辑</el-button>
|
||||
<el-button link type="danger" :disabled="menuDeleteReason(row) !== undefined" @click="removeMenu(row)">删除</el-button>
|
||||
<el-button link type="primary" @click="openEdit(row as MenuManageNode)">编辑</el-button>
|
||||
<el-button link type="danger" :disabled="menuDeleteReason(row as MenuManageNode) !== undefined" @click="removeMenu(row as MenuManageNode)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
@@ -155,8 +228,13 @@ onMounted(loadMenus)
|
||||
<el-form-item label="权限标识">
|
||||
<el-input v-model="menuForm.columnKey" placeholder="如:admin_users(唯一,稳定授权标识)" />
|
||||
</el-form-item>
|
||||
<el-form-item label="菜单类型">
|
||||
<el-select v-model="menuForm.menuType" style="width: 100%" @change="onMenuTypeChange">
|
||||
<el-option v-for="opt in MENU_TYPE_OPTIONS" :key="opt.value" :label="opt.label" :value="opt.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="父菜单">
|
||||
<el-select v-model="menuForm.parentId" clearable placeholder="作为根菜单" style="width: 100%">
|
||||
<el-select v-model="menuForm.parentId" clearable placeholder="无(一级菜单)" style="width: 100%">
|
||||
<el-option
|
||||
v-for="node in parentOptions"
|
||||
:key="node.id"
|
||||
@@ -179,3 +257,28 @@ onMounted(loadMenus)
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.menu-drag-tip {
|
||||
margin: 0 0 10px;
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
.menu-drag-handle {
|
||||
display: inline-block;
|
||||
cursor: grab;
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 15px;
|
||||
line-height: 1;
|
||||
padding: 4px 6px;
|
||||
border-radius: 4px;
|
||||
user-select: none;
|
||||
}
|
||||
.menu-drag-handle:hover {
|
||||
background: var(--el-fill-color-light);
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
.menu-drag-handle:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
import { http } from '@/api/http'
|
||||
import { unwrap } from '@/api/envelope'
|
||||
import {
|
||||
ADMIN_MENU_TYPE,
|
||||
buildMenuManageTree,
|
||||
parseMenuManageItem,
|
||||
parseMenuManageList,
|
||||
@@ -15,9 +14,9 @@ import {
|
||||
export const MENUS_ENDPOINT = '/api/admin/permission-menus'
|
||||
export const REORDER_ENDPOINT = '/api/admin/column/reorder'
|
||||
|
||||
/** 拉取扁平可编辑菜单节点。 */
|
||||
/** 拉取扁平可编辑菜单节点(全量类型,对齐 admin.js loadColumns 的 /api/admin/columns 语义)。 */
|
||||
export async function fetchMenuManageNodes(): Promise<MenuManageNode[]> {
|
||||
const { data } = await http.get<unknown>(MENUS_ENDPOINT, { params: { menuType: ADMIN_MENU_TYPE } })
|
||||
const { data } = await http.get<unknown>(MENUS_ENDPOINT)
|
||||
return parseMenuManageList(data)
|
||||
}
|
||||
|
||||
@@ -46,8 +45,8 @@ export async function deleteMenu(id: number): Promise<void> {
|
||||
unwrap<unknown>(data)
|
||||
}
|
||||
|
||||
/** 同级菜单排序:POST /api/admin/column/reorder 提交新的同级 id 顺序(含子树节点时仅同级生效)。 */
|
||||
export async function reorderMenus(orderedIds: number[]): Promise<void> {
|
||||
const { data } = await http.post<unknown>(REORDER_ENDPOINT, { ordered_ids: orderedIds })
|
||||
/** 同级菜单排序:POST /api/admin/column/reorder 提交 { menu_type, ordered_ids }(对齐 admin.js persistColumnOrder)。 */
|
||||
export async function reorderMenus(menuType: string, orderedIds: number[]): Promise<void> {
|
||||
const { data } = await http.post<unknown>(REORDER_ENDPOINT, { menu_type: menuType, ordered_ids: orderedIds })
|
||||
unwrap<unknown>(data)
|
||||
}
|
||||
|
||||
@@ -18,11 +18,17 @@ export interface MenuManageNode {
|
||||
children?: MenuManageNode[]
|
||||
}
|
||||
|
||||
/** 菜单类型展示文案(对齐 admin 菜单类型语义)。 */
|
||||
/** 菜单类型展示文案(对齐 admin.js:6824:admin → 后台,其余一律 → 软件)。 */
|
||||
export function menuTypeLabel(type: string | null | undefined): string {
|
||||
return (type || '').toLowerCase() === 'app' ? '应用菜单' : '后台菜单'
|
||||
return (type || '').toLowerCase() === 'admin' ? '后台' : '软件'
|
||||
}
|
||||
|
||||
/** 菜单类型下拉选项(对齐 admin.html:5931-5934 后台/软件)。 */
|
||||
export const MENU_TYPE_OPTIONS: Array<{ value: 'admin' | 'app'; label: string }> = [
|
||||
{ value: 'admin', label: '后台' },
|
||||
{ value: 'app', label: '软件' },
|
||||
]
|
||||
|
||||
export interface MenuManageForm {
|
||||
name: string
|
||||
columnKey: string
|
||||
@@ -198,6 +204,55 @@ export function siblingOrderAfterMove(group: number[], targetId: number, dir: nu
|
||||
return next
|
||||
}
|
||||
|
||||
/** 拖拽分组键:同"菜单类型 + 父级"才算同级(对齐 admin.js columnSiblingGroupKey)。 */
|
||||
export function menuSiblingGroupKey(node: MenuManageNode): string {
|
||||
return `${node.menuType || 'app'}|${node.parentId ?? ''}`
|
||||
}
|
||||
|
||||
/** 拖放排序:把 draggedId 插到 targetId 前/后,返回新的同级 id 顺序;任一 id 缺失或同 id 则原序。 */
|
||||
export function siblingOrderAfterDrop(
|
||||
group: number[],
|
||||
draggedId: number,
|
||||
targetId: number,
|
||||
placeAfter: boolean,
|
||||
): number[] {
|
||||
if (!Array.isArray(group) || group.length === 0) return []
|
||||
if (draggedId === targetId) return [...group]
|
||||
const next = [...group]
|
||||
const from = next.indexOf(draggedId)
|
||||
if (from < 0) return next
|
||||
const target = next.indexOf(targetId)
|
||||
if (target < 0) return next
|
||||
next.splice(from, 1)
|
||||
const insertAt = next.indexOf(targetId) + (placeAfter ? 1 : 0)
|
||||
next.splice(insertAt, 0, draggedId)
|
||||
return next
|
||||
}
|
||||
|
||||
/** 父菜单候选:仅同菜单类型的节点(对齐 admin 的类型联动父级下拉)。 */
|
||||
export function menuParentCandidates(nodes: MenuManageNode[], menuType: string): MenuManageNode[] {
|
||||
const type = (menuType || '').trim()
|
||||
return flattenMenuNodes(nodes).filter((node) => (node.menuType || '') === type)
|
||||
}
|
||||
|
||||
/** 同级重排请求体(对齐 admin.js persistColumnOrder:{ menu_type, ordered_ids })。 */
|
||||
export function toMenuReorderRequest(
|
||||
menuType: string,
|
||||
orderedIds: number[],
|
||||
): { menu_type: string; ordered_ids: number[] } {
|
||||
return { menu_type: text(menuType) || 'app', ordered_ids: dedupeOrderIds(orderedIds) }
|
||||
}
|
||||
|
||||
function dedupeOrderIds(ids: unknown[]): number[] {
|
||||
if (!Array.isArray(ids)) return []
|
||||
const set = new Set<number>()
|
||||
for (const v of ids) {
|
||||
const n = numberOrNull(v)
|
||||
if (n !== null && n > 0) set.add(n)
|
||||
}
|
||||
return [...set]
|
||||
}
|
||||
|
||||
/** 新建/编辑表单空值工厂:默认菜单类型为 admin、排序 0、根父节点。 */
|
||||
export function emptyMenuManageForm(): MenuManageForm {
|
||||
return { name: '', columnKey: '', parentId: null, menuType: ADMIN_MENU_TYPE, routePath: '', sortOrder: 0 }
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readSource } from './helpers.ts'
|
||||
import {
|
||||
MENU_TYPE_OPTIONS,
|
||||
menuParentCandidates,
|
||||
menuSiblingGroupKey,
|
||||
menuTypeLabel,
|
||||
siblingOrderAfterDrop,
|
||||
toMenuReorderRequest,
|
||||
type MenuManageNode,
|
||||
} from '../src/pages/account/menu-manage-model.ts'
|
||||
|
||||
/** 对齐 admin.js:6799-6830(全量列/类型文案 后台|软件)与 6882-6964(同级拖拽 + reorder 载荷)。 */
|
||||
|
||||
function node(id: number, menuType = 'admin', parentId: number | null = null): MenuManageNode {
|
||||
return { id, name: `菜单${id}`, columnKey: `k${id}`, parentId, menuType, routePath: `/r/${id}`, sortOrder: id }
|
||||
}
|
||||
|
||||
test('align_menu_type_label_matches_reference', () => {
|
||||
// 参考:admin → 后台,其余一律 → 软件(含空值)。
|
||||
assert.equal(menuTypeLabel('admin'), '后台')
|
||||
assert.equal(menuTypeLabel('app'), '软件')
|
||||
assert.equal(menuTypeLabel('APP'), '软件')
|
||||
assert.equal(menuTypeLabel(null), '软件')
|
||||
assert.equal(menuTypeLabel(undefined), '软件')
|
||||
})
|
||||
|
||||
test('align_menu_type_options_backend_software', () => {
|
||||
assert.deepEqual(MENU_TYPE_OPTIONS, [
|
||||
{ value: 'admin', label: '后台' },
|
||||
{ value: 'app', label: '软件' },
|
||||
])
|
||||
})
|
||||
|
||||
test('align_menu_sibling_group_key_is_type_plus_parent', () => {
|
||||
assert.equal(menuSiblingGroupKey(node(1, 'admin', 5)), 'admin|5')
|
||||
assert.equal(menuSiblingGroupKey(node(2, 'app', null)), 'app|')
|
||||
assert.equal(menuSiblingGroupKey(node(3, 'app', 5)), 'app|5')
|
||||
})
|
||||
|
||||
test('align_menu_sibling_order_after_drop_before_target', () => {
|
||||
const group = [1, 2, 3, 4]
|
||||
assert.deepEqual(siblingOrderAfterDrop(group, 4, 2, false), [1, 4, 2, 3])
|
||||
})
|
||||
|
||||
test('align_menu_sibling_order_after_drop_after_target', () => {
|
||||
const group = [1, 2, 3, 4]
|
||||
assert.deepEqual(siblingOrderAfterDrop(group, 4, 2, true), [1, 2, 4, 3])
|
||||
})
|
||||
|
||||
test('align_menu_sibling_order_after_drop_invalid_returns_original', () => {
|
||||
const group = [1, 2, 3]
|
||||
assert.deepEqual(siblingOrderAfterDrop(group, 9, 2, false), group)
|
||||
assert.deepEqual(siblingOrderAfterDrop(group, 1, 9, true), group)
|
||||
assert.deepEqual(siblingOrderAfterDrop(group, 2, 2, false), group)
|
||||
assert.deepEqual(siblingOrderAfterDrop([], 1, 2, false), [])
|
||||
})
|
||||
|
||||
test('align_menu_parent_candidates_same_type_only', () => {
|
||||
const nodes = [node(1, 'admin', null), node(2, 'app', null), node(3, 'admin', 1), node(4, 'app', 2)]
|
||||
assert.deepEqual(menuParentCandidates(nodes, 'admin').map((n) => n.id), [1, 3])
|
||||
assert.deepEqual(menuParentCandidates(nodes, 'app').map((n) => n.id), [2, 4])
|
||||
})
|
||||
|
||||
test('align_menu_reorder_request_includes_menu_type', () => {
|
||||
// 参考 persistColumnOrder 载荷:{ menu_type, ordered_ids }。
|
||||
assert.deepEqual(toMenuReorderRequest('admin', [1, 2, 3]), { menu_type: 'admin', ordered_ids: [1, 2, 3] })
|
||||
assert.deepEqual(toMenuReorderRequest('', [1]), { menu_type: 'app', ordered_ids: [1] })
|
||||
})
|
||||
|
||||
test('align_menu_list_loads_all_types_no_filter', () => {
|
||||
// 参考 loadColumns 拉全量列(无 menuType 过滤),类型由列内区分。
|
||||
const api = readSource('src/pages/account/menu-manage-api.ts')
|
||||
assert.doesNotMatch(api, /params:\s*\{\s*menuType/, '列表请求不再按 admin 过滤')
|
||||
assert.match(api, /ordered_ids/, '重排载荷带 ordered_ids')
|
||||
assert.match(api, /menu_type/, '重排载荷带 menu_type')
|
||||
})
|
||||
|
||||
test('align_menu_page_drag_and_type_wiring', () => {
|
||||
const page = readSource('src/pages/account/MenusPage.vue')
|
||||
assert.match(page, /拖动每行左侧的手柄可调整同级菜单的显示顺序,松开后自动保存/, '顶部拖拽提示文案对齐')
|
||||
assert.match(page, /draggable/, '行手柄可拖拽')
|
||||
assert.match(page, /menuSiblingGroupKey/, '拖拽分组按类型+父级')
|
||||
assert.match(page, /MENU_TYPE_OPTIONS|菜单类型/, '新建/编辑弹窗含菜单类型下拉')
|
||||
assert.match(page, /menuParentCandidates/, '父菜单候选按类型过滤')
|
||||
})
|
||||
@@ -24,16 +24,16 @@ test('test_task_252_menus_list_normal_variant_input', () => {
|
||||
})
|
||||
|
||||
test('test_task_252_menus_list_normal_repeated_operation_is_idempotent', () => {
|
||||
// 类型映射幂等。
|
||||
assert.equal(menuTypeLabel('admin'), '后台菜单')
|
||||
assert.equal(menuTypeLabel('APP'), '应用菜单')
|
||||
// 类型映射幂等(对齐 admin.js:admin→后台,其余→软件)。
|
||||
assert.equal(menuTypeLabel('admin'), '后台')
|
||||
assert.equal(menuTypeLabel('APP'), '软件')
|
||||
assert.equal(menuTypeLabel('app'), menuTypeLabel('app'))
|
||||
})
|
||||
|
||||
test('test_task_252_menus_list_boundary_empty_input', () => {
|
||||
// 边界:未知类型归后台菜单;创建/编辑标题区分。
|
||||
assert.equal(menuTypeLabel(null), '后台菜单')
|
||||
assert.equal(menuTypeLabel(undefined), '后台菜单')
|
||||
// 边界:未知类型归软件;创建/编辑标题区分。
|
||||
assert.equal(menuTypeLabel(null), '软件')
|
||||
assert.equal(menuTypeLabel(undefined), '软件')
|
||||
const page = readSource('src/pages/account/MenusPage.vue')
|
||||
assert.match(page, /新增菜单/, '页头新增菜单入口')
|
||||
assert.match(page, /editingNode.*编辑菜单|编辑菜单/, '编辑态标题')
|
||||
|
||||
@@ -79,5 +79,7 @@ test('test_task_054_menu_manage_list_dependency_failure_returns_actionable_messa
|
||||
assert.match(api, /permission-menus/)
|
||||
assert.match(api, /parseMenuManageList/)
|
||||
assert.match(api, /buildMenuManageTree/)
|
||||
assert.match(api, /menuType:\s*ADMIN_MENU_TYPE|ADMIN_MENU_TYPE/)
|
||||
// 对齐 admin.js loadColumns:列表拉全量菜单(不再按 admin 类型过滤),类型在行内区分。
|
||||
assert.doesNotMatch(api, /menuType:\s*ADMIN_MENU_TYPE/, '列表请求不再过滤 menuType')
|
||||
assert.match(api, /menu_type/, '重排载荷带 menu_type')
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user