統一登入與廠商工具頁新版設計
All checks were successful
CD Pipeline / deploy (push) Successful in 58s

This commit is contained in:
OoO
2026-05-14 20:43:21 +08:00
parent 87a83aed64
commit 9dfb7d1514
13 changed files with 2237 additions and 414 deletions

View File

@@ -4,6 +4,10 @@
================================================================================
【已完成】
- V10.151 接續前端 V3 全站 UI/UX廠商缺貨 `/vendor-stockout/vendor-management`、`/vendor-stockout/send-email`、`/vendor-stockout/history` 改走新版 `ewoooc_base.html` shell 與 `page-vendor-tools.css`,移除舊紫藍 navbar/live route。
- V10.151 補 `/abc_analysis/detail` 新版 ABC 詳情頁與安全 loading state移除 raw HTML fallback資料表維持正式快取資料來源與匯出連結。
- V10.151 補 `/login` 新版登入頁暖紙背景、點陣視覺、EwoooC 品牌、手機版 390px 無水平 overflow。
- V10.151 本機全站 responsive overflow guard 通過 147/147`?ui=legacy` 入口已不再回到舊 `dashboard.html` / `edm_dashboard.html` / vendor legacy templates。
- 新版設計系統資產已落到正式 Flask static path`web/static/css/ewoooc-tokens.css`、`web/static/css/ewoooc-shell.css`、`web/static/css/ewoooc-dotmatrix.css`、`web/static/css/page-*.css` 與對應 `web/static/js/page-*.js`。
- V3 page-level 資產已補做設計規範清理:移除 UI 表面殘留 radial-gradient / pure-white rgba / `bg-white` headerAI 推薦頁 keyword 狀態改用語意 class。
- `ewoooc_base.html` 補 `extra_head` / `content` / `extra_scripts` 相容 block支援新版包混用 block 命名,避免頁面空白或互動 JS 不輸出。

View File

@@ -320,7 +320,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '')
# ==========================================
# 系統版本與路徑
# ==========================================
SYSTEM_VERSION = "V10.150"
SYSTEM_VERSION = "V10.151"
LOG_FILE_PATH = os.path.join(BASE_DIR, 'logs/system.log')
public_url = PUBLIC_URL # 用於模板顯示

View File

@@ -1461,7 +1461,7 @@ def index():
data['competitor_overview'] = competitor_overview
_DASHBOARD_DATA_CACHE['full_data'] = data
_write_shared_full_dashboard_cache(data)
template_name = 'dashboard.html' if request.args.get('ui') == 'legacy' else 'dashboard_v2.html'
template_name = 'dashboard_v2.html'
return render_template(template_name,
total_products=total_products_history,

View File

@@ -374,7 +374,7 @@ def edm_dashboard():
scheduler_stats = load_scheduler_stats()
now_taipei = datetime.now(TAIPEI_TZ)
template_name = 'edm_dashboard.html' if request.args.get('ui') == 'legacy' else 'edm_dashboard_v2.html'
template_name = 'edm_dashboard_v2.html'
return render_template(template_name,
promo_pages=promo_pages,
@@ -429,7 +429,7 @@ def festival_dashboard():
scheduler_stats = load_scheduler_stats()
template_name = 'edm_dashboard.html' if request.args.get('ui') == 'legacy' else 'edm_dashboard_v2.html'
template_name = 'edm_dashboard_v2.html'
return render_template(template_name,
promo_pages=promo_pages,
@@ -482,7 +482,7 @@ def mothers_day_dashboard():
scheduler_stats = load_scheduler_stats()
template_name = 'edm_dashboard.html' if request.args.get('ui') == 'legacy' else 'edm_dashboard_v2.html'
template_name = 'edm_dashboard_v2.html'
return render_template(template_name,
promo_pages=promo_pages,
@@ -535,7 +535,7 @@ def valentine_520_dashboard():
scheduler_stats = load_scheduler_stats()
template_name = 'edm_dashboard.html' if request.args.get('ui') == 'legacy' else 'edm_dashboard_v2.html'
template_name = 'edm_dashboard_v2.html'
return render_template(template_name,
promo_pages=promo_pages,
@@ -588,7 +588,7 @@ def labor_day_dashboard():
scheduler_stats = load_scheduler_stats()
template_name = 'edm_dashboard.html' if request.args.get('ui') == 'legacy' else 'edm_dashboard_v2.html'
template_name = 'edm_dashboard_v2.html'
return render_template(template_name,
promo_pages=promo_pages,

View File

@@ -1938,45 +1938,19 @@ def abc_analysis_detail():
target_df, cols_map, err = _get_filtered_sales_data(table_name)
if err:
# V-Fix: 如果自動重載也失敗,則顯示稍後再試,並引導回主頁面
return f'''
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>數據加載中 - WOOO TECH</title>
<style>
body {{ font-family: sans-serif; display: flex; align-items: center; justify-content: center; height: 100vh; margin: 0; background: #f8f9fa; }}
.card {{ background: white; padding: 2rem; border-radius: 12px; box-shadow: 0 4px 20px rgba(0,0,0,0.08); text-align: center; }}
.spinner {{ border: 3px solid #f3f3f3; border-top: 3px solid #1e3c72; border-radius: 50%; width: 30px; height: 30px; animation: spin 1s linear infinite; margin: 0 auto 1rem; }}
@keyframes spin {{ 0% {{ transform: rotate(0deg); }} 100% {{ transform: rotate(360deg); }} }}
</style>
</head>
<body>
<div class="card">
<div class="spinner"></div>
<h3>數據準備中</h3>
<p>正在自動重新加載數據,請稍後...</p>
<script>
// 1.5 秒後嘗試重載當前頁面
setTimeout(function() {{
window.location.reload();
}}, 1500);
// 若重試 3 次仍失敗,引導回主頁
let retryCount = parseInt(sessionStorage.getItem('abc_retry') || '0');
if (retryCount > 3) {{
sessionStorage.removeItem('abc_retry');
alert('數據載入過久,請先在業績分析主頁重新整理。');
window.location.href = '/sales_analysis';
}} else {{
sessionStorage.setItem('abc_retry', retryCount + 1);
}}
</script>
</div>
</body>
</html>
''', 200
return render_template(
'abc_analysis_detail.html',
loading_state=True,
info={'title': '數據準備中', 'desc': '業績分析快取正在重新載入。'},
items=[],
cols={},
current_factor=0,
target_class=target_class,
total_revenue=0,
sort_col_index=0,
query_string=request.query_string.decode(),
active_page='sales',
), 200
# 恢復欄位變數
col_name = cols_map.get('name')
@@ -1995,6 +1969,7 @@ def abc_analysis_detail():
# 3. 執行 ABC 分類
items = []
total_revenue = 0
current_factor = 0.0
if col_amount and not target_df.empty:
# V-Fix: 先針對商品進行聚合,確保 ABC 分析是基於「商品總銷量」而非「單筆訂單」
agg_rules = {col_amount: 'sum'}
@@ -2081,7 +2056,8 @@ def abc_analysis_detail():
cols={'name': col_name, 'amount': col_amount, 'qty': col_qty, 'cat': col_category,
'vendor': col_vendor, 'brand': col_brand, 'cost': col_cost, 'profit': col_profit, 'date': col_date, 'pid': col_pid},
# 傳遞當前查詢參數以供匯出連結使用
query_string=request.query_string.decode())
query_string=request.query_string.decode(),
active_page='sales')
except Exception as e:
sys_log.error(f"ABC Detail Error: {e}")

View File

@@ -63,8 +63,6 @@ vendor_db = VendorDatabaseManager()
def index():
"""廠商缺貨系統主頁"""
sys_log.info("[VendorStockout] 進入廠商缺貨系統主頁")
if request.args.get('ui') == 'legacy':
return _render_vendor_template('vendor_stockout/index.html')
return render_template(
'vendor_stockout_index_v2.html',
active_page='vendor_stockout',
@@ -76,8 +74,6 @@ def index():
def import_page():
"""Excel 匯入頁面"""
sys_log.info("[VendorStockout] 進入匯入頁面")
if request.args.get('ui') == 'legacy':
return _render_vendor_template('vendor_stockout/import.html')
return render_template(
'vendor_stockout_import_v2.html',
active_page='vendor_stockout'
@@ -88,8 +84,6 @@ def import_page():
def list_page():
"""缺貨清單頁面"""
sys_log.info("[VendorStockout] 進入缺貨清單頁面")
if request.args.get('ui') == 'legacy':
return _render_vendor_template('vendor_stockout/list.html')
return render_template(
'vendor_stockout_list_v2.html',
active_page='vendor_stockout',
@@ -109,21 +103,21 @@ def list_page():
def vendor_management_page():
"""廠商管理頁面"""
sys_log.info("[VendorStockout] 進入廠商管理頁面")
return _render_vendor_template('vendor_stockout/vendor_management.html')
return render_template('vendor_stockout_vendor_management_v2.html', active_page='vendor_stockout')
@vendor_bp.route('/send-email')
def send_email_page():
"""郵件發送頁面"""
sys_log.info("[VendorStockout] 進入郵件發送頁面")
return _render_vendor_template('vendor_stockout/send_email.html')
return render_template('vendor_stockout_send_email_v2.html', active_page='vendor_stockout')
@vendor_bp.route('/history')
def history_page():
"""發送歷史頁面"""
sys_log.info("[VendorStockout] 進入發送歷史頁面")
return _render_vendor_template('vendor_stockout/history.html')
return render_template('vendor_stockout_history_v2.html', active_page='vendor_stockout')
# ==========================================

View File

@@ -1,196 +1,184 @@
<!-- cspell:ignore MOMO datatables -->
<!DOCTYPE html>
<html lang="zh-TW">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ABC 分析詳情 - {{ info.title }}</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
<link rel="stylesheet" href="https://cdn.datatables.net/1.11.5/css/dataTables.bootstrap5.min.css">
<style>
body { font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; background-color: #f4f6f9;
padding-top: 70px;
}
.card { border: none; border-radius: 12px; box-shadow: 0 4px 20px rgba(0,0,0,0.03); margin-bottom: 1.5rem; background: #fff; }
.table th { font-weight: 600; color: #495057; background-color: #f8f9fa; }
.text-truncate-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
{% extends "ewoooc_base.html" %}
{% block title %}ABC 分析詳情 - {{ info.title if info else '資料準備中' }} - EwoooC{% endblock %}
.navbar-dark.bg-primary {
background: linear-gradient(135deg, #4F46E5 0%, #6366F1 100%) !important;
box-shadow: 0 4px 12px rgba(79, 70, 229, 0.3);
}
{% block extra_css %}
<link rel="stylesheet" href="https://cdn.datatables.net/1.11.5/css/dataTables.bootstrap5.min.css">
<link rel="stylesheet" href="{{ url_for('static', filename='css/page-abc-analysis-detail.css') }}">
{% endblock %}
.navbar-dark .navbar-brand {
color: #ffffff !important;
font-weight: 600;
}
.navbar-dark .navbar-nav .nav-link {
color: rgba(255, 255, 255, 0.9) !important;
font-weight: 500;
transition: all 0.3s;
}
.navbar-dark .navbar-nav .nav-link:hover {
color: #ffffff !important;
background: rgba(255, 255, 255, 0.1);
border-radius: 6px;
}
.navbar-dark .navbar-nav .nav-link.active {
color: #ffffff !important;
background: rgba(255, 255, 255, 0.15);
border-radius: 6px;
font-weight: 600;
}
.navbar-dark .navbar-text {
color: rgba(255, 255, 255, 0.8) !important;
}
</style>
</head>
<body class="bg-body-tertiary">
{% include 'components/_navbar.html' %}
<div class="container-fluid px-4">
<div class="d-flex justify-content-between align-items-center mb-4 mt-4">
<div>
<h4 class="mb-1 fw-bold text-{{ info.color }}">
<i class="fas fa-layer-group me-2"></i>{{ info.title }}
</h4>
<span class="text-muted">{{ info.desc }}</span>
</div>
<div class="d-flex align-items-center gap-2">
<!-- V-New: 動態補貨係數調整 -->
<div class="input-group input-group-sm" style="width: 200px;">
<span class="input-group-text bg-white">補貨係數 x</span>
<input type="number" id="restockFactor" class="form-control text-center" value="{{ current_factor }}" step="0.1" min="0">
<button class="btn btn-outline-primary" onclick="applyFactor()">應用</button>
</div>
<!-- V-Fix: 匯出連結需包含 factor 參數 -->
<a href="/api/export/excel/abc?class={{ target_class }}&factor={{ current_factor }}&{{ query_string }}" class="btn btn-success btn-sm">
<i class="fas fa-file-excel me-2"></i>匯出此類別報表
</a>
<button onclick="window.close()" class="btn btn-outline-secondary ms-2">關閉</button>
</div>
</div>
<div class="card">
<div class="card-body">
<table id="dataTable" class="table table-hover align-middle mb-0" style="width:100%">
<thead>
<tr>
<th class="text-center" style="width: 60px;">排名</th>
{% if cols.pid %}<th>商品ID</th>{% endif %}
<th style="width: 30%;">商品名稱</th>
{% if cols.brand %}<th>品牌</th>{% endif %}
{% if cols.vendor %}<th>廠商名稱</th>{% endif %}
{% if cols.cat %}<th>分類</th>{% endif %}
{% if cols.cost or cols.profit %}<th>毛利率</th>{% endif %}
{% if cols.qty %}<th>平均單價</th>{% endif %}
{% if cols.qty %}<th class="text-end">銷售數量</th>{% endif %}
{% if cols.qty %}<th class="text-end table-info">建議補貨 (x{{ current_factor }})</th>{% endif %}
<th class="text-end">銷售金額</th>
<th class="text-end">累積營收佔比</th>
</tr>
</thead>
<tbody>
{% for item in items %}
<tr>
<td class="text-center fw-bold text-muted">{{ loop.index }}</td>
{% if cols.pid %}
<td class="small text-muted">{{ item[cols.pid] }}</td>
{% endif %}
<td>
<div class="text-truncate-2" title="{{ item[cols.name] | e }}">
{{ item[cols.name] | e }}
</div>
</td>
{% if cols.brand %}
<td class="small text-muted">{{ item[cols.brand] }}</td>
{% endif %}
{% if cols.vendor %}
<td class="small text-muted">{{ item[cols.vendor] }}</td>
{% endif %}
{% if cols.cat %}
<td><span class="badge bg-light text-dark border">{{ item[cols.cat] }}</span></td>
{% endif %}
{% if cols.cost or cols.profit %}
<td>
{% set margin = item['calculated_margin_rate'] %}
<span class="{{ 'text-success' if margin >= 30 else ('text-danger' if margin < 10 else 'text-dark') }} fw-bold">
{{ "{:.1f}%".format(margin) }}
</span>
</td>
{% endif %}
{% if cols.qty %}
<td class="small">${{ "{:,.0f}".format(item['avg_unit_price']) }}</td>
{% endif %}
{% if cols.qty %}
<td class="text-end">{{ "{:,.0f}".format(item[cols.qty]) }}</td>
{% endif %}
{% if cols.qty %}
<td class="text-end table-info fw-bold">
{% if item['suggested_restock'] > 0 %}
{{ "{:,.0f}".format(item['suggested_restock']) }}
{% else %}
<span class="text-muted small">建議清倉</span>
{% endif %}
</td>
{% endif %}
<td class="text-end fw-bold text-danger">
${{ "{:,.0f}".format(item[cols.amount]) }}
</td>
<td class="text-end text-muted small">
{{ "{:.2f}%".format(item['cumulative_pct']) }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% block content %}
<section class="abc-detail-page">
{% if loading_state|default(false) %}
<div class="abc-loading-card">
<div class="abc-loading-spinner" aria-hidden="true"></div>
<div>
<h1 class="h4 mb-2">數據準備中</h1>
<p class="text-muted mb-0">正在重新載入業績分析快取,稍後會自動重試。</p>
</div>
<a class="btn btn-outline-secondary" href="/sales_analysis">回業績分析</a>
</div>
{% else %}
<header class="abc-detail-header">
<div>
<span class="abc-detail-kicker">
<i class="fas fa-layer-group" aria-hidden="true"></i>
ABC Analysis
</span>
<h1>{{ info.title }}</h1>
<p>{{ info.desc }}</p>
<div class="abc-detail-statline">
<span class="abc-detail-pill">
<i class="fas fa-coins" aria-hidden="true"></i>
總營收 ${{ "{:,.0f}".format(total_revenue|default(0)) }}
</span>
<span class="abc-detail-pill">
<i class="fas fa-cubes" aria-hidden="true"></i>
{{ items|length }} 個商品
</span>
</div>
</div>
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<script src="https://cdn.datatables.net/1.11.5/js/jquery.dataTables.min.js"></script>
<script src="https://cdn.datatables.net/1.11.5/js/dataTables.bootstrap5.min.js"></script>
<script>
// V-Fix: 提取排序索引變數,避免在物件實字中混用模板語法導致編輯器誤判
const sortColIndex = parseInt("{{ sort_col_index }}");
<div class="abc-detail-actions">
<div class="abc-factor-control input-group input-group-sm">
<span class="input-group-text">補貨 x</span>
<input type="number" id="restockFactor" class="form-control text-center" value="{{ current_factor }}" step="0.1" min="0" aria-label="補貨係數">
<button class="btn btn-outline-primary" type="button" onclick="applyFactor()">套用</button>
</div>
<a href="/api/export/excel/abc?class={{ target_class }}&factor={{ current_factor }}&{{ query_string }}" class="btn btn-primary btn-sm">
<i class="fas fa-file-excel me-2" aria-hidden="true"></i>匯出報表
</a>
<a href="/sales_analysis" class="btn btn-outline-secondary btn-sm">
<i class="fas fa-arrow-left me-2" aria-hidden="true"></i>回分析頁
</a>
</div>
</header>
$(document).ready(function() {
$('#dataTable').DataTable({
"language": {
"url": "//cdn.datatables.net/plug-ins/1.11.5/i18n/zh-HANT.json",
"paginate": {
"previous": "上一頁",
"next": "下一頁"
}
},
"pageLength": 25, // V-Adj: 預設每頁 25 筆,避免過長
"lengthMenu": [[10, 25, 50, 100, -1], [10, 25, 50, 100, "全部"]], // V-New: 允許使用者選擇每頁筆數
"order": [[ sortColIndex, "desc" ]], // 預設按金額排序
"paging": true, // 確保分頁開啟
"info": true // 顯示 "第 x 至 x 筆,共 x 筆"
});
});
<div class="abc-detail-card">
<div class="abc-detail-card__head">
<h2>商品明細</h2>
<span class="abc-detail-pill">{{ target_class }} 類排序</span>
</div>
<div class="abc-detail-table">
<div class="table-responsive">
<table id="dataTable" class="table table-hover align-middle mb-0">
<thead>
<tr>
<th class="text-center">排名</th>
{% if cols.pid %}<th>商品 ID</th>{% endif %}
<th>商品名稱</th>
{% if cols.brand %}<th>品牌</th>{% endif %}
{% if cols.vendor %}<th>廠商名稱</th>{% endif %}
{% if cols.cat %}<th>分類</th>{% endif %}
{% if cols.cost or cols.profit %}<th>毛利率</th>{% endif %}
{% if cols.qty %}<th>平均單價</th>{% endif %}
{% if cols.qty %}<th class="text-end">銷售數量</th>{% endif %}
{% if cols.qty %}<th class="text-end">建議補貨</th>{% endif %}
<th class="text-end">銷售金額</th>
<th class="text-end">累積營收佔比</th>
</tr>
</thead>
<tbody>
{% for item in items %}
<tr>
<td class="text-center fw-bold text-muted">{{ loop.index }}</td>
{% if cols.pid %}
<td class="small text-muted">{{ item[cols.pid] }}</td>
{% endif %}
<td>
<div class="abc-product-name" title="{{ item[cols.name] | e }}">
{{ item[cols.name] | e }}
</div>
</td>
{% if cols.brand %}
<td class="small text-muted">{{ item[cols.brand] }}</td>
{% endif %}
{% if cols.vendor %}
<td class="small text-muted">{{ item[cols.vendor] }}</td>
{% endif %}
{% if cols.cat %}
<td><span class="badge bg-light text-dark border">{{ item[cols.cat] }}</span></td>
{% endif %}
{% if cols.cost or cols.profit %}
<td>
{% set margin = item['calculated_margin_rate'] %}
<span class="{{ 'text-success' if margin >= 30 else ('text-danger' if margin < 10 else 'text-dark') }} fw-bold">
{{ "{:.1f}%".format(margin) }}
</span>
</td>
{% endif %}
{% if cols.qty %}
<td class="small">${{ "{:,.0f}".format(item['avg_unit_price']) }}</td>
<td class="text-end">{{ "{:,.0f}".format(item[cols.qty]) }}</td>
<td class="text-end fw-bold">
{% if item['suggested_restock'] > 0 %}
{{ "{:,.0f}".format(item['suggested_restock']) }}
{% else %}
<span class="text-muted small">建議清倉</span>
{% endif %}
</td>
{% endif %}
<td class="text-end fw-bold text-danger">
${{ "{:,.0f}".format(item[cols.amount]) }}
</td>
<td class="text-end text-muted small">
{{ "{:.2f}%".format(item['cumulative_pct']) }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endif %}
</section>
{% endblock %}
// V-New: 應用新的補貨係數
function applyFactor() {
const factor = document.getElementById('restockFactor').value;
const url = new URL(window.location.href);
url.searchParams.set('factor', factor);
window.location.href = url.toString();
{% block extra_js %}
{% if loading_state|default(false) %}
<script>
setTimeout(function () {
window.location.reload();
}, 1500);
const retryCount = parseInt(sessionStorage.getItem('abc_retry') || '0', 10);
if (retryCount > 3) {
sessionStorage.removeItem('abc_retry');
window.location.href = '/sales_analysis';
} else {
sessionStorage.setItem('abc_retry', String(retryCount + 1));
}
</script>
{% else %}
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<script src="https://cdn.datatables.net/1.11.5/js/jquery.dataTables.min.js"></script>
<script src="https://cdn.datatables.net/1.11.5/js/dataTables.bootstrap5.min.js"></script>
<script>
const sortColIndex = parseInt("{{ sort_col_index }}", 10);
$(document).ready(function () {
$('#dataTable').DataTable({
language: {
url: '//cdn.datatables.net/plug-ins/1.11.5/i18n/zh-HANT.json',
paginate: {
previous: '上一頁',
next: '下一頁'
}
</script>
</body>
</html>
},
pageLength: 25,
lengthMenu: [[10, 25, 50, 100, -1], [10, 25, 50, 100, '全部']],
order: [[sortColIndex, 'desc']],
paging: true,
info: true
});
});
function applyFactor() {
const factor = document.getElementById('restockFactor').value;
const url = new URL(window.location.href);
url.searchParams.set('factor', factor);
window.location.href = url.toString();
}
</script>
{% endif %}
{% endblock %}

View File

@@ -3,224 +3,325 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>登入 - WOOO TECH</title>
<title>登入 - EwoooC</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@500;700&family=Noto+Sans+TC:wght@400;500;600;700&display=swap" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
<link rel="stylesheet" href="{{ url_for('static', filename='css/ewoooc-tokens.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/ewoooc-tokens-v2-alias.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/ewoooc-dotmatrix.css') }}">
<style>
* {
box-sizing: border-box;
}
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
margin: 0;
color: var(--momo-text-primary);
font-family: var(--momo-font-family);
background:
radial-gradient(circle at 14px 14px, rgba(172, 92, 58, 0.14) 1.2px, transparent 1.4px) 0 0 / 18px 18px,
var(--momo-bg-page);
}
.login-page {
display: grid;
min-height: 100vh;
grid-template-columns: minmax(0, 1fr) minmax(360px, 440px);
}
.login-brand {
display: flex;
flex-direction: column;
justify-content: space-between;
padding: clamp(2rem, 5vw, 4.5rem);
border-right: 1px solid var(--momo-border-light);
background:
radial-gradient(circle at 18px 18px, rgba(42, 37, 32, 0.08) 1.1px, transparent 1.3px) 0 0 / 22px 22px,
rgba(255, 248, 239, 0.55);
}
.brand-mark {
display: inline-flex;
align-items: center;
justify-content: center;
padding-top: 70px;
gap: 12px;
color: var(--momo-text-primary);
font-family: var(--momo-font-display);
font-size: 1.15rem;
font-weight: 800;
text-decoration: none;
}
.brand-mark__icon {
display: inline-grid;
width: 34px;
height: 34px;
place-items: center;
border: 1px solid var(--momo-text-primary);
border-radius: var(--momo-radius-sm);
background: var(--momo-text-primary);
color: var(--momo-bg-paper);
}
.login-brand h1 {
max-width: 720px;
margin: clamp(4rem, 10vw, 8rem) 0 var(--momo-space-3);
color: var(--momo-text-primary);
font-family: var(--momo-font-display);
font-size: clamp(2.2rem, 5vw, 4.6rem);
font-weight: 850;
letter-spacing: 0;
line-height: 1.02;
}
.login-brand p {
max-width: 620px;
margin: 0;
color: var(--momo-text-secondary);
font-size: 1.02rem;
line-height: 1.8;
}
.login-meta {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: var(--momo-space-6);
}
.login-pill {
display: inline-flex;
align-items: center;
gap: 8px;
min-height: 32px;
padding: 5px 10px;
border: 1px solid var(--momo-border-light);
border-radius: var(--momo-radius-sm);
background: var(--momo-bg-elevated);
color: var(--momo-text-secondary);
font-family: var(--momo-font-mono);
font-size: var(--momo-text-label);
font-weight: 700;
}
.login-panel-wrap {
display: grid;
align-items: center;
padding: clamp(1rem, 4vw, 3rem);
background: var(--momo-bg-page);
}
.login-card {
background: white;
border-radius: 20px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
overflow: hidden;
width: 100%;
max-width: 420px;
border: 1px solid var(--momo-border-light);
border-radius: var(--momo-radius-lg);
background: var(--momo-bg-surface);
overflow: hidden;
}
.login-header {
background: linear-gradient(135deg, #1e3c72 0%, #2a5298 100%);
color: white;
padding: 2rem;
text-align: center;
.login-card__head {
padding: var(--momo-space-5);
border-bottom: 1px solid var(--momo-border-light);
}
.login-header h1 {
font-size: 1.75rem;
font-weight: 700;
.login-card__head span {
display: inline-flex;
align-items: center;
gap: 8px;
margin-bottom: var(--momo-space-2);
color: var(--momo-page-accent-dark, var(--momo-accent-caramel));
font-family: var(--momo-font-mono);
font-size: var(--momo-text-label);
font-weight: 760;
letter-spacing: 0.04em;
}
.login-card__head h2 {
margin: 0;
margin-bottom: 0.5rem;
color: var(--momo-text-primary);
font-family: var(--momo-font-display);
font-size: 1.55rem;
font-weight: 800;
letter-spacing: 0;
}
.login-header p {
margin: 0;
opacity: 0.9;
font-size: 0.9rem;
}
.login-body {
padding: 2rem;
padding-top: 70px;
.login-card__body {
padding: var(--momo-space-5);
}
.form-label {
font-weight: 600;
color: #333;
margin-bottom: 0.5rem;
color: var(--momo-text-primary);
font-family: var(--momo-font-display);
font-weight: 700;
}
.form-control {
border-radius: 10px;
border: 2px solid #e0e0e0;
padding: 0.75rem 1rem;
transition: all 0.3s ease;
min-height: 46px;
border: 1px solid var(--momo-border-light);
border-radius: var(--momo-radius-md);
background: var(--momo-bg-elevated);
color: var(--momo-text-primary);
box-shadow: none;
}
.form-control:focus {
border-color: #667eea;
box-shadow: 0 0 0 0.2rem rgba(102, 126, 234, 0.25);
}
.btn-login {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border: none;
border-radius: 10px;
padding: 0.75rem;
font-weight: 600;
font-size: 1.1rem;
color: white;
width: 100%;
transition: all 0.3s ease;
}
.btn-login:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.4);
}
.alert {
border-radius: 10px;
border: none;
}
.alert-danger {
background-color: #fee;
color: #c33;
}
.password-toggle {
position: absolute;
right: 15px;
top: 50%;
transform: translateY(-50%);
cursor: pointer;
color: #666;
border-color: var(--momo-page-accent-line, rgba(172, 92, 58, 0.35));
box-shadow: 0 0 0 3px rgba(172, 92, 58, 0.12);
}
.password-wrapper {
position: relative;
}
.password-toggle {
position: absolute;
top: 50%;
right: 14px;
color: var(--momo-text-tertiary);
cursor: pointer;
transform: translateY(-50%);
}
.btn-login {
width: 100%;
min-height: 46px;
border: 1px solid var(--momo-text-primary);
border-radius: var(--momo-radius-md);
background: var(--momo-text-primary);
color: var(--momo-bg-paper);
font-family: var(--momo-font-display);
font-weight: 800;
}
.btn-login:hover,
.btn-login:focus {
border-color: var(--momo-page-accent-dark, #7a4a18);
background: var(--momo-page-accent-dark, #7a4a18);
color: var(--momo-bg-paper);
}
.alert-danger {
border: 1px solid var(--momo-danger-border);
border-radius: var(--momo-radius-md);
background: var(--momo-danger-bg);
color: var(--momo-danger-text);
}
.login-footer {
text-align: center;
padding: 1rem 2rem 2rem;
color: #666;
font-size: 0.85rem;
padding: 0 var(--momo-space-5) var(--momo-space-5);
color: var(--momo-text-tertiary);
font-family: var(--momo-font-mono);
font-size: var(--momo-text-label);
}
.security-badge {
display: inline-flex;
align-items: center;
gap: 0.5rem;
background: #f0f0f0;
padding: 0.5rem 1rem;
border-radius: 8px;
font-size: 0.85rem;
color: #555;
margin-top: 1rem;
@media (max-width: 900px) {
.login-page {
grid-template-columns: 1fr;
}
.login-brand {
min-height: auto;
border-right: 0;
border-bottom: 1px solid var(--momo-border-light);
}
.login-brand h1 {
margin-top: var(--momo-space-6);
}
.login-panel-wrap {
align-items: start;
}
}
.navbar-dark.bg-primary {
background: linear-gradient(135deg, #4F46E5 0%, #6366F1 100%) !important;
box-shadow: 0 4px 12px rgba(79, 70, 229, 0.3);
}
@media (max-width: 520px) {
.login-brand,
.login-panel-wrap {
padding: var(--momo-space-4);
}
.navbar-dark .navbar-brand {
color: #ffffff !important;
font-weight: 600;
}
.navbar-dark .navbar-nav .nav-link {
color: rgba(255, 255, 255, 0.9) !important;
font-weight: 500;
transition: all 0.3s;
}
.navbar-dark .navbar-nav .nav-link:hover {
color: #ffffff !important;
background: rgba(255, 255, 255, 0.1);
border-radius: 6px;
}
.navbar-dark .navbar-nav .nav-link.active {
color: #ffffff !important;
background: rgba(255, 255, 255, 0.15);
border-radius: 6px;
font-weight: 600;
}
.navbar-dark .navbar-text {
color: rgba(255, 255, 255, 0.8) !important;
.login-card__head,
.login-card__body {
padding: var(--momo-space-4);
}
}
</style>
</head>
<body>
<div class="login-card">
<div class="login-header">
<i class="fas fa-shield-alt fa-2x mb-2"></i>
<h1>WOOO TECH</h1>
<p>請輸入密碼以繼續</p>
</div>
<div class="login-body">
{% if error %}
<div class="alert alert-danger" role="alert">
<i class="fas fa-exclamation-circle me-2"></i>
{{ error }}
</div>
{% endif %}
<form method="POST" action="{{ url_for('login') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<div class="mb-4">
<label for="password" class="form-label">
<i class="fas fa-lock me-1"></i> 密碼
</label>
<div class="password-wrapper">
<input
type="password"
class="form-control"
id="password"
name="password"
placeholder="請輸入密碼"
required
autofocus
autocomplete="current-password"
>
<i class="fas fa-eye password-toggle" onclick="togglePassword()"></i>
</div>
<main class="login-page">
<section class="login-brand" aria-label="EwoooC 登入品牌區">
<div>
<a class="brand-mark" href="/login">
<span class="brand-mark__icon"><i class="fas fa-grip"></i></span>
<span>EwoooC</span>
</a>
<h1>營運數據工作台</h1>
<p>商品、活動、廠商缺貨與 AI 觀測台都在同一個控制面。登入後會回到正式資料頁面。</p>
<div class="login-meta">
<span class="login-pill"><i class="fas fa-shield-halved"></i>CSRF 防護</span>
<span class="login-pill"><i class="fas fa-clock"></i>Session 2h</span>
<span class="login-pill"><i class="fas fa-database"></i>PostgreSQL</span>
</div>
<button type="submit" class="btn btn-login">
<i class="fas fa-sign-in-alt me-2"></i> 登入
</button>
</form>
</div>
<div class="login-footer">
<div class="security-badge">
<i class="fas fa-check-circle text-success"></i>
<span>安全連線 • CSRF 防護已啟用</span>
</div>
<div class="mt-3">
<small>&copy; 2024 WOOO TECH V9.4</small>
<div class="login-footer d-none d-md-block">
EwoooC V{{ config.SYSTEM_VERSION if config is defined else '' }}
</div>
</div>
</div>
</section>
<section class="login-panel-wrap" aria-label="登入表單">
<div class="login-card">
<div class="login-card__head">
<span><i class="fas fa-lock"></i> Secure Access</span>
<h2>登入系統</h2>
</div>
<div class="login-card__body">
{% if error %}
<div class="alert alert-danger" role="alert">
<i class="fas fa-circle-exclamation me-2"></i>
{{ error }}
</div>
{% endif %}
<form method="POST" action="{{ url_for('login') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-4">
<label for="password" class="form-label">密碼</label>
<div class="password-wrapper">
<input
type="password"
class="form-control"
id="password"
name="password"
placeholder="請輸入密碼"
required
autofocus
autocomplete="current-password"
>
<i class="fas fa-eye password-toggle" onclick="togglePassword()" aria-hidden="true"></i>
</div>
</div>
<button type="submit" class="btn btn-login">
<i class="fas fa-right-to-bracket me-2"></i>登入
</button>
</form>
</div>
<div class="login-footer d-md-none">
EwoooC V{{ config.SYSTEM_VERSION if config is defined else '' }}
</div>
</div>
</section>
</main>
<script>
function togglePassword() {
const passwordInput = document.getElementById('password');
const toggleIcon = document.querySelector('.password-toggle');
if (!passwordInput || !toggleIcon) return;
if (passwordInput.type === 'password') {
passwordInput.type = 'text';
@@ -233,9 +334,8 @@
}
}
// Auto-focus on password field
document.addEventListener('DOMContentLoaded', function() {
document.getElementById('password').focus();
document.getElementById('password')?.focus();
});
</script>
</body>

View File

@@ -0,0 +1,36 @@
{% extends "ewoooc_base.html" %}
{% block title %}發送歷史 - EwoooC{% endblock %}
{% block extra_css %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/page-vendor-tools.css') }}">
{% endblock %}
{% block content %}
<section class="vendor-tools-page" data-vendor-tool="history">
<header class="vendor-tools-header">
<div>
<span class="vendor-tools-kicker"><i class="fas fa-boxes-stacked"></i> Vendor Stockout</span>
<h1>發送歷史</h1>
<p>保留廠商缺貨通知的操作歷程入口;目前進階查詢、統計圖表與重試機制仍在後續階段。</p>
</div>
<div class="vendor-tools-nav" aria-label="廠商缺貨工具導覽">
<a href="/vendor-stockout/" ><i class="fas fa-table-columns"></i>總覽</a>
<a href="/vendor-stockout/list" ><i class="fas fa-list-check"></i>缺貨清單</a>
<a href="/vendor-stockout/import" ><i class="fas fa-file-arrow-up"></i>匯入</a>
<a href="/vendor-stockout/vendor-management" ><i class="fas fa-building"></i>廠商</a>
<a href="/vendor-stockout/send-email" ><i class="fas fa-paper-plane"></i>郵件記錄</a>
<a href="/vendor-stockout/history" class="is-active" aria-current="page"><i class="fas fa-clock-rotate-left"></i>歷史</a>
</div>
</header>
<div class="vendor-tools-body">
<div class="card">
<div class="card-body">
<div class="alert alert-info mb-0">
<i class="fas fa-circle-info me-2"></i>
此頁面功能將在 Phase 6 實作:記錄查詢、統計圖表與重試機制。現階段請先使用「郵件記錄」查看正式發送狀態。
</div>
</div>
</div>
</div>
</section>
{% endblock %}

View File

@@ -0,0 +1,328 @@
{% extends "ewoooc_base.html" %}
{% block title %}郵件發送記錄 - EwoooC{% endblock %}
{% block extra_css %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/page-vendor-tools.css') }}">
{% endblock %}
{% block content %}
<section class="vendor-tools-page" data-vendor-tool="email">
<header class="vendor-tools-header">
<div>
<span class="vendor-tools-kicker"><i class="fas fa-boxes-stacked"></i> Vendor Stockout</span>
<h1>郵件發送記錄</h1>
<p>追蹤廠商缺貨通知的發送狀態、成功率與錯誤訊息,方便營運快速補救。</p>
</div>
<div class="vendor-tools-nav" aria-label="廠商缺貨工具導覽">
<a href="/vendor-stockout/" ><i class="fas fa-table-columns"></i>總覽</a>
<a href="/vendor-stockout/list" ><i class="fas fa-list-check"></i>缺貨清單</a>
<a href="/vendor-stockout/import" ><i class="fas fa-file-arrow-up"></i>匯入</a>
<a href="/vendor-stockout/vendor-management" ><i class="fas fa-building"></i>廠商</a>
<a href="/vendor-stockout/send-email" class="is-active" aria-current="page"><i class="fas fa-paper-plane"></i>郵件記錄</a>
<a href="/vendor-stockout/history" ><i class="fas fa-clock-rotate-left"></i>歷史</a>
</div>
</header>
<div class="vendor-tools-body">
<div class="container-fluid" style="padding: 2rem;">
<!-- 標題 -->
<div class="d-flex justify-content-between align-items-center mb-4">
<h2 class="mb-0">
<i class="fas fa-paper-plane text-primary me-3"></i>郵件發送記錄
</h2>
<a href="/vendor-stockout" class="btn btn-outline-secondary">
<i class="fas fa-home me-2"></i>返回主頁
</a>
</div>
<!-- 統計卡片 -->
<div class="row mb-4">
<div class="col-md-3 mb-3">
<div class="card stats-card">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center">
<div>
<div class="text-muted small">總發送數</div>
<h3 class="mb-0" id="statTotal">0</h3>
</div>
<div class="fs-1 text-primary">
<i class="fas fa-envelope"></i>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-3 mb-3">
<div class="card stats-card">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center">
<div>
<div class="text-muted small">成功</div>
<h3 class="mb-0 text-success" id="statSuccess">0</h3>
</div>
<div class="fs-1 text-success">
<i class="fas fa-check-circle"></i>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-3 mb-3">
<div class="card stats-card">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center">
<div>
<div class="text-muted small">失敗</div>
<h3 class="mb-0 text-danger" id="statFailed">0</h3>
</div>
<div class="fs-1 text-danger">
<i class="fas fa-times-circle"></i>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-3 mb-3">
<div class="card stats-card">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center">
<div>
<div class="text-muted small">成功率</div>
<h3 class="mb-0 text-info" id="statRate">0%</h3>
</div>
<div class="fs-1 text-info">
<i class="fas fa-chart-line"></i>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 篩選區 -->
<div class="card mb-3" style="border-radius: 12px;">
<div class="card-body">
<div class="row g-3">
<div class="col-md-3">
<label class="form-label small">狀態</label>
<select class="form-select" id="filterStatus">
<option value="">全部</option>
<option value="sent">成功</option>
<option value="failed">失敗</option>
<option value="pending">待發送</option>
</select>
</div>
<div class="col-md-3">
<label class="form-label small">廠商代碼</label>
<input type="text" class="form-control" id="filterVendor" placeholder="輸入廠商代碼">
</div>
<div class="col-md-2">
<label class="form-label small">開始日期</label>
<input type="date" class="form-control" id="filterDateFrom">
</div>
<div class="col-md-2">
<label class="form-label small">結束日期</label>
<input type="date" class="form-control" id="filterDateTo">
</div>
<div class="col-md-2 d-flex align-items-end">
<button class="btn btn-primary w-100" onclick="loadData()">
<i class="fas fa-search me-2"></i>查詢
</button>
</div>
</div>
</div>
</div>
<!-- 資料表格 -->
<div class="table-container">
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>ID</th>
<th>批次編號</th>
<th>廠商</th>
<th>收件者</th>
<th>主旨</th>
<th>商品數</th>
<th>狀態</th>
<th>發送時間</th>
</tr>
</thead>
<tbody id="tableBody">
<tr>
<td colspan="8" class="text-center text-muted">
<i class="fas fa-spinner fa-spin me-2"></i>載入中...
</td>
</tr>
</tbody>
</table>
</div>
<!-- 分頁 -->
<div class="d-flex justify-content-between align-items-center mt-3">
<div class="text-muted">
<span id="totalCount">0</span> 筆記錄
</div>
<nav>
<ul class="pagination mb-0" id="pagination"></ul>
</nav>
</div>
</div>
</div>
</div>
</section>
{% endblock %}
{% block extra_js %}
<script>
let currentPage = 1;
const pageSize = 20;
// 載入統計資料
async function loadStats() {
try {
const response = await fetch('/vendor-stockout/api/email/stats');
const result = await response.json();
if (result.success) {
const data = result.data;
document.getElementById('statTotal').textContent = data.total_sent;
document.getElementById('statSuccess').textContent = data.success_count;
document.getElementById('statFailed').textContent = data.failed_count;
document.getElementById('statRate').textContent = data.success_rate + '%';
}
} catch (error) {
console.error('載入統計資料失敗:', error);
}
}
// 載入郵件記錄
async function loadData(page = 1) {
currentPage = page;
const status = document.getElementById('filterStatus').value;
const vendor = document.getElementById('filterVendor').value;
const dateFrom = document.getElementById('filterDateFrom').value;
const dateTo = document.getElementById('filterDateTo').value;
const params = new URLSearchParams({
page: page,
page_size: pageSize
});
if (status) params.append('status', status);
if (vendor) params.append('vendor_code', vendor);
if (dateFrom) params.append('date_from', dateFrom);
if (dateTo) params.append('date_to', dateTo);
try {
const response = await fetch(`/vendor-stockout/api/email/logs?${params}`);
const result = await response.json();
if (result.success) {
renderTable(result.data);
renderPagination(result.total, page);
document.getElementById('totalCount').textContent = result.total;
} else {
alert('查詢失敗: ' + result.message);
}
} catch (error) {
console.error('載入資料失敗:', error);
alert('載入資料失敗: ' + error.message);
}
}
// 渲染表格
function renderTable(data) {
const tbody = document.getElementById('tableBody');
if (data.length === 0) {
tbody.innerHTML = `
<tr>
<td colspan="8" class="text-center text-muted">
<i class="fas fa-inbox me-2"></i>沒有記錄
</td>
</tr>
`;
return;
}
tbody.innerHTML = data.map(item => {
const statusClass = item.status === 'sent' ? 'status-sent' :
item.status === 'failed' ? 'status-failed' : 'status-pending';
const statusText = item.status === 'sent' ? '成功' :
item.status === 'failed' ? '失敗' : '待發送';
return `
<tr>
<td>${item.id}</td>
<td><small>${item.batch_id}</small></td>
<td>
<div><strong>${item.vendor_name}</strong></div>
<small class="text-muted">${item.vendor_code}</small>
</td>
<td><small>${item.recipient_email}</small></td>
<td style="max-width: 300px;">
<small>${item.subject}</small>
${item.error_message ? `<div class="text-danger small mt-1">❌ ${item.error_message}</div>` : ''}
</td>
<td>${item.product_count}</td>
<td><span class="status-badge ${statusClass}">${statusText}</span></td>
<td><small>${item.sent_at || item.created_at}</small></td>
</tr>
`;
}).join('');
}
// 渲染分頁
function renderPagination(total, current) {
const totalPages = Math.ceil(total / pageSize);
const pagination = document.getElementById('pagination');
if (totalPages <= 1) {
pagination.innerHTML = '';
return;
}
let html = '';
// 上一頁
html += `
<li class="page-item ${current === 1 ? 'disabled' : ''}">
<a class="page-link" href="#" onclick="loadData(${current - 1}); return false;">上一頁</a>
</li>
`;
// 頁碼
for (let i = 1; i <= totalPages; i++) {
if (i === 1 || i === totalPages || (i >= current - 2 && i <= current + 2)) {
html += `
<li class="page-item ${i === current ? 'active' : ''}">
<a class="page-link" href="#" onclick="loadData(${i}); return false;">${i}</a>
</li>
`;
} else if (i === current - 3 || i === current + 3) {
html += '<li class="page-item disabled"><span class="page-link">...</span></li>';
}
}
// 下一頁
html += `
<li class="page-item ${current === totalPages ? 'disabled' : ''}">
<a class="page-link" href="#" onclick="loadData(${current + 1}); return false;">下一頁</a>
</li>
`;
pagination.innerHTML = html;
}
// 初始化
document.addEventListener('DOMContentLoaded', function() {
loadStats();
loadData(1);
// 每 30 秒自動重新整理統計資料
setInterval(loadStats, 30000);
});
</script>
{% endblock %}

View File

@@ -0,0 +1,838 @@
{% extends "ewoooc_base.html" %}
{% block title %}廠商管理 - EwoooC{% endblock %}
{% block extra_css %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/page-vendor-tools.css') }}">
{% endblock %}
{% block content %}
<section class="vendor-tools-page" data-vendor-tool="management">
<header class="vendor-tools-header">
<div>
<span class="vendor-tools-kicker"><i class="fas fa-boxes-stacked"></i> Vendor Stockout</span>
<h1>廠商管理</h1>
<p>維護來源供應商、收件信箱與 Excel 匯入,所有異動直接接正式廠商缺貨資料。</p>
</div>
<div class="vendor-tools-nav" aria-label="廠商缺貨工具導覽">
<a href="/vendor-stockout/" ><i class="fas fa-table-columns"></i>總覽</a>
<a href="/vendor-stockout/list" ><i class="fas fa-list-check"></i>缺貨清單</a>
<a href="/vendor-stockout/import" ><i class="fas fa-file-arrow-up"></i>匯入</a>
<a href="/vendor-stockout/vendor-management" class="is-active" aria-current="page"><i class="fas fa-building"></i>廠商</a>
<a href="/vendor-stockout/send-email" ><i class="fas fa-paper-plane"></i>郵件記錄</a>
<a href="/vendor-stockout/history" ><i class="fas fa-clock-rotate-left"></i>歷史</a>
</div>
</header>
<div class="vendor-tools-body">
<div class="container-fluid" style="padding: 2rem;">
<h2 class="mb-4">
<i class="fas fa-building me-2" style="color: var(--momo-page-accent-dark);"></i>廠商管理
</h2>
<!-- 分頁標籤 -->
<div class="section-tabs">
<ul class="nav nav-tabs" id="vendorTabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="list-tab" data-bs-toggle="tab" data-bs-target="#list" type="button">
<i class="fas fa-list me-2"></i>廠商清單
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="import-tab" data-bs-toggle="tab" data-bs-target="#import" type="button">
<i class="fas fa-file-upload me-2"></i>Excel 匯入
</button>
</li>
</ul>
</div>
<div class="tab-content" id="vendorTabContent">
<!-- 廠商清單 Tab -->
<div class="tab-pane fade show active" id="list" role="tabpanel">
<!-- 統計卡片 -->
<div class="row mb-4">
<div class="col-md-4">
<div class="stats-card">
<div class="stats-number text-primary" style="font-size: 2rem; font-weight: bold;" id="totalVendors">0</div>
<div class="stats-label text-muted">總廠商數</div>
</div>
</div>
<div class="col-md-4">
<div class="stats-card">
<div class="stats-number" style="font-size: 2rem; font-weight: bold; color: var(--momo-page-accent-dark);" id="totalEmails">0</div>
<div class="stats-label text-muted">郵件地址總數</div>
</div>
</div>
<div class="col-md-4">
<div class="stats-card">
<div class="stats-number text-success" style="font-size: 2rem; font-weight: bold;" id="avgEmails">0</div>
<div class="stats-label text-muted">平均每廠商郵件數</div>
</div>
</div>
</div>
<!-- 篩選與操作 -->
<div class="card mb-3" style="border-radius: 12px;">
<div class="card-body">
<div class="row align-items-end">
<div class="col-md-4">
<label class="form-label">搜尋廠商</label>
<input type="text" class="form-control" id="searchVendor" placeholder="輸入廠商代碼或名稱">
</div>
<div class="col-md-3">
<button class="btn btn-success" id="searchBtn">
<i class="fas fa-search me-2"></i>搜尋
</button>
<button class="btn btn-outline-secondary" id="resetBtn">
<i class="fas fa-redo me-2"></i>重置
</button>
</div>
<div class="col-md-5 text-end">
<button class="btn btn-primary" id="addVendorBtn" style="background: var(--momo-page-accent-dark); border-color: var(--momo-page-accent-dark);">
<i class="fas fa-plus me-2"></i>新增廠商
</button>
</div>
</div>
</div>
</div>
<!-- 廠商列表 -->
<div class="table-container">
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th style="width: 150px;">廠商代碼</th>
<th>廠商名稱</th>
<th>郵件地址</th>
<th style="width: 120px;">郵件數量</th>
<th style="width: 180px;">操作</th>
</tr>
</thead>
<tbody id="vendorTable">
<tr>
<td colspan="5" class="text-center py-5">
<i class="fas fa-spinner fa-spin fa-2x text-muted mb-3"></i>
<p class="text-muted">載入中...</p>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- 分頁 -->
<div class="d-flex justify-content-between align-items-center mt-4">
<div class="text-muted">
顯示 <span id="pageInfo">0-0 / 0</span>
</div>
<nav>
<ul class="pagination mb-0" id="pagination"></ul>
</nav>
</div>
</div>
<!-- Excel 匯入 Tab -->
<div class="tab-pane fade" id="import" role="tabpanel">
<div class="row justify-content-center">
<div class="col-md-10">
<!-- 上傳區域 -->
<div class="card mb-4" style="border-radius: 16px;">
<div class="card-body">
<div class="upload-area" id="uploadArea">
<input type="file" id="fileInput" accept=".xlsx,.xls" style="display: none;">
<div class="upload-icon">
<i class="fas fa-cloud-upload-alt"></i>
</div>
<h4 class="mb-3">拖曳檔案到此處或點擊選擇檔案</h4>
<p class="text-muted">支援格式: Excel (.xlsx, .xls)</p>
<p class="text-muted small">
<strong>必要欄位:</strong>來源供應商編號、來源供應商名稱<br>
<strong>選填欄位:</strong>Mail支援多個郵件用逗號或分號分隔
</p>
<button type="button" class="btn btn-primary btn-lg mt-3" id="selectFileBtn" style="background: var(--momo-page-accent-dark); border-color: var(--momo-page-accent-dark);">
<i class="fas fa-folder-open me-2"></i>選擇檔案
</button>
</div>
<div class="mt-3 text-center">
<a href="/vendor-stockout/api/vendor/import/template" class="btn btn-outline-secondary">
<i class="fas fa-download me-2"></i>下載 Excel 範本
</a>
</div>
<!-- 檔案資訊 -->
<div id="fileInfo" class="mt-4" style="display: none;">
<div class="alert alert-info">
<i class="fas fa-file me-2"></i>
<strong>已選擇檔案:</strong> <span id="fileName"></span>
<span class="badge bg-primary ms-2" id="fileSize"></span>
</div>
<button type="button" class="btn btn-success w-100 btn-lg" id="uploadBtn" style="background: var(--momo-page-accent-dark); border-color: var(--momo-page-accent-dark);">
<i class="fas fa-upload me-2"></i>開始匯入
</button>
</div>
<!-- 進度條 -->
<div id="progressArea" class="mt-4" style="display: none;">
<div class="progress" style="height: 30px; border-radius: 15px;">
<div class="progress-bar progress-bar-striped progress-bar-animated"
role="progressbar" style="width: 100%; background: var(--momo-page-accent-dark);">
匯入中...
</div>
</div>
</div>
</div>
</div>
<!-- 匯入結果 -->
<div id="resultArea" style="display: none;">
<div class="card" style="border-radius: 16px; border-left: 5px solid var(--momo-page-accent-dark);">
<div class="card-body">
<h5 class="card-title">
<i class="fas fa-check-circle text-success me-2"></i>匯入完成
</h5>
<div class="row mt-4">
<div class="col-md-3">
<div class="text-center">
<h3 class="text-primary" id="totalCount">0</h3>
<small class="text-muted">總筆數</small>
</div>
</div>
<div class="col-md-3">
<div class="text-center">
<h3 class="text-success" id="successCount">0</h3>
<small class="text-muted">新增廠商</small>
</div>
</div>
<div class="col-md-3">
<div class="text-center">
<h3 class="text-info" id="updatedCount">0</h3>
<small class="text-muted">更新廠商</small>
</div>
</div>
<div class="col-md-3">
<div class="text-center">
<h3 style="color: var(--momo-page-accent-dark);" id="emailCount">0</h3>
<small class="text-muted">郵件地址</small>
</div>
</div>
</div>
<div class="row mt-3">
<div class="col-md-12">
<div class="text-center">
<h3 class="text-danger" id="failedCount">0</h3>
<small class="text-muted">失敗</small>
</div>
</div>
</div>
<div class="mt-4 text-center">
<button type="button" class="btn btn-outline-secondary" id="importAgainBtn">
<i class="fas fa-redo me-2"></i>再次匯入
</button>
<button type="button" class="btn btn-primary" style="background: var(--momo-page-accent-dark); border-color: var(--momo-page-accent-dark);" onclick="refreshVendorList()">
<i class="fas fa-list me-2"></i>查看廠商清單
</button>
</div>
</div>
</div>
</div>
<!-- 錯誤訊息 -->
<div id="errorArea" style="display: none;">
<div class="alert alert-danger">
<h5><i class="fas fa-exclamation-circle me-2"></i>匯入失敗</h5>
<p class="mb-0" id="errorMessage"></p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 新增/編輯廠商 Modal -->
<div class="modal fade" id="vendorModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header" style="background: var(--momo-page-accent-dark); color: white;">
<h5 class="modal-title" id="vendorModalTitle">
<i class="fas fa-building me-2"></i>新增廠商
</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<input type="hidden" id="editVendorId">
<div class="mb-3">
<label class="form-label">廠商代碼 <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="vendorCode" placeholder="輸入廠商代碼">
</div>
<div class="mb-3">
<label class="form-label">廠商名稱 <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="vendorName" placeholder="輸入廠商名稱">
</div>
<div class="mb-3">
<label class="form-label">郵件地址 <small class="text-muted">(每行一個郵件地址)</small></label>
<textarea class="form-control" id="vendorEmails" rows="5" placeholder="輸入郵件地址,每行一個"></textarea>
<small class="text-muted">提示:可以從 Excel 複製貼上多個郵件地址</small>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
<button type="button" class="btn btn-primary" id="saveVendorBtn" style="background: var(--momo-page-accent-dark); border-color: var(--momo-page-accent-dark);">
<i class="fas fa-save me-2"></i>儲存
</button>
</div>
</div>
</div>
</div>
<!-- 管理郵件 Modal -->
<div class="modal fade" id="emailModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header" style="background: var(--momo-page-accent-dark); color: white;">
<h5 class="modal-title">
<i class="fas fa-envelope me-2"></i>管理郵件地址
</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<input type="hidden" id="emailVendorCode">
<h6 class="mb-3">
<strong id="emailVendorName"></strong> (<span id="emailVendorCode2"></span>)
</h6>
<!-- 新增郵件 -->
<div class="card mb-3">
<div class="card-body">
<h6><i class="fas fa-plus-circle me-2"></i>新增郵件地址</h6>
<div class="input-group">
<input type="email" class="form-control" id="newEmail" placeholder="輸入新的郵件地址">
<button class="btn btn-success" id="addEmailBtn">
<i class="fas fa-plus me-1"></i>新增
</button>
</div>
</div>
</div>
<!-- 現有郵件列表 -->
<div class="card">
<div class="card-body">
<h6><i class="fas fa-list me-2"></i>現有郵件地址</h6>
<div id="emailList" class="mt-3">
<p class="text-center text-muted">載入中...</p>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">關閉</button>
</div>
</div>
</div>
</div>
</div>
</section>
{% endblock %}
{% block extra_js %}
<script>
// ====== Global Variables ======
const vendorCsrfToken = document.querySelector('meta[name="csrf-token"]').getAttribute('content');
let currentPage = 1;
let pageSize = 20;
let totalVendors = 0;
let searchKeyword = '';
let selectedFile = null;
let vendorModal, emailModal;
// ====== Initialize ======
document.addEventListener('DOMContentLoaded', () => {
vendorModal = new bootstrap.Modal(document.getElementById('vendorModal'));
emailModal = new bootstrap.Modal(document.getElementById('emailModal'));
loadVendorList();
bindEvents();
});
// ====== Bind Events ======
function bindEvents() {
// 廠商清單相關
document.getElementById('searchBtn').addEventListener('click', () => {
searchKeyword = document.getElementById('searchVendor').value;
currentPage = 1;
loadVendorList();
});
document.getElementById('resetBtn').addEventListener('click', () => {
document.getElementById('searchVendor').value = '';
searchKeyword = '';
currentPage = 1;
loadVendorList();
});
document.getElementById('addVendorBtn').addEventListener('click', () => {
openVendorModal();
});
document.getElementById('saveVendorBtn').addEventListener('click', saveVendor);
// 郵件管理相關
document.getElementById('addEmailBtn').addEventListener('click', addEmail);
// Excel 匯入相關
bindImportEvents();
}
// ====== Vendor List Functions ======
async function loadVendorList() {
try {
const params = new URLSearchParams({
page: currentPage,
page_size: pageSize
});
if (searchKeyword) {
params.append('search', searchKeyword);
}
const response = await fetch(`/vendor-stockout/api/vendor/list?${params}`);
const result = await response.json();
if (result.success) {
renderVendorTable(result.data.vendors);
updateVendorStats(result.data.stats);
updatePagination(result.data.total);
totalVendors = result.data.total;
}
} catch (error) {
console.error('載入廠商清單失敗:', error);
document.getElementById('vendorTable').innerHTML = `
<tr>
<td colspan="5" class="text-center py-5 text-danger">
<i class="fas fa-exclamation-circle fa-2x mb-3"></i>
<p>載入失敗: ${error.message}</p>
</td>
</tr>
`;
}
}
function renderVendorTable(vendors) {
const tbody = document.getElementById('vendorTable');
if (vendors.length === 0) {
tbody.innerHTML = `
<tr>
<td colspan="5" class="text-center py-5">
<i class="fas fa-inbox fa-3x text-muted mb-3"></i>
<p class="text-muted">暫無廠商資料</p>
</td>
</tr>
`;
return;
}
tbody.innerHTML = vendors.map(vendor => `
<tr>
<td><strong>${vendor.vendor_code}</strong></td>
<td>${vendor.vendor_name}</td>
<td>
${vendor.emails.slice(0, 2).map(email =>
`<span class="email-badge">${email}</span>`
).join('')}
${vendor.email_count > 2 ? `<span class="badge bg-secondary">+${vendor.email_count - 2} 個</span>` : ''}
</td>
<td class="text-center">
<span class="badge bg-info">${vendor.email_count} 個</span>
</td>
<td>
<button class="btn btn-sm btn-outline-primary me-1" onclick="editVendor('${vendor.vendor_code}')">
<i class="fas fa-edit"></i>
</button>
<button class="btn btn-sm btn-outline-info me-1" onclick="manageEmails('${vendor.vendor_code}', '${vendor.vendor_name}')">
<i class="fas fa-envelope"></i>
</button>
<button class="btn btn-sm btn-outline-danger" onclick="deleteVendor('${vendor.vendor_code}', '${vendor.vendor_name}')">
<i class="fas fa-trash"></i>
</button>
</td>
</tr>
`).join('');
}
function updateVendorStats(stats) {
document.getElementById('totalVendors').textContent = stats.total_vendors || 0;
document.getElementById('totalEmails').textContent = stats.total_emails || 0;
document.getElementById('avgEmails').textContent = stats.avg_emails || '0.0';
}
function updatePagination(total) {
const totalPages = Math.ceil(total / pageSize);
const start = (currentPage - 1) * pageSize + 1;
const end = Math.min(currentPage * pageSize, total);
document.getElementById('pageInfo').textContent = `${start}-${end} / ${total}`;
const pagination = document.getElementById('pagination');
let html = '';
html += `<li class="page-item ${currentPage === 1 ? 'disabled' : ''}">
<a class="page-link" href="#" onclick="changePage(${currentPage - 1}); return false;">上一頁</a>
</li>`;
for (let i = 1; i <= totalPages; i++) {
if (i === 1 || i === totalPages || (i >= currentPage - 2 && i <= currentPage + 2)) {
html += `<li class="page-item ${i === currentPage ? 'active' : ''}">
<a class="page-link" href="#" onclick="changePage(${i}); return false;">${i}</a>
</li>`;
} else if (i === currentPage - 3 || i === currentPage + 3) {
html += `<li class="page-item disabled"><a class="page-link">...</a></li>`;
}
}
html += `<li class="page-item ${currentPage === totalPages ? 'disabled' : ''}">
<a class="page-link" href="#" onclick="changePage(${currentPage + 1}); return false;">下一頁</a>
</li>`;
pagination.innerHTML = html;
}
function changePage(page) {
const totalPages = Math.ceil(totalVendors / pageSize);
if (page < 1 || page > totalPages) return;
currentPage = page;
loadVendorList();
}
// ====== Vendor CRUD Functions ======
function openVendorModal(vendorCode = null) {
document.getElementById('editVendorId').value = vendorCode || '';
document.getElementById('vendorModalTitle').innerHTML = vendorCode
? '<i class="fas fa-edit me-2"></i>編輯廠商'
: '<i class="fas fa-plus me-2"></i>新增廠商';
if (vendorCode) {
loadVendorDetail(vendorCode);
} else {
document.getElementById('vendorCode').value = '';
document.getElementById('vendorCode').readOnly = false;
document.getElementById('vendorName').value = '';
document.getElementById('vendorEmails').value = '';
}
vendorModal.show();
}
async function loadVendorDetail(vendorCode) {
try {
const response = await fetch(`/vendor-stockout/api/vendor/${vendorCode}`);
const result = await response.json();
if (result.success) {
const vendor = result.data;
document.getElementById('vendorCode').value = vendor.vendor_code;
document.getElementById('vendorCode').readOnly = true;
document.getElementById('vendorName').value = vendor.vendor_name;
document.getElementById('vendorEmails').value = vendor.emails.join('\n');
}
} catch (error) {
alert('載入廠商資料失敗: ' + error.message);
}
}
async function editVendor(vendorCode) {
openVendorModal(vendorCode);
}
async function saveVendor() {
const vendorCode = document.getElementById('vendorCode').value.trim();
const vendorName = document.getElementById('vendorName').value.trim();
const emailsText = document.getElementById('vendorEmails').value;
if (!vendorCode || !vendorName) {
alert('廠商代碼和名稱為必填欄位!');
return;
}
const emails = emailsText.split('\n')
.map(e => e.trim())
.filter(e => e && e.includes('@'));
const data = {
vendor_code: vendorCode,
vendor_name: vendorName,
emails: emails
};
try {
const isEdit = document.getElementById('editVendorId').value;
const url = isEdit
? `/vendor-stockout/api/vendor/${vendorCode}`
: '/vendor-stockout/api/vendor';
const method = isEdit ? 'PUT' : 'POST';
const response = await fetch(url, {
method: method,
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': vendorCsrfToken
},
body: JSON.stringify(data)
});
const result = await response.json();
if (result.success) {
vendorModal.hide();
await loadVendorList();
alert(isEdit ? '更新成功!' : '新增成功!');
} else {
alert(result.message || '操作失敗');
}
} catch (error) {
alert('操作失敗: ' + error.message);
}
}
async function deleteVendor(vendorCode, vendorName) {
if (!confirm(`確定要刪除廠商「${vendorName}」嗎?這將同時刪除所有相關郵件地址。`)) return;
try {
const response = await fetch(`/vendor-stockout/api/vendor/${vendorCode}`, {
method: 'DELETE',
headers: {
'X-CSRFToken': vendorCsrfToken
}
});
const result = await response.json();
if (result.success) {
await loadVendorList();
alert('刪除成功!');
} else {
alert(result.message || '刪除失敗');
}
} catch (error) {
alert('刪除失敗: ' + error.message);
}
}
// ====== Email Management Functions ======
async function manageEmails(vendorCode, vendorName) {
document.getElementById('emailVendorCode').value = vendorCode;
document.getElementById('emailVendorCode2').textContent = vendorCode;
document.getElementById('emailVendorName').textContent = vendorName;
document.getElementById('newEmail').value = '';
await loadEmailList(vendorCode);
emailModal.show();
}
async function loadEmailList(vendorCode) {
try {
const response = await fetch(`/vendor-stockout/api/vendor/${vendorCode}/emails`);
const result = await response.json();
if (result.success) {
const emailList = document.getElementById('emailList');
if (result.data.length === 0) {
emailList.innerHTML = '<p class="text-muted text-center">暫無郵件地址</p>';
} else {
emailList.innerHTML = result.data.map(email => `
<div class="d-flex justify-content-between align-items-center mb-2 p-2 border rounded">
<div>
<i class="fas fa-envelope me-2" style="color: var(--momo-page-accent-dark);"></i>
<strong>${email.email}</strong>
<span class="badge bg-secondary ms-2">${email.email_type}</span>
</div>
<button class="btn btn-sm btn-outline-danger" onclick="deleteEmail(${email.id})">
<i class="fas fa-trash"></i>
</button>
</div>
`).join('');
}
}
} catch (error) {
document.getElementById('emailList').innerHTML =
'<p class="text-danger text-center">載入失敗: ' + error.message + '</p>';
}
}
async function addEmail() {
const vendorCode = document.getElementById('emailVendorCode').value;
const email = document.getElementById('newEmail').value.trim();
if (!email) {
alert('請輸入郵件地址!');
return;
}
if (!email.includes('@')) {
alert('請輸入有效的郵件地址!');
return;
}
try {
const response = await fetch(`/vendor-stockout/api/vendor/${vendorCode}/emails`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': vendorCsrfToken
},
body: JSON.stringify({ email: email, email_type: 'primary' })
});
const result = await response.json();
if (result.success) {
document.getElementById('newEmail').value = '';
await loadEmailList(vendorCode);
await loadVendorList();
alert('新增成功!');
} else {
alert(result.message || '新增失敗');
}
} catch (error) {
alert('新增失敗: ' + error.message);
}
}
async function deleteEmail(emailId) {
if (!confirm('確定要刪除這個郵件地址嗎?')) return;
const vendorCode = document.getElementById('emailVendorCode').value;
try {
const response = await fetch(`/vendor-stockout/api/vendor/email/${emailId}`, {
method: 'DELETE',
headers: {
'X-CSRFToken': vendorCsrfToken
}
});
const result = await response.json();
if (result.success) {
await loadEmailList(vendorCode);
await loadVendorList();
alert('刪除成功!');
} else {
alert(result.message || '刪除失敗');
}
} catch (error) {
alert('刪除失敗: ' + error.message);
}
}
// ====== Excel Import Functions ======
function bindImportEvents() {
const uploadArea = document.getElementById('uploadArea');
const fileInput = document.getElementById('fileInput');
const selectFileBtn = document.getElementById('selectFileBtn');
uploadArea.addEventListener('click', () => fileInput.click());
selectFileBtn.addEventListener('click', (e) => {
e.stopPropagation();
fileInput.click();
});
fileInput.addEventListener('change', (e) => handleFile(e.target.files[0]));
uploadArea.addEventListener('dragover', (e) => {
e.preventDefault();
uploadArea.classList.add('dragover');
});
uploadArea.addEventListener('dragleave', () => {
uploadArea.classList.remove('dragover');
});
uploadArea.addEventListener('drop', (e) => {
e.preventDefault();
uploadArea.classList.remove('dragover');
handleFile(e.dataTransfer.files[0]);
});
document.getElementById('uploadBtn').addEventListener('click', uploadExcel);
document.getElementById('importAgainBtn').addEventListener('click', resetImport);
}
function handleFile(file) {
if (!file) return;
if (!file.name.match(/\.(xlsx|xls)$/i)) {
alert('請選擇 Excel 檔案 (.xlsx 或 .xls)');
return;
}
selectedFile = file;
document.getElementById('fileName').textContent = file.name;
document.getElementById('fileSize').textContent = formatFileSize(file.size);
document.getElementById('fileInfo').style.display = 'block';
document.getElementById('resultArea').style.display = 'none';
document.getElementById('errorArea').style.display = 'none';
}
async function uploadExcel() {
if (!selectedFile) return;
const formData = new FormData();
formData.append('file', selectedFile);
document.getElementById('fileInfo').style.display = 'none';
document.getElementById('progressArea').style.display = 'block';
document.getElementById('errorArea').style.display = 'none';
document.getElementById('resultArea').style.display = 'none';
try {
const response = await fetch('/vendor-stockout/api/vendor/import/excel', {
method: 'POST',
headers: {
'X-CSRFToken': vendorCsrfToken
},
body: formData
});
const result = await response.json();
document.getElementById('progressArea').style.display = 'none';
if (result.success) {
document.getElementById('totalCount').textContent = result.data.total_count;
document.getElementById('successCount').textContent = result.data.success_count;
document.getElementById('updatedCount').textContent = result.data.updated_count;
document.getElementById('emailCount').textContent = result.data.email_count;
document.getElementById('failedCount').textContent = result.data.failed_count;
document.getElementById('resultArea').style.display = 'block';
} else {
document.getElementById('errorMessage').textContent = result.message;
document.getElementById('errorArea').style.display = 'block';
}
} catch (error) {
document.getElementById('progressArea').style.display = 'none';
document.getElementById('errorMessage').textContent = '網路錯誤: ' + error.message;
document.getElementById('errorArea').style.display = 'block';
}
}
function resetImport() {
selectedFile = null;
document.getElementById('fileInput').value = '';
document.getElementById('fileInfo').style.display = 'none';
document.getElementById('resultArea').style.display = 'none';
document.getElementById('errorArea').style.display = 'none';
}
function refreshVendorList() {
const listTab = new bootstrap.Tab(document.getElementById('list-tab'));
listTab.show();
loadVendorList();
}
function formatFileSize(bytes) {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
}
</script>
{% endblock %}

View File

@@ -0,0 +1,189 @@
.abc-detail-page {
display: grid;
gap: var(--momo-space-5);
color: var(--momo-text-primary);
}
.abc-detail-header {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: var(--momo-space-4);
align-items: end;
padding: var(--momo-space-5);
border: 1px solid var(--momo-border-light);
border-radius: var(--momo-radius-lg);
background:
radial-gradient(circle at 14px 14px, rgba(172, 92, 58, 0.15) 1.2px, transparent 1.4px) 0 0 / 18px 18px,
var(--momo-bg-surface);
}
.abc-detail-kicker {
display: inline-flex;
align-items: center;
gap: 8px;
margin-bottom: var(--momo-space-2);
color: var(--momo-page-accent-dark);
font-family: var(--momo-font-mono);
font-size: var(--momo-text-label);
font-weight: 750;
letter-spacing: 0.04em;
}
.abc-detail-header h1 {
margin: 0;
font-family: var(--momo-font-display);
font-size: clamp(1.55rem, 2.1vw, 2.15rem);
font-weight: 820;
letter-spacing: 0;
color: var(--momo-text-primary);
}
.abc-detail-header p {
max-width: 760px;
margin: var(--momo-space-2) 0 0;
color: var(--momo-text-secondary);
line-height: 1.65;
}
.abc-detail-actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
}
.abc-factor-control {
display: flex;
align-items: stretch;
min-width: min(100%, 260px);
}
.abc-factor-control .input-group-text {
border-color: var(--momo-border-light);
background: var(--momo-bg-paper);
color: var(--momo-text-secondary);
font-family: var(--momo-font-mono);
font-weight: 700;
}
.abc-detail-statline {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: var(--momo-space-3);
}
.abc-detail-pill {
display: inline-flex;
align-items: center;
gap: 8px;
min-height: 32px;
padding: 5px 10px;
border: 1px solid var(--momo-border-light);
border-radius: var(--momo-radius-sm);
background: var(--momo-bg-elevated);
color: var(--momo-text-secondary);
font-family: var(--momo-font-mono);
font-size: var(--momo-text-label);
font-weight: 700;
}
.abc-detail-card {
overflow: hidden;
border: 1px solid var(--momo-border-light);
border-radius: var(--momo-radius-lg);
background: var(--momo-bg-surface);
}
.abc-detail-card__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--momo-space-3);
padding: var(--momo-space-4);
border-bottom: 1px solid var(--momo-border-light);
}
.abc-detail-card__head h2 {
margin: 0;
font-family: var(--momo-font-display);
font-size: 1.08rem;
font-weight: 780;
letter-spacing: 0;
}
.abc-detail-table {
padding: 0;
}
.abc-detail-page .table-responsive {
max-width: 100%;
overflow-x: auto;
}
.abc-detail-page .table {
min-width: 1180px;
}
.abc-detail-page .table td,
.abc-detail-page .table th {
vertical-align: middle;
}
.abc-detail-page .abc-product-name {
display: -webkit-box;
overflow: hidden;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
}
.abc-loading-card {
display: grid;
justify-items: center;
gap: var(--momo-space-3);
padding: clamp(2rem, 7vw, 4rem);
border: 1px solid var(--momo-border-light);
border-radius: var(--momo-radius-lg);
background:
radial-gradient(circle at 14px 14px, rgba(172, 92, 58, 0.14) 1.2px, transparent 1.4px) 0 0 / 18px 18px,
var(--momo-bg-surface);
text-align: center;
}
.abc-loading-spinner {
width: 36px;
height: 36px;
border: 3px solid var(--momo-border-light);
border-top-color: var(--momo-page-accent-dark);
border-radius: 50%;
animation: abc-spin 0.9s linear infinite;
}
@keyframes abc-spin {
to { transform: rotate(360deg); }
}
@media (max-width: 900px) {
.abc-detail-header {
grid-template-columns: 1fr;
align-items: start;
padding: var(--momo-space-4);
}
.abc-detail-actions {
justify-content: flex-start;
}
}
@media (max-width: 640px) {
.abc-detail-actions,
.abc-factor-control {
width: 100%;
}
.abc-detail-actions .btn,
.abc-factor-control .btn {
flex: 1 1 auto;
}
}

View File

@@ -0,0 +1,370 @@
.vendor-tools-page {
display: grid;
gap: var(--momo-space-5);
color: var(--momo-text-primary);
}
.vendor-tools-page .container,
.vendor-tools-page .container-fluid {
max-width: 100%;
padding: 0 !important;
}
.vendor-tools-kicker {
display: inline-flex;
align-items: center;
gap: 8px;
margin-bottom: var(--momo-space-2);
color: var(--momo-page-accent-dark);
font-family: var(--momo-font-mono);
font-size: var(--momo-text-label);
font-weight: 700;
letter-spacing: 0.04em;
}
.vendor-tools-header {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: var(--momo-space-4);
align-items: end;
padding: var(--momo-space-5);
border: 1px solid var(--momo-border-light);
border-radius: var(--momo-radius-lg);
background:
radial-gradient(circle at 14px 14px, rgba(172, 92, 58, 0.16) 1.2px, transparent 1.4px) 0 0 / 18px 18px,
var(--momo-bg-surface);
}
.vendor-tools-header h1 {
margin: 0;
font-family: var(--momo-font-display);
font-size: clamp(1.55rem, 2.2vw, 2.2rem);
font-weight: 800;
letter-spacing: 0;
color: var(--momo-text-primary);
}
.vendor-tools-header p {
max-width: 760px;
margin: var(--momo-space-2) 0 0;
color: var(--momo-text-secondary);
line-height: 1.65;
}
.vendor-tools-nav {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
}
.vendor-tools-nav a {
display: inline-flex;
align-items: center;
gap: 7px;
min-height: 36px;
padding: 7px 11px;
border: 1px solid var(--momo-border-light);
border-radius: var(--momo-radius-sm);
background: var(--momo-bg-elevated);
color: var(--momo-text-secondary);
font-family: var(--momo-font-display);
font-size: var(--momo-text-body-sm);
font-weight: 650;
text-decoration: none;
}
.vendor-tools-nav a:hover,
.vendor-tools-nav a.is-active {
border-color: var(--momo-page-accent-line);
background: var(--momo-page-accent-soft);
color: var(--momo-page-accent-dark);
}
.vendor-tools-body {
display: grid;
gap: var(--momo-space-4);
}
.vendor-tools-page h2,
.vendor-tools-page h3,
.vendor-tools-page h4,
.vendor-tools-page h5,
.vendor-tools-page h6 {
color: var(--momo-text-primary);
font-family: var(--momo-font-display);
letter-spacing: 0;
}
.vendor-tools-page h2 {
font-size: 1.28rem;
font-weight: 800;
}
.vendor-tools-page .section-tabs,
.vendor-tools-page .table-container,
.vendor-tools-page .stats-card,
.vendor-tools-page .card {
border: 1px solid var(--momo-border-light) !important;
border-radius: var(--momo-radius-md) !important;
background: var(--momo-bg-surface) !important;
box-shadow: none !important;
}
.vendor-tools-page .section-tabs {
overflow: hidden;
margin-bottom: var(--momo-space-4);
}
.vendor-tools-page .section-tabs .nav-link {
border: 0;
border-radius: 0;
padding: 0.82rem 1rem;
color: var(--momo-text-secondary);
font-family: var(--momo-font-display);
font-weight: 700;
}
.vendor-tools-page .section-tabs .nav-link.active {
background: var(--momo-text-primary) !important;
color: var(--momo-bg-paper) !important;
}
.vendor-tools-page .section-tabs .nav-link:hover:not(.active) {
background: var(--momo-bg-paper);
color: var(--momo-text-primary);
}
.vendor-tools-page .stats-card {
padding: var(--momo-space-4);
min-height: 118px;
}
.vendor-tools-page .stats-number,
.vendor-tools-page h3[id^="stat"] {
color: var(--momo-text-primary) !important;
font-family: var(--momo-font-mono);
font-size: clamp(1.45rem, 2vw, 1.95rem) !important;
font-weight: 800 !important;
letter-spacing: 0;
}
.vendor-tools-page .table-container {
overflow: hidden;
}
.vendor-tools-page .table-container .table-responsive {
max-width: 100%;
overflow-x: auto;
}
.vendor-tools-page .table {
min-width: 860px;
}
.vendor-tools-page .table tbody tr:hover {
transform: none !important;
}
.vendor-tools-page .email-badge,
.vendor-tools-page .status-badge {
display: inline-flex;
align-items: center;
min-height: 24px;
margin: 2px;
padding: 3px 8px;
border: 1px solid var(--momo-border-light);
border-radius: var(--momo-radius-sm);
background: var(--momo-bg-paper);
color: var(--momo-text-secondary);
font-family: var(--momo-font-mono);
font-size: 0.78rem;
font-weight: 700;
}
.vendor-tools-page .status-sent {
border-color: var(--momo-success-border);
background: var(--momo-success-bg);
color: var(--momo-success-text);
}
.vendor-tools-page .status-failed {
border-color: var(--momo-danger-border);
background: var(--momo-danger-bg);
color: var(--momo-danger-text);
}
.vendor-tools-page .status-pending {
border-color: var(--momo-warning-border);
background: var(--momo-warning-bg);
color: var(--momo-warning-text);
}
.vendor-tools-page .upload-area {
border: 1px dashed var(--momo-page-accent-line) !important;
border-radius: var(--momo-radius-md) !important;
padding: clamp(1.2rem, 4vw, 2.6rem) !important;
background:
radial-gradient(circle at 10px 10px, rgba(172, 92, 58, 0.13) 1.1px, transparent 1.3px) 0 0 / 16px 16px,
var(--momo-bg-elevated) !important;
cursor: pointer;
}
.vendor-tools-page .upload-area:hover,
.vendor-tools-page .upload-area.dragover {
border-color: var(--momo-page-accent-dark) !important;
background-color: var(--momo-page-accent-soft) !important;
transform: none !important;
}
.vendor-tools-page .upload-icon {
color: var(--momo-page-accent-dark) !important;
}
.vendor-tools-page .modal-header {
border-bottom: 1px solid var(--momo-border-light);
background: var(--momo-text-primary) !important;
color: var(--momo-bg-paper) !important;
}
.vendor-tools-page .modal-content {
border: 1px solid var(--momo-border-light);
border-radius: var(--momo-radius-md);
background: var(--momo-bg-surface);
}
.vendor-tools-page .alert-info {
border-color: var(--momo-info-border);
background: var(--momo-info-bg);
color: var(--momo-info-text);
}
.vendor-tools-page .alert-danger {
border-color: var(--momo-danger-border);
background: var(--momo-danger-bg);
color: var(--momo-danger-text);
}
@media (max-width: 900px) {
.vendor-tools-header {
grid-template-columns: 1fr;
align-items: start;
padding: var(--momo-space-4);
}
.vendor-tools-nav {
justify-content: flex-start;
}
}
@media (max-width: 760px) {
.vendor-tools-page {
gap: var(--momo-space-4);
}
.vendor-tools-header {
border-radius: var(--momo-radius-md);
}
.vendor-tools-nav a {
flex: 1 1 calc(50% - 8px);
justify-content: center;
}
.vendor-tools-page .row {
row-gap: var(--momo-space-3);
}
.vendor-tools-page .table-container {
padding: 10px;
}
.vendor-tools-page .table {
min-width: 0;
}
.vendor-tools-page .table-container .table-responsive {
overflow-x: visible;
}
.vendor-tools-page .table-container table,
.vendor-tools-page .table-container tbody,
.vendor-tools-page .table-container tr,
.vendor-tools-page .table-container td {
display: block;
width: 100%;
}
.vendor-tools-page .table-container thead {
display: none;
}
.vendor-tools-page .table-container tbody {
display: grid;
gap: 10px;
}
.vendor-tools-page .table-container tbody tr {
overflow: hidden;
border: 1px solid var(--momo-border-light);
border-radius: var(--momo-radius-sm);
background: var(--momo-bg-elevated);
}
.vendor-tools-page .table-container tbody td {
display: grid;
grid-template-columns: 5.8rem minmax(0, 1fr);
gap: 10px;
min-height: 40px;
padding: 10px 12px;
border-bottom: 1px solid var(--momo-border-light);
text-align: left !important;
overflow-wrap: anywhere;
}
.vendor-tools-page .table-container tbody td:last-child {
border-bottom: 0;
}
.vendor-tools-page .table-container tbody td::before {
color: var(--momo-text-tertiary);
font-family: var(--momo-font-mono);
font-size: 0.74rem;
font-weight: 800;
}
.vendor-tools-page .table-container tbody td[colspan] {
display: block;
}
.vendor-tools-page .table-container tbody td[colspan]::before {
content: none;
}
.vendor-tools-page[data-vendor-tool="management"] .table-container tbody td:nth-child(1)::before { content: "代碼"; }
.vendor-tools-page[data-vendor-tool="management"] .table-container tbody td:nth-child(2)::before { content: "廠商"; }
.vendor-tools-page[data-vendor-tool="management"] .table-container tbody td:nth-child(3)::before { content: "郵件"; }
.vendor-tools-page[data-vendor-tool="management"] .table-container tbody td:nth-child(4)::before { content: "數量"; }
.vendor-tools-page[data-vendor-tool="management"] .table-container tbody td:nth-child(5)::before { content: "操作"; }
.vendor-tools-page[data-vendor-tool="email"] .table-container tbody td:nth-child(1)::before { content: "ID"; }
.vendor-tools-page[data-vendor-tool="email"] .table-container tbody td:nth-child(2)::before { content: "批次"; }
.vendor-tools-page[data-vendor-tool="email"] .table-container tbody td:nth-child(3)::before { content: "廠商"; }
.vendor-tools-page[data-vendor-tool="email"] .table-container tbody td:nth-child(4)::before { content: "收件"; }
.vendor-tools-page[data-vendor-tool="email"] .table-container tbody td:nth-child(5)::before { content: "主旨"; }
.vendor-tools-page[data-vendor-tool="email"] .table-container tbody td:nth-child(6)::before { content: "商品"; }
.vendor-tools-page[data-vendor-tool="email"] .table-container tbody td:nth-child(7)::before { content: "狀態"; }
.vendor-tools-page[data-vendor-tool="email"] .table-container tbody td:nth-child(8)::before { content: "時間"; }
}
@media (max-width: 520px) {
.vendor-tools-nav a {
flex-basis: 100%;
}
.vendor-tools-page .d-flex.justify-content-between {
align-items: stretch !important;
flex-direction: column;
gap: 10px;
}
}