快取業績分析頁面資料
Some checks failed
CD Pipeline / deploy (push) Has been cancelled

This commit is contained in:
OoO
2026-05-13 11:56:08 +08:00
parent d02b712439
commit 72daa53040
3 changed files with 316 additions and 187 deletions

View File

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

View File

@@ -26,6 +26,8 @@ from services.logger_manager import SystemLogger
from services.daily_sales_service import prepare_marketing_summary
from services.cache_manager import (
_SALES_PROCESSED_CACHE,
_SALES_OPTIONS_CACHE,
_SALES_ANALYSIS_RESULT_CACHE,
set_sales_processed_cache,
)
from utils.text_helpers import get_color_for_string
@@ -41,6 +43,10 @@ sales_bp = Blueprint('sales', __name__)
_TABLE_DATA_CACHE = {}
_TABLE_DATA_CACHE_TTL = 60
_SALES_PREVIEW_CACHE_TTL = 600
_SALES_OPTIONS_CACHE_TTL = 1800
_SALES_PAGE_CONTEXT_CACHE_TTL = 180
_SALES_PAGE_CONTEXT_CACHE_MAX = 24
# ==========================================
@@ -52,6 +58,214 @@ from utils.df_helpers import find_col # noqa: E402, F401
from utils.security import validate_table_name, safe_read_sql # noqa: E402, F401
def _get_timed_cache(store, key, ttl_seconds):
entry = store.get(key)
if not entry:
return None
if time.time() - entry.get('time', 0) >= ttl_seconds:
store.pop(key, None)
return None
return entry.get('data')
def _set_timed_cache(store, key, data, max_entries=64):
if len(store) >= max_entries:
oldest_key = min(store, key=lambda k: store[k].get('time', 0))
store.pop(oldest_key, None)
store[key] = {'data': data, 'time': time.time()}
def _sales_analysis_args_fingerprint(table_name, cache_key, processed_time=None):
args = tuple(
(key, tuple(values))
for key, values in sorted(request.args.lists())
)
raw = repr((table_name, cache_key, processed_time or 0, args)).encode('utf-8')
return hashlib.md5(raw).hexdigest()
def _get_sales_page_context_cache(cache_key):
return _get_timed_cache(
_SALES_ANALYSIS_RESULT_CACHE,
cache_key,
_SALES_PAGE_CONTEXT_CACHE_TTL,
)
def _set_sales_page_context_cache(cache_key, context):
_set_timed_cache(
_SALES_ANALYSIS_RESULT_CACHE,
cache_key,
context,
max_entries=_SALES_PAGE_CONTEXT_CACHE_MAX,
)
def _format_sales_data_range(min_date, max_date):
if not min_date or not max_date:
return ''
try:
if isinstance(min_date, str):
min_date_obj = datetime.strptime(min_date.split()[0], '%Y-%m-%d')
max_date_obj = datetime.strptime(max_date.split()[0], '%Y-%m-%d')
else:
min_date_obj = min_date
max_date_obj = max_date
return f"{min_date_obj.year}{min_date_obj.month}月 ~ {max_date_obj.year}{max_date_obj.month}"
except Exception:
return f"{min_date} ~ {max_date}"
def _fetch_sales_data_range(engine, table_name):
validate_table_name(table_name)
cache_key = f"sales_analysis:data_range:{table_name}"
cached = _get_timed_cache(_SALES_OPTIONS_CACHE, cache_key, _SALES_PREVIEW_CACHE_TTL)
if cached is not None:
return cached
try:
date_query = text(f'SELECT MIN("日期") as min_date, MAX("日期") as max_date FROM "{table_name}"')
with engine.connect() as conn:
result = conn.execute(date_query).fetchone()
db_data_range = _format_sales_data_range(result[0], result[1]) if result else ''
except Exception as e:
sys_log.warning(f"[Sales Analysis] 無法取得資料期間範圍: {e}")
db_data_range = ''
_set_timed_cache(_SALES_OPTIONS_CACHE, cache_key, db_data_range)
return db_data_range
def _preview_sales_filter_options(engine, table_name):
"""首次進入業績分析時的輕量下拉資料,避免每次載入都掃預覽資料。"""
validate_table_name(table_name)
cache_key = f"sales_analysis:preview_options:{table_name}"
cached = _get_timed_cache(_SALES_OPTIONS_CACHE, cache_key, _SALES_PREVIEW_CACHE_TTL)
if cached:
return cached
options = {
'categories': [],
'brands': [],
'vendors': [],
'activities': [],
'payments': [],
'months': [],
}
preview_df = pd.read_sql(f'SELECT * FROM "{table_name}" LIMIT 1000', engine)
if preview_df.empty:
_set_timed_cache(_SALES_OPTIONS_CACHE, cache_key, options)
return options
cols = preview_df.columns.tolist()
def find_preview_col(keywords):
for k in keywords:
for col in cols:
if k in str(col):
return col
return None
col_category = find_preview_col(['館別', '商品館', '分類', 'Category'])
col_brand = find_preview_col(['品牌', 'Brand'])
col_vendor = find_preview_col(['廠商名稱', 'Vendor Name', '廠商', '供應商', 'Vendor', 'Supplier'])
col_activity = find_preview_col(['折扣活動名稱', '折價券活動名稱', '滿額再折扣活動名稱', '活動', 'Activity', 'Campaign'])
col_payment = find_preview_col(['付款', 'Payment', 'Pay'])
col_date_part = find_preview_col(['日期', '交易日期', 'Date', 'Day'])
def clean_values(col):
if not col:
return []
return sorted([
x for x in preview_df[col].dropna().astype(str).unique().tolist()
if x and x.strip()
])
options['categories'] = clean_values(col_category)
options['brands'] = clean_values(col_brand)
options['vendors'] = clean_values(col_vendor)
options['activities'] = clean_values(col_activity)
options['payments'] = clean_values(col_payment)
if col_date_part:
try:
with engine.connect() as conn:
result = conn.execute(text(f"""
SELECT DISTINCT replace(substr("{col_date_part}", 1, 7), '/', '-') as month
FROM "{table_name}"
WHERE "{col_date_part}" IS NOT NULL AND "{col_date_part}" != ''
ORDER BY month
""")).fetchall()
options['months'] = [row[0] for row in result if row[0] and '-' in str(row[0])]
sys_log.info(f"[Sales Analysis] 預覽模式從「{col_date_part}」欄位提取到 {len(options['months'])} 個月份")
except Exception as e:
sys_log.warning(f"[Sales Analysis] 無法從「{col_date_part}」欄位提取月份: {e}")
_set_timed_cache(_SALES_OPTIONS_CACHE, cache_key, options)
return options
def _get_sales_filter_options(engine, table_name, cols_map):
"""完整篩選選項共用快取;資料匯入後 clear_sales_cache() 會一起清掉。"""
validate_table_name(table_name)
col_category = cols_map.get('category')
col_brand = cols_map.get('brand')
col_vendor = cols_map.get('vendor')
col_activity = cols_map.get('activity')
col_payment = cols_map.get('payment')
col_date = cols_map.get('date')
option_cols = (col_category, col_brand, col_vendor, col_activity, col_payment, col_date)
digest = hashlib.md5(repr(option_cols).encode('utf-8')).hexdigest()
cache_key = f"sales_analysis:filter_options:{table_name}:{digest}"
cached = _get_timed_cache(_SALES_OPTIONS_CACHE, cache_key, _SALES_OPTIONS_CACHE_TTL)
if cached:
return cached
options = {
'categories': [],
'brands': [],
'vendors': [],
'activities': [],
'payments': [],
'months': [],
}
def read_distinct(conn, col):
if not col:
return []
sql = f'SELECT DISTINCT "{col}" FROM "{table_name}" WHERE "{col}" IS NOT NULL AND "{col}" <> \'\' ORDER BY "{col}"'
result = conn.execute(text(sql)).fetchall()
return [str(row[0]) for row in result if row[0]]
with engine.connect() as conn:
options['categories'] = read_distinct(conn, col_category)
options['brands'] = read_distinct(conn, col_brand)
options['vendors'] = read_distinct(conn, col_vendor)
options['activities'] = read_distinct(conn, col_activity)
options['payments'] = read_distinct(conn, col_payment)
if col_date:
date_fields = ['日期', '訂單日期', '時間']
for field in date_fields:
try:
result = conn.execute(text(f"""
SELECT DISTINCT replace(substr("{field}", 1, 7), '/', '-') as month
FROM "{table_name}"
WHERE "{field}" IS NOT NULL AND "{field}" != ''
ORDER BY month
""")).fetchall()
months = [row[0] for row in result if row[0] and '-' in str(row[0])]
if months:
options['months'] = months
sys_log.info(f"[Sales Analysis] 從欄位 {field} 提取到 {len(months)} 個月份: {months}")
break
except Exception as ex:
sys_log.warning(f"[Sales Analysis] 從欄位 {field} 提取月份失敗: {ex}")
_set_timed_cache(_SALES_OPTIONS_CACHE, cache_key, options)
return options
def _growth_empty_payload(now_taipei=None):
now_taipei = now_taipei or datetime.now(TAIPEI_TZ)
return (
@@ -558,7 +772,7 @@ def sales_analysis():
# 1. 檢查資料表是否存在
inspector = inspect(db.engine)
if table_name not in inspector.get_table_names():
if not inspector.has_table(table_name):
return render_template('sales_analysis.html',
error="尚未匯入「即時業績(全月)」資料,請先至設定頁面匯入 Excel。",
table_name=table_name,
@@ -571,31 +785,8 @@ def sales_analysis():
active_page='sales',
db_data_range='')
# V-New: 查詢資料庫的資料期間範圍
db_data_range = ''
try:
# 取得日期欄位的最小值和最大值
from sqlalchemy import text
date_query = text(f"SELECT MIN(日期) as min_date, MAX(日期) as max_date FROM {table_name}")
# V-Fix: SQLAlchemy 2.0 需要使用 connection 對象
with db.engine.connect() as conn:
result = conn.execute(date_query).fetchone()
if result and result[0] and result[1]:
min_date = result[0]
max_date = result[1]
# 格式化為 YYYY年MM月 格式
if isinstance(min_date, str):
from datetime import datetime
try:
min_date_obj = datetime.strptime(min_date.split()[0], '%Y-%m-%d')
max_date_obj = datetime.strptime(max_date.split()[0], '%Y-%m-%d')
db_data_range = f"{min_date_obj.year}{min_date_obj.month}月 ~ {max_date_obj.year}{max_date_obj.month}"
except:
db_data_range = f"{min_date} ~ {max_date}"
else:
db_data_range = f"{min_date.year}{min_date.month}月 ~ {max_date.year}{max_date.month}"
except Exception as e:
sys_log.warning(f"[Sales Analysis] 無法取得資料期間範圍: {e}")
# V-Opt: 資料期間在同一批匯入後穩定,避免每次首屏都查 MIN/MAX。
db_data_range = _fetch_sales_data_range(db.engine, table_name)
# V-New: 取得篩選參數
data_range_param = request.args.get('data_range', '') # 不再設預設值
@@ -605,63 +796,13 @@ def sales_analysis():
# V-New: 按需載入 - 如果沒有任何篩選條件,顯示引導頁面
if not data_range_param and not start_date and not end_date:
sys_log.info("[Sales Analysis] 👋 首次進入頁面,等待用戶選擇篩選條件")
# V-Fix: 即使在引導頁面,也要提供下拉選單選項
# V-Opt: 讀取較多筆數1000筆以獲得更完整的月份資訊
preview_df = pd.read_sql(f"SELECT * FROM {table_name} LIMIT 1000", db.engine)
preview_categories = []
preview_brands = []
preview_vendors = []
preview_activities = []
preview_payments = []
preview_months = [] # V-New: 新增月份列表
if not preview_df.empty:
cols = preview_df.columns.tolist()
def find_col(keywords):
for k in keywords:
for col in cols:
if k in str(col): return col
return None
col_category = find_col(['館別', '商品館', '分類', 'Category'])
col_brand = find_col(['品牌', 'Brand'])
col_vendor = find_col(['廠商名稱', 'Vendor Name', '廠商', '供應商', 'Vendor', 'Supplier'])
# V-Fix: 優先匹配具體的活動欄位名稱
col_activity = find_col(['折扣活動名稱', '折價券活動名稱', '滿額再折扣活動名稱', '活動', 'Activity', 'Campaign'])
col_payment = find_col(['付款', 'Payment', 'Pay'])
# V-Fix: 優先匹配「日期」欄位(「訂單日期」是固定文字,不是實際日期)
col_date_part = find_col(['日期', '交易日期', 'Date', 'Day'])
col_time_part = find_col(['訂單時間', '成立時間', '下單時間', '購買時間', '時間', 'Time', 'Created'])
# V-Fix: 篩選掉空字串,只保留有效數據
if col_category:
preview_categories = sorted([x for x in preview_df[col_category].dropna().astype(str).unique().tolist() if x and x.strip()])
if col_brand:
preview_brands = sorted([x for x in preview_df[col_brand].dropna().astype(str).unique().tolist() if x and x.strip()])
if col_vendor:
preview_vendors = sorted([x for x in preview_df[col_vendor].dropna().astype(str).unique().tolist() if x and x.strip()])
if col_activity:
preview_activities = sorted([x for x in preview_df[col_activity].dropna().astype(str).unique().tolist() if x and x.strip()])
if col_payment:
preview_payments = sorted([x for x in preview_df[col_payment].dropna().astype(str).unique().tolist() if x and x.strip()])
# V-Fix: 從數據庫直接查詢所有月份(而不是從預覽數據提取,避免只獲得部分月份)
if col_date_part:
try:
from sqlalchemy import text
with db.engine.connect() as conn:
result = conn.execute(text(f"""
SELECT DISTINCT replace(substr("{col_date_part}", 1, 7), '/', '-') as month
FROM {table_name}
WHERE "{col_date_part}" IS NOT NULL AND "{col_date_part}" != ''
ORDER BY month
""")).fetchall()
preview_months = [row[0] for row in result if row[0] and '-' in str(row[0])]
sys_log.info(f"[Sales Analysis] 預覽模式從「{col_date_part}」欄位提取到 {len(preview_months)} 個月份")
except Exception as e:
sys_log.warning(f"[Sales Analysis] 無法從「{col_date_part}」欄位提取月份: {e}")
pass
preview_options = _preview_sales_filter_options(db.engine, table_name)
preview_categories = preview_options['categories']
preview_brands = preview_options['brands']
preview_vendors = preview_options['vendors']
preview_activities = preview_options['activities']
preview_payments = preview_options['payments']
preview_months = preview_options['months']
# 傳遞必要的變數以避免模板錯誤
selected_metric = request.args.get('metric', 'amount')
@@ -1020,6 +1161,16 @@ def sales_analysis():
}
set_sales_processed_cache(cache_key, cache_entry, aliases=(table_name,))
processed_entry = _SALES_PROCESSED_CACHE.get(cache_key, {})
page_cache_key = "sales_analysis:page_context:" + _sales_analysis_args_fingerprint(
table_name,
cache_key,
processed_entry.get('time'),
)
cached_context = _get_sales_page_context_cache(page_cache_key)
if cached_context:
return render_template('sales_analysis.html', **cached_context)
# 🚩 V-Opt: 使用共用篩選函式
target_df, cols_map, err = _get_filtered_sales_data(cache_key)
if err:
@@ -1043,9 +1194,6 @@ def sales_analysis():
col_date = cols_map.get('date')
col_pid = cols_map.get('pid')
# V-Fix: 準備前端需要的下拉選單資料
# V-Opt: 從數據庫直接查詢所有可用選項,而不是只從篩選後的快取中讀取
# 這樣可以確保下拉選單顯示完整的可選項,即使當前篩選範圍很小
all_categories = []
all_brands = []
all_vendors = []
@@ -1054,58 +1202,13 @@ def sales_analysis():
all_months = []
try:
from sqlalchemy import text
# V-Fix: SQLAlchemy 2.0 需要使用 connection 對象
with db.engine.connect() as conn:
# 讀取完整資料表的所有可用選項(使用 DISTINCT 以提升效能)
# V-Fix: 使用單引號空字串,兼容 PostgreSQL
if col_category:
sql = f"SELECT DISTINCT \"{col_category}\" FROM {table_name} WHERE \"{col_category}\" IS NOT NULL AND \"{col_category}\" <> ''"
result = conn.execute(text(sql)).fetchall()
all_categories = sorted([str(row[0]) for row in result if row[0]])
if col_brand:
sql = f"SELECT DISTINCT \"{col_brand}\" FROM {table_name} WHERE \"{col_brand}\" IS NOT NULL AND \"{col_brand}\" <> ''"
result = conn.execute(text(sql)).fetchall()
all_brands = sorted([str(row[0]) for row in result if row[0]])
if col_vendor:
sql = f"SELECT DISTINCT \"{col_vendor}\" FROM {table_name} WHERE \"{col_vendor}\" IS NOT NULL AND \"{col_vendor}\" <> ''"
result = conn.execute(text(sql)).fetchall()
all_vendors = sorted([str(row[0]) for row in result if row[0]])
if col_activity:
sql = f"SELECT DISTINCT \"{col_activity}\" FROM {table_name} WHERE \"{col_activity}\" IS NOT NULL AND \"{col_activity}\" <> ''"
result = conn.execute(text(sql)).fetchall()
all_activities = sorted([str(row[0]) for row in result if row[0]])
if col_payment:
sql = f"SELECT DISTINCT \"{col_payment}\" FROM {table_name} WHERE \"{col_payment}\" IS NOT NULL AND \"{col_payment}\" <> ''"
result = conn.execute(text(sql)).fetchall()
all_payments = sorted([str(row[0]) for row in result if row[0]])
# V-Fix: 從數據庫提取所有月份格式YYYY-MM
if col_date:
# 從日期欄位提取月份(支援多種日期欄位名稱)
date_fields = ['日期', '訂單日期', '時間']
for field in date_fields:
try:
# V-Fix: 使用 substr 提取年月部分,並將斜線替換為橫線
# 數據庫格式: "2025/07/01" -> 提取前7個字符 "2025/07" -> 替換斜線 "2025-07"
result = conn.execute(text(f"""
SELECT DISTINCT replace(substr(\"{field}\", 1, 7), '/', '-') as month
FROM {table_name}
WHERE \"{field}\" IS NOT NULL AND \"{field}\" != ''
ORDER BY month
""")).fetchall()
if result and len(result) > 0:
all_months = [row[0] for row in result if row[0] and '-' in str(row[0])]
if all_months: # 如果成功提取到月份,就使用這個欄位
sys_log.info(f"[Sales Analysis] 從欄位 {field} 提取到 {len(all_months)} 個月份: {all_months}")
break
except Exception as ex:
sys_log.warning(f"[Sales Analysis] 從欄位 {field} 提取月份失敗: {ex}")
continue
filter_options = _get_sales_filter_options(db.engine, table_name, cols_map)
all_categories = filter_options['categories']
all_brands = filter_options['brands']
all_vendors = filter_options['vendors']
all_activities = filter_options['activities']
all_payments = filter_options['payments']
all_months = filter_options['months']
except Exception as e:
sys_log.warning(f"[Sales Analysis] 從數據庫查詢下拉選項失敗: {e}")
# 如果查詢失敗,回退到從快取讀取
@@ -1531,54 +1634,78 @@ def sales_analysis():
if not target_df.empty:
marketing_data = prepare_marketing_summary(target_df, sort_by=selected_metric)
return render_template('sales_analysis.html',
marketing_data=marketing_data, # V-New: 傳遞行銷活動數據
items=table_items,
kpi={
'revenue': total_revenue,
'qty': total_qty,
'count': total_count,
'sku_count': sku_count, # V-Fix 2026-01-15: 唯一商品數
'cost': total_cost,
'gross_margin': gross_margin,
'gross_margin_rate': gross_margin_rate,
'avg_price': avg_price
},
insights=insights,
abc_stats=abc_stats, # V-New: 傳遞 ABC 分析數據
vendor_stats=vendor_stats, # V-New: 傳遞廠商排行數據
seasonality_data=seasonality_data, # V-New: 傳遞淡旺季數據
bar_data=bar_data,
cat_data=cat_data,
category_data=cat_data,
price_dist_data=price_dist_data,
scatter_data=scatter_data,
bcg_data=bcg_data, # V-New: 傳遞 BCG 數據
dow_data=dow_data,
hourly_data=hourly_data,
monthly_data=monthly_data,
weekly_data=weekly_data,
heatmap_data=heatmap_data,
treemap_data=treemap_data,
all_categories=all_categories,
all_brands=all_brands, all_vendors=all_vendors, all_activities=all_activities, all_payments=all_payments,
all_months=all_months, # V-New: 傳遞月份列表
selected_category=selected_category,
selected_brand=selected_brand, selected_vendor=selected_vendor,
selected_activity=selected_activity, selected_payment=selected_payment,
selected_dow=selected_dow, selected_hour=selected_hour,
selected_month=selected_month,
selected_metric=selected_metric,
keyword=keyword, min_price=min_price, max_price=max_price,
min_margin=min_margin, max_margin=max_margin,
cols={'name': col_name, 'amount': col_amount, 'qty': col_qty, 'cat': col_category, 'date': col_date, 'cost': col_cost, 'profit': col_profit, 'vendor': col_vendor, 'brand': col_brand, 'return_qty': col_return_qty, 'pid': col_pid},
table_name=table_name,
data_range_months=data_range_months,
start_date=start_date, # V-New: 傳遞自訂開始日期
end_date=end_date, # V-New: 傳遞自訂結束日期
total_records=len(df),
active_page='sales',
db_data_range=db_data_range) # V-New: 傳遞資料庫資料期間
context = {
'marketing_data': marketing_data,
'items': table_items,
'kpi': {
'revenue': total_revenue,
'qty': total_qty,
'count': total_count,
'sku_count': sku_count,
'cost': total_cost,
'gross_margin': gross_margin,
'gross_margin_rate': gross_margin_rate,
'avg_price': avg_price,
},
'insights': insights,
'abc_stats': abc_stats,
'vendor_stats': vendor_stats,
'seasonality_data': seasonality_data,
'bar_data': bar_data,
'cat_data': cat_data,
'category_data': cat_data,
'price_dist_data': price_dist_data,
'scatter_data': scatter_data,
'bcg_data': bcg_data,
'dow_data': dow_data,
'hourly_data': hourly_data,
'monthly_data': monthly_data,
'weekly_data': weekly_data,
'heatmap_data': heatmap_data,
'treemap_data': treemap_data,
'all_categories': all_categories,
'all_brands': all_brands,
'all_vendors': all_vendors,
'all_activities': all_activities,
'all_payments': all_payments,
'all_months': all_months,
'selected_category': selected_category,
'selected_brand': selected_brand,
'selected_vendor': selected_vendor,
'selected_activity': selected_activity,
'selected_payment': selected_payment,
'selected_dow': selected_dow,
'selected_hour': selected_hour,
'selected_month': selected_month,
'selected_metric': selected_metric,
'keyword': keyword,
'min_price': min_price,
'max_price': max_price,
'min_margin': min_margin,
'max_margin': max_margin,
'cols': {
'name': col_name,
'amount': col_amount,
'qty': col_qty,
'cat': col_category,
'date': col_date,
'cost': col_cost,
'profit': col_profit,
'vendor': col_vendor,
'brand': col_brand,
'return_qty': col_return_qty,
'pid': col_pid,
},
'table_name': table_name,
'data_range_months': data_range_months,
'start_date': start_date,
'end_date': end_date,
'total_records': len(df),
'active_page': 'sales',
'db_data_range': db_data_range,
}
_set_sales_page_context_cache(page_cache_key, context)
return render_template('sales_analysis.html', **context)
except Exception as e:
sys_log.error(f"Sales Analysis Error: {e}")

View File

@@ -847,11 +847,13 @@
{% endblock %}
{% block extra_js %}
{% if not no_filter %}
<script src="https://cdn.jsdelivr.net/npm/chart.js@3.9.1/dist/chart.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chartjs-chart-treemap@2.0.2/dist/chartjs-chart-treemap.min.js"></script>
<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>
{% endif %}
<script src="https://cdn.jsdelivr.net/npm/flatpickr@4.6.13/dist/flatpickr.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/flatpickr@4.6.13/dist/l10n/zh-tw.js"></script>