菜单修改优化
This commit is contained in:
+592
-590
File diff suppressed because it is too large
Load Diff
+345
-211
@@ -382,115 +382,322 @@
|
||||
});
|
||||
}
|
||||
var allColumnsList = [];
|
||||
var columnPermissionCatalogReady = false;
|
||||
var columnCardsContainerMap = { createColumnPermissionWrap: 'createColumnCards', editColumnPermissionWrap: 'editColumnCards' };
|
||||
// Hierarchical permission editor: only direct IDs are submitted; descendants are visual inheritance.
|
||||
function columnParentId(item) {
|
||||
var value = item && item.parent_id != null ? item.parent_id : item && item.parentId;
|
||||
var parsed = value == null || value === '' ? null : Number(value);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
|
||||
}
|
||||
function columnId(item) { return Number(item && item.id); }
|
||||
function columnMenuType(item) {
|
||||
return String(item && item.menu_type || 'app').toLowerCase() === 'admin' ? 'admin' : 'app';
|
||||
}
|
||||
function columnDescendantIds(id) {
|
||||
var result = [], pending = [Number(id)];
|
||||
while (pending.length) {
|
||||
var parent = pending.shift();
|
||||
allColumnsList.forEach(function (item) {
|
||||
if (columnParentId(item) !== parent) return;
|
||||
var childId = columnId(item);
|
||||
if (result.indexOf(childId) < 0) {
|
||||
result.push(childId);
|
||||
pending.push(childId);
|
||||
}
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
function columnTreeHasDirectDescendant(id, directIds) {
|
||||
return columnDescendantIds(id).some(function (descendantId) { return !!directIds[descendantId]; });
|
||||
}
|
||||
function columnTreeFullyCovered(id, directIds) {
|
||||
if (directIds[id]) return true;
|
||||
var children = allColumnsList.filter(function (item) { return columnParentId(item) === Number(id); });
|
||||
return children.length > 0 && children.every(function (child) {
|
||||
return columnTreeFullyCovered(columnId(child), directIds);
|
||||
});
|
||||
}
|
||||
function loadColumnsForPermission() {
|
||||
var ownedKeys = {};
|
||||
var ownedRoutes = {};
|
||||
columnPermissionCatalogReady = false;
|
||||
var ownedKeys = {}, ownedRoutes = {};
|
||||
var ownPermissionRequest = (currentUserRole === 'admin' && currentUserId)
|
||||
? fetch('/api/admin/user/' + currentUserId + '/column-permissions')
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (res) {
|
||||
if (!res.success) throw new Error(res.error || '当前用户权限加载失败');
|
||||
(res.items || []).forEach(function (item) {
|
||||
if (item.column_key) ownedKeys[String(item.column_key).trim()] = true;
|
||||
if (item.route_path) ownedRoutes[String(item.route_path).trim()] = true;
|
||||
});
|
||||
})
|
||||
.catch(function () { })
|
||||
: Promise.resolve();
|
||||
ownPermissionRequest.then(function () {
|
||||
return fetch('/api/admin/columns');
|
||||
}).then(function (r) { return r.json(); })
|
||||
ownPermissionRequest.then(function () { return fetch('/api/admin/columns'); })
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (res) {
|
||||
if (!res.success) return;
|
||||
allColumnsList = (res.items || []).filter(function (item) {
|
||||
var availableColumns = (res.items || []).filter(function (item) {
|
||||
if (item.column_key === 'admin_image_video_task_data') return false;
|
||||
if (currentUserRole !== 'admin') return true;
|
||||
return !!ownedKeys[String(item.column_key || '').trim()] ||
|
||||
!!ownedRoutes[String(item.route_path || '').trim()];
|
||||
return true;
|
||||
});
|
||||
if (currentUserRole === 'admin') {
|
||||
var columnsById = {}, grantableIds = {}, visibleIds = {};
|
||||
availableColumns.forEach(function (item) {
|
||||
var id = columnId(item);
|
||||
columnsById[id] = item;
|
||||
if (ownedKeys[String(item.column_key || '').trim()] || ownedRoutes[String(item.route_path || '').trim()]) {
|
||||
grantableIds[id] = true;
|
||||
}
|
||||
});
|
||||
Object.keys(grantableIds).forEach(function (id) {
|
||||
var currentId = Number(id), visited = {};
|
||||
while (currentId > 0 && !visited[currentId] && columnsById[currentId]) {
|
||||
visited[currentId] = true;
|
||||
visibleIds[currentId] = true;
|
||||
currentId = columnParentId(columnsById[currentId]);
|
||||
}
|
||||
});
|
||||
allColumnsList = availableColumns.filter(function (item) {
|
||||
return !!visibleIds[columnId(item)];
|
||||
}).map(function (item) {
|
||||
item._structureOnly = !grantableIds[columnId(item)];
|
||||
return item;
|
||||
});
|
||||
} else {
|
||||
allColumnsList = availableColumns.map(function (item) {
|
||||
item._structureOnly = false;
|
||||
return item;
|
||||
});
|
||||
}
|
||||
columnPermissionCatalogReady = true;
|
||||
renderColumnPermissionWrap('createColumnPermissionWrap');
|
||||
renderColumnPermissionWrap('editColumnPermissionWrap');
|
||||
var selCreate = document.getElementById('createColumnPermissionWrap');
|
||||
var selEdit = document.getElementById('editColumnPermissionWrap');
|
||||
if (selCreate && !selCreate._colCardsBound) {
|
||||
selCreate._colCardsBound = true;
|
||||
selCreate.onchange = function () { renderColumnCards('createColumnPermissionWrap'); };
|
||||
}
|
||||
if (selEdit && !selEdit._colCardsBound) {
|
||||
selEdit._colCardsBound = true;
|
||||
selEdit.onchange = function () { renderColumnCards('editColumnPermissionWrap'); };
|
||||
}
|
||||
})
|
||||
.catch(function () { });
|
||||
populateColumnParentSelects();
|
||||
}).catch(function () {
|
||||
columnPermissionCatalogReady = false;
|
||||
allColumnsList = [];
|
||||
var editPermissionWrap = document.getElementById('editColumnPermissionWrap');
|
||||
if (editPermissionWrap) editPermissionWrap._permissionsReady = false;
|
||||
renderColumnPermissionWrap('createColumnPermissionWrap');
|
||||
renderColumnPermissionWrap('editColumnPermissionWrap');
|
||||
});
|
||||
}
|
||||
var columnCardsContainerMap = { createColumnPermissionWrap: 'createColumnCards', editColumnPermissionWrap: 'editColumnCards' };
|
||||
function renderColumnCards(selectId) {
|
||||
var sel = document.getElementById(selectId);
|
||||
var cardsId = columnCardsContainerMap[selectId];
|
||||
var cardsEl = cardsId ? document.getElementById(cardsId) : null;
|
||||
if (!sel || sel.tagName !== 'SELECT' || !cardsEl) return;
|
||||
function renderColumnCards(wrapId) {
|
||||
var wrap = document.getElementById(wrapId);
|
||||
var cardsEl = document.getElementById(columnCardsContainerMap[wrapId]);
|
||||
if (!wrap || !cardsEl) return;
|
||||
var directIds = wrap._directIds || {};
|
||||
cardsEl.innerHTML = '';
|
||||
for (var i = 0; i < sel.options.length; i++) {
|
||||
var opt = sel.options[i];
|
||||
if (opt.disabled || !opt.selected) continue;
|
||||
allColumnsList.forEach(function (item) {
|
||||
var id = columnId(item);
|
||||
if (!directIds[id] || item._structureOnly) return;
|
||||
var card = document.createElement('span');
|
||||
card.className = 'column-permission-card';
|
||||
card.textContent = opt.textContent;
|
||||
card.textContent = (item.name || '') + ' (' + (item.column_key || '') + ')';
|
||||
var btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'col-card-remove';
|
||||
btn.setAttribute('aria-label', '移除');
|
||||
btn.textContent = '×';
|
||||
(function (option, seldId) {
|
||||
btn.onclick = function () {
|
||||
option.selected = false;
|
||||
renderColumnCards(seldId);
|
||||
};
|
||||
})(opt, selectId);
|
||||
btn.onclick = function () {
|
||||
delete directIds[id];
|
||||
renderColumnPermissionWrap(wrapId);
|
||||
};
|
||||
card.appendChild(btn);
|
||||
cardsEl.appendChild(card);
|
||||
}
|
||||
});
|
||||
}
|
||||
function renderColumnPermissionWrap(wrapId) {
|
||||
var sel = document.getElementById(wrapId);
|
||||
if (!sel || sel.tagName !== 'SELECT') return;
|
||||
sel.innerHTML = '';
|
||||
allColumnsList.forEach(function (c) {
|
||||
var opt = document.createElement('option');
|
||||
opt.value = c.id;
|
||||
opt.textContent = c.name + ' (' + c.column_key + ')';
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
if (allColumnsList.length === 0) {
|
||||
var opt = document.createElement('option');
|
||||
opt.disabled = true;
|
||||
opt.textContent = '暂无菜单,请先在“栏目权限配置”中新增菜单';
|
||||
sel.appendChild(opt);
|
||||
var wrap = document.getElementById(wrapId);
|
||||
if (!wrap) return;
|
||||
var directIds = wrap._directIds || {};
|
||||
var collapsedIds = wrap._collapsedIds || {};
|
||||
var collapsedMenuTypes = wrap._collapsedMenuTypes || {};
|
||||
var previousScrollTop = wrap.scrollTop;
|
||||
wrap._directIds = directIds;
|
||||
wrap._collapsedIds = collapsedIds;
|
||||
wrap._collapsedMenuTypes = collapsedMenuTypes;
|
||||
wrap.innerHTML = '';
|
||||
if (!allColumnsList.length) {
|
||||
wrap.textContent = '暂无可分配菜单';
|
||||
renderColumnCards(wrapId);
|
||||
return;
|
||||
}
|
||||
var byParent = {};
|
||||
allColumnsList.forEach(function (item) {
|
||||
var parent = columnParentId(item);
|
||||
var key = parent == null ? 'root' : String(parent);
|
||||
(byParent[key] || (byParent[key] = [])).push(item);
|
||||
});
|
||||
if (!wrap._collapseStateInitialized) {
|
||||
allColumnsList.forEach(function (item) {
|
||||
var id = columnId(item);
|
||||
if ((byParent[String(id)] || []).length) collapsedIds[id] = true;
|
||||
});
|
||||
wrap._collapseStateInitialized = true;
|
||||
}
|
||||
if (!wrap._menuTypeCollapseInitialized) {
|
||||
collapsedMenuTypes.admin = true;
|
||||
collapsedMenuTypes.app = true;
|
||||
wrap._menuTypeCollapseInitialized = true;
|
||||
}
|
||||
function renderNode(item, depth, inherited) {
|
||||
var id = columnId(item);
|
||||
var itemMenuType = columnMenuType(item);
|
||||
var children = (byParent[String(id)] || []).filter(function (child) {
|
||||
return columnMenuType(child) === itemMenuType;
|
||||
});
|
||||
var structureOnly = item._structureOnly === true;
|
||||
var effective = inherited || !!directIds[id];
|
||||
var covered = effective || columnTreeFullyCovered(id, directIds);
|
||||
var node = document.createElement('div');
|
||||
node.className = 'column-tree-node';
|
||||
node.setAttribute('data-depth', String(depth));
|
||||
var row = document.createElement('div');
|
||||
row.className = 'column-tree-row';
|
||||
if (children.length) {
|
||||
var toggle = document.createElement('button');
|
||||
var collapsed = !!collapsedIds[id];
|
||||
toggle.type = 'button';
|
||||
toggle.className = 'column-tree-toggle';
|
||||
toggle.setAttribute('aria-expanded', String(!collapsed));
|
||||
toggle.setAttribute('aria-label', (collapsed ? '展开' : '收起') + (item.name || '菜单'));
|
||||
toggle.title = collapsed ? '展开子菜单' : '收起子菜单';
|
||||
toggle.onclick = function () {
|
||||
if (collapsedIds[id]) delete collapsedIds[id];
|
||||
else collapsedIds[id] = true;
|
||||
renderColumnPermissionWrap(wrapId);
|
||||
};
|
||||
row.appendChild(toggle);
|
||||
} else {
|
||||
var spacer = document.createElement('span');
|
||||
spacer.className = 'column-tree-toggle-spacer';
|
||||
spacer.setAttribute('aria-hidden', 'true');
|
||||
row.appendChild(spacer);
|
||||
}
|
||||
var label = document.createElement('label');
|
||||
var checkbox = document.createElement('input');
|
||||
checkbox.type = 'checkbox';
|
||||
checkbox.checked = covered;
|
||||
checkbox.indeterminate = !covered && columnTreeHasDirectDescendant(id, directIds);
|
||||
checkbox.disabled = inherited || structureOnly;
|
||||
checkbox.onchange = function () {
|
||||
if (structureOnly) return;
|
||||
if (checkbox.checked) {
|
||||
directIds[id] = true;
|
||||
columnDescendantIds(id).forEach(function (descendantId) { delete directIds[descendantId]; });
|
||||
} else {
|
||||
delete directIds[id];
|
||||
columnDescendantIds(id).forEach(function (descendantId) { delete directIds[descendantId]; });
|
||||
}
|
||||
renderColumnPermissionWrap(wrapId);
|
||||
};
|
||||
var text = document.createElement('span');
|
||||
text.textContent = (item.name || '') + ' (' + (item.column_key || '') + ')';
|
||||
if (inherited || structureOnly) text.className = 'inherited-label';
|
||||
label.appendChild(checkbox);
|
||||
label.appendChild(text);
|
||||
row.appendChild(label);
|
||||
node.appendChild(row);
|
||||
if (children.length) {
|
||||
var childrenWrap = document.createElement('div');
|
||||
childrenWrap.className = 'column-tree-children';
|
||||
childrenWrap.hidden = !!collapsedIds[id];
|
||||
children.forEach(function (child) {
|
||||
childrenWrap.appendChild(renderNode(child, depth + 1, effective));
|
||||
});
|
||||
node.appendChild(childrenWrap);
|
||||
}
|
||||
return node;
|
||||
}
|
||||
[
|
||||
{ key: 'admin', label: '后台菜单' },
|
||||
{ key: 'app', label: 'APP(软件)菜单' }
|
||||
].forEach(function (group) {
|
||||
var groupItems = allColumnsList.filter(function (item) {
|
||||
return columnMenuType(item) === group.key;
|
||||
});
|
||||
if (!groupItems.length) return;
|
||||
var section = document.createElement('div');
|
||||
section.className = 'column-tree-group';
|
||||
var groupToggle = document.createElement('button');
|
||||
var groupCollapsed = !!collapsedMenuTypes[group.key];
|
||||
groupToggle.type = 'button';
|
||||
groupToggle.className = 'column-tree-group-toggle';
|
||||
groupToggle.setAttribute('aria-expanded', String(!groupCollapsed));
|
||||
groupToggle.setAttribute('aria-label', (groupCollapsed ? '展开' : '收起') + group.label);
|
||||
var groupName = document.createElement('span');
|
||||
groupName.className = 'column-tree-group-name';
|
||||
groupName.textContent = group.label;
|
||||
var groupCount = document.createElement('span');
|
||||
groupCount.className = 'column-tree-group-count';
|
||||
groupCount.textContent = groupItems.length + ' 项';
|
||||
groupToggle.appendChild(groupName);
|
||||
groupToggle.appendChild(groupCount);
|
||||
groupToggle.onclick = function () {
|
||||
if (collapsedMenuTypes[group.key]) delete collapsedMenuTypes[group.key];
|
||||
else collapsedMenuTypes[group.key] = true;
|
||||
renderColumnPermissionWrap(wrapId);
|
||||
};
|
||||
section.appendChild(groupToggle);
|
||||
var groupBody = document.createElement('div');
|
||||
groupBody.className = 'column-tree-group-body';
|
||||
groupBody.hidden = groupCollapsed;
|
||||
groupItems.filter(function (item) {
|
||||
var parentId = columnParentId(item);
|
||||
if (parentId == null) return true;
|
||||
var parent = allColumnsList.find(function (candidate) {
|
||||
return columnId(candidate) === parentId;
|
||||
});
|
||||
return !parent || columnMenuType(parent) !== group.key;
|
||||
}).forEach(function (item) {
|
||||
groupBody.appendChild(renderNode(item, 0, false));
|
||||
});
|
||||
section.appendChild(groupBody);
|
||||
wrap.appendChild(section);
|
||||
});
|
||||
renderColumnCards(wrapId);
|
||||
wrap.scrollTop = previousScrollTop;
|
||||
}
|
||||
function getSelectedColumnIds(wrapId) {
|
||||
var sel = document.getElementById(wrapId);
|
||||
if (!sel || sel.tagName !== 'SELECT') return [];
|
||||
var ids = [];
|
||||
for (var i = 0; i < sel.options.length; i++) {
|
||||
if (sel.options[i].selected) {
|
||||
var v = parseInt(sel.options[i].value, 10);
|
||||
if (!isNaN(v)) ids.push(v);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
var wrap = document.getElementById(wrapId);
|
||||
return wrap && wrap._directIds ? Object.keys(wrap._directIds).map(Number).filter(function (id) {
|
||||
var item = allColumnsList.find(function (column) { return columnId(column) === id; });
|
||||
return id > 0 && item && !item._structureOnly;
|
||||
}) : [];
|
||||
}
|
||||
function setColumnPermissionCheckboxes(wrapId, columnIds) {
|
||||
var sel = document.getElementById(wrapId);
|
||||
if (!sel || sel.tagName !== 'SELECT') return;
|
||||
var set = {};
|
||||
(columnIds || []).forEach(function (id) { set[id] = true; });
|
||||
for (var i = 0; i < sel.options.length; i++) {
|
||||
var opt = sel.options[i];
|
||||
if (opt.disabled) continue;
|
||||
opt.selected = set[parseInt(opt.value, 10)] || false;
|
||||
}
|
||||
renderColumnCards(wrapId);
|
||||
var wrap = document.getElementById(wrapId);
|
||||
if (!wrap) return;
|
||||
wrap._directIds = {};
|
||||
(columnIds || []).forEach(function (id) {
|
||||
var parsed = Number(id);
|
||||
if (Number.isFinite(parsed) && parsed > 0) wrap._directIds[parsed] = true;
|
||||
});
|
||||
wrap._permissionsReady = true;
|
||||
renderColumnPermissionWrap(wrapId);
|
||||
}
|
||||
function populateColumnParentSelects() {
|
||||
['columnParentId', 'editColumnParentId'].forEach(function (selectId) {
|
||||
var select = document.getElementById(selectId);
|
||||
if (!select) return;
|
||||
var current = select.value;
|
||||
var typeSelect = document.getElementById(selectId === 'editColumnParentId' ? 'editColumnMenuType' : 'columnMenuType');
|
||||
var menuType = typeSelect ? (typeSelect.value || 'app') : 'app';
|
||||
var editingId = selectId === 'editColumnParentId' ? Number(document.getElementById('editColumnId').value || 0) : 0;
|
||||
var blockedIds = editingId > 0 ? [editingId].concat(columnDescendantIds(editingId)) : [];
|
||||
select.innerHTML = '<option value="">无(根菜单)</option>';
|
||||
allColumnsList.filter(function (item) {
|
||||
return !item._structureOnly && String(item.menu_type || 'app') === String(menuType) && blockedIds.indexOf(columnId(item)) < 0;
|
||||
}).forEach(function (item) {
|
||||
var option = document.createElement('option');
|
||||
option.value = item.id;
|
||||
option.textContent = (item.name || '') + ' (' + (item.column_key || '') + ')';
|
||||
select.appendChild(option);
|
||||
});
|
||||
select.value = current;
|
||||
});
|
||||
}
|
||||
function updateCreateFormByRole() {
|
||||
var roleSel = document.getElementById('createRole');
|
||||
@@ -531,14 +738,25 @@
|
||||
editCreatorName.value = u.creator_username || '';
|
||||
if (u.role !== 'super_admin') { editRole.value = u.role || 'normal'; }
|
||||
document.getElementById('msgEdit').textContent = '';
|
||||
if (allColumnsList.length) {
|
||||
renderColumnPermissionWrap('editColumnPermissionWrap');
|
||||
fetch('/api/admin/user/' + (u.id) + '/columns')
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (res) {
|
||||
if (res.success && res.column_ids) setColumnPermissionCheckboxes('editColumnPermissionWrap', res.column_ids);
|
||||
});
|
||||
}
|
||||
var editPermissionWrap = document.getElementById('editColumnPermissionWrap');
|
||||
editPermissionWrap._directIds = {};
|
||||
editPermissionWrap._permissionsReady = false;
|
||||
renderColumnPermissionWrap('editColumnPermissionWrap');
|
||||
fetch('/api/admin/user/' + (u.id) + '/columns')
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (res) {
|
||||
if (String(document.getElementById('editUserId').value || '') !== String(u.id || '')) return;
|
||||
if (!res.success || !Array.isArray(res.column_ids)) {
|
||||
throw new Error(res.error || '权限加载失败');
|
||||
}
|
||||
setColumnPermissionCheckboxes('editColumnPermissionWrap', res.column_ids);
|
||||
})
|
||||
.catch(function (error) {
|
||||
if (String(document.getElementById('editUserId').value || '') !== String(u.id || '')) return;
|
||||
var msgEl = document.getElementById('msgEdit');
|
||||
msgEl.textContent = error.message || '权限加载失败';
|
||||
msgEl.className = 'msg err';
|
||||
});
|
||||
document.getElementById('editUserModal').classList.add('show');
|
||||
};
|
||||
});
|
||||
@@ -575,6 +793,11 @@
|
||||
msgEl.classList.add('err');
|
||||
return;
|
||||
}
|
||||
if (!columnPermissionCatalogReady) {
|
||||
msgEl.textContent = '菜单权限尚未加载,请稍后重试';
|
||||
msgEl.classList.add('err');
|
||||
return;
|
||||
}
|
||||
var body = { username: username, password: password, role: role };
|
||||
if (role === 'normal' && currentUserRole === 'super_admin' && createdById) body.created_by_id = createdById;
|
||||
body.column_ids = getSelectedColumnIds('createColumnPermissionWrap');
|
||||
@@ -609,6 +832,12 @@
|
||||
var msgEl = document.getElementById('msgEdit');
|
||||
msgEl.textContent = '';
|
||||
msgEl.className = 'msg';
|
||||
var editPermissionWrap = document.getElementById('editColumnPermissionWrap');
|
||||
if (!columnPermissionCatalogReady || !editPermissionWrap._permissionsReady) {
|
||||
msgEl.textContent = '用户权限尚未加载,不能保存';
|
||||
msgEl.classList.add('err');
|
||||
return;
|
||||
}
|
||||
var body = {};
|
||||
if (password) body.password = password;
|
||||
if (currentUserRole === 'super_admin' && editRoleEl && editRoleEl.offsetParent !== null)
|
||||
@@ -4483,114 +4712,6 @@
|
||||
|
||||
|
||||
// ========== 栏目权限配置 ==========
|
||||
function loadColumns() {
|
||||
fetch('/api/admin/columns')
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (res) {
|
||||
var tbody = document.getElementById('columnListBody');
|
||||
if (!res.success) {
|
||||
tbody.innerHTML = '<tr><td colspan="5" class="empty-tip">加载失败: ' + (res.error || '') + '</td></tr>';
|
||||
return;
|
||||
}
|
||||
allColumnsList = res.items || [];
|
||||
if (allColumnsList.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="5" class="empty-tip">暂无菜单,请在上方新增</td></tr>';
|
||||
} else {
|
||||
tbody.innerHTML = allColumnsList.map(function (c) {
|
||||
return '<tr><td>' + c.id + '</td><td>' + (c.name || '') + '</td><td>' + (c.column_key || '') + '</td><td>' + (c.created_at || '') + '</td><td>' +
|
||||
'<button class="btn btn-sm" data-column-edit="' + c.id + '" data-name="' + (c.name || '').replace(/"/g, '"') + '" data-key="' + (c.column_key || '').replace(/"/g, '"') + '">编辑</button> ' +
|
||||
'<button class="btn btn-sm btn-danger" data-column-delete="' + c.id + '" data-name="' + (c.name || '').replace(/"/g, '"') + '">删除</button></td></tr>';
|
||||
}).join('');
|
||||
}
|
||||
bindColumnActions();
|
||||
})
|
||||
.catch(function () {
|
||||
document.getElementById('columnListBody').innerHTML = '<tr><td colspan="5" class="empty-tip">请求失败</td></tr>';
|
||||
});
|
||||
}
|
||||
function bindColumnActions() {
|
||||
document.querySelectorAll('[data-column-edit]').forEach(function (btn) {
|
||||
btn.onclick = function () {
|
||||
document.getElementById('editColumnId').value = btn.dataset.columnEdit || '';
|
||||
document.getElementById('editColumnName').value = (btn.dataset.name || '').replace(/"/g, '"');
|
||||
document.getElementById('editColumnKey').value = (btn.dataset.key || '').replace(/"/g, '"');
|
||||
document.getElementById('msgEditColumn').textContent = '';
|
||||
document.getElementById('editColumnModal').classList.add('show');
|
||||
};
|
||||
});
|
||||
document.querySelectorAll('[data-column-delete]').forEach(function (btn) {
|
||||
btn.onclick = function () {
|
||||
if (!confirm('确定删除菜单“' + (btn.dataset.name || '').replace(/"/g, '"') + '”吗?')) return;
|
||||
fetch('/api/admin/column/' + btn.dataset.columnDelete, { method: 'DELETE' })
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (res) {
|
||||
if (res.success) { loadColumns(); loadColumnsForPermission(); loadAdminMenus(getActiveAdminTabName()); }
|
||||
else { alert(res.error || '删除失败'); }
|
||||
});
|
||||
};
|
||||
});
|
||||
}
|
||||
document.getElementById('btnAddColumn').onclick = function () {
|
||||
var name = (document.getElementById('columnName').value || '').trim();
|
||||
var key = (document.getElementById('columnKey').value || '').trim();
|
||||
var msgEl = document.getElementById('msgColumn');
|
||||
msgEl.textContent = '';
|
||||
msgEl.className = 'msg';
|
||||
if (!name) { msgEl.textContent = '请填写菜单名称'; msgEl.classList.add('err'); return; }
|
||||
if (!key) {
|
||||
msgEl.textContent = '请填写栏目标识'; msgEl.classList.add('err'); return;
|
||||
}
|
||||
fetch('/api/admin/column', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: name, column_key: key })
|
||||
})
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (res) {
|
||||
if (res.success) {
|
||||
msgEl.textContent = res.msg || '新增成功';
|
||||
msgEl.classList.add('ok');
|
||||
document.getElementById('columnName').value = '';
|
||||
document.getElementById('columnKey').value = '';
|
||||
loadColumns();
|
||||
loadColumnsForPermission();
|
||||
loadAdminMenus(getActiveAdminTabName());
|
||||
} else {
|
||||
msgEl.textContent = res.error || '新增失败';
|
||||
msgEl.classList.add('err');
|
||||
}
|
||||
})
|
||||
.catch(function () { msgEl.textContent = '请求失败'; msgEl.classList.add('err'); });
|
||||
};
|
||||
document.getElementById('btnSaveColumn').onclick = function () {
|
||||
var cid = document.getElementById('editColumnId').value;
|
||||
var name = (document.getElementById('editColumnName').value || '').trim();
|
||||
var key = (document.getElementById('editColumnKey').value || '').trim();
|
||||
var msgEl = document.getElementById('msgEditColumn');
|
||||
msgEl.textContent = '';
|
||||
msgEl.className = 'msg';
|
||||
if (!name) { msgEl.textContent = '请填写菜单名称'; msgEl.classList.add('err'); return; }
|
||||
if (!key) {
|
||||
msgEl.textContent = '请填写栏目标识'; msgEl.classList.add('err'); return;
|
||||
}
|
||||
fetch('/api/admin/column/' + cid, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: name, column_key: key })
|
||||
})
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (res) {
|
||||
if (res.success) {
|
||||
document.getElementById('editColumnModal').classList.remove('show');
|
||||
loadColumns();
|
||||
loadColumnsForPermission();
|
||||
loadAdminMenus(getActiveAdminTabName());
|
||||
} else {
|
||||
msgEl.textContent = res.error || '保存失败';
|
||||
msgEl.classList.add('err');
|
||||
}
|
||||
});
|
||||
};
|
||||
document.getElementById('btnCloseEditColumn').onclick = function () {
|
||||
document.getElementById('editColumnModal').classList.remove('show');
|
||||
};
|
||||
@@ -4601,29 +4722,39 @@
|
||||
.then(function (res) {
|
||||
var tbody = document.getElementById('columnListBody');
|
||||
if (!res.success) {
|
||||
tbody.innerHTML = '<tr><td colspan="7" class="empty-tip">加载失败: ' + (res.error || '') + '</td></tr>';
|
||||
tbody.innerHTML = '<tr><td colspan="9" class="empty-tip">加载失败: ' + (res.error || '') + '</td></tr>';
|
||||
return;
|
||||
}
|
||||
allColumnsList = res.items || [];
|
||||
if (allColumnsList.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="8" class="empty-tip">暂无菜单,请先在上方新增</td></tr>';
|
||||
tbody.innerHTML = '<tr><td colspan="9" class="empty-tip">暂无菜单,请先在上方新增</td></tr>';
|
||||
} else {
|
||||
tbody.innerHTML = allColumnsList.map(function (c, index) {
|
||||
tbody.innerHTML = allColumnsList.map(function (c) {
|
||||
var siblings = columnSiblingItems(c);
|
||||
var siblingIndex = siblings.findIndex(function (item) { return String(item.id) === String(c.id); });
|
||||
var moveButtons =
|
||||
'<button class="btn btn-sm btn-secondary" data-column-move-up="' + c.id + '"' + (index === 0 ? ' disabled' : '') + '>上移</button> ' +
|
||||
'<button class="btn btn-sm btn-secondary" data-column-move-down="' + c.id + '"' + (index === allColumnsList.length - 1 ? ' disabled' : '') + '>下移</button> ';
|
||||
return '<tr><td>' + c.id + '</td><td>' + (c.name || '') + '</td><td>' + (c.column_key || '') + '</td><td>' + ((c.menu_type || '') === 'admin' ? '后台(admin)' : '软件(app)') + '</td><td>' + (c.sort_order != null ? c.sort_order : 0) + '</td><td>' + (c.route_path || '') + '</td><td>' + (c.created_at || '') + '</td><td>' +
|
||||
'<button class="btn btn-sm btn-secondary" data-column-move-up="' + c.id + '"' + (siblingIndex <= 0 ? ' disabled' : '') + '>上移</button> ' +
|
||||
'<button class="btn btn-sm btn-secondary" data-column-move-down="' + c.id + '"' + (siblingIndex < 0 || siblingIndex === siblings.length - 1 ? ' disabled' : '') + '>下移</button> ';
|
||||
var parent = allColumnsList.find(function (item) { return String(item.id) === String(c.parent_id || c.parentId || ''); });
|
||||
return '<tr><td>' + c.id + '</td><td>' + (c.name || '') + '</td><td>' + (c.column_key || '') + '</td><td>' + ((c.menu_type || '') === 'admin' ? '后台(admin)' : '软件(app)') + '</td><td>' + (parent ? (parent.name || '') : '-') + '</td><td>' + (c.sort_order != null ? c.sort_order : 0) + '</td><td>' + (c.route_path || '') + '</td><td>' + (c.created_at || '') + '</td><td>' +
|
||||
moveButtons +
|
||||
'<button class="btn btn-sm" data-column-edit="' + c.id + '" data-name="' + (c.name || '').replace(/"/g, '"') + '" data-key="' + (c.column_key || '').replace(/"/g, '"') + '" data-route="' + (c.route_path || '').replace(/"/g, '"') + '" data-menu-type="' + (c.menu_type || 'app').replace(/"/g, '"') + '" data-sort-order="' + (c.sort_order != null ? String(c.sort_order) : '0').replace(/"/g, '"') + '">编辑</button> ' +
|
||||
'<button class="btn btn-sm" data-column-edit="' + c.id + '" data-name="' + (c.name || '').replace(/"/g, '"') + '" data-key="' + (c.column_key || '').replace(/"/g, '"') + '" data-route="' + (c.route_path || '').replace(/"/g, '"') + '" data-menu-type="' + (c.menu_type || 'app').replace(/"/g, '"') + '" data-sort-order="' + (c.sort_order != null ? String(c.sort_order) : '0').replace(/"/g, '"') + '" data-parent-id="' + String(c.parent_id || c.parentId || '').replace(/"/g, '"') + '">编辑</button> ' +
|
||||
'<button class="btn btn-sm btn-danger" data-column-delete="' + c.id + '" data-name="' + (c.name || '').replace(/"/g, '"') + '">删除</button></td></tr>';
|
||||
}).join('');
|
||||
}
|
||||
bindColumnActions();
|
||||
})
|
||||
.catch(function () {
|
||||
document.getElementById('columnListBody').innerHTML = '<tr><td colspan="8" class="empty-tip">请求失败</td></tr>';
|
||||
document.getElementById('columnListBody').innerHTML = '<tr><td colspan="9" class="empty-tip">请求失败</td></tr>';
|
||||
});
|
||||
}
|
||||
function columnSiblingItems(item) {
|
||||
var menuType = String(item && item.menu_type || 'app');
|
||||
var parentId = columnParentId(item);
|
||||
return allColumnsList.filter(function (candidate) {
|
||||
return String(candidate.menu_type || 'app') === menuType && columnParentId(candidate) === parentId;
|
||||
});
|
||||
}
|
||||
function bindColumnActions() {
|
||||
document.querySelectorAll('[data-column-move-up]').forEach(function (btn) {
|
||||
btn.onclick = function () {
|
||||
@@ -4645,6 +4776,8 @@
|
||||
document.getElementById('editColumnRoutePath').value = (btn.dataset.route || '').replace(/"/g, '"');
|
||||
document.getElementById('editColumnMenuType').value = ((btn.dataset.menuType || 'app').replace(/"/g, '"') || 'app');
|
||||
document.getElementById('editColumnSortOrder').value = ((btn.dataset.sortOrder || '0').replace(/"/g, '"') || '0');
|
||||
populateColumnParentSelects();
|
||||
document.getElementById('editColumnParentId').value = btn.dataset.parentId || '';
|
||||
document.getElementById('msgEditColumn').textContent = '';
|
||||
document.getElementById('editColumnModal').classList.add('show');
|
||||
};
|
||||
@@ -4662,26 +4795,22 @@
|
||||
});
|
||||
}
|
||||
function moveColumnItem(columnId, delta, triggerBtn) {
|
||||
var currentIndex = -1;
|
||||
for (var i = 0; i < allColumnsList.length; i++) {
|
||||
if (String(allColumnsList[i].id) === String(columnId)) {
|
||||
currentIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
var currentItem = allColumnsList.find(function (item) { return String(item.id) === String(columnId); });
|
||||
if (!currentItem) return;
|
||||
var siblings = columnSiblingItems(currentItem);
|
||||
var currentIndex = siblings.findIndex(function (item) { return String(item.id) === String(columnId); });
|
||||
if (currentIndex < 0) return;
|
||||
var targetIndex = currentIndex + delta;
|
||||
if (targetIndex < 0 || targetIndex >= allColumnsList.length) return;
|
||||
var currentItem = allColumnsList[currentIndex];
|
||||
var targetItem = allColumnsList[targetIndex];
|
||||
if (!currentItem || !targetItem) return;
|
||||
if (targetIndex < 0 || targetIndex >= siblings.length) return;
|
||||
var targetItem = siblings[targetIndex];
|
||||
if (triggerBtn) triggerBtn.disabled = true;
|
||||
fetch('/api/admin/column/reorder', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
column_id: currentItem.id,
|
||||
target_id: targetItem.id
|
||||
target_id: targetItem.id,
|
||||
menu_type: currentItem.menu_type || 'app'
|
||||
})
|
||||
}).then(function (r) { return r.json(); })
|
||||
.then(function (res) {
|
||||
@@ -4704,6 +4833,7 @@
|
||||
var routePath = (document.getElementById('columnRoutePath').value || '').trim();
|
||||
var sortOrderValue = (document.getElementById('columnSortOrder').value || '').trim();
|
||||
var menuType = (document.getElementById('columnMenuType').value || 'admin').trim() || 'admin';
|
||||
var parentIdValue = (document.getElementById('columnParentId').value || '').trim();
|
||||
var sortOrder = sortOrderValue === '' ? null : parseInt(sortOrderValue, 10);
|
||||
var msgEl = document.getElementById('msgColumn');
|
||||
msgEl.textContent = '';
|
||||
@@ -4721,7 +4851,7 @@
|
||||
fetch('/api/admin/column', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: name, column_key: key, route_path: routePath, menu_type: menuType, sort_order: sortOrder })
|
||||
body: JSON.stringify({ name: name, column_key: key, route_path: routePath, menu_type: menuType, sort_order: sortOrder, parent_id: parentIdValue ? Number(parentIdValue) : null })
|
||||
})
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (res) {
|
||||
@@ -4733,6 +4863,7 @@
|
||||
document.getElementById('columnRoutePath').value = '';
|
||||
document.getElementById('columnSortOrder').value = '';
|
||||
document.getElementById('columnMenuType').value = 'admin';
|
||||
document.getElementById('columnParentId').value = '';
|
||||
loadColumns();
|
||||
loadColumnsForPermission();
|
||||
loadAdminMenus(getActiveAdminTabName());
|
||||
@@ -4750,6 +4881,7 @@
|
||||
var routePath = (document.getElementById('editColumnRoutePath').value || '').trim();
|
||||
var sortOrderValue = (document.getElementById('editColumnSortOrder').value || '').trim();
|
||||
var menuType = (document.getElementById('editColumnMenuType').value || 'admin').trim() || 'admin';
|
||||
var parentIdValue = (document.getElementById('editColumnParentId').value || '').trim();
|
||||
var sortOrder = sortOrderValue === '' ? null : parseInt(sortOrderValue, 10);
|
||||
var msgEl = document.getElementById('msgEditColumn');
|
||||
msgEl.textContent = '';
|
||||
@@ -4767,7 +4899,7 @@
|
||||
fetch('/api/admin/column/' + cid, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: name, column_key: key, route_path: routePath, menu_type: menuType, sort_order: sortOrder })
|
||||
body: JSON.stringify({ name: name, column_key: key, route_path: routePath, menu_type: menuType, sort_order: sortOrder, parent_id: parentIdValue ? Number(parentIdValue) : null })
|
||||
})
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (res) {
|
||||
@@ -4782,6 +4914,8 @@
|
||||
}
|
||||
});
|
||||
};
|
||||
if (document.getElementById('columnMenuType')) document.getElementById('columnMenuType').onchange = populateColumnParentSelects;
|
||||
if (document.getElementById('editColumnMenuType')) document.getElementById('editColumnMenuType').onchange = populateColumnParentSelects;
|
||||
|
||||
// ========== 分页 ==========
|
||||
function renderPagination(elId, total, page, pageSize, onPage) {
|
||||
|
||||
@@ -1,160 +0,0 @@
|
||||
import io
|
||||
import unittest
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from flask import jsonify
|
||||
|
||||
from app import app
|
||||
from blueprints import admin_api as admin_module
|
||||
|
||||
|
||||
class DedupeTotalDataAdminTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
app.config.update(TESTING=True, SECRET_KEY='test')
|
||||
self.client = app.test_client()
|
||||
|
||||
def _login(self, user_id=23):
|
||||
with self.client.session_transaction() as session:
|
||||
session['user_id'] = user_id
|
||||
|
||||
def test_list_forwards_session_operator_and_username_search(self):
|
||||
self._login()
|
||||
java_response = {
|
||||
'success': True,
|
||||
'data': {
|
||||
'items': [{
|
||||
'id': 91,
|
||||
'dataValue': 'B012345678',
|
||||
'uploaderUserId': 23,
|
||||
'username': 'member-a',
|
||||
'createdAt': '2026-07-20T12:30:00',
|
||||
}],
|
||||
'total': 1,
|
||||
'page': 1,
|
||||
'pageSize': 15,
|
||||
},
|
||||
}
|
||||
with patch('utils.auth.is_session_user_valid', return_value=True), \
|
||||
patch.object(admin_module, '_ensure_dedupe_total_data_access',
|
||||
return_value=('normal', {'id': 23, 'username': 'member-a'}, None)), \
|
||||
patch.object(admin_module, '_proxy_backend_java',
|
||||
return_value=(java_response, None, 200)) as proxy:
|
||||
response = self.client.get(
|
||||
'/api/admin/dedupe-total-data?keyword=B01&username=member&page=1&page_size=15')
|
||||
|
||||
self.assertEqual(200, response.status_code)
|
||||
payload = response.get_json()
|
||||
self.assertTrue(payload['success'])
|
||||
self.assertEqual('member-a', payload['items'][0]['username'])
|
||||
self.assertEqual(23, payload['items'][0]['uploader_user_id'])
|
||||
self.assertEqual({
|
||||
'page': 1,
|
||||
'pageSize': 15,
|
||||
'keyword': 'B01',
|
||||
'username': 'member',
|
||||
'operatorId': 23,
|
||||
}, proxy.call_args.kwargs['params'])
|
||||
|
||||
def test_import_binds_session_operator_to_java_request(self):
|
||||
self._login(31)
|
||||
java_response = {'success': True, 'message': '开始导入', 'data': {'importId': 'import-1'}}
|
||||
with patch('utils.auth.is_session_user_valid', return_value=True), \
|
||||
patch.object(admin_module, '_ensure_dedupe_total_data_access',
|
||||
return_value=('normal', {'id': 31, 'username': 'member-b'}, None)), \
|
||||
patch.object(admin_module, '_proxy_backend_java',
|
||||
return_value=(java_response, None, 200)) as proxy:
|
||||
response = self.client.post(
|
||||
'/api/admin/dedupe-total-data/import',
|
||||
data={'file': (io.BytesIO(b'excel'), 'data.xlsx')},
|
||||
content_type='multipart/form-data')
|
||||
|
||||
self.assertEqual(200, response.status_code)
|
||||
self.assertEqual('import-1', response.get_json()['import_id'])
|
||||
self.assertEqual({'operatorId': 31}, proxy.call_args.kwargs['data'])
|
||||
|
||||
def test_export_forwards_filters_and_session_operator(self):
|
||||
self._login(31)
|
||||
java_response = Mock(
|
||||
status_code=200,
|
||||
content=b'xlsx-data',
|
||||
headers={
|
||||
'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'Content-Disposition': 'attachment; filename="dedupe-total-data.xlsx"',
|
||||
},
|
||||
)
|
||||
java_session = Mock()
|
||||
java_session.get.return_value = java_response
|
||||
with patch('utils.auth.is_session_user_valid', return_value=True), \
|
||||
patch.object(admin_module, '_ensure_dedupe_total_data_access',
|
||||
return_value=('normal', {'id': 31, 'username': 'member-b'}, None)), \
|
||||
patch.object(admin_module, '_get_backend_java_session', return_value=java_session):
|
||||
response = self.client.get(
|
||||
'/api/admin/dedupe-total-data/export'
|
||||
'?username=member&start_date=2026-07-01&end_date=2026-07-20')
|
||||
|
||||
self.assertEqual(200, response.status_code)
|
||||
self.assertEqual(b'xlsx-data', response.data)
|
||||
self.assertEqual({
|
||||
'operatorId': 31,
|
||||
'username': 'member',
|
||||
'startDate': '2026-07-01',
|
||||
'endDate': '2026-07-20',
|
||||
}, java_session.get.call_args.kwargs['params'])
|
||||
self.assertEqual(60, java_session.get.call_args.kwargs['timeout'])
|
||||
|
||||
def test_delete_ignores_client_operator_and_uses_session_user(self):
|
||||
self._login(41)
|
||||
java_response = {'success': True, 'message': '删除成功', 'data': None}
|
||||
with patch('utils.auth.is_session_user_valid', return_value=True), \
|
||||
patch.object(admin_module, '_ensure_dedupe_total_data_access',
|
||||
return_value=('admin', {'id': 41, 'username': 'leader'}, None)), \
|
||||
patch.object(admin_module, '_proxy_backend_java',
|
||||
return_value=(java_response, None, 200)) as proxy:
|
||||
response = self.client.delete('/api/admin/dedupe-total-data/91?operatorId=1')
|
||||
|
||||
self.assertEqual(200, response.status_code)
|
||||
self.assertEqual({'operatorId': 41}, proxy.call_args.kwargs['params'])
|
||||
|
||||
def test_menu_permission_is_still_required_for_normal_user(self):
|
||||
self._login()
|
||||
|
||||
def deny_access():
|
||||
return 'normal', {'id': 23}, (jsonify({'success': False, 'error': '无权访问'}), 403)
|
||||
|
||||
with patch('utils.auth.is_session_user_valid', return_value=True), \
|
||||
patch.object(admin_module, '_ensure_dedupe_total_data_access', side_effect=deny_access), \
|
||||
patch.object(admin_module, '_proxy_backend_java') as proxy:
|
||||
response = self.client.get('/api/admin/dedupe-total-data')
|
||||
|
||||
self.assertEqual(403, response.status_code)
|
||||
self.assertFalse(response.get_json()['success'])
|
||||
proxy.assert_not_called()
|
||||
|
||||
def test_dedupe_access_uses_login_capable_backend_menu_check(self):
|
||||
expected = ('normal', {'id': 23}, None)
|
||||
with patch.object(admin_module, '_ensure_backend_menu_access', return_value=expected) as backend_access, \
|
||||
patch.object(admin_module, '_ensure_admin_menu_access') as admin_access:
|
||||
result = admin_module._ensure_dedupe_total_data_access()
|
||||
|
||||
self.assertEqual(expected, result)
|
||||
backend_access.assert_called_once_with('dedupe-total-data')
|
||||
admin_access.assert_not_called()
|
||||
|
||||
def test_group_list_accepts_dedupe_menu_as_access_source(self):
|
||||
self._login()
|
||||
access_result = ('normal', {'id': 23, 'username': 'member-a'}, None)
|
||||
with patch('utils.auth.is_session_user_valid', return_value=True), \
|
||||
patch.object(admin_module, '_ensure_backend_menu_access',
|
||||
return_value=access_result) as backend_access, \
|
||||
patch.object(admin_module, '_load_expanded_shop_manage_groups',
|
||||
return_value=([], None, 200)):
|
||||
response = self.client.get('/api/admin/shop-manage-groups')
|
||||
|
||||
self.assertEqual(200, response.status_code)
|
||||
self.assertTrue(response.get_json()['success'])
|
||||
backend_access.assert_called_once_with(
|
||||
'shop-manage', 'skip-price-asin', 'query-asin', 'dedupe-total-data')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -1,340 +0,0 @@
|
||||
import io
|
||||
import json
|
||||
import unittest
|
||||
import zipfile
|
||||
from datetime import datetime
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from app import app
|
||||
from blueprints import admin_api as admin_module
|
||||
|
||||
|
||||
class FakeCursor:
|
||||
def __init__(self, rows):
|
||||
self.rows = rows
|
||||
self.last_sql = ''
|
||||
self.executions = []
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
def execute(self, sql, params=()):
|
||||
self.last_sql = sql
|
||||
self.executions.append((sql, params))
|
||||
|
||||
def fetchall(self):
|
||||
return self.rows
|
||||
|
||||
def fetchone(self):
|
||||
if 'COUNT(*)' in self.last_sql:
|
||||
return {'total': len(self.rows)}
|
||||
return self.rows[0] if self.rows else None
|
||||
|
||||
|
||||
class FakeConnection:
|
||||
def __init__(self, rows):
|
||||
self.cursor_value = FakeCursor(rows)
|
||||
|
||||
def cursor(self):
|
||||
return self.cursor_value
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
|
||||
class FakeDownloadResponse:
|
||||
def __init__(self, content=b'video-bytes', error=None):
|
||||
self.content = content
|
||||
self.error = error
|
||||
self.closed = False
|
||||
|
||||
def raise_for_status(self):
|
||||
if self.error:
|
||||
raise self.error
|
||||
|
||||
def iter_content(self, chunk_size):
|
||||
yield self.content
|
||||
|
||||
def close(self):
|
||||
self.closed = True
|
||||
|
||||
|
||||
class ImageVideoAdminTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
app.config.update(TESTING=True, SECRET_KEY='test')
|
||||
self.client = app.test_client()
|
||||
|
||||
def test_masks_nested_secrets_and_resolves_mode(self):
|
||||
payload = {
|
||||
'parameters': {
|
||||
'api_key_info': {'ai_conductor_key': 'secret', 't8_video_key': 'secret-2'},
|
||||
'video_info': {'mode': '2'},
|
||||
},
|
||||
'token': 'secret-3',
|
||||
}
|
||||
masked = admin_module._mask_image_video_secrets(payload)
|
||||
self.assertEqual('******', masked['parameters']['api_key_info']['ai_conductor_key'])
|
||||
self.assertEqual('******', masked['parameters']['api_key_info']['t8_video_key'])
|
||||
self.assertEqual('******', masked['token'])
|
||||
self.assertEqual('视频复刻', admin_module._image_video_mode(payload))
|
||||
|
||||
def test_admin_access_clause_is_limited_to_self_and_direct_users(self):
|
||||
sql, params = admin_module._image_video_access_sql('admin', {'id': 17})
|
||||
self.assertIn('created_by_id = %s', sql)
|
||||
self.assertEqual([17, 17], params)
|
||||
|
||||
def test_list_api_applies_pagination_and_returns_task_summary(self):
|
||||
row = {
|
||||
'id': 91,
|
||||
'user_id': 21,
|
||||
'username': 'demo',
|
||||
'group_name': '测试分组、第二分组',
|
||||
'status': 'SUCCESS',
|
||||
'request_json': json.dumps({'parameters': {'video_info': {'mode': '1'}}}),
|
||||
'submit_response_json': '{}',
|
||||
'result_json': '{}',
|
||||
'video_urls_json': json.dumps(['https://coze.example/video.mp4']),
|
||||
'debug_url': 'https://coze.example/debug',
|
||||
'archived_videos_json': json.dumps([{
|
||||
'sourceUrl': 'https://coze.example/video.mp4',
|
||||
'url': 'https://oss.example/video.mp4',
|
||||
'objectKey': 'result/image_video/1/video.mp4',
|
||||
'status': 'SUCCESS',
|
||||
}]),
|
||||
'archive_status': 'SUCCESS',
|
||||
'archive_error': None,
|
||||
'archive_attempt_count': 1,
|
||||
'archived_at': datetime(2026, 7, 15, 12, 5),
|
||||
'error_message': None,
|
||||
'coze_execute_id': 'exec-91',
|
||||
'coze_status': 'SUCCESS',
|
||||
'submitted_at': datetime(2026, 7, 15, 12, 0),
|
||||
'completed_at': datetime(2026, 7, 15, 12, 4),
|
||||
}
|
||||
connection = FakeConnection([row])
|
||||
with self.client.session_transaction() as session:
|
||||
session['user_id'] = 17
|
||||
with patch('utils.auth.is_session_user_valid', return_value=True), \
|
||||
patch('utils.auth.get_current_admin_role', return_value=('admin', {'id': 17})), \
|
||||
patch.object(admin_module, '_ensure_backend_menu_access', return_value=('admin', {'id': 17}, None)), \
|
||||
patch.object(admin_module, '_ensure_image_video_data_access', return_value=('admin', {'id': 17}, None)), \
|
||||
patch.object(admin_module, 'get_db', return_value=connection):
|
||||
response = self.client.get('/api/admin/image-video-tasks?page=2&page_size=20&status=success')
|
||||
|
||||
self.assertEqual(200, response.status_code)
|
||||
data = response.get_json()
|
||||
self.assertTrue(data['success'])
|
||||
self.assertEqual(2, data['page'])
|
||||
self.assertEqual('测试分组、第二分组', data['items'][0]['group_name'])
|
||||
self.assertEqual('图生视频', data['items'][0]['mode'])
|
||||
self.assertEqual('https://oss.example/video.mp4', data['items'][0]['video_url'])
|
||||
self.assertEqual([{
|
||||
'source_url': 'https://coze.example/video.mp4',
|
||||
'archived_url': 'https://oss.example/video.mp4',
|
||||
'object_key': 'result/image_video/1/video.mp4',
|
||||
'archive_status': 'SUCCESS',
|
||||
'archive_error': '',
|
||||
'display_url': 'https://oss.example/video.mp4',
|
||||
}], data['items'][0]['videos'])
|
||||
self.assertNotIn('request', data['items'][0])
|
||||
self.assertNotIn('submit_response', data['items'][0])
|
||||
self.assertNotIn('result', data['items'][0])
|
||||
list_sql, list_params = connection.cursor_value.executions[0]
|
||||
self.assertIn("t.task_type = 'IMAGE_VIDEO_WORKFLOW'", list_sql)
|
||||
self.assertIn('t.submitted_at >= DATE_SUB(NOW(), INTERVAL 3 DAY)', list_sql)
|
||||
self.assertNotIn('created_by_id = %s', list_sql)
|
||||
self.assertEqual(('SUCCESS', 20, 20), list_params)
|
||||
|
||||
def test_authorized_normal_user_receives_all_video_tasks(self):
|
||||
connection = FakeConnection([])
|
||||
with self.client.session_transaction() as session:
|
||||
session['user_id'] = 23
|
||||
with patch('utils.auth.is_session_user_valid', return_value=True), \
|
||||
patch.object(admin_module, '_ensure_backend_menu_access', return_value=('normal', {'id': 23}, None)), \
|
||||
patch.object(admin_module, '_ensure_image_video_data_access', return_value=('normal', {'id': 23}, None)), \
|
||||
patch.object(admin_module, 'get_db', return_value=connection):
|
||||
response = self.client.get('/api/admin/image-video-tasks')
|
||||
|
||||
self.assertEqual(200, response.status_code)
|
||||
list_sql, list_params = connection.cursor_value.executions[0]
|
||||
self.assertNotIn('t.user_id = %s', list_sql)
|
||||
self.assertEqual((20, 0), list_params)
|
||||
|
||||
def test_batch_download_returns_single_zip_with_selected_video(self):
|
||||
row = {
|
||||
'id': 91,
|
||||
'video_urls_json': json.dumps(['https://coze.example/video.mp4']),
|
||||
'archived_videos_json': json.dumps([{'url': 'https://oss.example/video.mp4'}]),
|
||||
}
|
||||
connection = FakeConnection([row])
|
||||
remote_response = FakeDownloadResponse()
|
||||
with self.client.session_transaction() as session:
|
||||
session['user_id'] = 23
|
||||
with patch('utils.auth.is_session_user_valid', return_value=True), \
|
||||
patch.object(admin_module, '_ensure_backend_menu_access', return_value=('normal', {'id': 23}, None)), \
|
||||
patch.object(admin_module, '_ensure_image_video_data_access', return_value=('normal', {'id': 23}, None)), \
|
||||
patch.object(admin_module, 'get_db', return_value=connection), \
|
||||
patch.object(admin_module.requests, 'get', return_value=remote_response):
|
||||
response = self.client.post('/api/admin/image-video-tasks/download-zip', json={
|
||||
'items': [{'task_id': 91, 'video_index': 0}],
|
||||
})
|
||||
|
||||
self.assertEqual(200, response.status_code)
|
||||
self.assertEqual('application/zip', response.mimetype)
|
||||
self.assertEqual('1', response.headers['X-Archive-File-Count'])
|
||||
with zipfile.ZipFile(io.BytesIO(response.get_data())) as archive:
|
||||
self.assertEqual(['task-91-video-1.mp4'], archive.namelist())
|
||||
self.assertEqual(b'video-bytes', archive.read('task-91-video-1.mp4'))
|
||||
response.close()
|
||||
self.assertTrue(remote_response.closed)
|
||||
|
||||
def test_batch_download_writes_failures_into_zip(self):
|
||||
row = {
|
||||
'id': 91,
|
||||
'video_urls_json': json.dumps(['https://coze.example/video.mp4']),
|
||||
'archived_videos_json': '[]',
|
||||
}
|
||||
connection = FakeConnection([row])
|
||||
remote_response = FakeDownloadResponse(error=admin_module.requests.RequestException('upstream failed'))
|
||||
with self.client.session_transaction() as session:
|
||||
session['user_id'] = 23
|
||||
with patch('utils.auth.is_session_user_valid', return_value=True), \
|
||||
patch.object(admin_module, '_ensure_backend_menu_access', return_value=('normal', {'id': 23}, None)), \
|
||||
patch.object(admin_module, '_ensure_image_video_data_access', return_value=('normal', {'id': 23}, None)), \
|
||||
patch.object(admin_module, 'get_db', return_value=connection), \
|
||||
patch.object(admin_module.requests, 'get', return_value=remote_response):
|
||||
response = self.client.post('/api/admin/image-video-tasks/download-zip', json={
|
||||
'items': [{'task_id': 91, 'video_index': 0}],
|
||||
})
|
||||
|
||||
self.assertEqual(200, response.status_code)
|
||||
self.assertEqual('1', response.headers['X-Archive-Error-Count'])
|
||||
with zipfile.ZipFile(io.BytesIO(response.get_data())) as archive:
|
||||
self.assertEqual(['download-errors.txt'], archive.namelist())
|
||||
self.assertIn('下载失败', archive.read('download-errors.txt').decode('utf-8'))
|
||||
response.close()
|
||||
|
||||
def test_unassigned_normal_user_is_denied_video_tasks(self):
|
||||
denied = (app.response_class('{"success":false}', content_type='application/json'), 403)
|
||||
with self.client.session_transaction() as session:
|
||||
session['user_id'] = 24
|
||||
with patch('utils.auth.is_session_user_valid', return_value=True), \
|
||||
patch.object(admin_module, '_ensure_backend_menu_access', return_value=('normal', {'id': 24}, denied)):
|
||||
response = self.client.get('/api/admin/image-video-tasks')
|
||||
|
||||
self.assertEqual(403, response.status_code)
|
||||
|
||||
def test_only_super_admin_can_manage_image_video_permissions(self):
|
||||
with self.client.session_transaction() as session:
|
||||
session['user_id'] = 17
|
||||
with patch('utils.auth.is_session_user_valid', return_value=True), \
|
||||
patch.object(admin_module, 'get_current_admin_role', return_value=('admin', {'id': 17})):
|
||||
response = self.client.get('/api/admin/image-video-task-permissions')
|
||||
|
||||
self.assertEqual(403, response.status_code)
|
||||
|
||||
def test_super_admin_lists_image_video_permission_users(self):
|
||||
cursor = MagicMock()
|
||||
cursor.__enter__.return_value = cursor
|
||||
cursor.__exit__.return_value = False
|
||||
cursor.fetchone.return_value = {'id': 75}
|
||||
cursor.fetchall.return_value = [
|
||||
{'id': 2, 'username': 'admin-a', 'role': 'admin', 'is_admin': 1, 'created_by_id': 1, 'granted': 1},
|
||||
{'id': 3, 'username': 'user-a', 'role': 'normal', 'is_admin': 0, 'created_by_id': 2, 'granted': 0},
|
||||
]
|
||||
connection = MagicMock()
|
||||
connection.cursor.return_value = cursor
|
||||
with self.client.session_transaction() as session:
|
||||
session['user_id'] = 1
|
||||
with patch('utils.auth.is_session_user_valid', return_value=True), \
|
||||
patch.object(admin_module, 'get_current_admin_role', return_value=('super_admin', {'id': 1})), \
|
||||
patch.object(admin_module, 'get_db', return_value=connection):
|
||||
response = self.client.get('/api/admin/image-video-task-permissions')
|
||||
|
||||
self.assertEqual(200, response.status_code)
|
||||
self.assertEqual([True, False], [item['granted'] for item in response.get_json()['items']])
|
||||
|
||||
def test_super_admin_replaces_only_image_video_permissions(self):
|
||||
cursor = MagicMock()
|
||||
cursor.__enter__.return_value = cursor
|
||||
cursor.__exit__.return_value = False
|
||||
cursor.fetchone.return_value = {'id': 75}
|
||||
cursor.fetchall.return_value = [{'id': 2}, {'id': 3}]
|
||||
connection = MagicMock()
|
||||
connection.cursor.return_value = cursor
|
||||
with self.client.session_transaction() as session:
|
||||
session['user_id'] = 1
|
||||
with patch('utils.auth.is_session_user_valid', return_value=True), \
|
||||
patch.object(admin_module, 'get_current_admin_role', return_value=('super_admin', {'id': 1})), \
|
||||
patch.object(admin_module, 'get_db', return_value=connection):
|
||||
response = self.client.put('/api/admin/image-video-task-permissions', json={'user_ids': [2, 3]})
|
||||
|
||||
self.assertEqual(200, response.status_code)
|
||||
self.assertEqual(2, response.get_json()['granted_count'])
|
||||
statements = [call.args[0] for call in cursor.execute.call_args_list]
|
||||
self.assertTrue(any('DELETE ucp FROM user_column_permission' in sql for sql in statements))
|
||||
self.assertEqual(2, sum('INSERT INTO user_column_permission' in sql for sql in statements))
|
||||
connection.commit.assert_called_once()
|
||||
|
||||
def test_generic_permission_update_preserves_video_permission(self):
|
||||
cursor = MagicMock()
|
||||
cursor.fetchall.side_effect = [[{'id': 75}], [{'column_id': 75}]]
|
||||
|
||||
admin_module._set_user_column_permissions(
|
||||
cursor,
|
||||
7,
|
||||
[2, 75],
|
||||
preserve_column_keys=(admin_module.IMAGE_VIDEO_PERMISSION_KEY,),
|
||||
)
|
||||
|
||||
inserts = [call.args[1] for call in cursor.execute.call_args_list if call.args[0].startswith('INSERT INTO')]
|
||||
self.assertEqual([(7, 2), (7, 75)], inserts)
|
||||
|
||||
def test_video_items_fall_back_to_source_and_handle_empty_values(self):
|
||||
row = {
|
||||
'video_urls_json': json.dumps([
|
||||
'https://coze.example/first.mp4',
|
||||
'https://coze.example/second.mp4',
|
||||
]),
|
||||
'archived_videos_json': json.dumps([{
|
||||
'sourceUrl': 'https://coze.example/first.mp4',
|
||||
'status': 'FAILED',
|
||||
'error': 'archive failed',
|
||||
}]),
|
||||
}
|
||||
|
||||
videos = admin_module._image_video_urls(row)
|
||||
|
||||
self.assertEqual(2, len(videos))
|
||||
self.assertEqual('https://coze.example/first.mp4', videos[0]['display_url'])
|
||||
self.assertEqual('FAILED', videos[0]['archive_status'])
|
||||
self.assertEqual('archive failed', videos[0]['archive_error'])
|
||||
self.assertEqual('https://coze.example/second.mp4', videos[1]['display_url'])
|
||||
self.assertEqual([], admin_module._image_video_urls({
|
||||
'video_urls_json': None,
|
||||
'archived_videos_json': 'not-json',
|
||||
}))
|
||||
|
||||
def test_detail_item_returns_masked_request_and_responses(self):
|
||||
row = {
|
||||
'id': 8,
|
||||
'request_json': json.dumps({'token': 'secret', 'name': 'demo'}),
|
||||
'submit_response_json': json.dumps({'execute_id': 'exec-8'}),
|
||||
'result_json': json.dumps({'status': 'SUCCESS'}),
|
||||
'video_urls_json': '[]',
|
||||
'archived_videos_json': '[]',
|
||||
}
|
||||
|
||||
item = admin_module._image_video_admin_item(row, include_json=True)
|
||||
|
||||
self.assertEqual('******', item['request']['token'])
|
||||
self.assertEqual('demo', item['request']['name'])
|
||||
self.assertEqual({'execute_id': 'exec-8'}, item['submit_response'])
|
||||
self.assertEqual({'status': 'SUCCESS'}, item['result'])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -129,44 +129,6 @@ def init_db():
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""")
|
||||
cur.execute("""
|
||||
CREATE TABLE IF NOT EXISTS columns (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(128) NOT NULL COMMENT '栏目名',
|
||||
column_key VARCHAR(64) NOT NULL COMMENT '栏目标识',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uk_column_key (column_key)
|
||||
)
|
||||
""")
|
||||
try:
|
||||
cur.execute("ALTER TABLE columns ADD COLUMN menu_type VARCHAR(20) NOT NULL DEFAULT 'app' COMMENT '菜单类型: app/admin' AFTER column_key")
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
cur.execute("ALTER TABLE columns ADD COLUMN route_path VARCHAR(255) NOT NULL DEFAULT '' COMMENT '菜单路由或页面标识' AFTER menu_type")
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
cur.execute("ALTER TABLE columns ADD COLUMN sort_order INT NOT NULL DEFAULT 0 COMMENT '菜单排序' AFTER route_path")
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
cur.execute("UPDATE columns SET menu_type = 'app' WHERE menu_type IS NULL OR menu_type = ''")
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
cur.execute("UPDATE columns SET sort_order = id WHERE sort_order IS NULL OR sort_order = 0")
|
||||
except Exception:
|
||||
pass
|
||||
cur.execute("""
|
||||
CREATE TABLE IF NOT EXISTS user_column_permission (
|
||||
user_id INT NOT NULL,
|
||||
column_id INT NOT NULL,
|
||||
PRIMARY KEY (user_id, column_id),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (column_id) REFERENCES columns(id) ON DELETE CASCADE
|
||||
)
|
||||
""")
|
||||
try:
|
||||
cur.execute("UPDATE users SET role = 'normal' WHERE (role IS NULL OR role = '') AND (is_admin = 0 OR is_admin IS NULL)")
|
||||
cur.execute("SELECT MIN(id) AS mid FROM users WHERE is_admin = 1")
|
||||
|
||||
@@ -713,6 +713,136 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.column-permission-tree {
|
||||
max-height: 280px;
|
||||
overflow: auto;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 6px;
|
||||
padding: 8px 10px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.column-tree-node {
|
||||
display: block;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.column-tree-group + .column-tree-group {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.column-tree-group-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
min-height: 36px;
|
||||
padding: 6px 10px;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
background: #f3f5f8;
|
||||
color: #24292f;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.column-tree-group-toggle::before {
|
||||
content: '\203A';
|
||||
flex: 0 0 20px;
|
||||
font-size: 20px;
|
||||
line-height: 1;
|
||||
color: #667085;
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
|
||||
.column-tree-group-toggle[aria-expanded="true"]::before {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.column-tree-group-toggle:hover {
|
||||
background: #e9edf3;
|
||||
}
|
||||
|
||||
.column-tree-group-toggle:focus-visible {
|
||||
outline: 2px solid #3159d8;
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.column-tree-group-name {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.column-tree-group-count {
|
||||
margin-left: auto;
|
||||
font-size: 12px;
|
||||
color: #667085;
|
||||
}
|
||||
|
||||
.column-tree-group-body {
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.column-tree-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 28px;
|
||||
}
|
||||
|
||||
.column-tree-node label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
margin: 0;
|
||||
min-height: 28px;
|
||||
font-size: 13px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.column-tree-toggle,
|
||||
.column-tree-toggle-spacer {
|
||||
flex: 0 0 24px;
|
||||
width: 24px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.column-tree-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #666;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.column-tree-toggle::before {
|
||||
content: '\203A';
|
||||
display: block;
|
||||
font-size: 20px;
|
||||
line-height: 1;
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
|
||||
.column-tree-toggle[aria-expanded="true"]::before {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.column-tree-toggle:hover {
|
||||
color: #3159d8;
|
||||
}
|
||||
|
||||
.column-tree-toggle:focus-visible {
|
||||
outline: 2px solid #3159d8;
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.column-tree-node[data-depth="1"] > .column-tree-row { padding-left: 22px; }
|
||||
.column-tree-node[data-depth="2"] > .column-tree-row { padding-left: 44px; }
|
||||
.column-tree-node input[type="checkbox"] { width: auto; }
|
||||
.column-tree-node .inherited-label { color: #999; }
|
||||
|
||||
.multi-select-native {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
@@ -1416,9 +1546,9 @@
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group" id="formGroupCreateColumns">
|
||||
<label>菜单权限(可多选,Ctrl/Shift 点选)</label>
|
||||
<label>菜单权限(勾选父菜单会继承全部子菜单)</label>
|
||||
<div id="createColumnCards" class="column-permission-cards"></div>
|
||||
<select id="createColumnPermissionWrap" class="column-permission-select" multiple></select>
|
||||
<div id="createColumnPermissionWrap" class="column-permission-tree"></div>
|
||||
</div>
|
||||
<button class="btn" id="btnCreate">创建用户</button>
|
||||
<p class="msg" id="msgCreate"></p>
|
||||
@@ -1484,6 +1614,10 @@
|
||||
<option value="app">软件(app)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group" style="min-width:220px;">
|
||||
<label>父菜单(可选)</label>
|
||||
<select id="columnParentId"><option value="">无(根菜单)</option></select>
|
||||
</div>
|
||||
<button class="btn" id="btnAddColumn">新增菜单</button>
|
||||
</div>
|
||||
<p class="msg" id="msgColumn"></p>
|
||||
@@ -1497,6 +1631,7 @@
|
||||
<th>菜单名称</th>
|
||||
<th>栏目标识</th>
|
||||
<th>菜单类型</th>
|
||||
<th>父菜单</th>
|
||||
<th>排序</th>
|
||||
<th>菜单路由</th>
|
||||
<th>创建时间</th>
|
||||
@@ -2233,9 +2368,9 @@
|
||||
<input type="text" id="editCreatorName" readonly style="background:#f5f5f5;">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>菜单权限(可多选,Ctrl/Shift 点选)</label>
|
||||
<label>菜单权限(勾选父菜单会继承全部子菜单)</label>
|
||||
<div id="editColumnCards" class="column-permission-cards"></div>
|
||||
<select id="editColumnPermissionWrap" class="column-permission-select" multiple></select>
|
||||
<div id="editColumnPermissionWrap" class="column-permission-tree"></div>
|
||||
</div>
|
||||
<p class="msg" id="msgEdit"></p>
|
||||
<div style="margin-top:16px;display:flex;gap:8px;">
|
||||
@@ -2311,6 +2446,10 @@
|
||||
<option value="app">软件(app)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>父菜单(可选)</label>
|
||||
<select id="editColumnParentId"><option value="">无(根菜单)</option></select>
|
||||
</div>
|
||||
<p class="msg" id="msgEditColumn"></p>
|
||||
<div style="margin-top:16px;display:flex;gap:8px;">
|
||||
<button class="btn" id="btnSaveColumn">保存</button>
|
||||
@@ -2607,7 +2746,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="/static/admin.js?v=shop-key-remark-1"></script>
|
||||
<script src="/static/admin.js?v=permission-tree-groups-1"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user