chore: rename procurement management system

This commit is contained in:
Codex 2026-10-13 10:30:53 +08:00
parent 9e77a7cdc0
commit c88182bcd8
3 changed files with 888 additions and 0 deletions

662
templates/base.html Normal file
View File

@ -0,0 +1,662 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
<meta http-equiv="Pragma" content="no-cache">
<meta http-equiv="Expires" content="0">
<title>蝶雅采购管理系统</title>
<link rel="manifest" href="/static/manifest.json">
<meta name="theme-color" content="#1677ff">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<meta name="apple-mobile-web-app-title" content="蝶雅数据">
<link rel="icon" type="image/png" href="/static/logo-small.png">
<link rel="apple-touch-icon" href="/static/logo-small.png">
<link rel="stylesheet" href="/static/css/style.css?v=1785481700">
<script src="/static/js/chart.umd.min.js"></script>
<style>
.nav-hidden { display: none !important; }
/* 侧边栏折叠时,子菜单弹出面板 */
.sidebar.collapsed .nav-group {
position: relative;
}
.sidebar-popup-menu {
display: none;
position: fixed;
left: 60px;
min-width: 180px;
background: #fff;
border: 1px solid #e8e8e8;
border-radius: 8px;
box-shadow: 0 6px 16px rgba(0,0,0,0.12);
z-index: 99999;
padding: 6px 0;
}
.sidebar-popup-menu.open {
display: block;
}
.sidebar-popup-menu a {
display: block;
padding: 10px 16px;
font-size: 14px;
color: #333;
text-decoration: none;
}
.sidebar-popup-menu a:hover {
background: #f5f5f5;
color: #1677ff;
}
/* BOM树形表格 */
#bomTable .bom-product-row td { border-bottom: 1px solid var(--border); }
#bomTable .bom-material-row td { border-bottom: 1px dashed #e0e0e0; font-size: 13px; }
#bomTable .bom-material-row:last-child td { border-bottom: 1px solid var(--border); }
#bomTable .bom-product-row:hover { background: #e8f0fe !important; }
#bomTable .bom-material-row:hover { background: #f0f4f8 !important; }
.filter-wrap .filter-clear{position:absolute;right:5px;top:50%;transform:translateY(-50%);cursor:pointer;color:#bfbfbf;font-size:13px;display:none;z-index:2;padding:1px 4px;border-radius:3px;line-height:1;background:var(--bg,#fff)}
.filter-wrap.has-value:hover .filter-clear{display:block}
.filter-clear:hover{color:#cf1322;background:#eee}
/* 产品详情明细弹窗 - 美化样式 */
.detail-two-col{display:grid;grid-template-columns:1fr 1fr;gap:8px 16px}
.detail-col{display:flex;flex-direction:column;gap:6px}
.detail-pair{display:flex;border:1px solid #d0d0d0;border-radius:5px;overflow:hidden;min-height:36px;background:#fff;box-shadow:0 1px 2px rgba(0,0,0,0.05)}
.detail-lbl{width:115px;min-width:115px;padding:6px 10px;background:#b0b0b0;color:#333;font-size:12px;font-weight:600;white-space:nowrap;display:flex;align-items:center;justify-content:flex-end;letter-spacing:0.3px;border-right:1px solid #999}
.detail-val-wrap{flex:1;padding:4px 10px;display:flex;align-items:center}
.detail-val{font-size:13px;color:#333;word-break:break-all;line-height:1.5}
.detail-input{width:100%;box-sizing:border-box;font-size:13px;border:1px solid #ccc;border-radius:3px;padding:5px 8px;color:#333;background:#fff;min-width:0;transition:border-color .2s,box-shadow .2s}
.detail-input:hover{border-color:#999}
.detail-input:focus{border-color:#1890ff;outline:none;box-shadow:0 0 0 2px rgba(24,144,255,0.15)}
.detail-select{appearance:auto;-webkit-appearance:auto;padding:5px 6px!important;cursor:pointer;background:#fff!important}
.detail-textarea:focus{border-color:#1890ff!important;outline:none;box-shadow:0 0 0 2px rgba(24,144,255,0.15)}
.orderer-select{background:#fff;cursor:pointer;min-height:28px}
.orderer-select option{padding:3px 6px}
.ac{width:auto;min-width:0}
.col-resize-handle{cursor:col-resize;transition:background .15s}
.col-resize-handle:hover{background:rgba(102,126,234,0.3)!important}
.filter-wrap.has-value::after{content:"▾";position:absolute;right:5px;top:50%;transform:translateY(-50%);font-size:11px;color:#999;pointer-events:none;transition:opacity .15s}
.filter-wrap.has-value:hover::after{opacity:0}
.pd-fields-2col{display:grid;grid-template-columns:1fr 1fr;gap:0}
.pd-fields-3col{display:grid;grid-template-columns:1fr 1fr 1fr;gap:0}
.pd-fields-4col{display:grid;grid-template-columns:1fr 1fr 1fr 1fr;gap:0}
.pd-row-2col{border-bottom:1px solid #f0f0f0}
.pd-row-3col{border-bottom:1px solid #f0f0f0}
.pd-row-4col{border-bottom:1px solid #f0f0f0}
.filter-wrap{position:relative;display:inline-block;vertical-align:middle}
.filter-wrap select{appearance:none;-webkit-appearance:none;-moz-appearance:none;padding-right:22px!important}
.filter-wrap::after{content:"\25be";position:absolute;right:6px;top:50%;transform:translateY(-50%);font-size:11px;color:#999;pointer-events:none;transition:opacity .15s}
.filter-wrap.has-value:hover::after{opacity:0}
.filter-wrap .filter-clear{position:absolute;right:4px;top:50%;transform:translateY(-50%);cursor:pointer;color:#999;font-size:14px;display:none;z-index:3;line-height:1;padding:0 3px}
.filter-wrap.has-value:hover .filter-clear{display:block}
.filter-clear:hover{color:#cf1322}
</style>
</head>
<body>
<!-- 侧边栏 -->
<aside class="sidebar" id="sidebar">
<div class="sidebar-header">
<div class="sidebar-logo">
<img src="/static/logo-small.png" alt="Logo" style="width:32px;height:32px;border-radius:6px;">
</div>
<span class="sidebar-title">采购管理系统</span>
<button class="sidebar-toggle" id="sidebarToggle" onclick="toggleSidebar()" aria-label="切换菜单">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/>
</svg>
</button>
</div>
<nav class="sidebar-nav">
{# 所有用户可见 #}
<a href="/dashboard" class="nav-item {% if page == 'dashboard' %}active{% endif %}" data-page="dashboard" data-roles="all">
<span class="nav-icon nav-icon-accent">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/>
<rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/>
</svg>
</span>
<span class="nav-text">数据概览</span>
</a>
{# 采销协同(含子菜单): 所有用户可见 #}
<div class="nav-group" data-roles="all">
<a href="#" class="nav-item nav-group-header" data-group="collaboration" onclick="toggleSubmenu(event)">
<span class="nav-icon nav-icon-accent">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/>
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>
</svg>
</span>
<span class="nav-text">采销协同</span>
<span class="nav-arrow">&#9662;</span>
</a>
<div class="nav-submenu" id="submenu-collaboration">
<a href="/collaboration/inventory-dashboard" class="nav-sub-item {% if page == 'collaboration_inventory_dashboard' %}active{% endif %}" data-page="collaboration_inventory_dashboard" data-roles="all"><span class="nav-sub-icon">&#9679;</span><span>库存数据看板</span></a>
<a href="/collaboration/key-sku-forecast" class="nav-sub-item {% if page == 'collaboration_key_sku_forecast' %}active{% endif %}" data-page="collaboration_key_sku_forecast" data-roles="all"><span class="nav-sub-icon">&#9679;</span><span>重点SKU预测</span></a>
<a href="/collaboration/sku-health-index" class="nav-sub-item {% if page == 'collaboration_sku_health_index' %}active{% endif %}" data-page="collaboration_sku_health_index" data-roles="all"><span class="nav-sub-icon">&#9679;</span><span>SKU库存健康指数</span></a>
<a href="/collaboration/slow-moving-sku" class="nav-sub-item {% if page == 'collaboration_slow_moving_sku' %}active{% endif %}" data-page="collaboration_slow_moving_sku" data-roles="all"><span class="nav-sub-icon">&#9679;</span><span>呆滞SKU清单</span></a>
<a href="/collaboration/sales-management" class="nav-sub-item {% if page == 'collaboration_sales_management' %}active{% endif %}" data-page="collaboration_sales_management" data-roles="all"><span class="nav-sub-icon">&#9679;</span><span>动销管理</span></a>
</div>
</div>
{# 基础数据(含子菜单): 所有非管理员也可查看 #}
<div class="nav-group" data-roles="admin,super_admin,procurement_planner,procurement_follower,product_developer,operator,viewer,查看者">
<a href="#" class="nav-item nav-group-header" data-group="basic" onclick="toggleSubmenu(event)">
<span class="nav-icon nav-icon-accent">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/>
</svg>
</span>
<span class="nav-text">基础数据</span>
<span class="nav-arrow">&#9662;</span>
</a>
<div class="nav-submenu" id="submenu-basic">
<a href="/products" class="nav-sub-item {% if page == 'products' %}active{% endif %}" data-page="products">
<span class="nav-sub-icon">&#9679;</span>
<span>产品信息管理</span>
</a>
<a href="/brand-management" class="nav-sub-item {% if page == 'brand_management' %}active{% endif %}" data-page="brand_management">
<span class="nav-sub-icon">&#9679;</span>
<span>品牌信息管理</span>
</a>
<a href="/bom" class="nav-sub-item {% if page == 'bom' %}active{% endif %}" data-page="bom">
<span class="nav-sub-icon">&#9679;</span>
<span>物料清单管理</span>
</a>
{# 仓库管理: 所有非管理员也可查看 #}
<a href="/warehouse" class="nav-sub-item {% if page == 'warehouse' %}active{% endif %}" data-page="warehouse" data-roles="admin,super_admin,viewer,查看者">
<span class="nav-sub-icon">&#9679;</span>
<span>仓库信息管理</span>
</a>
{# 供应商管理: 所有非管理员也可查看 #}
<a href="/suppliers" class="nav-sub-item {% if page == 'suppliers' %}active{% endif %}" data-page="suppliers" data-roles="admin,super_admin,procurement_planner,procurement_follower,viewer,查看者">
<span class="nav-sub-icon">&#9679;</span>
<span>供应商管理</span>
</a>
{# 人员信息管理: 所有非管理员也可查看 #}
<a href="/personnel" class="nav-sub-item {% if page == 'personnel' %}active{% endif %}" data-page="personnel" data-roles="admin,super_admin,procurement_planner,procurement_follower,viewer,查看者">
<span class="nav-sub-icon">&#9679;</span>
<span>人员信息管理</span>
</a>
</div>
</div>
{# 库存与出库(含子菜单): 所有人 #}
<div class="nav-group" data-roles="all">
<a href="#" class="nav-item nav-group-header" data-group="inventory" onclick="toggleSubmenu(event)">
<span class="nav-icon nav-icon-accent">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"/>
</svg>
</span>
<span class="nav-text">库存与出库</span>
<span class="nav-arrow">&#9662;</span>
</a>
<div class="nav-submenu" id="submenu-inventory">
<a href="/inventory/finished" class="nav-sub-item {% if page == 'inventory_finished' %}active{% endif %}" data-page="inventory_finished">
<span class="nav-sub-icon">&#9679;</span>
<span>成品库存</span>
</a>
<a href="/inventory/packaging" class="nav-sub-item {% if page == 'inventory_packaging' %}active{% endif %}" data-page="inventory_packaging">
<span class="nav-sub-icon">&#9679;</span>
<span>包材库存</span>
</a>
<a href="/data/summary" class="nav-sub-item {% if page == 'data_summary' %}active{% endif %}" data-page="data_summary">
<span class="nav-sub-icon">&#9679;</span>
<span>出库数据</span>
</a>
<a href="/data/detail" class="nav-sub-item {% if page == 'data_detail' %}active{% endif %}" data-page="data_detail">
<span class="nav-sub-icon">&#9679;</span>
<span>出库明细</span>
</a>
</div>
</div>
{# 计划预测: 所有非管理员也可查看 #}
<a href="/planning" data-roles="admin,super_admin,procurement_planner,procurement_follower,product_developer,operator,viewer,查看者" class="nav-item {% if page == 'planning' %}active{% endif %}" data-page="planning">
<span class="nav-icon nav-icon-accent">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/>
</svg>
</span>
<span class="nav-text">计划预测</span>
</a>
{# 现货模式(含子菜单): 所有非管理员也可查看 #}
<div class="nav-group" data-roles="admin,super_admin,procurement_planner,procurement_follower,product_developer,operator,viewer,查看者">
<a href="#" class="nav-item nav-group-header" data-group="spot" onclick="toggleSubmenu(event)">
<span class="nav-icon nav-icon-accent">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/>
</svg>
</span>
<span class="nav-text">现货模式</span>
<span class="nav-arrow">&#9662;</span>
</a>
<div class="nav-submenu" id="submenu-spot">
<a href="/spot/dashboard" class="nav-sub-item {% if page == 'spot_dashboard' %}active{% endif %}" data-page="spot_dashboard">
<span class="nav-sub-icon">&#9679;</span>
<span>现货看板</span>
</a>
<a href="/spot/application" class="nav-sub-item {% if page == 'spot_application' %}active{% endif %}" data-page="spot_application">
<span class="nav-sub-icon">&#9679;</span>
<span>现货申请</span>
</a>
<a href="/spot/confirmation" class="nav-sub-item {% if page == 'spot_confirmation' %}active{% endif %}" data-page="spot_confirmation">
<span class="nav-sub-icon">&#9679;</span>
<span>现货确认</span>
</a>
<a href="/spot/sales" class="nav-sub-item {% if page == 'spot_sales' %}active{% endif %}" data-page="spot_sales">
<span class="nav-sub-icon">&#9679;</span>
<span>销量管理</span>
</a>
</div>
</div>
{# 采购订单(含子菜单): 所有非管理员也可查看 #}
<div class="nav-group" data-roles="admin,super_admin,procurement_planner,procurement_follower,viewer,查看者">
<a href="#" class="nav-item nav-group-header" data-group="purchase" onclick="toggleSubmenu(event)">
<span class="nav-icon nav-icon-accent">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/><rect x="8" y="2" width="8" height="4" rx="1" ry="1"/>
</svg>
</span>
<span class="nav-text">订单管理</span>
<span class="nav-arrow">&#9662;</span>
</a>
<div class="nav-submenu" id="submenu-purchase">
<a href="/purchase" class="nav-sub-item {% if page == 'purchase' %}active{% endif %}" data-page="purchase">
<span class="nav-sub-icon">&#9679;</span>
<span>待下单管理</span>
</a>
<a href="/orders/domestic" class="nav-sub-item {% if page == 'orders_domestic' %}active{% endif %}" data-page="orders_domestic">
<span class="nav-sub-icon">&#9679;</span>
<span>国内订单管理</span>
</a>
<a href="/orders/overseas" class="nav-sub-item {% if page == 'orders_overseas' %}active{% endif %}" data-page="orders_overseas">
<span class="nav-sub-icon">&#9679;</span>
<span>海外订单管理</span>
</a>
<a href="/orders/packaging" class="nav-sub-item {% if page == 'orders_packaging' %}active{% endif %}" data-page="orders_packaging">
<span class="nav-sub-icon">&#9679;</span>
<span>包材订单管理</span>
</a>
<a href="/orders/finished" class="nav-sub-item {% if page == 'orders_finished' %}active{% endif %}" data-page="orders_finished">
<span class="nav-sub-icon">&#9679;</span>
<span>成品提货管理</span>
</a>
<a href="/purchase/ready" class="nav-sub-item {% if page == 'purchase_ready' %}active{% endif %}" data-page="purchase_ready">
<span class="nav-sub-icon">&#9679;</span>
<span>下单备份管理</span>
</a>
{# 合同模板: 所有用户可见 #}
<a href="/contract-templates" class="nav-sub-item {% if page == 'contract_templates' %}active{% endif %}" data-page="contract_templates">
<span class="nav-sub-icon">&#9679;</span>
<span>合同模板管理</span>
</a>
</div>
</div>
{# 产品下单(含子菜单): 所有用户可见 #}
<div class="nav-group" data-roles="all">
<a href="#" class="nav-item nav-item-accent nav-group-header" data-group="product-order" onclick="toggleSubmenu(event)">
<span class="nav-icon nav-icon-accent">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="9" cy="21" r="1"/><circle cx="20" cy="21" r="1"/>
<path d="M1 1h4l2.68 13.39a2 2 0 0 0 2 1.61h9.72a2 2 0 0 0 2-1.61L23 6H6"/>
</svg>
</span>
<span class="nav-text">新品下单</span>
<span class="nav-arrow">&#9662;</span>
</a>
<div class="nav-submenu" id="submenu-product-order">
<a href="/product-new" class="nav-sub-item {% if page == 'product_new' %}active{% endif %}" data-page="product_new" data-roles="all">
<span class="nav-sub-icon">&#9679;</span>
<span>新品成品管理</span>
</a>
<a href="/product-packaging" class="nav-sub-item {% if page == 'product_packaging' %}active{% endif %}" data-page="product_packaging" data-roles="all">
<span class="nav-sub-icon">&#9679;</span>
<span>新品包材管理</span>
</a>
<a href="/product-category" class="nav-sub-item {% if page == 'product_category' %}active{% endif %}" data-page="product_category" data-roles="all">
<span class="nav-sub-icon">&#9679;</span>
<span>类目管理</span>
</a>
</div>
</div>
{# 知识库: 所有用户可见 #}
<a href="/knowledge" class="nav-item {% if page == 'knowledge' %}active{% endif %}" data-page="knowledge" data-roles="all">
<span class="nav-icon nav-icon-accent">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/><path d="M12 6v6l2-1 2 1V6"/>
</svg>
</span>
<span class="nav-text">知识库</span>
</a>
{# 测试问题: 所有用户可见 #}
<a href="/feedback" class="nav-item {% if page == 'feedback' %}active{% endif %}" data-page="feedback" data-roles="all">
<span class="nav-icon nav-icon-accent">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/>
</svg>
</span>
<span class="nav-text">测试问题</span>
</a>
{# AI分析: 所有用户可见 #}
<a href="/ai-analysis" class="nav-item {% if page == 'ai_analysis' %}active{% endif %}" data-page="ai_analysis" data-roles="all">
<span class="nav-icon nav-icon-accent">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/>
</svg>
</span>
<span class="nav-text">AI分析</span>
</a>
{# 系统设置(含子菜单): 仅管理员 #}
<div class="nav-group" data-roles="admin,super_admin">
<a href="#" class="nav-item nav-group-header" data-group="sysconfig" onclick="toggleSubmenu(event)">
<span class="nav-icon nav-icon-accent">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
</svg>
</span>
<span class="nav-text">系统设置</span>
<span class="nav-arrow">&#9662;</span>
</a>
<div class="nav-submenu" id="submenu-sysconfig">
<a href="/users" class="nav-sub-item {% if page == 'users' %}active{% endif %}" data-page="users">
<span class="nav-sub-icon">&#9679;</span>
<span>用户管理</span>
</a>
<a href="/permissions" class="nav-sub-item {% if page == 'permissions' %}active{% endif %}" data-page="permissions">
<span class="nav-sub-icon">&#9679;</span>
<span>权限配置</span>
</a>
<a href="/config" class="nav-sub-item {% if page == 'config' %}active{% endif %}" data-page="config">
<span class="nav-sub-icon">&#9679;</span>
<span>参数配置</span>
</a>
<a href="/logs" class="nav-sub-item {% if page == 'logs' %}active{% endif %}" data-page="logs">
<span class="nav-sub-icon">&#9679;</span>
<span>操作日志</span>
</a>
<a href="/login-records" class="nav-sub-item {% if page == 'login_records' %}active{% endif %}" data-page="login_records">
<span class="nav-sub-icon">&#9679;</span>
<span>访问记录</span>
</a>
</div>
</div>
</nav>
<!-- 底部用户信息 -->
<div class="sidebar-footer">
<div class="sidebar-user">
<div class="sidebar-avatar">
<img src="/static/logo-small.png" alt="avatar" style="width:32px;height:32px;border-radius:50%">
</div>
<div class="sidebar-user-info" style="flex:1;min-width:0">
<div style="font-size:13px;font-weight:600;color:rgba(255,255,255,0.85);white-space:nowrap;overflow:hidden;text-overflow:ellipsis">{{ session.get('real_name', session.get('username', '用户')) }}</div>
<div style="font-size:11px;color:rgba(255,255,255,0.45)">
{% set role = session.get('role', '') %}
{% set role_names = {'admin':'管理员','procurement_planner':'采购计划','procurement_follower':'采购跟单','product_developer':'产品开发','operator':'运营专员'} %}
{{ role_names.get(role, '') }}
{% set level = session.get('user_level', '') %}
{% set level_names = {'employee':'','leader':'·组长','director':'·主管'} %}
{{ level_names.get(level, '') }}
</div>
</div>
<div onclick="handleLogout()" title="退出登录" style="cursor:pointer;color:rgba(255,255,255,0.5);padding:4px;border-radius:4px;display:flex;align-items:center" onmouseover="this.style.color='#fff'" onmouseout="this.style.color='rgba(255,255,255,0.5)'">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/>
</svg>
</div>
</div>
</div>
</aside>
<div class="sidebar-overlay" id="sidebarOverlay" onclick="toggleSidebar()"></div>
<!-- 主内容区 -->
<main class="main-content" id="mainContent">
<div class="page-loading" id="pageLoading">
<div class="loading-spinner"></div>
</div>
</main>
<script>
window._currentRole = '{{ session.get('role', '') }}';
window._currentUserLevel = '{{ session.get('user_level', 'employee') }}';
window._currentUser = {
user_id: {{ session.get('user_id', 0) }},
username: '{{ session.get('username', '') }}',
role: '{{ session.get('role', '') }}',
permissions: {{ _user_permissions_json|safe }}
};
</script>
<script src="/static/js/marked.min.js"></script>
<script src="/static/js/dompurify.min.js"></script>
<script src="/static/js/app.js?v={{ app_js_version }}"></script>
<script src="/static/js/feedback.js?v=1786347000"></script>
<script src="/static/js/knowledge-actions.js?v=1"></script>
<script>
// 根据角色隐藏无权限的菜单
(function() {
var role = window._currentRole || '';
document.querySelectorAll('[data-roles]').forEach(function(el) {
var roles = el.getAttribute('data-roles');
if (roles === 'all') return;
var allowed = roles.split(',').map(function(r) { return r.trim(); });
if (role === 'super_admin') return; // 超级管理员全可见
if (allowed.indexOf(role) === -1) {
el.classList.add('nav-hidden');
}
});
})();
function handleLogout() {
fetch('/api/logout', {method: 'POST'})
.then(function() { window.location.href = '/login'; });
}
function toggleSidebar() {
var sidebar = document.getElementById('sidebar');
sidebar.classList.toggle('collapsed');
var overlay = document.getElementById('sidebarOverlay');
if (sidebar.classList.contains('collapsed')) {
overlay.style.display = 'none';
// 侧边栏缩进后,给图标添加title提示
sidebar.querySelectorAll('.nav-item').forEach(function(item) {
var textEl = item.querySelector('.nav-text');
if (textEl && textEl.textContent.trim()) {
item.setAttribute('title', textEl.textContent.trim());
}
});
sidebar.querySelectorAll('.nav-sub-item').forEach(function(item) {
var textEl = item.querySelector('span:not(.nav-sub-icon)');
if (textEl && textEl.textContent.trim()) {
item.setAttribute('title', textEl.textContent.trim());
}
});
} else {
// 展开后移除title
sidebar.querySelectorAll('.nav-item, .nav-sub-item').forEach(function(item) {
item.removeAttribute('title');
});
if (window.innerWidth <= 768) {
overlay.style.display = 'block';
}
}
}
function toggleSubmenu(event) {
event.preventDefault();
var header = event.currentTarget;
var group = header.dataset.group;
var submenu = document.getElementById('submenu-' + group);
var arrow = header.querySelector('.nav-arrow');
if (!submenu) return;
var sidebar = document.getElementById('sidebar');
if (sidebar && sidebar.classList.contains('collapsed')) {
// 折叠状态:直接打开独立的弹出面板
var popup = document.querySelector('.sidebar-popup-menu');
if (popup && popup.dataset.group === group) {
popup.remove();
return;
}
if (popup) popup.remove();
// 收集子菜单链接
var itemsHtml = '';
submenu.querySelectorAll('a').forEach(function(a) {
var href = a.getAttribute('href') || '#';
var text = a.textContent.trim();
var isActive = window.location.pathname === href;
itemsHtml += '<a href="' + href + '" style="display:block;padding:10px 16px;font-size:14px;text-decoration:none;' + (isActive ? 'color:#1677ff;font-weight:600;background:#e6f7ff' : 'color:#333') + '">' + text + '</a>';
});
popup = document.createElement('div');
popup.className = 'sidebar-popup-menu';
popup.dataset.group = group;
popup.innerHTML = itemsHtml;
var rect = header.getBoundingClientRect();
popup.style.top = (rect.top - 4) + 'px';
document.body.appendChild(popup);
// 延迟后再添加 open 类确保过渡效果
setTimeout(function() { popup.classList.add('open'); }, 10);
// SPA导航
popup.addEventListener('click', function(e) {
var link = e.target.closest('a');
if (link) {
e.preventDefault();
popup.remove();
var href = link.getAttribute('href');
if (href && typeof navigateTo === 'function') navigateTo(href);
else window.location.href = href;
}
});
// 点击外部关闭
setTimeout(function() {
document.addEventListener('click', function popupOutside(e) {
if (!popup.contains(e.target) && e.target !== header && !header.contains(e.target)) {
if (popup.parentNode) popup.remove();
document.removeEventListener('click', popupOutside);
}
});
}, 50);
} else {
submenu.classList.toggle('open');
if (arrow) arrow.classList.toggle('rotated');
}
}
// 页面加载时检查侧边栏状态
function updateSidebarTitles() {
var sidebar = document.getElementById('sidebar');
if (!sidebar) return;
if (sidebar.classList.contains('collapsed')) {
sidebar.querySelectorAll('.nav-item').forEach(function(item) {
var textEl = item.querySelector('.nav-text');
if (textEl && textEl.textContent.trim()) {
item.setAttribute('title', textEl.textContent.trim());
}
});
sidebar.querySelectorAll('.nav-sub-item').forEach(function(item) {
var textEl = item.querySelector('span:not(.nav-sub-icon)');
if (textEl && textEl.textContent.trim()) {
item.setAttribute('title', textEl.textContent.trim());
}
});
}
}
document.addEventListener('DOMContentLoaded', function() {
updateSidebarTitles();
var page = '{{ page }}';
// 自动展开当前页面对应的子菜单
var activeSubItem = document.querySelector('.nav-sub-item.active');
if (activeSubItem) {
var submenu = activeSubItem.closest('.nav-submenu');
if (submenu) {
submenu.classList.add('open');
var arrow = submenu.closest('.nav-group') ? submenu.closest('.nav-group').querySelector('.nav-arrow') : null;
if (arrow) arrow.classList.add('rotated');
}
}
// 展开基础数据子菜单
var basicActive = document.querySelector('#submenu-basic .nav-sub-item.active');
if (basicActive) {
var bMenu = document.getElementById('submenu-basic');
if (bMenu) bMenu.classList.add('open');
var bArrow = document.querySelector('.nav-group-header[data-group="basic"] .nav-arrow');
if (bArrow) bArrow.classList.add('rotated');
}
// 展开库存与出库子菜单
var invActive = document.querySelector('#submenu-inventory .nav-sub-item.active');
if (invActive) {
var iMenu = document.getElementById('submenu-inventory');
if (iMenu) iMenu.classList.add('open');
var iArrow = document.querySelector('.nav-group-header[data-group="inventory"] .nav-arrow');
if (iArrow) iArrow.classList.add('rotated');
}
// 展开采购订单菜单
var purchaseActive = document.querySelector('#submenu-purchase .nav-sub-item.active');
if (purchaseActive) {
var pMenu = document.getElementById('submenu-purchase');
if (pMenu) pMenu.classList.add('open');
var pArrow = document.querySelector('.nav-group-header[data-group="purchase"] .nav-arrow');
if (pArrow) pArrow.classList.add('rotated');
}
// 如果当前在高权限页面,展开系统设置菜单
var sysConfigActive = document.querySelector('#submenu-sysconfig .nav-sub-item.active');
if (sysConfigActive) {
var sysMenu = document.getElementById('submenu-sysconfig');
if (sysMenu) sysMenu.classList.add('open');
var sysArrow = document.querySelector('.nav-group-header[data-group="sysconfig"] .nav-arrow');
if (sysArrow) sysArrow.classList.add('rotated');
}
// 权限检查:如果初始页面无查看权限,跳转到第一个可访问的页面
var startPage = page;
if (page !== 'login' && window._currentUser && window._currentUser.role !== 'super_admin') {
var perms = window._currentUser.permissions || {};
var pagePerms = perms[page] || (page === 'product_packaging' ? (perms.product_new || {}) : {});
if (pagePerms['view'] === 0 || (page === 'contract_templates' && pagePerms['view'] !== 1)) {
// 找第一个有权限的页面
var accessiblePages = ['planning','purchase','products','bom','warehouse','suppliers','knowledge','feedback','users','permissions','logs','config','login_records','personnel','contract_templates','purchase_ready','orders_domestic','orders_overseas','orders_packaging','orders_finished','inventory_finished','inventory_packaging','data_summary','data_detail','product_new','product_packaging','product_category','brand_management'];
for (var pi = 0; pi < accessiblePages.length; pi++) {
var ap = accessiblePages[pi];
var apPerms = perms[ap] || {};
if (apPerms['view'] !== 0) {
startPage = ap;
break;
}
}
}
}
window._currentPage = startPage;
loadPage(startPage);
});
</script>
<script>
if ('serviceWorker' in navigator) {
// 先清除所有旧 SW 缓存
if (caches) {
caches.keys().then(function(keys) {
keys.forEach(function(k) { if (k.startsWith('dy-dashboard-')) caches.delete(k); });
});
}
window.addEventListener('load', function() {
navigator.serviceWorker.register('/static/sw.js?v=21').then(function(reg) {
reg.update();
if (reg.installing) {
reg.installing.addEventListener('statechange', function() {
if (this.state === 'activated') location.reload();
});
}
}).catch(function(err) {
console.log('SW failed:', err);
});
});
}
</script>
<div style="position:fixed;bottom:4px;right:8px;font-size:10px;color:#ccc;z-index:99999">v202607311130</div>
</body>
</html>

202
templates/login.html Normal file
View File

@ -0,0 +1,202 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>蝶雅采购管理系统 - 登录</title>
<link rel="stylesheet" href="/static/css/style.css?v=317">
</head>
<body class="login-page">
<div class="login-container">
<div class="login-card">
<div class="login-header">
<div class="login-logo">
<img src="/static/logo-small.png" alt="Logo" style="width:64px;height:64px;border-radius:12px;">
</div>
<h1>蝶雅采购管理系统</h1>
<p class="login-subtitle">采购智能管理平台</p>
</div>
<form id="loginForm" class="login-form" onsubmit="return false;">
<div class="form-group">
<label>用户名</label>
<input type="text" id="username" class="form-input" placeholder="请输入用户名" autocomplete="off" autofocus>
</div>
<div class="form-group">
<label>密码</label>
<div class="password-input-wrap">
<input type="password" id="password" class="form-input" placeholder="请输入密码" autocomplete="off">
<button type="button" class="password-toggle" onclick="togglePassword('password', this)" aria-label="显示密码" title="显示密码">
<svg class="eye-open" viewBox="0 0 24 24" aria-hidden="true"><path d="M2 12s3.5-6 10-6 10 6 10 6-3.5 6-10 6S2 12 2 12Z"/><circle cx="12" cy="12" r="3"/></svg>
<svg class="eye-closed" viewBox="0 0 24 24" aria-hidden="true"><path d="m3 3 18 18M10.6 10.7a2 2 0 0 0 2.7 2.7M9.9 5.2A11.7 11.7 0 0 1 12 5c6.5 0 10 7 10 7a18.8 18.8 0 0 1-2.1 3M6.6 6.7C3.7 8.5 2 12 2 12s3.5 7 10 7a10.5 10.5 0 0 0 4.1-.8"/></svg>
</button>
</div>
</div>
<div class="form-error" id="loginError"></div>
<button type="submit" class="btn btn-primary login-btn" onclick="handleLogin()">
<span class="btn-text">登 录</span>
<span class="btn-loading" style="display:none">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10" stroke-dasharray="31.4 31.4" stroke-linecap="round"/>
</svg>
</span>
</button>
</form>
<form id="changePasswordForm" class="login-form" onsubmit="return false;" style="display:none">
<p class="password-notice">首次登录或密码重置后,必须先设置新密码。</p>
<div class="form-group">
<label>新密码</label>
<div class="password-input-wrap">
<input type="password" id="newPassword" class="form-input" placeholder="至少10位,包含字母、数字和特殊字符" autocomplete="off">
<button type="button" class="password-toggle" onclick="togglePassword('newPassword', this)" aria-label="显示密码" title="显示密码">
<svg class="eye-open" viewBox="0 0 24 24" aria-hidden="true"><path d="M2 12s3.5-6 10-6 10 6 10 6-3.5 6-10 6S2 12 2 12Z"/><circle cx="12" cy="12" r="3"/></svg>
<svg class="eye-closed" viewBox="0 0 24 24" aria-hidden="true"><path d="m3 3 18 18M10.6 10.7a2 2 0 0 0 2.7 2.7M9.9 5.2A11.7 11.7 0 0 1 12 5c6.5 0 10 7 10 7a18.8 18.8 0 0 1-2.1 3M6.6 6.7C3.7 8.5 2 12 2 12s3.5 7 10 7a10.5 10.5 0 0 0 4.1-.8"/></svg>
</button>
</div>
</div>
<div class="form-group">
<label>确认新密码</label>
<div class="password-input-wrap">
<input type="password" id="confirmPassword" class="form-input" placeholder="请再次输入新密码" autocomplete="off">
<button type="button" class="password-toggle" onclick="togglePassword('confirmPassword', this)" aria-label="显示密码" title="显示密码">
<svg class="eye-open" viewBox="0 0 24 24" aria-hidden="true"><path d="M2 12s3.5-6 10-6 10 6 10 6-3.5 6-10 6S2 12 2 12Z"/><circle cx="12" cy="12" r="3"/></svg>
<svg class="eye-closed" viewBox="0 0 24 24" aria-hidden="true"><path d="m3 3 18 18M10.6 10.7a2 2 0 0 0 2.7 2.7M9.9 5.2A11.7 11.7 0 0 1 12 5c6.5 0 10 7 10 7a18.8 18.8 0 0 1-2.1 3M6.6 6.7C3.7 8.5 2 12 2 12s3.5 7 10 7a10.5 10.5 0 0 0 4.1-.8"/></svg>
</button>
</div>
</div>
<div class="form-error" id="changePasswordError"></div>
<button type="submit" class="btn btn-primary login-btn" onclick="handleChangePassword()">
<span class="btn-text">确认修改</span>
<span class="btn-loading" style="display:none">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10" stroke-dasharray="31.4 31.4" stroke-linecap="round"/>
</svg>
</span>
</button>
</form>
</div>
</div>
<div id="passwordPrompt" class="password-prompt-overlay" style="display:none" role="dialog" aria-modal="true" aria-labelledby="passwordPromptTitle">
<div class="password-prompt-card">
<div class="password-prompt-icon">!</div>
<h2 id="passwordPromptTitle">需要修改密码</h2>
<p>为了账号安全,本次登录需要先设置新密码。修改完成后才能正常访问系统。</p>
<button type="button" class="btn btn-primary password-prompt-btn" onclick="enterChangePassword()">我知道了,去修改</button>
</div>
</div>
<script>
function handleLogin() {
var btn = document.querySelector('.login-btn');
var text = btn.querySelector('.btn-text');
var loading = btn.querySelector('.btn-loading');
var error = document.getElementById('loginError');
btn.disabled = true;
text.style.display = 'none';
loading.style.display = 'inline';
error.textContent = '';
fetch('/api/login', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
username: document.getElementById('username').value,
password: document.getElementById('password').value
})
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.code === 0) {
if (data.data && data.data.must_change_password) {
document.getElementById('password').value = '';
document.getElementById('passwordPrompt').style.display = 'flex';
document.querySelector('.password-prompt-btn').focus();
} else {
window.location.href = '/dashboard';
}
} else {
error.textContent = data.msg;
btn.disabled = false;
text.style.display = 'inline';
loading.style.display = 'none';
}
})
.catch(function() {
error.textContent = '网络错误,请重试';
btn.disabled = false;
text.style.display = 'inline';
loading.style.display = 'none';
});
}
function enterChangePassword() {
document.getElementById('passwordPrompt').style.display = 'none';
document.getElementById('loginForm').style.display = 'none';
document.getElementById('changePasswordForm').style.display = 'block';
document.querySelector('.login-subtitle').textContent = '请设置新的登录密码';
document.getElementById('newPassword').focus();
}
function togglePassword(inputId, button) {
var input = document.getElementById(inputId);
var show = input.type === 'password';
input.type = show ? 'text' : 'password';
button.classList.toggle('is-visible', show);
button.setAttribute('aria-label', show ? '隐藏密码' : '显示密码');
button.setAttribute('title', show ? '隐藏密码' : '显示密码');
input.focus();
}
function handleChangePassword() {
var form = document.getElementById('changePasswordForm');
var btn = form.querySelector('.login-btn');
var text = btn.querySelector('.btn-text');
var loading = btn.querySelector('.btn-loading');
var error = document.getElementById('changePasswordError');
var newPassword = document.getElementById('newPassword').value;
var confirmPassword = document.getElementById('confirmPassword').value;
error.textContent = '';
if (newPassword !== confirmPassword) {
error.textContent = '两次输入的密码不一致';
return;
}
btn.disabled = true;
text.style.display = 'none';
loading.style.display = 'inline';
fetch('/api/change-password', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
new_password: newPassword,
confirm_password: confirmPassword
})
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.code === 0) {
window.location.href = '/dashboard';
return;
}
error.textContent = data.msg;
btn.disabled = false;
text.style.display = 'inline';
loading.style.display = 'none';
})
.catch(function() {
error.textContent = '网络错误,请重试';
btn.disabled = false;
text.style.display = 'inline';
loading.style.display = 'none';
});
}
document.getElementById('loginForm').addEventListener('keydown', function(e) {
if (e.key === 'Enter') handleLogin();
});
document.getElementById('changePasswordForm').addEventListener('keydown', function(e) {
if (e.key === 'Enter') handleChangePassword();
});
</script>
</body>
</html>

View File

@ -0,0 +1,24 @@
"""登录页与系统导航名称回归测试。"""
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
LOGIN_HTML = (ROOT / 'templates/login.html').read_text(encoding='utf-8')
BASE_HTML = (ROOT / 'templates/base.html').read_text(encoding='utf-8')
class SystemBrandingTest(unittest.TestCase):
def test_login_uses_full_management_system_name(self):
self.assertIn('<h1>蝶雅采购管理系统</h1>', LOGIN_HTML)
self.assertIn('<title>蝶雅采购管理系统 - 登录</title>', LOGIN_HTML)
self.assertNotIn('蝶雅采购智能体平台', LOGIN_HTML)
def test_sidebar_uses_short_management_system_name(self):
self.assertIn('<span class="sidebar-title">采购管理系统</span>', BASE_HTML)
self.assertIn('<title>蝶雅采购管理系统</title>', BASE_HTML)
if __name__ == '__main__':
unittest.main()