feat: allow deleting unused brands

This commit is contained in:
Codex 2026-10-12 10:17:24 +08:00
parent 912b8a4242
commit 6a94176f8a
4 changed files with 5694 additions and 2 deletions

2021
routes/auth.py Normal file

File diff suppressed because it is too large Load Diff

3609
routes/products.py Normal file

File diff suppressed because it is too large Load Diff

View File

@ -3364,7 +3364,7 @@ function previewNewImage(url) {
// ─── 品牌信息管理页面 ─── // ─── 品牌信息管理页面 ───
function loadBrandManagementPage(container) { function loadBrandManagementPage(container) {
container.innerHTML = `<style>.brand-category-domestic{color:#1677ff!important;background:#e6f4ff!important;border-color:#91caff!important;font-weight:600}.brand-category-overseas{color:#d46b08!important;background:#fff7e6!important;border-color:#ffd591!important}.brand-manage-pagination{display:flex;justify-content:center;align-items:center;gap:6px;padding:12px}.brand-manage-pagination button{min-width:32px}.brand-manage-pagination .active{color:#fff;background:#1677ff;border-color:#1677ff}</style> container.innerHTML = `<style>.brand-category-domestic{color:#1677ff!important;background:#e6f4ff!important;border-color:#91caff!important;font-weight:600}.brand-category-overseas{color:#d46b08!important;background:#fff7e6!important;border-color:#ffd591!important}.brand-manage-pagination{display:flex;justify-content:center;align-items:center;gap:6px;padding:12px}.brand-manage-pagination button{min-width:32px}.brand-manage-pagination .active{color:#fff;background:#1677ff;border-color:#1677ff}</style>
<div class="page-header"><h1>品牌信息管理</h1><p class="page-desc">提取当前有效产品中的全部品牌,可手动新增和编辑品牌名称</p></div> <div class="page-header"><h1>品牌信息管理</h1><p class="page-desc">提取当前有效产品中的全部品牌,可手动新增、编辑或删除未被产品使用的品牌</p></div>
<div class="card"><div class="card-body" style="padding:12px 16px"><div class="filter-bar" style="gap:8px;align-items:center;flex-wrap:wrap"> <div class="card"><div class="card-body" style="padding:12px 16px"><div class="filter-bar" style="gap:8px;align-items:center;flex-wrap:wrap">
<button class="btn btn-primary" onclick="showBrandAdd()">➕ 新增品牌</button><input type="text" id="brandManageSearch" class="form-input" style="width:240px" placeholder="搜索品牌" autocomplete="off" onkeydown="if(event.key==='Enter')loadBrandManagementData(1)"> <button class="btn btn-primary" onclick="showBrandAdd()">➕ 新增品牌</button><input type="text" id="brandManageSearch" class="form-input" style="width:240px" placeholder="搜索品牌" autocomplete="off" onkeydown="if(event.key==='Enter')loadBrandManagementData(1)">
<select id="brandManageCategory" class="form-input" style="width:120px" onchange="loadBrandManagementData(1)"><option value="">全部分类</option><option value="未分类">未分类</option><option value="国内">国内</option><option value="海外">海外</option></select> <select id="brandManageCategory" class="form-input" style="width:120px" onchange="loadBrandManagementData(1)"><option value="">全部分类</option><option value="未分类">未分类</option><option value="国内">国内</option><option value="海外">海外</option></select>
@ -3381,7 +3381,7 @@ function loadBrandManagementData(page) {
if(res.code!==0){body.innerHTML='<tr><td colspan="5" style="text-align:center;padding:40px;color:#f5222d">'+escHtml(res.msg||'加载失败')+'</td></tr>';return;} if(res.code!==0){body.innerHTML='<tr><td colspan="5" style="text-align:center;padding:40px;color:#f5222d">'+escHtml(res.msg||'加载失败')+'</td></tr>';return;}
var rows=res.data||[], total=document.getElementById('brandManageTotal'); if(total) total.textContent=res.total||0; var rows=res.data||[], total=document.getElementById('brandManageTotal'); if(total) total.textContent=res.total||0;
if(!rows.length){body.innerHTML='<tr><td colspan="5" style="text-align:center;padding:40px;color:var(--text-light)">暂无品牌</td></tr>';document.getElementById('brandManagePagination').innerHTML='';return;} if(!rows.length){body.innerHTML='<tr><td colspan="5" style="text-align:center;padding:40px;color:var(--text-light)">暂无品牌</td></tr>';document.getElementById('brandManagePagination').innerHTML='';return;}
body.innerHTML=rows.map(function(r,i){var cat=r.category||'';var cls=cat==='国内'?'brand-category-domestic':(cat==='海外'?'brand-category-overseas':'');var select='<select class="form-input '+cls+'" style="width:120px;margin:auto;text-align:center" onchange="saveBrandCategory('+inlineJsString(r.brand)+',this.value)"><option value="">未分类</option><option value="国内" '+(cat==='国内'?'selected':'')+'>国内</option><option value="海外" '+(cat==='海外'?'selected':'')+'>海外</option></select>';return '<tr><td style="text-align:center">'+((window._brandManagePage-1)*10+i+1)+'</td><td style="text-align:center">'+escHtml(r.brand)+'</td><td style="text-align:center">'+select+'</td><td style="text-align:center;color:#999">'+escHtml(r.updated_at||'-')+'</td><td style="text-align:center"><button class="btn btn-sm btn-outline" onclick="showBrandEdit('+inlineJsString(r.brand)+')">编辑</button></td></tr>';}).join(''); body.innerHTML=rows.map(function(r,i){var cat=r.category||'';var cls=cat==='国内'?'brand-category-domestic':(cat==='海外'?'brand-category-overseas':'');var select='<select class="form-input '+cls+'" style="width:120px;margin:auto;text-align:center" onchange="saveBrandCategory('+inlineJsString(r.brand)+',this.value)"><option value="">未分类</option><option value="国内" '+(cat==='国内'?'selected':'')+'>国内</option><option value="海外" '+(cat==='海外'?'selected':'')+'>海外</option></select>';var deleteButton=window._hasPagePerm('brand_management','delete')?' <button class="btn btn-sm btn-outline-danger" style="color:#f5222d;border-color:#f5222d" onclick="deleteBrand('+inlineJsString(r.brand)+')">删除</button>':'';return '<tr><td style="text-align:center">'+((window._brandManagePage-1)*10+i+1)+'</td><td style="text-align:center">'+escHtml(r.brand)+'</td><td style="text-align:center">'+select+'</td><td style="text-align:center;color:#999">'+escHtml(r.updated_at||'-')+'</td><td style="text-align:center;white-space:nowrap"><button class="btn btn-sm btn-outline" onclick="showBrandEdit('+inlineJsString(r.brand)+')">编辑</button>'+deleteButton+'</td></tr>';}).join('');
renderBrandManagementPagination(res.total||0,10,window._brandManagePage); renderBrandManagementPagination(res.total||0,10,window._brandManagePage);
}).catch(function(){body.innerHTML='<tr><td colspan="5" style="text-align:center;padding:40px;color:#f5222d">网络异常,请稍后重试</td></tr>';}); }).catch(function(){body.innerHTML='<tr><td colspan="5" style="text-align:center;padding:40px;color:#f5222d">网络异常,请稍后重试</td></tr>';});
} }
@ -3391,6 +3391,7 @@ function showBrandEdit(brand){fetch('/api/brand-management/list?search='+encodeU
function showBrandModal(title,brand,category){var old=document.getElementById('brandManageModal');if(old)old.remove();var overlay=document.createElement('div');overlay.id='brandManageModal';overlay.className='modal-overlay';overlay.style.display='flex';overlay.onclick=function(e){if(e.target===overlay){}};overlay.innerHTML='<div class="modal" style="width:460px"><div class="modal-header"><h3>'+title+'</h3><span class="modal-close" onclick="document.getElementById(\'brandManageModal\').remove()">&times;</span></div><div class="modal-body" style="padding:20px"><label style="display:block;margin-bottom:6px">品牌名称</label><input id="brandManageName" class="form-input" style="width:100%;box-sizing:border-box" autocomplete="off" value="'+escHtml(brand)+'" placeholder="请输入品牌名称"><label style="display:block;margin:14px 0 6px">分类</label><select id="brandManageModalCategory" class="form-input" style="width:100%;text-align:center"><option value="">未分类</option><option value="国内">国内</option><option value="海外">海外</option></select></div><div class="modal-footer"><button class="btn" onclick="document.getElementById(\'brandManageModal\').remove()">取消</button><button class="btn btn-primary" onclick="submitBrandModal('+inlineJsString(brand)+')">保存</button></div></div>';document.body.appendChild(overlay);document.getElementById('brandManageModalCategory').value=category||'';document.getElementById('brandManageName').focus();} function showBrandModal(title,brand,category){var old=document.getElementById('brandManageModal');if(old)old.remove();var overlay=document.createElement('div');overlay.id='brandManageModal';overlay.className='modal-overlay';overlay.style.display='flex';overlay.onclick=function(e){if(e.target===overlay){}};overlay.innerHTML='<div class="modal" style="width:460px"><div class="modal-header"><h3>'+title+'</h3><span class="modal-close" onclick="document.getElementById(\'brandManageModal\').remove()">&times;</span></div><div class="modal-body" style="padding:20px"><label style="display:block;margin-bottom:6px">品牌名称</label><input id="brandManageName" class="form-input" style="width:100%;box-sizing:border-box" autocomplete="off" value="'+escHtml(brand)+'" placeholder="请输入品牌名称"><label style="display:block;margin:14px 0 6px">分类</label><select id="brandManageModalCategory" class="form-input" style="width:100%;text-align:center"><option value="">未分类</option><option value="国内">国内</option><option value="海外">海外</option></select></div><div class="modal-footer"><button class="btn" onclick="document.getElementById(\'brandManageModal\').remove()">取消</button><button class="btn btn-primary" onclick="submitBrandModal('+inlineJsString(brand)+')">保存</button></div></div>';document.body.appendChild(overlay);document.getElementById('brandManageModalCategory').value=category||'';document.getElementById('brandManageName').focus();}
function submitBrandModal(oldBrand){var name=(document.getElementById('brandManageName').value||'').trim(),category=document.getElementById('brandManageModalCategory').value||'';if(!name){alert('品牌名称不能为空');return;}var isEdit=!!oldBrand;fetch(isEdit?'/api/brand-management/update':'/api/brand-management/create',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(isEdit?{old_brand:oldBrand,new_brand:name,category:category}:{brand:name,category:category})}).then(function(r){return r.json();}).then(function(res){if(res.code!==0){alert(res.msg||'保存失败');return;}document.getElementById('brandManageModal').remove();loadBrandManagementData(window._brandManagePage||1);}).catch(function(){alert('网络异常,保存失败');});} function submitBrandModal(oldBrand){var name=(document.getElementById('brandManageName').value||'').trim(),category=document.getElementById('brandManageModalCategory').value||'';if(!name){alert('品牌名称不能为空');return;}var isEdit=!!oldBrand;fetch(isEdit?'/api/brand-management/update':'/api/brand-management/create',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(isEdit?{old_brand:oldBrand,new_brand:name,category:category}:{brand:name,category:category})}).then(function(r){return r.json();}).then(function(res){if(res.code!==0){alert(res.msg||'保存失败');return;}document.getElementById('brandManageModal').remove();loadBrandManagementData(window._brandManagePage||1);}).catch(function(){alert('网络异常,保存失败');});}
function saveBrandCategory(brand,category){fetch('/api/brand-management/update',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({old_brand:brand,new_brand:brand,category:category})}).then(function(r){return r.json();}).then(function(res){if(res.code!==0){alert(res.msg||'保存失败');}else{loadBrandManagementData(window._brandManagePage||1);}}).catch(function(){alert('网络异常,保存失败');});} function saveBrandCategory(brand,category){fetch('/api/brand-management/update',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({old_brand:brand,new_brand:brand,category:category})}).then(function(r){return r.json();}).then(function(res){if(res.code!==0){alert(res.msg||'保存失败');}else{loadBrandManagementData(window._brandManagePage||1);}}).catch(function(){alert('网络异常,保存失败');});}
function deleteBrand(brand){showCenteredConfirm('确认删除','确定删除品牌“'+brand+'”吗?删除后不可恢复;正在被产品使用的品牌将受到保护,无法删除。').then(function(ok){if(!ok)return;fetch('/api/brand-management/delete',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({brand:brand})}).then(function(r){return r.json();}).then(function(res){if(res.code!==0){showCenteredAlert('删除失败',res.msg||'删除失败');return;}showCenteredAlert('删除成功',res.msg||'品牌已删除');loadBrandManagementData(window._brandManagePage||1);}).catch(function(){showCenteredAlert('删除失败','网络异常,请稍后重试');});});}
// ─── 类目管理页面 ─── // ─── 类目管理页面 ───
function loadProductCategoryPage(container) { function loadProductCategoryPage(container) {

View File

@ -0,0 +1,61 @@
"""品牌信息管理删除功能回归测试。"""
import unittest
from unittest import mock
import test_wsgi # noqa: F401
from app import app
from routes import products
class BrandManagementDeleteTest(unittest.TestCase):
def setUp(self):
app.config.update(TESTING=True)
self.client = app.test_client()
with self.client.session_transaction() as session:
session.update(user_id=1, username='brand-delete-test', role='super_admin')
def test_delete_manual_brand(self):
with mock.patch.object(products, 'operation_required', return_value=True), \
mock.patch.object(products, 'query', side_effect=[
[{'cnt': 0}],
[{'brand': '误添加品牌', 'category': '国内'}],
0,
]) as query, \
mock.patch.object(products, 'add_operation_log') as operation_log:
response = self.client.post(
'/api/brand-management/delete', json={'brand': ' 误添加品牌 '}
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.get_json()['code'], 0)
self.assertIn('DELETE FROM brand_information', query.call_args_list[2].args[0])
operation_log.assert_called_once()
def test_reject_delete_when_brand_is_used_by_products(self):
with mock.patch.object(products, 'operation_required', return_value=True), \
mock.patch.object(products, 'query', return_value=[{'cnt': 3}]) as query, \
mock.patch.object(products, 'add_operation_log') as operation_log:
response = self.client.post(
'/api/brand-management/delete', json={'brand': '在用品牌'}
)
body = response.get_json()
self.assertEqual(body['code'], 409)
self.assertEqual(body['product_count'], 3)
self.assertEqual(query.call_count, 1)
operation_log.assert_not_called()
def test_reject_delete_without_edit_permission(self):
with mock.patch.object(products, 'operation_required', return_value=False), \
mock.patch.object(products, 'query') as query:
response = self.client.post(
'/api/brand-management/delete', json={'brand': '无权限品牌'}
)
self.assertEqual(response.get_json()['code'], 403)
query.assert_not_called()
if __name__ == '__main__':
unittest.main()