# -*- coding: utf-8 -*-
"""HTML 报告生成器 - Vue 3 + shadcn-vue 风格组件 + violet 主题

生成的静态页面使用:
- Vue 3 (global build CDN)
- Tailwind CSS (Play CDN) + shadcn-vue violet CSS 变量
- Chart.js v4 (折线图)
组件样式复刻 shadcn-vue: Card / Table / Badge / Button / Dialog
移动端适配: ≤640px 表格转卡片布局、弹窗接近全屏、深色模式持久化 (localStorage)
"""

from __future__ import annotations

import json
import os

from . import config

# CDN 资源
VUE_CDN = "https://unpkg.com/vue@3/dist/vue.global.prod.js"
TAILWIND_CDN = "https://cdn.tailwindcss.com"
CHART_JS_CDN = "https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"

# shadcn-vue violet 主题 CSS 变量 (HSL channels, 不含 hsl())
# 来源: shadcn-vue 官方 violet 主题
VIOLET_THEME_CSS = """
:root {
  --background: 0 0% 100%;
  --foreground: 224 71.4% 4.1%;
  --card: 0 0% 100%;
  --card-foreground: 224 71.4% 4.1%;
  --popover: 0 0% 100%;
  --popover-foreground: 224 71.4% 4.1%;
  --primary: 262.1 83.3% 57.8%;
  --primary-foreground: 210 20% 98%;
  --secondary: 220 14.3% 95.9%;
  --secondary-foreground: 220.9 39.3% 11%;
  --muted: 220 14.3% 95.9%;
  --muted-foreground: 220 8.9% 46.1%;
  --accent: 220 14.3% 95.9%;
  --accent-foreground: 220.9 39.3% 11%;
  --destructive: 0 84.2% 60.2%;
  --destructive-foreground: 210 20% 98%;
  --border: 220 13% 91%;
  --input: 220 13% 91%;
  --ring: 262.1 83.3% 57.8%;
  --radius: 0.5rem;
}
.dark {
  --background: 224 71.4% 4.1%;
  --foreground: 210 20% 98%;
  --card: 224 71.4% 4.1%;
  --card-foreground: 210 20% 98%;
  --popover: 224 71.4% 4.1%;
  --popover-foreground: 210 20% 98%;
  --primary: 263.4 70% 50.4%;
  --primary-foreground: 210 20% 98%;
  --secondary: 215 27.9% 16.9%;
  --secondary-foreground: 210 20% 98%;
  --muted: 215 27.9% 16.9%;
  --muted-foreground: 217.9 10.6% 64.9%;
  --accent: 215 27.9% 16.9%;
  --accent-foreground: 210 20% 98%;
  --destructive: 0 62.8% 30.6%;
  --destructive-foreground: 210 20% 98%;
  --border: 215 27.9% 16.9%;
  --input: 215 27.9% 16.9%;
  --ring: 263.4 70% 50.4%;
}
"""

# Tailwind 配置 - 扩展 shadcn-vue 语义色
TAILWIND_CONFIG = """
tailwind.config = {
  darkMode: 'class',
  theme: {
    extend: {
      colors: {
        border: 'hsl(var(--border))',
        input: 'hsl(var(--input))',
        ring: 'hsl(var(--ring))',
        background: 'hsl(var(--background))',
        foreground: 'hsl(var(--foreground))',
        primary: { DEFAULT: 'hsl(var(--primary))', foreground: 'hsl(var(--primary-foreground))' },
        secondary: { DEFAULT: 'hsl(var(--secondary))', foreground: 'hsl(var(--secondary-foreground))' },
        destructive: { DEFAULT: 'hsl(var(--destructive))', foreground: 'hsl(var(--destructive-foreground))' },
        muted: { DEFAULT: 'hsl(var(--muted))', foreground: 'hsl(var(--muted-foreground))' },
        accent: { DEFAULT: 'hsl(var(--accent))', foreground: 'hsl(var(--accent-foreground))' },
        popover: { DEFAULT: 'hsl(var(--popover))', foreground: 'hsl(var(--popover-foreground))' },
        card: { DEFAULT: 'hsl(var(--card))', foreground: 'hsl(var(--card-foreground))' },
      },
      borderRadius: {
        lg: 'var(--radius)',
        md: 'calc(var(--radius) - 2px)',
        sm: 'calc(var(--radius) - 4px)',
      },
      fontFamily: {
        sans: ['-apple-system', 'BlinkMacSystemFont', 'Segoe UI', 'Roboto', 'Helvetica Neue', 'Microsoft YaHei', 'sans-serif'],
      },
    }
  }
}
"""

# shadcn-vue 组件样式 (原生 CSS，避免 Tailwind Play CDN JIT 对自定义类的支持问题)
COMPONENT_STYLES = """
/* shadcn-vue 组件样式复刻 - 原生 CSS */
.card { border-radius: var(--radius); border: 1px solid hsl(var(--border)); background: hsl(var(--card)); color: hsl(var(--card-foreground)); box-shadow: 0 1px 2px 0 rgba(0,0,0,0.05); }
.card-header { display: flex; flex-direction: column; gap: 0.375rem; padding: 1.5rem; }
.card-title { font-size: 1.5rem; font-weight: 600; line-height: 1; letter-spacing: -0.025em; }
.card-description { font-size: 0.875rem; color: hsl(var(--muted-foreground)); }
.card-content { padding: 1.5rem; padding-top: 0; }
.btn { display: inline-flex; align-items: center; justify-content: center; border-radius: calc(var(--radius) - 2px); font-size: 0.875rem; font-weight: 500; transition: background-color 0.15s; outline: none; cursor: pointer; }
.btn-default { background: hsl(var(--primary)); color: hsl(var(--primary-foreground)); height: 2.5rem; padding: 0.5rem 1rem; }
.btn-default:hover { background: hsl(var(--primary) / 0.9); }
.btn-outline { border: 1px solid hsl(var(--input)); background: hsl(var(--background)); height: 2.5rem; padding: 0.5rem 1rem; }
.btn-outline:hover { background: hsl(var(--accent)); color: hsl(var(--accent-foreground)); }
.btn-ghost { height: 2.5rem; padding: 0 1rem; }
.btn-ghost:hover { background: hsl(var(--accent)); color: hsl(var(--accent-foreground)); }
.badge { display: inline-flex; align-items: center; border-radius: 9999px; border: 1px solid transparent; padding: 0.125rem 0.625rem; font-size: 0.75rem; font-weight: 600; }
.badge-default { background: hsl(var(--primary)); color: hsl(var(--primary-foreground)); }
.badge-default:hover { background: hsl(var(--primary) / 0.8); }
.badge-secondary { background: hsl(var(--secondary)); color: hsl(var(--secondary-foreground)); }
.badge-secondary:hover { background: hsl(var(--secondary) / 0.8); }
.badge-outline { color: hsl(var(--foreground)); border-color: hsl(var(--border)); }
/* Table */
.table-wrapper { width: 100%; overflow: auto; border: 1px solid hsl(var(--border)); border-radius: calc(var(--radius) - 2px); -webkit-overflow-scrolling: touch; }
.table { width: 100%; font-size: 0.875rem; border-collapse: collapse; table-layout: auto; }
/* 表头吸顶（长列表滚动时始终可见） */
.table-header { position: sticky; top: 0; z-index: 1; }
.table-header [tr], .table-header tr { border-bottom: 1px solid hsl(var(--border)); }
.table-body tr:last-child { border-bottom: 0; }
.table-row { border-bottom: 1px solid hsl(var(--border)); transition: background-color 0.15s; }
.table-row:hover { background: hsl(var(--muted) / 0.5); }
.table-head { height: 3rem; padding: 0 1rem; text-align: left; vertical-align: middle; font-weight: 500; color: hsl(var(--muted-foreground)); border: 1px solid hsl(var(--border)); background: hsl(var(--muted) / 0.3); }
.table-cell { padding: 0.75rem 1rem; vertical-align: middle; border: 1px solid hsl(var(--border)); }
/* Dialog */
.dialog-overlay { position: fixed; inset: 0; z-index: 50; background: rgba(0,0,0,0.8); }
.dialog-content { position: fixed; left: 50%; top: 50%; z-index: 50; transform: translate(-50%, -50%); width: 100%; max-width: 42rem; display: grid; gap: 1rem; border: 1px solid hsl(var(--border)); background: hsl(var(--background)); padding: 1.5rem; box-shadow: 0 10px 15px -3px rgba(0,0,0,0.1); border-radius: var(--radius); max-height: 85vh; overflow-y: auto; }
.dialog-title { font-size: 1.125rem; font-weight: 600; line-height: 1; letter-spacing: -0.025em; }
.dialog-close { position: absolute; right: 1rem; top: 1rem; border-radius: calc(var(--radius) - 4px); opacity: 0.7; transition: opacity 0.15s; cursor: pointer; background: transparent; border: none; font-size: 1.5rem; line-height: 1; }
.dialog-close:hover { opacity: 1; }
/* 链接 */
.link { color: hsl(var(--primary)); cursor: pointer; text-decoration: none; }
.link:hover { text-decoration: underline; }
/* 原文本滚动 */
.raw-text-scroll { max-height: 80px; overflow-y: auto; max-width: 480px; line-height: 1.5; font-size: 0.8125rem; color: hsl(var(--muted-foreground)); white-space: pre-wrap; word-break: break-all; padding: 4px; border-radius: 4px; background: hsl(var(--muted) / 0.3); }
/* 搜索框 */
.input-search { width: 100%; height: 2.5rem; padding: 0.5rem 0.75rem; border: 1px solid hsl(var(--input)); border-radius: calc(var(--radius) - 2px); background: hsl(var(--background)); color: hsl(var(--foreground)); font-size: 0.875rem; outline: none; transition: border-color 0.15s; }
.input-search:focus { border-color: hsl(var(--ring)); box-shadow: 0 0 0 2px hsl(var(--ring) / 0.2); }
/* 排序图标 */
.sort-icon { margin-left: 0.25rem; font-size: 0.75rem; color: hsl(var(--muted-foreground)); }
.table-head.sortable { cursor: pointer; user-select: none; }
.table-head.sortable:hover { color: hsl(var(--primary)); }
/* 工具类兼容 */
.text-muted-foreground { color: hsl(var(--muted-foreground)); }
.text-destructive { color: hsl(var(--destructive)); }
.text-primary { color: hsl(var(--primary)); }
.font-medium { font-weight: 500; }
.font-semibold { font-weight: 600; }
.text-sm { font-size: 0.875rem; }
.text-xs { font-size: 0.75rem; }
.text-center { text-align: center; }
.whitespace-nowrap { white-space: nowrap; }
.ml-1 { margin-left: 0.25rem; }
.mt-2 { margin-top: 0.5rem; }
.mt-4 { margin-top: 1rem; }
.mb-6 { margin-bottom: 1.5rem; }
.py-8 { padding-top: 2rem; padding-bottom: 2rem; }
/* ===== 移动端适配 (≤640px) ===== */
@media (max-width: 640px) {
  /* 表格转卡片布局：表头隐藏，每行一张卡片，单元格左侧显示字段名 */
  .table-wrapper { overflow: visible; border: 0; }
  .table thead { display: none; }
  .table tbody { display: block; }
  .table tr, .table td { display: block; width: 100%; }
  .table-row { margin-bottom: 0.75rem; border: 1px solid hsl(var(--border)); border-radius: calc(var(--radius) - 2px); padding: 0.5rem 0.75rem; }
  .table-row:hover { background: transparent; }
  .table-cell { display: flex; align-items: flex-start; justify-content: flex-start; gap: 1.25rem; padding: 0.5rem 0; border: 0; border-bottom: 1px dashed hsl(var(--border)); white-space: normal; text-align: left; font-size: 0.8125rem; }
  .table-cell:last-child { border-bottom: 0; }
  .table-cell::before { content: attr(data-label); flex-shrink: 0; min-width: 4.5rem; color: hsl(var(--muted-foreground)); font-size: 0.75rem; line-height: 1.5; text-align: left; }
  .table-cell > * { min-width: 0; }
  .table-cell .raw-text-scroll { max-width: 100%; }
  /* 弹窗接近全屏 */
  .dialog-content { width: calc(100% - 2rem); max-height: 80vh; }
  .dialog-close { padding: 0.375rem; }
  /* 避免 iOS 聚焦输入框自动放大 */
  .input-search { font-size: 1rem; }
  /* 卡片内边距收紧 */
  .card-header { padding: 1.25rem; }
  .card-content { padding: 1.25rem; }
}
"""


def _esc(s) -> str:
    """HTML 转义"""
    if s is None:
        return ""
    s = str(s)
    return s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace('"', "&quot;")


def generate_daily_site(db, trade_date: str, output_dir: str) -> str:
    """生成当日静态页面 (Vue3 + shadcn-vue violet) + 各币种历史价格 JSON。返回页面路径。"""
    os.makedirs(output_dir, exist_ok=True)
    charts_dir = os.path.join(output_dir, "charts")
    os.makedirs(charts_dir, exist_ok=True)

    rows_data = db.fetch_prices_by_date(trade_date)

    # 收集所有币种名称，建立 name -> chart_id 映射（纯 ASCII 文件名，避免非法字符/编码问题）
    coin_names = set()
    for r in rows_data:
        if r["name"]:
            coin_names.add(r["name"])
    name_to_id = {name: str(i) for i, name in enumerate(sorted(coin_names), 1)}

    # 生成各币种历史 JSON
    for name, chart_id in name_to_id.items():
        history = db.fetch_coin_history(name, days=30)
        detail_keys: list[str] = []
        seen: set[str] = set()
        chart_points = []
        for h in history:
            details = json.loads(h["price_details"]) if h["price_details"] else {}
            for k in details.keys():
                if k not in seen:
                    seen.add(k)
                    detail_keys.append(k)
            chart_points.append({
                "date": h["trade_date"],
                "ref_price": h["ref_price"],
                "details": details,
            })
        combined = chart_points[:]
        combined.append({"detail_keys": detail_keys})
        json_path = os.path.join(charts_dir, f"{chart_id}.json")
        with open(json_path, "w", encoding="utf-8") as f:
            json.dump(combined, f, ensure_ascii=False)

    # 按品种聚合：每品种保留最高价 + 最大需求量（若同一条则一行，否则两行）
    groups: dict[str, list] = {}
    for r in rows_data:
        name = r["name"] or ""
        if not name or len(name.strip()) <= 1:
            continue
        groups.setdefault(name, []).append(r)

    table_data: list[dict] = []
    for name, recs in groups.items():
        # 解析单条记录的有效价格 [(label, value), ...]
        def _prices_of(rec):
            details = json.loads(rec["price_details"]) if rec["price_details"] else {}
            out = []
            for k, v in details.items():
                if not v:
                    continue
                try:
                    fv = float(v)
                except (ValueError, TypeError):
                    continue
                if 0 < fv <= 100000:
                    out.append((k, fv, str(v)))
            return out

        def _qty_of(rec):
            q = rec["quantity"] or "0"
            try:
                return float(q)
            except (ValueError, TypeError):
                return 0.0

        # 找最高价记录
        best_price_rec = None
        best_price_val = 0.0
        best_price_label = ""
        best_price_str = ""
        for rec in recs:
            for label, fv, sv in _prices_of(rec):
                if fv > best_price_val:
                    best_price_val = fv
                    best_price_label = label
                    best_price_str = sv
                    best_price_rec = rec

        # 找需求量最大记录
        best_qty_rec = max(recs, key=lambda r: _qty_of(r))
        best_qty_val = _qty_of(best_qty_rec)

        board_id = (best_price_rec or best_qty_rec)["board_id"]
        board_label = config.TARGET_BOARDS.get(board_id, str(board_id))
        chart_id = name_to_id.get(name, "")

        # 是否同一条记录，或最高价与最大需求量价格相同
        is_same = (best_price_rec is None
                   or best_price_rec["id"] == best_qty_rec["id"])
        # 检查最大需求量记录的价格是否与最高价相同
        if not is_same and best_price_rec is not None:
            qty_prices = _prices_of(best_qty_rec)
            qty_max_price = max((fv for _, fv, _ in qty_prices), default=0.0)
            if qty_max_price >= best_price_val:
                is_same = True

        def _make_row(rec):
            return {
                "name": name,
                "max_price": best_price_str,
                "max_price_label": best_price_label,
                "quantity": rec["quantity"] or "",
                "quantity_unit": rec["quantity_unit"] or "",
                "board_id": board_id,
                "board_label": board_label,
                "raw_text": rec["raw_text"] or "",
                "topic_url": rec["topic_url"] or "",
                "topic_title": rec["topic_title"] or "",
                "chart_id": chart_id,
            }

        if is_same:
            # 同一条或价格相同：显示最大需求量的条目，不标注
            table_data.append(_make_row(best_qty_rec))
        else:
            # 两行：最高价行 + 需求量最大行（不标注 tag）
            table_data.append(_make_row(best_price_rec))
            table_data.append(_make_row(best_qty_rec))

    page_data_json = json.dumps(table_data, ensure_ascii=False)

    html = _build_daily_html(trade_date, page_data_json, len(table_data))
    page_path = os.path.join(output_dir, "index.html")
    with open(page_path, "w", encoding="utf-8") as f:
        f.write(html)
    return page_path


def _build_daily_html(trade_date: str, page_data_json: str, count: int) -> str:
    """构建当日 Vue 3 页面 HTML"""
    return f'''<!DOCTYPE html>
<html lang="zh-CN" class="">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="theme-color" content="#ffffff">
<title>每日行情 - {trade_date}</title>
<script src="{TAILWIND_CDN}"></script>
<script>{TAILWIND_CONFIG}</script>
<style>{VIOLET_THEME_CSS}
{COMPONENT_STYLES}
body {{ background-color: hsl(var(--background)); color: hsl(var(--foreground)); }}
[v-cloak] {{ display: none; }}
</style>
</head>
<body>
<div id="app" v-cloak>
  <div class="container mx-auto max-w-[1400px] px-4 py-8">
    <!-- 返回链接 -->
    <a href="../../site/index.html" class="btn btn-ghost mb-4">&larr; 返回首页</a>

    <!-- Card: 标题区 -->
    <div class="card mb-6">
      <div class="card-header">
        <div class="flex flex-wrap items-center justify-between gap-3">
          <div>
            <h1 class="card-title">每日行情</h1>
            <p class="card-description mt-1">{trade_date} · 共 {count} 条价格记录</p>
          </div>
          <button class="btn btn-outline" @click="toggleDark">
            {{{{ isDark ? '☀️ 浅色' : '🌙 深色' }}}}
          </button>
        </div>
      </div>
    </div>

    <!-- Card: 价格表格 -->
    <div class="card">
      <div class="card-header">
        <h2 class="text-xl font-semibold">价格列表</h2>
        <p class="text-sm text-muted-foreground">点击品种名称查看近期行情折线图 · 点击表头排序 · 每品种保留最高价与最大需求量</p>
      </div>
      <div class="card-content">
        <div class="mb-4">
          <input v-model="searchQuery" placeholder="搜索品种名称..." class="input-search">
          <p v-if="searchQuery" class="text-xs text-muted-foreground mt-2">匹配 {{{{ filteredRows.length }}}} / {{{{ rows.length }}}} 条</p>
        </div>
        <div class="table-wrapper">
          <table class="table">
            <thead class="table-header">
              <tr class="table-row">
                <th class="table-head sortable" @click="toggleSort('name')">品种/钞种 <span class="sort-icon">{{{{ sortIcon('name') }}}}</span></th>
                <th class="table-head sortable" @click="toggleSort('max_price')">最高价 <span class="sort-icon">{{{{ sortIcon('max_price') }}}}</span></th>
                <th class="table-head sortable" @click="toggleSort('quantity')">需求量 <span class="sort-icon">{{{{ sortIcon('quantity') }}}}</span></th>
                <th class="table-head">原文本</th>
                <th class="table-head">板块</th>
                <th class="table-head">来源</th>
              </tr>
            </thead>
            <tbody class="table-body">
              <tr v-for="(row, idx) in sortedRows" :key="idx" class="table-row">
                <td class="table-cell whitespace-nowrap" data-label="品种">
                  <span class="link" @click="showChart(row.name, row.chart_id)">{{{{ row.name }}}}</span>
                </td>
                <td class="table-cell whitespace-nowrap font-medium" data-label="最高价">
                  <span v-if="row.max_price">{{{{ row.max_price_label }}}} {{{{ row.max_price }}}}</span>
                  <span v-else class="text-muted-foreground">-</span>
                </td>
                <td class="table-cell whitespace-nowrap" data-label="需求量">
                  <span v-if="row.quantity">{{{{ row.quantity }}}}{{{{ row.quantity_unit }}}}</span>
                  <span v-else class="text-muted-foreground">-</span>
                </td>
                <td class="table-cell" data-label="原文本">
                  <div class="raw-text-scroll">{{{{ row.raw_text || '-' }}}}</div>
                </td>
                <td class="table-cell" data-label="板块">
                  <span :class="['badge', row.board_id === 10 ? 'badge-default' : 'badge-secondary']">
                    {{{{ row.board_label }}}}
                  </span>
                </td>
                <td class="table-cell text-center" data-label="来源">
                  <a v-if="row.topic_url" :href="row.topic_url" target="_blank" class="link" :title="row.topic_title || '查看原帖'">&nearr;</a>
                  <span v-else class="text-muted-foreground">-</span>
                </td>
              </tr>
              <tr v-if="sortedRows.length === 0">
                <td colspan="6" class="table-cell text-center text-muted-foreground py-8">暂无数据</td>
              </tr>
            </tbody>
          </table>
        </div>
      </div>
    </div>
  </div>

  <!-- Dialog: 折线图 -->
  <div v-if="chartVisible" class="dialog-overlay" @click.self="closeChart">
    <div class="dialog-content" role="dialog" aria-modal="true" :aria-label="chartTitle">
      <span class="dialog-close" aria-label="关闭" @click="closeChart">&times;</span>
      <h3 class="dialog-title">{{{{ chartTitle }}}}</h3>
      <div class="mt-4">
        <canvas ref="chartCanvas" style="max-height:400px;"></canvas>
        <p v-if="chartLoading" class="text-center text-muted-foreground py-8">加载中...</p>
        <p v-if="chartError" class="text-center text-destructive py-8">暂无 {{{{ chartTitle }}}} 的历史数据</p>
        <p v-if="chartInfo" class="text-center text-muted-foreground text-sm mt-2">{{{{ chartInfo }}}}</p>
      </div>
    </div>
  </div>
</div>

<script src="{VUE_CDN}"></script>
<script src="{CHART_JS_CDN}"></script>
<script>
const {{ createApp, ref, computed, nextTick }} = Vue;

createApp({{
  setup() {{
    const rows = ref({page_data_json});
    // 深色模式：优先取 localStorage，其次跟随系统偏好
    const savedTheme = (() => {{ try {{ return localStorage.getItem('daily-market-theme'); }} catch (e) {{ return null; }} }})();
    const isDark = ref(savedTheme === 'dark' || (savedTheme === null && window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches));
    const themeMeta = document.querySelector('meta[name="theme-color"]');
    if (isDark.value) document.documentElement.classList.add('dark');
    if (themeMeta) themeMeta.setAttribute('content', isDark.value ? '#0b0b12' : '#ffffff');
    const chartVisible = ref(false);
    const chartTitle = ref('');
    const chartLoading = ref(false);
    const chartError = ref(false);
    const chartInfo = ref('');
    const chartCanvas = ref(null);
    let priceChart = null;
    // 小屏图例放底部，避免横向挤压
    const legendPosition = window.innerWidth < 640 ? 'bottom' : 'top';

    // 搜索与排序
    const searchQuery = ref('');
    const sortKey = ref('');
    const sortOrder = ref('asc');

    const toggleSort = (key) => {{
      if (sortKey.value === key) {{
        sortOrder.value = sortOrder.value === 'asc' ? 'desc' : 'asc';
      }} else {{
        sortKey.value = key;
        sortOrder.value = 'asc';
      }}
    }};

    const sortIcon = (key) => {{
      if (sortKey.value !== key) return '⇅';
      return sortOrder.value === 'asc' ? '▲' : '▼';
    }};

    const filteredRows = computed(() => {{
      const q = searchQuery.value.trim();
      if (!q) return rows.value;
      return rows.value.filter(r => (r.name || '').includes(q));
    }});

    const sortedRows = computed(() => {{
      if (!sortKey.value) return filteredRows.value;
      const key = sortKey.value;
      const dir = sortOrder.value === 'asc' ? 1 : -1;
      return [...filteredRows.value].sort((a, b) => {{
        if (key === 'name') return (a.name || '').localeCompare(b.name || '') * dir;
        const va = parseFloat(a[key]) || 0;
        const vb = parseFloat(b[key]) || 0;
        return (va - vb) * dir;
      }});
    }});

    const toggleDark = () => {{
      isDark.value = !isDark.value;
      document.documentElement.classList.toggle('dark', isDark.value);
      try {{ localStorage.setItem('daily-market-theme', isDark.value ? 'dark' : 'light'); }} catch (e) {{}}
      if (themeMeta) themeMeta.setAttribute('content', isDark.value ? '#0b0b12' : '#ffffff');
    }};

    const showChart = async (coinName, chartId) => {{
      if (!chartId) {{
        chartTitle.value = coinName;
        chartVisible.value = true;
        chartError.value = true;
        chartInfo.value = '';
        return;
      }}
      chartTitle.value = coinName + ' 近期行情';
      chartVisible.value = true;
      chartLoading.value = true;
      chartError.value = false;
      chartInfo.value = '';
      await nextTick();
      try {{
        const res = await fetch('charts/' + chartId + '.json');
        if (!res.ok) throw new Error('not found');
        const data = await res.json();
        chartLoading.value = false;
        const detailKeys = data[data.length - 1]?.detail_keys || [];
        const points = data.slice(0, -1);
        if (points.length === 0) {{
          chartError.value = true;
          return;
        }}
        if (points.length === 1) {{
          chartInfo.value = '仅有当日数据，暂无历史趋势（需多日积累）';
        }}
        const pointRadius = points.length <= 1 ? 6 : 3;
        const datasets = [{{
          label: '参考价',
          data: points.map(d => d.ref_price ? parseFloat(d.ref_price) : null),
          borderColor: 'hsl(262.1, 83.3%, 57.8%)',
          backgroundColor: 'hsl(262.1, 83.3%, 57.8% / 0.1)',
          fill: true, tension: 0.3, spanGaps: true, borderWidth: 2,
          pointRadius: pointRadius, pointHoverRadius: pointRadius + 2
        }}];
        const colors = ['hsl(142, 71%, 45%)', 'hsl(25, 95%, 53%)', 'hsl(330, 81%, 60%)', 'hsl(265, 89%, 78%)', 'hsl(187, 85%, 43%)'];
        detailKeys.forEach((key, i) => {{
          datasets.push({{
            label: key,
            data: points.map(d => (d.details && d.details[key]) ? parseFloat(d.details[key]) : null),
            borderColor: colors[i % colors.length],
            fill: false, tension: 0.3, spanGaps: true, borderWidth: 1.5, hidden: true,
            pointRadius: pointRadius, pointHoverRadius: pointRadius + 2
          }});
        }});
        if (priceChart) priceChart.destroy();
        priceChart = new Chart(chartCanvas.value.getContext('2d'), {{
          type: 'line',
          data: {{ labels: points.map(d => d.date), datasets }},
          options: {{
            responsive: true,
            interaction: {{ mode: 'index', intersect: false }},
            plugins: {{
              legend: {{ position: legendPosition, labels: {{ usePointStyle: true }} }},
              tooltip: {{ mode: 'index', intersect: false }}
            }},
            scales: {{ y: {{ beginAtZero: false }} }}
          }}
        }});
      }} catch (err) {{
        chartLoading.value = false;
        chartError.value = true;
      }}
    }};

    const closeChart = () => {{
      chartVisible.value = false;
      chartInfo.value = '';
      if (priceChart) {{ priceChart.destroy(); priceChart = null; }}
    }};

    document.addEventListener('keydown', e => {{ if (e.key === 'Escape') closeChart(); }});

    return {{ rows, isDark, chartVisible, chartTitle, chartLoading, chartError, chartInfo, chartCanvas,
             searchQuery, sortKey, sortOrder, toggleSort, sortIcon, filteredRows, sortedRows,
             toggleDark, showChart, closeChart }};
  }}
}}).mount('#app');
</script>
</body>
</html>'''


def generate_site_index(db, site_dir: str) -> str:
    """生成站点首页：日期列表 (Vue3 + shadcn-vue violet)。"""
    os.makedirs(site_dir, exist_ok=True)
    trade_dates = db.list_trade_dates()

    items = []
    for d in trade_dates:
        rows = db.fetch_prices_by_date(d)
        items.append({"date": d, "count": len(rows)})

    items_json = json.dumps(items, ensure_ascii=False)

    html = f'''<!DOCTYPE html>
<html lang="zh-CN" class="">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="theme-color" content="#ffffff">
<title>每日行情 - 日期索引</title>
<script src="{TAILWIND_CDN}"></script>
<script>{TAILWIND_CONFIG}</script>
<style>{VIOLET_THEME_CSS}
{COMPONENT_STYLES}
body {{ background-color: hsl(var(--background)); color: hsl(var(--foreground)); }}
[v-cloak] {{ display: none; }}
</style>
</head>
<body>
<div id="app" v-cloak>
  <div class="container mx-auto max-w-3xl px-4 py-8 md:py-12">
    <div class="card">
      <div class="card-header">
        <div class="flex flex-wrap items-center justify-between gap-3">
          <div>
            <h1 class="card-title">每日行情</h1>
            <p class="card-description">PM001 论坛纪念币/钞收购信息汇总</p>
          </div>
          <button class="btn btn-outline" @click="toggleDark">
            {{{{ isDark ? '☀️ 浅色' : '🌙 深色' }}}}
          </button>
        </div>
      </div>
      <div class="card-content">
        <div v-if="dates.length === 0" class="text-center text-muted-foreground py-8">
          暂无数据，请先运行抓取
        </div>
        <div v-else class="space-y-2">
          <a v-for="item in dates" :key="item.date"
             :href="'../data/daily/' + item.date + '/index.html'"
             class="flex items-center justify-between rounded-lg border border-border p-4 transition-colors hover:bg-accent">
            <span class="text-lg font-medium">{{{{ item.date }}}}</span>
            <span class="badge badge-secondary">{{{{ item.count }}}} 条</span>
          </a>
        </div>
      </div>
    </div>
  </div>
</div>
<script src="{VUE_CDN}"></script>
<script>
const {{ createApp, ref }} = Vue;
createApp({{
  setup() {{
    const dates = ref({items_json});
    // 深色模式：优先取 localStorage，其次跟随系统偏好
    const savedTheme = (() => {{ try {{ return localStorage.getItem('daily-market-theme'); }} catch (e) {{ return null; }} }})();
    const isDark = ref(savedTheme === 'dark' || (savedTheme === null && window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches));
    const themeMeta = document.querySelector('meta[name="theme-color"]');
    if (isDark.value) document.documentElement.classList.add('dark');
    if (themeMeta) themeMeta.setAttribute('content', isDark.value ? '#0b0b12' : '#ffffff');
    const toggleDark = () => {{
      isDark.value = !isDark.value;
      document.documentElement.classList.toggle('dark', isDark.value);
      try {{ localStorage.setItem('daily-market-theme', isDark.value ? 'dark' : 'light'); }} catch (e) {{}}
      if (themeMeta) themeMeta.setAttribute('content', isDark.value ? '#0b0b12' : '#ffffff');
    }};
    return {{ dates, isDark, toggleDark }};
  }}
}}).mount('#app');
</script>
</body>
</html>'''

    path = os.path.join(site_dir, "index.html")
    with open(path, "w", encoding="utf-8") as f:
        f.write(html)
    return path
