// screens-builder-misc.jsx — Page builder (06), Approvals demo, Tenants & Sites

// ─── 06 PAGE BUILDER ────────────────────────────────────────────────────────

const API_BASE = window.NebulaAdmin.API_BASE();
function getFrontendUrl() {
  try {
    const q = new URLSearchParams(window.location.search).get('frontend');
    if (q) return q;
  } catch (e) {}
  if (window.__NEBULA_FRONTEND_URL) return window.__NEBULA_FRONTEND_URL;
  return `${window.location.protocol}//${window.location.hostname}:5173/`;
}

const DEVICE_PRESETS = [
  { id: 'iphone13', name: 'iPhone 13', w: 390, h: 844 },
  { id: 'iphone-se', name: 'iPhone SE', w: 375, h: 667 },
  { id: 'galaxy',  name: 'Galaxy S22', w: 360, h: 780 },
  { id: 'tablet',  name: 'iPad mini',  w: 768, h: 1024 },
];
// Free-position canvas reference width. pos.x / pos.w are in this design space;
// the mobile-app renderer scales x/w by viewport via vw, the admin overlay
// scales by (device.w / DESIGN_W_REF) to align handles to what the iframe shows.
const DESIGN_W_REF = 375;

// Mirrors COMPONENT_REGISTRY in mobile-app/src/screens/home-renderers.jsx.
// Each entry: { type, name, defaults.props, schema?: { prop: { type, label, ...config } } }
// schema drives the visual Inspector; props without schema fall through to advanced JSON editor.
const ROUTE_OPTIONS = (t) => [
  { value: 'lottery',   label: t.tx('彩票大厅',  'Lottery Hall') },
  { value: 'cqssc',     label: t.tx('时时彩',    'SSC') },
  { value: 'games',     label: t.tx('游戏大厅',  'Game Hall') },
  { value: 'live-room', label: t.tx('直播间',    'Live Room') },
  { value: 'drama',     label: t.tx('短剧',      'Drama') },
  { value: 'theater',   label: t.tx('剧场',      'Theater') },
  { value: 'recharge',  label: t.tx('充值',      'Recharge') },
  { value: 'promos',    label: t.tx('活动中心',  'Promos') },
  { value: 'agent',     label: t.tx('推广',      'Agent') },
  { value: 'me',        label: t.tx('我的',      'Me') },
  { value: 'task',      label: t.tx('任务',      'Task') },
];
const LOTTERY_OPTIONS = (t) => [
  { value: 'cqssc',   label: t.tx('一分时时彩', '1m CQSSC') },
  { value: 'k3',      label: t.tx('一分快3',    '1m K3') },
  { value: 'pk10',    label: t.tx('极速 PK10',  'Speed PK10') },
  { value: 'lhc',     label: t.tx('六合彩',     'Mark Six') },
];
const COMPONENT_CATALOG = (t) => [
  { type: 'user-card', name: t.tx('会员卡','Member Card'), defaults: { props: {} }, schema: {} },
  { type: 'banner-featured-lottery', name: t.tx('主推彩种','Featured Lottery'),
    defaults: { props: { lotteryId: 'cqssc', badge: t.tx('🔥 主推','🔥 Featured') } },
    schema: {
      lotteryId: { type: 'select', label: t.tx('彩种','Lottery'), options: LOTTERY_OPTIONS(t) },
      badge:     { type: 'text',   label: t.tx('徽章文案','Badge text') },
    } },
  { type: 'win-marquee', name: t.tx('中奖跑马灯','Win Marquee'), defaults: { props: {} }, schema: {} },
  { type: 'platform-stats-strip', name: t.tx('实时大盘','Live Stats'),
    defaults: { props: { title: t.tx('实时大盘','Live Stats'), showLive: true } },
    schema: {
      title:    { type: 'text',    label: t.tx('标题','Title') },
      showLive: { type: 'boolean', label: t.tx('显示绿色实时指示点','Show live dot') },
    } },
  { type: 'live-activities-strip', name: t.tx('进行中活动','Active Promos'),
    defaults: { props: { title: t.tx('🎁 进行中活动','🎁 Active Promos'), max: 3 } },
    schema: {
      title: { type: 'text', label: t.tx('标题','Title') },
      max:   { type: 'number', label: t.tx('最多显示几条','Max items'), min: 1, max: 10 },
    } },
  { type: 'whales-leaderboard', name: t.tx('中奖排行榜','Top Winners'),
    defaults: { props: { title: t.tx('👑 今日中奖榜','👑 Today’s Top Winners'), max: 5 } },
    schema: {
      title: { type: 'text', label: t.tx('标题','Title') },
      max:   { type: 'number', label: t.tx('榜单条数','Item count'), min: 3, max: 20 },
    } },
  { type: 'hot-games-strip', name: t.tx('热门玩法','Hot Games'),
    defaults: { props: { title: t.tx('🎰 热门玩法','🎰 Hot Games'), max: 3 } },
    schema: {
      title: { type: 'text', label: t.tx('标题','Title') },
      max:   { type: 'number', label: t.tx('显示几个','Item count'), min: 2, max: 6 },
    } },
  { type: 'quick-entry-grid', name: t.tx('快捷入口网格','Quick Entry Grid'),
    defaults: { props: { items: [{ lbl: t.tx('彩票', 'Lottery'), ico: '🎯', go: 'lottery', grad: 'linear-gradient(135deg, #FF3D7F, #FF8A3D)' }] } },
    schema: {
      items: { type: 'items', label: t.tx('入口列表','Entry list'), itemFields: {
        lbl:  { type: 'text',   label: t.tx('文案','Label') },
        ico:  { type: 'text',   label: t.tx('图标 emoji','Icon emoji') },
        go:   { type: 'select', label: t.tx('跳转','Link to'), options: ROUTE_OPTIONS(t) },
        grad: { type: 'text',   label: t.tx('CSS 渐变','CSS gradient') },
      } },
    } },
  { type: 'hero-promo', name: t.tx('Hero 促销','Hero Promo'),
    defaults: { props: { byTier: { normal: { title: t.tx('新人首充 +20%','First recharge +20%'), sub: t.tx('充 ¥100 送 ¥20','Recharge ¥100, get ¥20'), cta: t.tx('立即首充','Recharge now'), grad: 'linear-gradient(135deg, #FF3D7F, #FF8A3D)' } } } },
    schema: {
      byTier: { type: 'byTier', label: t.tx('按等级文案','Per-tier copy'), tiers: ['normal', 'gold', 'diamond'], fields: {
        title: { type: 'text', label: t.tx('标题','Title') },
        sub:   { type: 'text', label: t.tx('副标题','Subtitle') },
        cta:   { type: 'text', label: t.tx('CTA 按钮','CTA button') },
        grad:  { type: 'text', label: t.tx('CSS 渐变','CSS gradient') },
      } },
    } },
  { type: 'live-rooms-grid', name: t.tx('直播间网格','Live Rooms Grid'),
    defaults: { props: { title: t.tx('🔴 正在直播','🔴 Live Now') } },
    schema: { title: { type: 'text', label: t.tx('标题','Title') } } },
  { type: 'drama-strip', name: t.tx('短剧推荐','Drama Picks'),
    defaults: { props: { title: t.tx('🎬 短剧 · 9.9 解锁全集','🎬 Drama · ¥9.9 unlocks all'), max: 5 } },
    schema: {
      title: { type: 'text',   label: t.tx('标题','Title') },
      max:   { type: 'number', label: t.tx('显示几条','Item count'), min: 3, max: 12 },
    } },
  { type: 'richtext', name: t.tx('富文本','Rich Text'),
    defaults: { props: { html: t.tx('<p>编辑内容…</p>', '<p>Edit content…</p>'), align: 'left' } },
    schema: {
      html:  { type: 'textarea', label: t.tx('HTML 内容','HTML content'), rows: 6 },
      align: { type: 'select', label: t.tx('对齐','Align'), options: [
        { value: 'left',   label: t.tx('左对齐','Left') },
        { value: 'center', label: t.tx('居中','Center') },
        { value: 'right',  label: t.tx('右对齐','Right') },
      ] },
    } },
  { type: 'countdown', name: t.tx('倒计时','Countdown'),
    defaults: { props: { title: t.tx('距活动结束','Time until end'), target_at: '', format: 'DD HH MM SS', expired_text: t.tx('已结束','Ended') } },
    schema: {
      title:        { type: 'text', label: t.tx('标题','Title') },
      target_at:    { type: 'text', label: t.tx('截止时间 (ISO 例 2026-06-01T00:00:00Z)','Deadline (ISO, e.g. 2026-06-01T00:00:00Z)') },
      format:       { type: 'select', label: t.tx('格式','Format'), options: [
        { value: 'DD HH MM SS', label: t.tx('天+时+分+秒','D+H+M+S') },
        { value: 'HH MM SS',    label: t.tx('时+分+秒','H+M+S') },
      ] },
      expired_text: { type: 'text', label: t.tx('结束后显示','Expired text') },
    } },
  { type: 'single-image-ad', name: t.tx('单图广告','Image Ad'),
    defaults: { props: { image_url: '', link: '', alt: '', aspect: '16:9' } },
    schema: {
      image_url: { type: 'image', label: t.tx('广告图','Ad image') },
      link:      { type: 'text',  label: t.tx('跳转链接 (内部路由 /xxx 或 https://)','Link (/xxx or https://)') },
      alt:       { type: 'text',  label: t.tx('替代文本（无障碍）','Alt text (a11y)') },
      aspect:    { type: 'select', label: t.tx('宽高比','Aspect'), options: [
        { value: '21:9', label: t.tx('21:9 通栏','21:9 banner') },
        { value: '16:9', label: '16:9' },
        { value: '4:3',  label: '4:3' },
        { value: '1:1',  label: t.tx('1:1 方形','1:1 square') },
      ] },
    } },
  { type: 'custom-html', name: t.tx('自定义 HTML','Custom HTML'),
    defaults: { props: { html: '<div style="padding:20px;text-align:center">Hello</div>', height_px: 240, sandbox: true } },
    schema: {
      html:      { type: 'textarea', label: t.tx('HTML 内容（<iframe sandbox>）','HTML (<iframe sandbox>)'), rows: 8 },
      height_px: { type: 'number',   label: t.tx('高度 (px)','Height (px)'), min: 60, max: 1200 },
      sandbox:   { type: 'boolean',  label: t.tx('启用沙盒（推荐开）','Enable sandbox (recommended)') },
    } },
  { type: 'video', name: t.tx('视频','Video'),
    defaults: { props: { video_url: '', poster_url: '', autoplay: false, loop: false, muted: true, controls: true } },
    schema: {
      video_url:  { type: 'text',    label: t.tx('视频 URL (.mp4 / .webm)','Video URL (.mp4 / .webm)') },
      poster_url: { type: 'image',   label: t.tx('封面图','Poster') },
      autoplay:   { type: 'boolean', label: t.tx('自动播放','Autoplay') },
      loop:       { type: 'boolean', label: t.tx('循环','Loop') },
      muted:      { type: 'boolean', label: t.tx('默认静音','Default muted') },
      controls:   { type: 'boolean', label: t.tx('显示控制条','Show controls') },
    } },
  { type: 'floating-cs', name: t.tx('客服浮窗','Floating Support'),
    defaults: { props: { icon: '💬', label: t.tx('在线客服','Live Support'), link: '/support' } },
    schema: {
      icon:  { type: 'text', label: t.tx('图标 emoji','Icon emoji') },
      label: { type: 'text', label: t.tx('悬浮文字','Floating text') },
      link:  { type: 'text', label: t.tx('跳转链接','Link') },
    } },
  { type: 'flash-sale', name: t.tx('限时秒杀','Flash Sale'),
    defaults: { props: { title: t.tx('⚡ 限时秒杀','⚡ Flash Sale'), end_at: '', items: [
      { name: t.tx('黄金 VIP 月卡','Gold VIP Monthly'), price: 88, original_price: 198, image_url: '', link: '/recharge' },
    ] } },
    schema: {
      title:  { type: 'text', label: t.tx('标题','Title') },
      end_at: { type: 'text', label: t.tx('结束时间 ISO','End time (ISO)') },
      items:  { type: 'items', label: t.tx('商品列表','Items'), itemFields: {
        name:           { type: 'text',   label: t.tx('商品名','Item name') },
        price:          { type: 'number', label: t.tx('秒杀价','Sale price') },
        original_price: { type: 'number', label: t.tx('原价','Original price') },
        image_url:      { type: 'image',  label: t.tx('图片','Image') },
        link:           { type: 'text',   label: t.tx('跳转','Link to') },
      } },
    } },
  { type: 'signin-entry', name: t.tx('签到入口','Check-in Entry'),
    defaults: { props: { title: t.tx('今日签到','Today’s check-in'), sub: t.tx('连续 N 天，奖励翻倍','N days streak, double rewards'), cta: t.tx('立即签到','Check in now'), link: '/task' } },
    schema: {
      title: { type: 'text', label: t.tx('标题','Title') },
      sub:   { type: 'text', label: t.tx('副标题','Subtitle') },
      cta:   { type: 'text', label: t.tx('按钮文案','Button text') },
      link:  { type: 'text', label: t.tx('跳转','Link to') },
    } },
  { type: 'me-balance-card', name: t.tx('余额卡','Balance Card'),
    defaults: { props: { show_coins: true, primary_action: 'recharge' } },
    schema: {
      show_coins:     { type: 'boolean', label: t.tx('显示金币','Show coins') },
      primary_action: { type: 'select',  label: t.tx('主按钮','Primary action'), options: [
        { value: 'recharge', label: t.tx('充值','Recharge') },
        { value: 'withdraw', label: t.tx('提现','Withdraw') },
        { value: 'task',     label: t.tx('去任务','Tasks') },
        { value: 'none',     label: t.tx('无主按钮','No primary') },
      ] },
    } },
  { type: 'me-actions-grid', name: t.tx('入口网格','Actions Grid'),
    defaults: { props: { items: [
      { icon: '💰', label: t.tx('充值','Recharge'), route: 'recharge' },
      { icon: '💸', label: t.tx('提现','Withdraw'), route: 'recharge' },
      { icon: '🎁', label: t.tx('任务','Task'),     route: 'task' },
      { icon: '👥', label: t.tx('邀请','Invite'),   route: 'task' },
      { icon: '🎧', label: t.tx('客服','Support'),  route: 'support' },
      { icon: '⚙️', label: t.tx('设置','Settings'), route: 'settings' },
    ] } },
    schema: {
      items: { type: 'items', label: t.tx('入口','Entries'), itemFields: {
        icon:  { type: 'text',   label: t.tx('图标 emoji','Icon emoji') },
        label: { type: 'text',   label: t.tx('文案','Label') },
        route: { type: 'select', label: t.tx('跳转','Link to'), options: [
          { value: 'home',      label: t.tx('首页','Home') },
          { value: 'recharge',  label: t.tx('充值','Recharge') },
          { value: 'task',      label: t.tx('任务','Task') },
          { value: 'support',   label: t.tx('客服','Support') },
          { value: 'settings',  label: t.tx('设置','Settings') },
          { value: 'level',     label: 'VIP' },
          { value: 'live-room', label: t.tx('直播','Live') },
          { value: 'theater',   label: t.tx('短剧','Drama') },
        ] },
      } },
    } },
  { type: 'me-vip-strip', name: t.tx('VIP 等级条','VIP Strip'),
    defaults: { props: { show_next_perk: true, cta_text: t.tx('查看权益','View perks'), cta_route: 'level' } },
    schema: {
      show_next_perk: { type: 'boolean', label: t.tx('显示下一级权益','Show next-tier perk') },
      cta_text:       { type: 'text',    label: t.tx('按钮文案','Button text') },
      cta_route:      { type: 'select',  label: t.tx('跳转','Link to'), options: [
        { value: 'level', label: t.tx('VIP 中心','VIP Center') },
        { value: 'task',  label: t.tx('任务','Task') },
      ] },
    } },
  { type: 'me-stat-row', name: t.tx('数据行','Stats Row'),
    defaults: { props: { items: [
      { label: t.tx('今日盈亏','Today P/L'),    value_key: 'today_profit',   color: '#FF6BB3' },
      { label: t.tx('累计充值','Total recharge'), value_key: 'total_recharge', color: '#7B5BFF' },
      { label: t.tx('累计投注','Total stakes'),  value_key: 'total_stake',    color: '#3DDC97' },
    ] } },
    schema: {
      items: { type: 'items', label: t.tx('统计项','Stats'), itemFields: {
        label:     { type: 'text',   label: t.tx('标签','Label') },
        value_key: { type: 'select', label: t.tx('数据键','Data key'), options: [
          { value: 'balance',        label: t.tx('当前余额','Current balance') },
          { value: 'coins',          label: t.tx('金币','Coins') },
          { value: 'today_profit',   label: t.tx('今日盈亏','Today P/L') },
          { value: 'total_recharge', label: t.tx('累计充值','Total recharge') },
          { value: 'total_stake',    label: t.tx('累计投注','Total stakes') },
          { value: 'total_payout',   label: t.tx('累计中奖','Total wins') },
          { value: 'signin_streak',  label: t.tx('连续签到','Check-in streak') },
        ] },
        color:     { type: 'text',   label: t.tx('数值色 (CSS color)','Value color (CSS)') },
      } },
    } },
  { type: 'task-signin-week', name: t.tx('7 日签到','Weekly Check-in'),
    defaults: { props: { rewards: [1, 2, 3, 5, 8, 13, 21], unit: t.tx('积分','points') } },
    schema: {
      rewards: { type: 'items', label: t.tx('每日奖励','Daily rewards'), itemFields: {
        value: { type: 'number', label: t.tx('数额','Amount') },
      } },
      unit:    { type: 'text', label: t.tx('单位（积分 / ¥）','Unit (points / ¥)') },
    } },
  { type: 'task-list', name: t.tx('任务列表','Task List'),
    defaults: { props: { filter: 'all', show_progress: true, max: 10 } },
    schema: {
      filter:        { type: 'select',  label: t.tx('筛选','Filter'), options: [
        { value: 'all',       label: t.tx('全部','All') },
        { value: 'available', label: t.tx('可领取','Available') },
        { value: 'completed', label: t.tx('已完成','Completed') },
        { value: 'claimed',   label: t.tx('已领取','Claimed') },
      ] },
      show_progress: { type: 'boolean', label: t.tx('显示进度条','Show progress') },
      max:           { type: 'number',  label: t.tx('最多显示','Max items'), min: 3, max: 30 },
    } },
  { type: 'task-stats', name: t.tx('任务进度','Task Progress'),
    defaults: { props: { title: t.tx('今日任务进度','Today’s task progress'), show_completion_pct: true } },
    schema: {
      title:               { type: 'text',    label: t.tx('标题','Title') },
      show_completion_pct: { type: 'boolean', label: t.tx('显示完成百分比','Show completion %') },
    } },
  { type: 'recharge-channel-grid', name: t.tx('充值通道','Recharge Channels'),
    defaults: { props: { channels: [
      { id: 'alipay', label: t.tx('支付宝','Alipay'),    icon: '🟦' },
      { id: 'wechat', label: t.tx('微信','WeChat'),      icon: '🟩' },
      { id: 'usdt',   label: 'USDT',                     icon: '🟨' },
      { id: 'bank',   label: t.tx('银行卡','Bank Card'), icon: '🟫' },
    ] } },
    schema: {
      channels: { type: 'items', label: t.tx('通道','Channels'), itemFields: {
        id:    { type: 'select', label: t.tx('类型','Type'), options: [
          { value: 'alipay', label: t.tx('支付宝','Alipay') },
          { value: 'wechat', label: t.tx('微信','WeChat') },
          { value: 'usdt',   label: 'USDT' },
          { value: 'bank',   label: t.tx('银行卡','Bank Card') },
        ] },
        label: { type: 'text', label: t.tx('显示名','Display name') },
        icon:  { type: 'text', label: t.tx('图标 emoji','Icon emoji') },
      } },
    } },
  { type: 'recharge-amount-presets', name: t.tx('金额预设','Amount Presets'),
    defaults: { props: { amounts: [68, 198, 388, 588, 888, 1888], bonus_pct: 0 } },
    schema: {
      amounts:   { type: 'items', label: t.tx('金额选项','Amount options'), itemFields: {
        value: { type: 'number', label: t.tx('金额','Amount'), min: 1 },
      } },
      bonus_pct: { type: 'number', label: t.tx('额外赠送 %','Bonus %'), min: 0, max: 100 },
    } },
  { type: 'recharge-form', name: t.tx('充值表单','Recharge Form'),
    defaults: { props: { min_amount: 1, max_amount: 100000, default_amount: 100, cta_text: t.tx('立即充值','Recharge now') } },
    schema: {
      min_amount:     { type: 'number', label: t.tx('最小金额','Min amount') },
      max_amount:     { type: 'number', label: t.tx('最大金额','Max amount') },
      default_amount: { type: 'number', label: t.tx('默认金额','Default amount') },
      cta_text:       { type: 'text',   label: t.tx('按钮文案','Button text') },
    } },
  { type: 'lottery-hall-grid', name: t.tx('彩种网格','Lottery Grid'),
    defaults: { props: { lotteries: [
      { id: 'cqssc', name: t.tx('重庆时时彩','Chongqing SSC'), icon: '🎯', freq: '1m' },
      { id: 'k3',    name: t.tx('快 3','K3'),                  icon: '🎲', freq: '1m' },
      { id: 'pk10',  name: 'PK10',                             icon: '🏁', freq: '3m' },
    ], columns: 2 } },
    schema: {
      lotteries: { type: 'items', label: t.tx('彩种列表','Lottery list'), itemFields: {
        id:   { type: 'text', label: 'ID' },
        name: { type: 'text', label: t.tx('名称','Name') },
        icon: { type: 'text', label: t.tx('图标 emoji','Icon emoji') },
        freq: { type: 'text', label: t.tx('频次 (1m / 3m / ...)','Frequency (1m / 3m / ...)') },
      } },
      columns:   { type: 'number', label: t.tx('每行列数','Columns'), min: 1, max: 4 },
    } },
  { type: 'lottery-recent-draws', name: t.tx('最近开奖','Recent Draws'),
    defaults: { props: { lottery_id: 'cqssc', max: 5, show_period: true } },
    schema: {
      lottery_id:  { type: 'select', label: t.tx('彩种','Lottery'), options: [
        { value: 'cqssc', label: t.tx('重庆时时彩','Chongqing SSC') },
        { value: 'k3',    label: t.tx('快 3','K3') },
        { value: 'pk10',  label: 'PK10' },
      ] },
      max:         { type: 'number',  label: t.tx('显示几期','Periods'), min: 1, max: 20 },
      show_period: { type: 'boolean', label: t.tx('显示期号','Show period') },
    } },
  { type: 'lottery-hot-plays', name: t.tx('热门玩法','Hot Plays'),
    defaults: { props: { title: t.tx('🔥 热门玩法','🔥 Hot Plays'), items: [
      { lottery: 'cqssc', play_id: 'dsds-dx', label: t.tx('大小','Big/Small'),  odds: '1.98' },
      { lottery: 'cqssc', play_id: 'dsds-ds', label: t.tx('单双','Odd/Even'),  odds: '1.98' },
      { lottery: 'cqssc', play_id: 'lhh-1',   label: t.tx('龙虎','Dragon/Tiger'), odds: '1.98' },
    ] } },
    schema: {
      title: { type: 'text', label: t.tx('标题','Title') },
      items: { type: 'items', label: t.tx('玩法','Plays'), itemFields: {
        lottery: { type: 'select', label: t.tx('彩种','Lottery'), options: [
          { value: 'cqssc', label: t.tx('重庆时时彩','Chongqing SSC') },
          { value: 'k3',    label: t.tx('快3','K3') },
          { value: 'pk10',  label: 'PK10' },
        ] },
        play_id: { type: 'text', label: t.tx('玩法 ID','Play ID') },
        label:   { type: 'text', label: t.tx('显示名','Display name') },
        odds:    { type: 'text', label: t.tx('赔率展示','Odds text') },
      } },
    } },
  { type: 'live-category-tabs', name: t.tx('直播类目 tab','Live Category Tabs'),
    defaults: { props: { categories: [
      { key: 'all',     label: t.tx('全部','All'),    icon: '🌐' },
      { key: 'sports',  label: t.tx('体育','Sports'), icon: '⚽' },
      { key: 'show',    label: t.tx('才艺','Talent'), icon: '🎤' },
      { key: 'chat',    label: t.tx('闲聊','Chat'),   icon: '💬' },
      { key: 'lottery', label: t.tx('彩票','Lottery'),icon: '🎯' },
      { key: 'game',    label: t.tx('游戏','Games'),  icon: '🎮' },
    ], default_key: 'all' } },
    schema: {
      categories:  { type: 'items', label: t.tx('类目','Categories'), itemFields: {
        key:   { type: 'text', label: 'key' },
        label: { type: 'text', label: t.tx('显示名','Display name') },
        icon:  { type: 'text', label: 'emoji' },
      } },
      default_key: { type: 'text', label: t.tx('默认选中','Default selected') },
    } },
  { type: 'live-hall-rooms-grid', name: t.tx('直播间网格','Live Rooms Grid'),
    defaults: { props: { max: 20, columns: 2, sort: 'viewer_count', show_viewer_count: true } },
    schema: {
      max:               { type: 'number',  label: t.tx('最多显示','Max items'), min: 4, max: 50 },
      columns:           { type: 'number',  label: t.tx('列数','Columns'),       min: 1, max: 3 },
      sort:              { type: 'select',  label: t.tx('排序','Sort'), options: [
        { value: 'viewer_count',      label: t.tx('按在线人数','By viewers') },
        { value: 'peak_viewers',      label: t.tx('按峰值','By peak') },
        { value: 'total_gifts_value', label: t.tx('按礼物值','By gift value') },
      ] },
      show_viewer_count: { type: 'boolean', label: t.tx('显示在线人数','Show viewer count') },
    } },
  { type: 'live-featured-banner', name: t.tx('主推直播','Featured Live'),
    defaults: { props: { title: t.tx('🔴 热门主播','🔴 Hot Streamers'), sort: 'peak_viewers' } },
    schema: {
      title: { type: 'text',   label: t.tx('标题','Title') },
      sort:  { type: 'select', label: t.tx('选取','Selection'), options: [
        { value: 'peak_viewers', label: t.tx('峰值最高','Peak viewers') },
        { value: 'viewer_count', label: t.tx('当前最热','Current hottest') },
      ] },
    } },
  { type: 'drama-category-tabs', name: t.tx('短剧类目 tab','Drama Category Tabs'),
    defaults: { props: { categories: [
      { key: 'all',       label: t.tx('全部','All'),      icon: '🎬' },
      { key: 'romance',   label: t.tx('爱情','Romance'),  icon: '💕' },
      { key: 'suspense',  label: t.tx('悬疑','Suspense'), icon: '🕵️' },
      { key: 'comedy',    label: t.tx('喜剧','Comedy'),   icon: '😄' },
      { key: 'family',    label: t.tx('家庭','Family'),   icon: '👨‍👩‍👧' },
      { key: 'fantasy',   label: t.tx('玄幻','Fantasy'),  icon: '✨' },
    ], default_key: 'all' } },
    schema: {
      categories:  { type: 'items', label: t.tx('类目','Categories'), itemFields: {
        key:   { type: 'text', label: 'key' },
        label: { type: 'text', label: t.tx('显示名','Display name') },
        icon:  { type: 'text', label: 'emoji' },
      } },
      default_key: { type: 'text', label: t.tx('默认选中','Default selected') },
    } },
  { type: 'drama-series-grid', name: t.tx('剧目网格','Drama Series Grid'),
    defaults: { props: { max: 12, columns: 3, sort: 'hot', show_price: true } },
    schema: {
      max:        { type: 'number',  label: t.tx('最多显示','Max items'), min: 3, max: 30 },
      columns:    { type: 'number',  label: t.tx('列数','Columns'),       min: 2, max: 4 },
      sort:       { type: 'select',  label: t.tx('排序','Sort'), options: [
        { value: 'hot', label: t.tx('按热度','By popularity') },
        { value: 'new', label: t.tx('按新上架','By newly added') },
      ] },
      show_price: { type: 'boolean', label: t.tx('显示单集价格','Show episode price') },
    } },
  { type: 'drama-featured-banner', name: t.tx('主推剧','Featured Drama'),
    defaults: { props: { code: '', title: t.tx('本周热剧推荐','This week’s hot picks'), auto_pick: true } },
    schema: {
      code:      { type: 'text',    label: t.tx('剧目 code（auto_pick=否时用）','Series code (when auto_pick=false)') },
      title:     { type: 'text',    label: t.tx('标题','Title') },
      auto_pick: { type: 'boolean', label: t.tx('自动选热度最高','Auto-pick hottest') },
    } },
  { type: 'game-category-tabs', name: t.tx('游戏类目 tab','Game Category Tabs'),
    defaults: { props: { categories: [
      { key: 'all',     label: t.tx('全部','All'),     icon: '🎮' },
      { key: 'slot',    label: t.tx('老虎机','Slots'), icon: '🎰' },
      { key: 'fishing', label: t.tx('捕鱼','Fishing'), icon: '🐟' },
      { key: 'poker',   label: t.tx('扑克','Poker'),   icon: '🃏' },
      { key: 'cards',   label: t.tx('棋牌','Card'),    icon: '♠️' },
      { key: 'arcade',  label: t.tx('街机','Arcade'),  icon: '🕹️' },
    ], default_key: 'all' } },
    schema: {
      categories:  { type: 'items', label: t.tx('类目','Categories'), itemFields: {
        key:   { type: 'text', label: 'key' },
        label: { type: 'text', label: t.tx('显示名','Display name') },
        icon:  { type: 'text', label: 'emoji' },
      } },
      default_key: { type: 'text', label: t.tx('默认选中','Default selected') },
    } },
  { type: 'game-hall-grid', name: t.tx('游戏网格','Game Grid'),
    defaults: { props: { max: 12, columns: 3, sort: 'hot', show_rtp: true } },
    schema: {
      max:      { type: 'number',  label: t.tx('最多显示','Max items'), min: 3, max: 30 },
      columns:  { type: 'number',  label: t.tx('列数','Columns'),       min: 2, max: 4 },
      sort:     { type: 'select',  label: t.tx('排序','Sort'), options: [
        { value: 'hot', label: t.tx('按热度','By popularity') },
        { value: 'new', label: t.tx('按新增','By newest') },
      ] },
      show_rtp: { type: 'boolean', label: t.tx('显示 RTP','Show RTP') },
    } },
];
const TIER_LIST = ['normal', 'bronze', 'silver', 'gold', 'diamond', 'supreme'];
const TIER_NAMES = (t) => ({
  normal:  t.tx('普通','Normal'),
  bronze:  t.tx('青铜','Bronze'),
  silver:  t.tx('白银','Silver'),
  gold:    t.tx('黄金','Gold'),
  diamond: t.tx('钻石','Diamond'),
  supreme: t.tx('至尊','Supreme'),
});

function uid() { return 'c-' + Math.random().toString(36).slice(2, 9); }

const WEEKDAY_NAMES = (t) => [
  t.tx('日','Sun'), t.tx('一','Mon'), t.tx('二','Tue'), t.tx('三','Wed'),
  t.tx('四','Thu'), t.tx('五','Fri'), t.tx('六','Sat'),
];
const MEDIA_KIND_OPTIONS = (t) => [
  { value: '',       label: t.tx('全部','All') },
  { value: 'banner', label: 'Banner' },
  { value: 'gift',   label: t.tx('礼物','Gifts') },
  { value: 'cover',  label: t.tx('封面','Covers') },
  { value: 'icon',   label: t.tx('图标','Icons') },
  { value: 'misc',   label: t.tx('杂项','Misc') },
];
const HISTORY_LIMIT = 50;

function defaultStyle() {
  return { bg: '', color: '', padding: 12, margin_top: 0, margin_bottom: 12, radius: 12, shadow: false };
}
function defaultByTier() {
  return TIER_LIST.reduce((a, t) => { a[t] = true; return a; }, {});
}
function defaultTimeWindow() {
  return { weekdays: [0,1,2,3,4,5,6], hour_start: 0, hour_end: 24 };
}
// Older draft JSONs in storage contain duplicate component IDs (seeded by
// scaffold scripts that didn't use uid()). filter-by-id deletes every match,
// which surfaced as "click × to delete one card, two disappear". Rewrite the
// 2nd+ occurrence to a fresh uid before the editor ever sees the array.
function dedupeIds(comps) {
  const seen = new Set();
  return (comps || []).map((c) => {
    let id = c.id;
    if (!id || seen.has(id)) id = uid();
    seen.add(id);
    return id === c.id ? c : { ...c, id };
  });
}
function ensureUniversal(c) {
  return {
    ...c,
    visible:        c.visible !== false,
    locked:         c.locked === true,
    by_tier:        c.by_tier && typeof c.by_tier === 'object' ? c.by_tier : defaultByTier(),
    time_window:    c.time_window || defaultTimeWindow(),
    ab_experiment:  c.ab_experiment || { experiment_id: '', variant: '' },
    style:          { ...defaultStyle(), ...(c.style || {}) },
  };
}

// ─── Media picker (modal) ──────────────────────────────────────────────────
function MediaPicker({ open, onClose, onPick, push, t }) {
  const tx = (t && t.tx) ? t.tx : ((zh) => zh);
  const [kind, setKind] = React.useState('');
  const [list, setList] = React.useState([]);
  const [loading, setLoading] = React.useState(false);
  const [progress, setProgress] = React.useState(0);
  const [uploadKind, setUploadKind] = React.useState('misc');
  const fileRef = React.useRef(null);

  const load = React.useCallback(() => {
    if (!open) return;
    setLoading(true);
    const q = kind ? `?prefix=${encodeURIComponent(kind + '/')}&limit=100` : '?limit=100';
    fetch(`${API_BASE}/api/admin/media/list${q}`, { headers: window.NebulaAdmin.authHeaders() })
      .then(r => r.ok ? r.json() : { items: [] })
      .then(d => setList(d.items || d.objects || []))
      .catch(() => setList([]))
      .finally(() => setLoading(false));
  }, [open, kind]);
  React.useEffect(() => { load(); }, [load]);

  const handleUpload = (file) => {
    if (!file) return;
    const fd = new FormData();
    fd.append('file', file);
    fd.append('kind', uploadKind);
    const xhr = new XMLHttpRequest();
    xhr.open('POST', `${API_BASE}/api/admin/media/upload`);
    const tok = window.NebulaAdmin.authHeaders().Authorization;
    if (tok) xhr.setRequestHeader('Authorization', tok);
    xhr.upload.onprogress = (e) => { if (e.lengthComputable) setProgress(Math.round(e.loaded / e.total * 100)); };
    xhr.onload = () => {
      setProgress(0);
      if (xhr.status >= 200 && xhr.status < 300) { push(tx('已上传','Uploaded')); load(); }
      else { push(tx('上传失败：','Upload failed: ') + xhr.status); }
    };
    xhr.onerror = () => { setProgress(0); push(tx('上传出错','Upload error')); };
    xhr.send(fd);
  };

  const remove = async (key) => {
    if (!confirm(tx('删除 ','Delete ') + key + tx(' ？','?'))) return;
    const r = await fetch(`${API_BASE}/api/admin/media/object`, {
      method: 'DELETE',
      headers: { ...window.NebulaAdmin.authHeaders(), 'Content-Type': 'application/json' },
      body: JSON.stringify({ key }),
    });
    if (r.ok) { push(tx('已删除','Deleted')); load(); } else { push(tx('删除失败：','Delete failed: ') + r.status); }
  };

  if (!open) return null;
  return (
    <Modal open={open} onClose={onClose} title={tx('媒体库','Media library')} width={820}
      footer={<button className="btn btn-sm" onClick={onClose}>{tx('关闭','Close')}</button>}>
      <div style={{ display: 'flex', gap: 8, alignItems: 'center', marginBottom: 10, flexWrap: 'wrap' }}>
        <span style={{ fontSize: 11, color: 'var(--text-muted)' }}>{tx('过滤','Filter')}</span>
        {MEDIA_KIND_OPTIONS(t).map(o => (
          <span key={o.value || 'all'} className={'chip ' + (kind === o.value ? 'active' : '')}
                style={{ fontSize: 10, cursor: 'pointer' }} onClick={() => setKind(o.value)}>{o.label}</span>
        ))}
        <div style={{ flex: 1 }} />
        <select className="select" value={uploadKind} onChange={(e) => setUploadKind(e.target.value)} style={{ minWidth: 110 }}>
          {MEDIA_KIND_OPTIONS(t).filter(o => o.value).map(o => <option key={o.value} value={o.value}>{tx('上传到 ','Upload to ') + o.label}</option>)}
        </select>
        <button className="btn btn-pri btn-sm" onClick={() => fileRef.current?.click()}>{tx('上传','Upload')}</button>
        <input ref={fileRef} type="file" accept="image/*" style={{ display: 'none' }}
               onChange={(e) => { handleUpload(e.target.files?.[0]); e.target.value = ''; }} />
      </div>
      {progress > 0 && (
        <div style={{ height: 6, background: 'var(--bg-inset)', borderRadius: 3, marginBottom: 10, overflow: 'hidden' }}>
          <div style={{ width: progress + '%', height: '100%', background: 'var(--accent)', transition: 'width .15s' }} />
        </div>
      )}
      {loading && <div style={{ padding: 20, textAlign: 'center', fontSize: 12, color: 'var(--text-muted)' }}>{tx('加载中…','Loading…')}</div>}
      {!loading && list.length === 0 && <div style={{ padding: 28, textAlign: 'center', fontSize: 12, color: 'var(--text-faint)' }}>{tx('无对象 · 试着上传一张','No objects · try uploading one')}</div>}
      <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(140px, 1fr))', gap: 10, maxHeight: 460, overflowY: 'auto' }}>
        {list.map((it) => {
          const url = it.url || it.public_url || it.URL || '';
          const key = it.key || it.Key || it.name || '';
          const size = it.size || it.Size || 0;
          return (
            <div key={key} style={{ border: '1px solid var(--border)', borderRadius: 6, overflow: 'hidden', background: 'var(--bg-card)' }}>
              <div onClick={() => { onPick(url, it); onClose(); }}
                   style={{ aspectRatio: '1/1', background: '#0A0A14 center/cover no-repeat', backgroundImage: url ? `url("${url}")` : 'none', cursor: 'pointer' }} />
              <div style={{ padding: 6, fontSize: 10 }}>
                <div style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontFamily: 'var(--font-mono)' }}>{key}</div>
                <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 3 }}>
                  <span className="text-faint">{size ? (size / 1024).toFixed(1) + ' KB' : ''}</span>
                  <button className="btn btn-sm btn-danger" style={{ padding: '1px 5px', fontSize: 9 }} onClick={() => remove(key)}>{tx('删','Del')}</button>
                </div>
              </div>
            </div>
          );
        })}
      </div>
    </Modal>
  );
}

function BuilderScreen({ t, push }) {
  const [pages, setPages] = React.useState([]);
  const [pageId, setPageId] = React.useState('home');
  const [draft, setDraft] = React.useState(null);
  const [selectedId, setSelectedId] = React.useState(null);
  const [audienceTier, setAudienceTier] = React.useState('gold');
  const [deviceId, setDeviceId] = React.useState('iphone13');
  const [dirty, setDirty] = React.useState(false);
  const [saving, setSaving] = React.useState(false);
  const [reloadKey, setReloadKey] = React.useState(0);
  const [mediaOpen, setMediaOpen] = React.useState(false);
  const [mediaTarget, setMediaTarget] = React.useState(null); // {kind, onPick}
  const [openSections, setOpenSections] = React.useState({ content: true, position: false, style: false, target: false, json: false });
  const iframeRef = React.useRef(null);

  // undo / redo stacks (snapshots of `components` array)
  const undoStack = React.useRef([]);
  const redoStack = React.useRef([]);
  const skipNextSnapshot = React.useRef(false);
  const [, forceUpdate] = React.useReducer(x => x + 1, 0);

  // ─── Lineage modal + autosave + localStorage safety net ──────────────────
  const [lineageOpen, setLineageOpen] = React.useState(false);
  const [lineage, setLineage] = React.useState([]);
  const [lineageLoading, setLineageLoading] = React.useState(false);
  const [lastSavedAt, setLastSavedAt] = React.useState(0);
  const [, tickRel] = React.useReducer(x => x + 1, 0);
  const [localPending, setLocalPending] = React.useState(null); // {ts, version, components}
  const autosaveTimer = React.useRef(null);
  const draftRef = React.useRef(null);
  const dirtyRef = React.useRef(false);
  const savingRef = React.useRef(false);
  const localKey = `nebula.builder.local-draft.${pageId}`;
  React.useEffect(() => { draftRef.current = draft; }, [draft]);
  React.useEffect(() => { dirtyRef.current = dirty; }, [dirty]);
  // rerender the "Saved Xs ago" indicator every 30s
  React.useEffect(() => {
    if (!lastSavedAt) return;
    const iv = setInterval(() => tickRel(), 30000);
    return () => clearInterval(iv);
  }, [lastSavedAt]);

  const fmtRel = (ts) => {
    if (!ts) return '';
    const s = Math.max(0, Math.floor((Date.now() - ts) / 1000));
    if (s < 5) return t.tx('刚刚','just now');
    if (s < 60) return t.tx(s + ' 秒前', s + 's ago');
    const m = Math.floor(s / 60);
    if (m < 60) return t.tx(m + ' 分钟前', m + 'm ago');
    const h = Math.floor(m / 60);
    if (h < 24) return t.tx(h + ' 小时前', h + 'h ago');
    return new Date(ts).toLocaleString('zh-CN', { hour12: false });
  };

  const localizedCatalog = React.useMemo(() => COMPONENT_CATALOG(t), [t.lang]);
  const device = DEVICE_PRESETS.find(d => d.id === deviceId) || DEVICE_PRESETS[0];
  const baseFront = getFrontendUrl();
  const frontendUrl = baseFront + (baseFront.includes('?') ? '&' : '?') + 'layout=draft';

  React.useEffect(() => {
    fetch(`${API_BASE}/api/layouts`).then(r => r.json()).then(d => setPages(d.pages || []));
  }, []);
  React.useEffect(() => {
    setSelectedId(null);
    undoStack.current = [];
    redoStack.current = [];
    setLocalPending(null);
    setLastSavedAt(0);
    if (autosaveTimer.current) { clearTimeout(autosaveTimer.current); autosaveTimer.current = null; }
    fetch(`${API_BASE}/api/layouts/${pageId}/draft`).then(r => r.json()).then((d) => {
      // normalize all components with universal defaults so editor never crashes on missing fields
      const normalized = { ...d, components: dedupeIds(d.components).map(ensureUniversal) };
      setDraft(normalized);
      setDirty(false);
      // localStorage safety net: detect newer local copy
      try {
        const raw = localStorage.getItem(`nebula.builder.local-draft.${pageId}`);
        if (raw) {
          const local = JSON.parse(raw);
          const serverTs = d.updated_at ? new Date(d.updated_at).getTime() : 0;
          if (local && local.ts && (!serverTs || local.ts > serverTs) && Array.isArray(local.components)) {
            setLocalPending(local);
          }
        }
      } catch (e) { /* ignore corrupt localStorage */ }
    });
  }, [pageId]);

  const pushToPreview = React.useCallback(() => {
    const f = iframeRef.current; if (!f || !f.contentWindow || !draft) return;
    f.contentWindow.postMessage({ type: 'nebula:set-tier', tier: audienceTier }, '*');
    f.contentWindow.postMessage({ type: 'nebula:layout-update', page: pageId, config: draft }, '*');
  }, [draft, audienceTier, pageId]);
  React.useEffect(() => {
    const onMsg = (e) => { if (e.data?.type === 'nebula:ready') pushToPreview(); };
    window.addEventListener('message', onMsg);
    return () => window.removeEventListener('message', onMsg);
  }, [pushToPreview]);
  React.useEffect(() => { pushToPreview(); }, [pushToPreview]);

  // Track iframe scrollY so the overlay handles stay aligned when the preview
  // page is scrolled. Same-origin → we can read contentWindow.scrollY directly.
  const [iframeScrollY, setIframeScrollY] = React.useState(0);
  React.useEffect(() => {
    const iv = setInterval(() => {
      try {
        const w = iframeRef.current && iframeRef.current.contentWindow;
        if (w) setIframeScrollY(w.scrollY || 0);
      } catch (e) { /* cross-origin paranoia */ }
    }, 100);
    return () => clearInterval(iv);
  }, []);

  const snapshot = (cur) => {
    if (!cur) return;
    if (skipNextSnapshot.current) { skipNextSnapshot.current = false; return; }
    const snap = JSON.stringify(cur.components || []);
    const top = undoStack.current[undoStack.current.length - 1];
    if (top === snap) return;
    undoStack.current.push(snap);
    if (undoStack.current.length > HISTORY_LIMIT) undoStack.current.shift();
    redoStack.current = [];
  };

  const updateDraft = (mut) => {
    setDraft((cur) => {
      const next = mut(cur);
      if (next === cur) return cur;
      snapshot(cur);
      setDirty(true);
      return next;
    });
    // debounced autosave (500ms) — the timer cancellation covers active typing
    if (autosaveTimer.current) clearTimeout(autosaveTimer.current);
    autosaveTimer.current = setTimeout(() => {
      autosaveTimer.current = null;
      if (dirtyRef.current && !savingRef.current) saveDraftRef.current && saveDraftRef.current();
    }, 500);
  };
  const saveDraftRef = React.useRef(null);

  const undo = React.useCallback(() => {
    if (!undoStack.current.length) return;
    const snap = undoStack.current.pop();
    setDraft((cur) => {
      if (!cur) return cur;
      redoStack.current.push(JSON.stringify(cur.components || []));
      if (redoStack.current.length > HISTORY_LIMIT) redoStack.current.shift();
      skipNextSnapshot.current = true;
      return { ...cur, components: dedupeIds(JSON.parse(snap)).map(ensureUniversal) };
    });
    setDirty(true);
    forceUpdate();
  }, []);
  const redo = React.useCallback(() => {
    if (!redoStack.current.length) return;
    const snap = redoStack.current.pop();
    setDraft((cur) => {
      if (!cur) return cur;
      undoStack.current.push(JSON.stringify(cur.components || []));
      if (undoStack.current.length > HISTORY_LIMIT) undoStack.current.shift();
      skipNextSnapshot.current = true;
      return { ...cur, components: dedupeIds(JSON.parse(snap)).map(ensureUniversal) };
    });
    setDirty(true);
    forceUpdate();
  }, []);

  React.useEffect(() => {
    const onKey = (e) => {
      const meta = e.metaKey || e.ctrlKey;
      if (!meta) return;
      // skip when typing in an input/textarea
      const tag = (e.target?.tagName || '').toLowerCase();
      if (tag === 'input' || tag === 'textarea' || e.target?.isContentEditable) return;
      if (e.key === 'z' || e.key === 'Z') {
        e.preventDefault();
        if (e.shiftKey) redo(); else undo();
      } else if (e.key === 'y' || e.key === 'Y') {
        e.preventDefault();
        redo();
      }
    };
    window.addEventListener('keydown', onKey);
    return () => window.removeEventListener('keydown', onKey);
  }, [undo, redo]);

  const addComponent = (type) => {
    const cat = localizedCatalog.find(c => c.type === type);
    if (!cat) return;
    const inst = ensureUniversal({
      id: uid(),
      type,
      props: JSON.parse(JSON.stringify(cat.defaults.props || {})),
      audience: { tiers: [...TIER_LIST] },
    });
    updateDraft((d) => ({ ...d, components: [...(d?.components || []), inst] }));
    setSelectedId(inst.id);
  };
  const deleteComponent = (id) => {
    const c = (draft?.components || []).find(x => x.id === id);
    if (c?.locked) { push(t.tx('已锁定，先解锁再删除','Locked — unlock before deleting')); return; }
    updateDraft((d) => ({ ...d, components: (d?.components || []).filter(c => c.id !== id) }));
    if (selectedId === id) setSelectedId(null);
  };
  const duplicateComponent = (id) => {
    updateDraft((d) => {
      const arr = d?.components || [];
      const idx = arr.findIndex(c => c.id === id);
      if (idx < 0) return d;
      const copy = ensureUniversal({ ...JSON.parse(JSON.stringify(arr[idx])), id: uid() });
      const next = [...arr];
      next.splice(idx + 1, 0, copy);
      return { ...d, components: next };
    });
  };
  const moveComponent = (fromIdx, toIdx) => {
    if (fromIdx === toIdx || toIdx == null) return;
    updateDraft((d) => {
      const arr = [...(d?.components || [])];
      const [item] = arr.splice(fromIdx, 1);
      arr.splice(toIdx, 0, item);
      return { ...d, components: arr };
    });
  };
  const moveBy = (id, delta) => {
    const arr = draft?.components || [];
    const idx = arr.findIndex(c => c.id === id);
    if (idx < 0) return;
    moveComponent(idx, Math.max(0, Math.min(arr.length - 1, idx + delta)));
  };
  const patchComponent = (id, patch) => {
    updateDraft((d) => ({
      ...d,
      components: (d?.components || []).map(c => c.id === id ? ensureUniversal({ ...c, ...patch }) : c),
    }));
  };
  const updateSelected = (patch) => {
    if (!selectedId) return;
    updateDraft((d) => ({
      ...d,
      components: (d?.components || []).map(c => {
        if (c.id !== selectedId) return c;
        return ensureUniversal({
          ...c,
          ...(patch.props !== undefined ? { props: patch.props } : {}),
          ...(patch.audience !== undefined ? { audience: { ...c.audience, ...patch.audience } } : {}),
          ...(patch.style !== undefined ? { style: { ...(c.style || {}), ...patch.style } } : {}),
          ...(patch.by_tier !== undefined ? { by_tier: patch.by_tier } : {}),
          ...(patch.time_window !== undefined ? { time_window: { ...(c.time_window || {}), ...patch.time_window } } : {}),
          ...(patch.ab_experiment !== undefined ? { ab_experiment: { ...(c.ab_experiment || {}), ...patch.ab_experiment } } : {}),
          ...(patch.visible !== undefined ? { visible: patch.visible } : {}),
          ...(patch.locked !== undefined ? { locked: patch.locked } : {}),
          ...(patch.pos !== undefined ? { pos: patch.pos } : {}),
          ...(patch.raw !== undefined ? patch.raw : {}),
        });
      }),
    }));
  };

  const openMedia = (onPick) => { setMediaTarget({ onPick }); setMediaOpen(true); };

  const authHeaders = window.NebulaAdmin.authHeaders;

  const saveDraft = async (silent) => {
    const cur = draftRef.current || draft;
    if (!cur) return;
    setSaving(true); savingRef.current = true;
    // localStorage safety net: snapshot BEFORE network attempt so a server
    // failure / page reload still has the local copy.
    try {
      localStorage.setItem(`nebula.builder.local-draft.${pageId}`,
        JSON.stringify({ ts: Date.now(), version: (cur.version || 0) + 1, components: cur.components || [] }));
    } catch (e) { /* quota / disabled */ }
    try {
      const r = await fetch(`${API_BASE}/api/layouts/${pageId}/draft`, {
        method: 'PUT',
        headers: authHeaders(),
        body: JSON.stringify({ version: (cur.version || 0) + 1, components: cur.components }),
      });
      if (!r.ok) { push(t.tx('保存失败：','Save failed: ') + r.status + (r.status === 401 ? t.tx(' 未登录',' not logged in') : '')); return; }
      const j = await r.json();
      const normalized = { ...j.draft, components: dedupeIds(j.draft.components).map(ensureUniversal) };
      setDraft(normalized);
      setDirty(false);
      setLastSavedAt(Date.now());
      // server save succeeded → drop the local safety copy
      try { localStorage.removeItem(`nebula.builder.local-draft.${pageId}`); } catch (e) {}
      if (!silent) push(t.tx('草稿已保存 v','Draft saved v') + j.draft.version);
    } finally { setSaving(false); savingRef.current = false; }
  };
  // bind ref so the debounce timer (defined earlier) can call us
  React.useEffect(() => { saveDraftRef.current = () => saveDraft(true); });
  const submitForApproval = async () => {
    if (dirty) await saveDraft();
    const r = await fetch(`${API_BASE}/api/layouts/${pageId}/submit`, {
      method: 'POST',
      headers: authHeaders(),
      body: JSON.stringify({}),
    });
    if (!r.ok) { push(t.tx('提交失败：','Submit failed: ') + r.status + (r.status === 401 ? t.tx(' 未登录',' not logged in') : '')); return; }
    const j = await r.json();
    push(t.tx('已提交发布审批 · ','Submitted for approval · ') + j.approval.id);
  };
  // cleanup pending autosave timer when component unmounts or page changes
  React.useEffect(() => () => { if (autosaveTimer.current) clearTimeout(autosaveTimer.current); }, []);

  const openLineage = async () => {
    setLineageOpen(true);
    setLineageLoading(true);
    try {
      const r = await fetch(`${API_BASE}/api/admin/layouts/${pageId}/template-history`, { headers: authHeaders() });
      if (!r.ok) { push(t.tx('谱系加载失败：','Lineage load failed: ') + r.status); setLineage([]); return; }
      const j = await r.json();
      setLineage(Array.isArray(j) ? j : (j?.history || []));
    } catch (e) {
      push(t.tx('谱系加载失败','Lineage load failed'));
      setLineage([]);
    } finally { setLineageLoading(false); }
  };
  const rollbackTemplateApply = async (historyId) => {
    if (!confirm(t.tx('将页面恢复到此次模板应用之前的状态？此操作不可撤销。','Restore the page to the state BEFORE this template apply? This cannot be undone.'))) return;
    const r = await fetch(`${API_BASE}/api/admin/layouts/${pageId}/rollback-template-apply`, {
      method: 'POST',
      headers: { ...authHeaders(), 'Content-Type': 'application/json' },
      body: JSON.stringify({ history_id: historyId }),
    });
    if (!r.ok) { push(t.tx('回退失败：','Rollback failed: ') + r.status); return; }
    push(t.tx('已回退到此前状态，正在重载草稿…','Rolled back. Reloading draft…'));
    setLineageOpen(false);
    // reload draft (same logic as the pageId-load useEffect)
    fetch(`${API_BASE}/api/layouts/${pageId}/draft`).then(r2 => r2.json()).then((d) => {
      const normalized = { ...d, components: dedupeIds(d.components).map(ensureUniversal) };
      setDraft(normalized);
      setDirty(false);
      undoStack.current = []; redoStack.current = [];
      try { localStorage.removeItem(`nebula.builder.local-draft.${pageId}`); } catch (e) {}
      setLocalPending(null);
    });
  };

  const restoreLocalDraft = () => {
    if (!localPending) return;
    setDraft((cur) => cur ? { ...cur, components: dedupeIds(localPending.components).map(ensureUniversal) } : cur);
    setDirty(true);
    setLocalPending(null);
    push(t.tx('已恢复本地草稿（未保存）','Local draft restored (unsaved)'));
  };
  const discardLocalDraft = () => {
    try { localStorage.removeItem(localKey); } catch (e) {}
    setLocalPending(null);
  };

  const resetDraft = () => {
    if (!confirm(t.tx('放弃所有未保存的修改，从服务器重载草稿？','Discard all unsaved changes and reload draft from server?'))) return;
    setSelectedId(null);
    undoStack.current = []; redoStack.current = [];
    fetch(`${API_BASE}/api/layouts/${pageId}/draft`).then(r => r.json()).then((d) => {
      setDraft({ ...d, components: dedupeIds(d.components).map(ensureUniversal) });
      setDirty(false);
    });
  };

  const selected = (draft?.components || []).find(c => c.id === selectedId) || null;

  return (
    <div className="stack">
      <div className="page-h">
        <div>
          <h1>{t.p_builder}</h1>
          <div className="sub">{pageId} · {draft ? t.tx('草稿 v','Draft v') + draft.version : t.tx('加载中…','Loading…')} {dirty && <span style={{ color: 'var(--accent)', marginLeft: 6 }}>● {t.tx('未保存','Unsaved')}</span>}</div>
        </div>
        <div className="actions">
          <button className="btn btn-sm" disabled={!undoStack.current.length} onClick={undo} title={t.tx('撤销 (Ctrl+Z)','Undo (Ctrl+Z)')}>↶ {t.tx('撤销','Undo')} {undoStack.current.length ? `(${undoStack.current.length})` : ''}</button>
          <button className="btn btn-sm" disabled={!redoStack.current.length} onClick={redo} title={t.tx('重做 (Ctrl+Shift+Z)','Redo (Ctrl+Shift+Z)')}>↷ {t.tx('重做','Redo')} {redoStack.current.length ? `(${redoStack.current.length})` : ''}</button>
          <button className="btn btn-sm" onClick={() => { setMediaTarget(null); setMediaOpen(true); }}>🖼 {t.tx('媒体库','Media')}</button>
          <button className="btn btn-sm" onClick={() => setReloadKey(k => k + 1)}><Icons.eye /> {t.tx('刷新预览','Refresh preview')}</button>
          <button className="btn btn-sm" onClick={resetDraft}>{t.tx('重置','Reset')}</button>
          <button className="btn btn-sm" onClick={openLineage} title={t.tx('查看本页面历次模板应用与回退','View past template applications and roll back')}>📜 {t.tx('模板谱系','Lineage')}</button>
          <a className="btn btn-sm" href={frontendUrl} target="_blank" rel="noreferrer">{t.tx('新窗口打开','Open in new window')} ↗</a>
          {saving ? (
            <span className="text-faint" style={{ fontSize: 11, alignSelf: 'center' }}>{t.tx('自动保存中…','Autosaving…')}</span>
          ) : lastSavedAt ? (
            <span className="text-faint" style={{ fontSize: 11, alignSelf: 'center' }} title={new Date(lastSavedAt).toLocaleString('zh-CN', { hour12: false })}>{t.tx('已保存 ' + fmtRel(lastSavedAt), 'Saved ' + fmtRel(lastSavedAt))}</span>
          ) : null}
          <button className="btn btn-sm" onClick={() => saveDraft(false)} disabled={!dirty || saving}>{saving ? t.tx('保存中…','Saving…') : t.tx('保存草稿','Save draft')}</button>
          <button className="btn btn-pri btn-sm" onClick={submitForApproval}>{t.tx('提交发布审批','Submit for approval')}</button>
        </div>
      </div>

      {localPending && (
        <div style={{ padding: '8px 12px', background: 'var(--bg-inset)', border: '1px solid var(--accent)', borderRadius: 6, display: 'flex', gap: 10, alignItems: 'center', fontSize: 12 }}>
          <span>⚠️ {t.tx('发现未保存的本地草稿（','Found unsaved local draft (')}{fmtRel(localPending.ts)}{t.tx('，组件数 ','，components ')}{(localPending.components || []).length}{t.tx('），是否恢复？','). Restore?')}</span>
          <div style={{ flex: 1 }} />
          <button className="btn btn-sm btn-pri" onClick={restoreLocalDraft}>{t.tx('恢复本地','Restore local')}</button>
          <button className="btn btn-sm" onClick={discardLocalDraft}>{t.tx('丢弃本地','Discard local')}</button>
        </div>
      )}

      <div className="tabs">
        {(pages.length ? pages : [{ page: 'home' }, { page: 'promos' }]).map(p => (
          <div key={p.page} className={'tab' + (pageId === p.page ? ' active' : '')} onClick={() => setPageId(p.page)}>
            {p.page === 'home' ? '🏠 ' + t.tx('首页','Home') : p.page === 'promos' ? '🎁 ' + t.tx('活动中心','Promos') : p.page}
            {p.has_pending_draft && <span style={{ marginLeft: 4, fontSize: 9, color: 'var(--accent)' }}>●</span>}
          </div>
        ))}
      </div>

      <div style={{ display: 'grid', gridTemplateColumns: '180px 240px 1fr 320px', gap: 12, alignItems: 'flex-start' }}>
        {/* Palette */}
        <div className="card" style={{ position: 'sticky', top: 0 }}>
          <div className="card-h"><h3>{t.tx('组件库','Components')}</h3></div>
          <div style={{ padding: '8px 10px', display: 'flex', flexDirection: 'column', gap: 6 }}>
            {localizedCatalog.map(c => (
              <button key={c.type} className="btn btn-sm" style={{ justifyContent: 'flex-start', textAlign: 'left', fontSize: 11 }} onClick={() => addComponent(c.type)}>
                + {c.name}
              </button>
            ))}
          </div>
          <div style={{ padding: '10px 12px', borderTop: '1px solid var(--border)', fontSize: 11, color: 'var(--text-faint)' }}>
            {t.tx('点击加入草稿底部','Click to add to the bottom of the draft')}
          </div>
        </div>

        {/* Canvas: structure list with per-item toolbar */}
        <div className="card">
          <div className="card-h"><h3>{t.tx('画布','Canvas')}</h3><span className="meta">{draft?.components?.length || 0} {t.tx('项','items')}</span></div>
          <div style={{ padding: 6, display: 'flex', flexDirection: 'column', gap: 4 }}>
            {(draft?.components || []).map((c, i) => {
              const cat = localizedCatalog.find(x => x.type === c.type);
              const sel = c.id === selectedId;
              const hidden = c.visible === false;
              const locked = c.locked === true;
              return (
                <CanvasCard
                  key={c.id}
                  t={t}
                  index={i}
                  total={(draft?.components || []).length}
                  comp={c}
                  cat={cat}
                  sel={sel}
                  hidden={hidden}
                  locked={locked}
                  onSelect={() => setSelectedId(c.id)}
                  onDup={() => duplicateComponent(c.id)}
                  onDel={() => deleteComponent(c.id)}
                  onToggleLock={() => patchComponent(c.id, { locked: !locked })}
                  onToggleVis={() => patchComponent(c.id, { visible: hidden })}
                  onUp={() => moveBy(c.id, -1)}
                  onDown={() => moveBy(c.id, +1)}
                  onDragStart={(e) => { e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', String(i)); }}
                  onDragOver={(e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; }}
                  onDrop={(e) => { e.preventDefault(); const from = parseInt(e.dataTransfer.getData('text/plain'), 10); if (!isNaN(from)) moveComponent(from, i); }}
                />
              );
            })}
            {!(draft?.components || []).length && (
              <div style={{ padding: 16, fontSize: 11, color: 'var(--text-faint)', textAlign: 'center' }}>{t.tx('从左侧组件库添加','Add from the component panel on the left')}</div>
            )}
          </div>
        </div>

        {/* Live preview */}
        <div className="pb-stage" style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 10, paddingTop: 8 }}>
          <div style={{ display: 'flex', gap: 6 }}>
            {DEVICE_PRESETS.map(d => (
              <span key={d.id} onClick={() => setDeviceId(d.id)}
                    className={'chip ' + (deviceId === d.id ? 'active' : '')}
                    style={{ fontSize: 10 }}>{d.name} · {d.w}×{d.h}</span>
            ))}
          </div>
          <div style={{ position: 'relative', width: device.w + 12, height: device.h + 12, borderRadius: 28, padding: 6, background: '#18181B', boxShadow: '0 10px 40px rgba(0,0,0,.22)' }}>
            <iframe
              key={reloadKey}
              ref={iframeRef}
              src={frontendUrl}
              title="Nebula H5 Live Preview"
              style={{ width: device.w, height: device.h, border: 0, borderRadius: 22, background: '#0A0A14', display: 'block' }}
            />
            {/* Absolutely-positioned overlay aligned to the iframe surface. Only
                renders interactive boxes for components that opted into free
                positioning — flow components fall through to the iframe. */}
            <div style={{ position: 'absolute', left: 6, top: 6, width: device.w, height: device.h, pointerEvents: 'none', borderRadius: 22, overflow: 'hidden' }}>
              <BuilderOverlay
                components={draft?.components || []}
                selectedId={selectedId}
                onSelect={setSelectedId}
                onPatch={(id, nextPos) => patchComponent(id, { pos: nextPos })}
                deviceW={device.w}
                scrollY={iframeScrollY}
                t={t}
              />
            </div>
          </div>
          <div className="text-faint" style={{ fontSize: 10 }}>{t.tx('预览 = 当前草稿（未保存只在本机显示）','Preview = current draft (unsaved changes are local only)')}</div>
        </div>

        {/* Inspector */}
        <div className="card" style={{ position: 'sticky', top: 0 }}>
          <div className="card-h"><h3>{t.tx('属性面板','Inspector')}</h3><span className="meta">{selected ? (localizedCatalog.find(c => c.type === selected.type)?.name || selected.type) : t.tx('未选','None')}</span></div>
          {!selected ? (
            <div style={{ padding: 18, fontSize: 11, color: 'var(--text-faint)' }}>
              {t.tx('点击画布里某个组件来编辑。','Click a component on the canvas to edit.')}
            </div>
          ) : (
            <div style={{ padding: 10, display: 'flex', flexDirection: 'column', gap: 6, maxHeight: 'calc(100vh - 220px)', overflowY: 'auto' }}>
              <InspectorSection title={t.tx('内容','Content')} open={openSections.content} onToggle={() => setOpenSections(s => ({ ...s, content: !s.content }))}>
                <PropsEditor component={selected} catalog={localizedCatalog} onChange={(p) => updateSelected({ props: p })} openMedia={openMedia} t={t} />
              </InspectorSection>

              <InspectorSection title={t.tx('定位 · 自由位置','Position · Free')} open={openSections.position} onToggle={() => setOpenSections(s => ({ ...s, position: !s.position }))}>
                <PositionEditor pos={selected.pos} onChange={(p) => updateSelected({ pos: p })} t={t} />
              </InspectorSection>

              <InspectorSection title={t.tx('样式','Style')} open={openSections.style} onToggle={() => setOpenSections(s => ({ ...s, style: !s.style }))}>
                <StyleEditor style={selected.style || defaultStyle()} onChange={(p) => updateSelected({ style: p })} t={t} />
              </InspectorSection>

              <InspectorSection title={t.tx('投放','Targeting')} open={openSections.target} onToggle={() => setOpenSections(s => ({ ...s, target: !s.target }))}>
                <TargetingEditor
                  component={selected}
                  audienceTier={audienceTier}
                  onAudienceTier={setAudienceTier}
                  onChange={updateSelected}
                  onDup={() => duplicateComponent(selected.id)}
                  onDel={() => deleteComponent(selected.id)}
                  t={t}
                />
              </InspectorSection>

              <InspectorSection title={t.tx('高级 JSON','Advanced JSON')} open={openSections.json} onToggle={() => setOpenSections(s => ({ ...s, json: !s.json }))}>
                <RawJsonEditor component={selected} onApply={(obj) => updateSelected({ raw: obj })} t={t} />
              </InspectorSection>
            </div>
          )}
        </div>
      </div>

      {/* Publish history + rollback */}
      <HistoryPanel pageId={pageId} push={push} t={t} catalog={localizedCatalog} onRollbackLoaded={(d) => { setDraft({ ...d, components: dedupeIds(d.components).map(ensureUniversal) }); setDirty(true); }} />

      <MediaPicker
        open={mediaOpen}
        onClose={() => { setMediaOpen(false); setMediaTarget(null); }}
        onPick={(url, item) => { if (mediaTarget?.onPick) mediaTarget.onPick(url, item); }}
        push={push}
        t={t}
      />

      <Modal open={lineageOpen} onClose={() => setLineageOpen(false)} title={t.tx('模板谱系 · ','Template lineage · ') + pageId} width={880}
        footer={<button className="btn btn-sm" onClick={() => setLineageOpen(false)}>{t.tx('关闭','Close')}</button>}>
        {lineageLoading ? (
          <div style={{ padding: 24, textAlign: 'center', fontSize: 12, color: 'var(--text-muted)' }}>{t.tx('加载中…','Loading…')}</div>
        ) : !lineage.length ? (
          <div style={{ padding: 24, textAlign: 'center', fontSize: 12, color: 'var(--text-faint)' }}>{t.tx('本页面尚未应用过任何模板','No template has been applied to this page yet')}</div>
        ) : (
          <div className="tbl-scroll">
          <table className="tbl tbl-stack">
            <thead><tr>
              <th>{t.tx('时间','Time')}</th>
              <th>{t.tx('模板','Template')}</th>
              <th>{t.tx('合并方式','Merge')}</th>
              <th>{t.tx('操作人','By')}</th>
              <th>{t.tx('组件变化','Comp delta')}</th>
              <th>{t.tx('操作','Action')}</th>
            </tr></thead>
            <tbody>
              {lineage.map((h) => (
                <tr key={h.id}>
                  <td data-label={t.tx('时间','Time')} className="text-mono" style={{ fontSize: 11 }} title={h.applied_at ? new Date(h.applied_at).toLocaleString('zh-CN', { hour12: false }) : ''}>
                    {h.applied_at ? fmtRel(new Date(h.applied_at).getTime()) : '—'}
                  </td>
                  <td data-label={t.tx('模板','Template')}>
                    <div>{h.tpl_name || '—'}</div>
                    <div className="text-faint" style={{ fontSize: 10, fontFamily: 'var(--font-mono)' }}>{h.tpl_id || ''}</div>
                  </td>
                  <td data-label={t.tx('合并方式','Merge')}>
                    <span className="chip" style={{ fontSize: 10 }}>{h.merge === 'append' ? t.tx('追加','append') : t.tx('替换','replace')}</span>
                  </td>
                  <td data-label={t.tx('操作人','By')} className="muted">{h.operator || '—'}</td>
                  <td data-label={t.tx('组件变化','Comp delta')} className="text-mono" style={{ fontSize: 11 }}>{h.prev_comp_count ?? '?'} → {h.new_comp_count ?? '?'}</td>
                  <td data-label={t.tx('操作','Action')}>
                    <button className="btn btn-sm btn-danger" style={{ fontSize: 10 }} onClick={() => rollbackTemplateApply(h.id)}>
                      {t.tx('回退到此前状态','Rollback')}
                    </button>
                  </td>
                </tr>
              ))}
            </tbody>
          </table>
          </div>
        )}
      </Modal>
    </div>
  );
}

function CanvasCard({ t, index, total, comp, cat, sel, hidden, locked, onSelect, onDup, onDel, onToggleLock, onToggleVis, onUp, onDown, onDragStart, onDragOver, onDrop }) {
  const tx = (t && t.tx) ? t.tx : ((zh) => zh);
  const [hover, setHover] = React.useState(false);
  return (
    <div draggable={!locked}
         onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)}
         onDragStart={onDragStart} onDragOver={onDragOver} onDrop={onDrop}
         onClick={onSelect}
         style={{
           position: 'relative',
           display: 'flex', flexDirection: 'column', gap: 4,
           padding: '8px 8px 6px',
           borderRadius: 6,
           background: sel ? 'var(--accent-bg, rgba(99,102,241,.08))' : 'var(--bg-card)',
           border: '1px solid ' + (sel ? 'var(--accent)' : locked ? 'var(--text-faint)' : 'var(--border)'),
           opacity: hidden ? 0.55 : 1,
           cursor: locked ? 'default' : 'grab',
           fontSize: 11,
         }}>
      {hidden && (
        <span style={{ position: 'absolute', top: 4, right: 6, background: 'var(--bg-inset)', color: 'var(--text-faint)', fontSize: 9, padding: '1px 5px', borderRadius: 3 }}>{tx('已隐藏','Hidden')}</span>
      )}
      <div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
        <span style={{ color: 'var(--text-faint)', fontFamily: 'var(--font-mono)' }}>{index + 1}</span>
        <span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{cat?.name || comp.type}</span>
        {locked && <span style={{ fontSize: 10 }}>🔒</span>}
      </div>
      {(hover || sel) && (
        <div onClick={(e) => e.stopPropagation()} style={{ display: 'flex', gap: 3, flexWrap: 'wrap' }}>
          <button className="btn btn-sm" style={{ padding: '1px 5px', fontSize: 10 }} title={tx('复制','Duplicate')} onClick={onDup}>⎘</button>
          <button className="btn btn-sm" style={{ padding: '1px 5px', fontSize: 10 }} title={locked ? tx('解锁','Unlock') : tx('锁定','Lock')} onClick={onToggleLock}>{locked ? '🔓' : '🔒'}</button>
          <button className="btn btn-sm" style={{ padding: '1px 5px', fontSize: 10 }} title={hidden ? tx('显示','Show') : tx('隐藏','Hide')} onClick={onToggleVis}>{hidden ? '👁' : '🚫'}</button>
          <button className="btn btn-sm" style={{ padding: '1px 5px', fontSize: 10 }} title={tx('上移','Move up')} disabled={index === 0} onClick={onUp}>↑</button>
          <button className="btn btn-sm" style={{ padding: '1px 5px', fontSize: 10 }} title={tx('下移','Move down')} disabled={index === total - 1} onClick={onDown}>↓</button>
          <button className="btn btn-sm btn-danger" style={{ padding: '1px 5px', fontSize: 10 }} title={tx('删除','Delete')} disabled={locked} onClick={onDel}>×</button>
        </div>
      )}
    </div>
  );
}

function InspectorSection({ title, open, onToggle, children }) {
  return (
    <div style={{ border: '1px solid var(--border)', borderRadius: 6 }}>
      <div onClick={onToggle} style={{ padding: '7px 10px', display: 'flex', justifyContent: 'space-between', cursor: 'pointer', background: 'var(--bg-inset)', borderRadius: '6px 6px 0 0', fontSize: 12, fontWeight: 600 }}>
        <span>{title}</span><span className="text-faint">{open ? '▾' : '▸'}</span>
      </div>
      {open && <div style={{ padding: 10, display: 'flex', flexDirection: 'column', gap: 10 }}>{children}</div>}
    </div>
  );
}

// BuilderOverlay — sits on top of the live-preview iframe in free-position mode.
// For each component with pos.mode='absolute' it renders a clickable rect; the
// selected one gets a colored frame and 8 resize handles. Drag/resize updates
// flow back through onPatch({pos}) so the iframe re-renders in real time.
//
// Coordinate system:
//   • pos.x / pos.w live in DESIGN_W_REF (375) units → scale by (deviceW / 375)
//     to admin pixel space.
//   • pos.y / pos.h live in raw px (no horizontal scale, since the page scrolls
//     vertically and design height isn't fixed).
//   • iframe scrolls independently → subtract scrollY from pos.y for overlay top.
function BuilderOverlay({ components, selectedId, onSelect, onPatch, deviceW, scrollY, t }) {
  const scale = deviceW / DESIGN_W_REF;
  const dragRef = React.useRef(null); // { kind:'move'|'resize-XX', startX, startY, basePos, id }
  const [, force] = React.useReducer(x => x + 1, 0);

  // Global mousemove/mouseup while a drag is active.
  React.useEffect(() => {
    const onMove = (e) => {
      const d = dragRef.current; if (!d) return;
      const dx = (e.clientX - d.startX) / scale;       // → design X
      const dy = (e.clientY - d.startY);               // raw px
      const b = d.basePos;
      let next = { ...b };
      if (d.kind === 'move') {
        next.x = Math.round(b.x + dx);
        next.y = Math.round(b.y + dy);
      } else {
        // resize-XX where XX is one of: n, s, e, w, ne, nw, se, sw
        const dir = d.kind.slice(7);
        if (dir.includes('e')) next.w = Math.max(20, Math.round(b.w + dx));
        if (dir.includes('w')) { next.w = Math.max(20, Math.round(b.w - dx)); next.x = Math.round(b.x + dx); }
        if (dir.includes('s')) next.h = Math.max(20, Math.round(b.h + dy));
        if (dir.includes('n')) { next.h = Math.max(20, Math.round(b.h - dy)); next.y = Math.round(b.y + dy); }
      }
      // Clamp to design surface: x ∈ [0, 375 - w], y ≥ 0 (no upper Y bound
      // since pages scroll vertically). Stops boxes from being dragged off
      // the visible canvas where they can't be clicked back.
      const W = DESIGN_W_REF;
      const w = next.w || 0;
      if (typeof next.x === 'number') next.x = Math.max(0, Math.min(next.x, Math.max(0, W - w)));
      if (typeof next.y === 'number') next.y = Math.max(0, next.y);
      if (typeof next.w === 'number') next.w = Math.min(next.w, W);
      onPatch(d.id, next);
    };
    const onUp = () => { if (dragRef.current) { dragRef.current = null; force(); } };
    window.addEventListener('mousemove', onMove);
    window.addEventListener('mouseup', onUp);
    return () => { window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp); };
  }, [scale, onPatch]);

  const startDrag = (kind, id, comp, e) => {
    e.stopPropagation(); e.preventDefault();
    dragRef.current = { kind, id, startX: e.clientX, startY: e.clientY, basePos: { ...comp.pos } };
    if (id !== selectedId) onSelect(id);
    force();
  };

  const abs = (components || []).filter(c => c && c.pos && c.pos.mode === 'absolute');
  const HANDLES = ['nw','n','ne','e','se','s','sw','w'];
  const handleCursor = { n:'ns-resize', s:'ns-resize', e:'ew-resize', w:'ew-resize', ne:'nesw-resize', sw:'nesw-resize', nw:'nwse-resize', se:'nwse-resize' };
  const handlePos = (dir, w, h) => {
    const HW = 8;
    const cx = dir.includes('e') ? w - HW/2 : dir.includes('w') ? -HW/2 : (w - HW) / 2;
    const cy = dir.includes('s') ? h - HW/2 : dir.includes('n') ? -HW/2 : (h - HW) / 2;
    return { left: cx, top: cy, width: HW, height: HW };
  };

  return (
    <div style={{ position: 'absolute', inset: 0, pointerEvents: 'none', overflow: 'hidden', zIndex: 50 }}>
      {abs.map(c => {
        const sel = c.id === selectedId;
        const left = c.pos.x * scale;
        const top  = (c.pos.y || 0) - scrollY;
        const w    = (c.pos.w || 0) * scale;
        const h    = (c.pos.h || 0);
        return (
          <div key={c.id}
               onMouseDown={(e) => startDrag('move', c.id, c, e)}
               onClick={(e) => { e.stopPropagation(); onSelect(c.id); }}
               title={(t && t.tx ? t.tx : ((zh) => zh))('拖动改位置 · 角上手柄改大小','Drag body to move · corner handles to resize') + ' (' + c.id + ' · ' + c.type + ')'}
               style={{
                 position: 'absolute',
                 left, top, width: w, height: h,
                 outline: sel ? '2px solid #6366F1' : '2px dashed rgba(99,102,241,0.9)',
                 background: sel ? 'rgba(99,102,241,0.12)' : 'rgba(99,102,241,0.04)',
                 pointerEvents: 'auto',
                 cursor: sel ? 'move' : 'pointer',
                 boxSizing: 'border-box',
               }}>
            {!sel && (
              <div style={{ position: 'absolute', top: 2, left: 2, fontSize: 9, padding: '1px 4px', background: 'rgba(99,102,241,0.85)', color: '#fff', borderRadius: 3, pointerEvents: 'none', whiteSpace: 'nowrap' }}>
                ⇱ {c.type}
              </div>
            )}
            {sel && (
              <>
                <div style={{ position: 'absolute', top: -18, left: 0, fontSize: 9, padding: '1px 4px', background: '#6366F1', color: '#fff', borderRadius: 3, whiteSpace: 'nowrap' }}>
                  {c.type} · {c.pos.x},{c.pos.y} · {c.pos.w}×{c.pos.h} · z{c.pos.z || 0}
                </div>
                {HANDLES.map(dir => (
                  <div key={dir}
                       onMouseDown={(e) => startDrag('resize-' + dir, c.id, c, e)}
                       style={{
                         position: 'absolute',
                         ...handlePos(dir, w, h),
                         background: '#fff',
                         border: '1.5px solid #6366F1',
                         cursor: handleCursor[dir],
                         pointerEvents: 'auto',
                       }} />
                ))}
              </>
            )}
          </div>
        );
      })}
    </div>
  );
}

// PositionEditor — free-form absolute positioning. Pos schema:
//   { mode:'absolute', x, y, w, h, z } — x/w scale via vw at render (375 design); y/h stay px.
// pos === null/undefined means flow mode (default vertical stack).
function PositionEditor({ pos, onChange, t }) {
  const enabled = pos && pos.mode === 'absolute';
  const p = pos || {};
  const tx = (zh, en) => t ? t.tx(zh, en) : zh;
  const setField = (k, v) => onChange({ mode: 'absolute', x: p.x || 0, y: p.y || 0, w: p.w || 200, h: p.h || 60, z: p.z || 1, [k]: v });
  const toggle = (e) => onChange(e.target.checked ? { mode: 'absolute', x: 0, y: 0, w: 200, h: 60, z: 1 } : null);
  return (
    <>
      <label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 12 }}>
        <input type="checkbox" checked={enabled} onChange={toggle} />
        <span>{tx('启用自由定位（脱离正常流，可与其他组件重叠）','Free position (absolute, can overlap others)')}</span>
      </label>
      {enabled && (
        <>
          <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 6 }}>
            <Field label="X (px)"><input className="input" type="number" value={p.x ?? 0} onChange={(e) => setField('x', parseInt(e.target.value, 10) || 0)} /></Field>
            <Field label="Y (px)"><input className="input" type="number" value={p.y ?? 0} onChange={(e) => setField('y', parseInt(e.target.value, 10) || 0)} /></Field>
            <Field label={tx('宽 W (px)','Width (px)')}><input className="input" type="number" value={p.w ?? 200} onChange={(e) => setField('w', parseInt(e.target.value, 10) || 0)} /></Field>
            <Field label={tx('高 H (px)','Height (px)')}><input className="input" type="number" value={p.h ?? 60} onChange={(e) => setField('h', parseInt(e.target.value, 10) || 0)} /></Field>
            <Field label={tx('层级 Z','Z-index')}><input className="input" type="number" value={p.z ?? 1} onChange={(e) => setField('z', parseInt(e.target.value, 10) || 0)} /></Field>
          </div>
          <div className="text-faint" style={{ fontSize: 10, lineHeight: 1.4 }}>
            {tx('设计基准 375px 宽：x / 宽按 vw 缩放跟随屏幕宽，y / 高保持 px。z 越大越在上层。','Reference 375px design width: x/w scale via vw to fit viewport, y/h stay in px. Higher z renders on top.')}
          </div>
          <div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
            <button className="btn btn-sm" onClick={() => setField('z', (p.z || 1) + 1)}>{tx('上移一层','Bring forward')}</button>
            <button className="btn btn-sm" onClick={() => setField('z', Math.max(0, (p.z || 1) - 1))}>{tx('下移一层','Send back')}</button>
            <button className="btn btn-sm" onClick={() => onChange({ mode: 'absolute', x: 0, y: 0, w: 375, h: 200, z: 1 })}>{tx('重置满宽','Reset full width')}</button>
            <button className="btn btn-sm" onClick={() => onChange({ mode: 'absolute', x: 0, y: 0, w: p.w || 200, h: p.h || 60, z: p.z || 1 })}>{tx('拉回画布 ↖','Bring back ↖')}</button>
          </div>
          {(p.x < 0 || p.y < 0 || p.x > 375) && (
            <div style={{ background: 'var(--warning-bg, #fff4e5)', color: 'var(--warning, #b45309)', padding: '6px 8px', borderRadius: 4, fontSize: 11 }}>
              ⚠️ {tx('当前位置在画布外（设计区是 0..375px 宽），点"拉回画布"恢复。','Off-canvas position (design surface is 0–375px). Click "Bring back" to recover.')}
            </div>
          )}
        </>
      )}
    </>
  );
}

function StyleEditor({ style, onChange, t }) {
  const s = style || {};
  const set = (k, v) => onChange({ ...s, [k]: v });
  const tx = (zh, en) => t ? t.tx(zh, en) : zh;
  return (
    <>
      <Field label={tx('背景色 / 渐变','Background / gradient')}>
        <div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
          <input type="color" value={/^#[0-9a-fA-F]{6}$/.test(s.bg || '') ? s.bg : '#ffffff'}
                 onChange={(e) => set('bg', e.target.value)}
                 style={{ width: 34, height: 28, border: '1px solid var(--border)', borderRadius: 4, background: 'transparent' }} />
          <input className="input" style={{ flex: 1, fontFamily: 'var(--font-mono)', fontSize: 11 }} placeholder={tx('#fff 或 linear-gradient(...)','#fff or linear-gradient(...)')} value={s.bg || ''} onChange={(e) => set('bg', e.target.value)} />
        </div>
      </Field>
      <Field label={tx('主文字色','Text color')}>
        <div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
          <input type="color" value={/^#[0-9a-fA-F]{6}$/.test(s.color || '') ? s.color : '#222222'}
                 onChange={(e) => set('color', e.target.value)}
                 style={{ width: 34, height: 28, border: '1px solid var(--border)', borderRadius: 4, background: 'transparent' }} />
          <input className="input" style={{ flex: 1, fontFamily: 'var(--font-mono)', fontSize: 11 }} placeholder="#fff" value={s.color || ''} onChange={(e) => set('color', e.target.value)} />
        </div>
      </Field>
      <SliderField label={tx('内边距 padding','Padding')} min={0} max={48} value={s.padding ?? 12} onChange={(v) => set('padding', v)} suffix="px" />
      <SliderField label={tx('上间距 margin_top','Margin top')} min={0} max={64} value={s.margin_top ?? 0} onChange={(v) => set('margin_top', v)} suffix="px" />
      <SliderField label={tx('下间距 margin_bottom','Margin bottom')} min={0} max={64} value={s.margin_bottom ?? 12} onChange={(v) => set('margin_bottom', v)} suffix="px" />
      <SliderField label={tx('圆角 radius','Radius')} min={0} max={32} value={s.radius ?? 12} onChange={(v) => set('radius', v)} suffix="px" />
      <Field label={tx('阴影 shadow','Shadow')}>
        <label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 12 }}>
          <input type="checkbox" checked={!!s.shadow} onChange={(e) => set('shadow', e.target.checked)} />
          <span className="text-muted">{s.shadow ? tx('开启','On') : tx('关闭','Off')}</span>
        </label>
      </Field>
    </>
  );
}

function SliderField({ label, min, max, value, onChange, suffix }) {
  return (
    <Field label={`${label}: ${value ?? min}${suffix || ''}`}>
      <input type="range" min={min} max={max} value={value ?? min}
             onChange={(e) => onChange(Number(e.target.value))}
             style={{ width: '100%' }} />
    </Field>
  );
}

function TargetingEditor({ component, audienceTier, onAudienceTier, onChange, onDup, onDel, t }) {
  const c = component;
  const byTier = c.by_tier || defaultByTier();
  const tw = c.time_window || defaultTimeWindow();
  const ab = c.ab_experiment || {};
  const [abOpen, setAbOpen] = React.useState(!!(ab.experiment_id || ab.variant));
  const tx = (zh, en) => t ? t.tx(zh, en) : zh;
  const TIER_NAMES_LOCAL = TIER_NAMES(t);
  const tierName = (tg) => TIER_NAMES_LOCAL[tg] || tg;
  return (
    <>
      <div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
        <button className={'btn btn-sm ' + (c.visible === false ? '' : 'btn-pri')} onClick={() => onChange({ visible: c.visible === false })}>
          {c.visible === false ? tx('已隐藏 · 点击显示','Hidden · click to show') : tx('可见 · 点击隐藏','Visible · click to hide')}
        </button>
        <button className={'btn btn-sm ' + (c.locked ? 'btn-pri' : '')} onClick={() => onChange({ locked: !c.locked })}>{c.locked ? tx('已锁定','Locked') : tx('锁定','Lock')}</button>
        <button className="btn btn-sm" onClick={onDup}>{tx('复制','Duplicate')}</button>
        <button className="btn btn-sm btn-danger" disabled={c.locked} onClick={onDel}>{tx('删除','Delete')}</button>
      </div>

      <Field label={tx('按等级显示 (by_tier)','Show by tier (by_tier)')}>
        <div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
          {TIER_LIST.map(tg => {
            const on = byTier[tg] !== false;
            return (
              <span key={tg}
                    className={'chip ' + (on ? 'active' : '')}
                    style={{ fontSize: 10, cursor: 'pointer' }}
                    onClick={() => onChange({ by_tier: { ...byTier, [tg]: !on } })}>
                {tierName(tg)}
              </span>
            );
          })}
        </div>
        <div className="text-faint" style={{ fontSize: 10, marginTop: 4 }}>{tx('当前预览等级：','Current preview tier: ')}{tierName(audienceTier)}</div>
      </Field>

      <Field label={tx('预览身份切换','Switch preview identity')}>
        <div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
          {TIER_LIST.map(tg => (
            <span key={tg}
                  className={'chip ' + (audienceTier === tg ? 'active' : '')}
                  style={{ fontSize: 10, cursor: 'pointer' }}
                  onClick={() => onAudienceTier(tg)}>
              {tierName(tg)}
            </span>
          ))}
        </div>
      </Field>

      <Field label={tx('周几显示','Days of week')}>
        <div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
          {WEEKDAY_NAMES(t).map((nm, d) => {
            const on = (tw.weekdays || []).includes(d);
            return (
              <span key={d} className={'chip ' + (on ? 'active' : '')}
                    style={{ fontSize: 10, cursor: 'pointer', minWidth: 20, textAlign: 'center' }}
                    onClick={() => {
                      const cur = tw.weekdays || [];
                      const next = on ? cur.filter(x => x !== d) : [...cur, d].sort();
                      onChange({ time_window: { ...tw, weekdays: next } });
                    }}>{nm}</span>
            );
          })}
        </div>
      </Field>

      <Field label={tx('时段 (小时 0-23)','Hours (0-23)')}>
        <div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
          <input className="input" type="number" min={0} max={23} value={tw.hour_start ?? 0}
                 onChange={(e) => onChange({ time_window: { ...tw, hour_start: Number(e.target.value) } })}
                 style={{ width: 60 }} />
          <span className="text-faint">{tx('至','to')}</span>
          <input className="input" type="number" min={0} max={24} value={tw.hour_end ?? 24}
                 onChange={(e) => onChange({ time_window: { ...tw, hour_end: Number(e.target.value) } })}
                 style={{ width: 60 }} />
        </div>
      </Field>

      <details open={abOpen} onToggle={(e) => setAbOpen(e.target.open)}>
        <summary style={{ fontSize: 11, color: 'var(--text-faint)', cursor: 'pointer', padding: '4px 0' }}>{tx('A/B 实验绑定','A/B experiment binding')}</summary>
        <div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginTop: 6 }}>
          <Field label="experiment_id">
            <input className="input" value={ab.experiment_id || ''} placeholder="exp_home_v3"
                   onChange={(e) => onChange({ ab_experiment: { ...ab, experiment_id: e.target.value } })} />
          </Field>
          <Field label="variant">
            <input className="input" value={ab.variant || ''} placeholder="A / B / control"
                   onChange={(e) => onChange({ ab_experiment: { ...ab, variant: e.target.value } })} />
          </Field>
        </div>
      </details>
    </>
  );
}

function RawJsonEditor({ component, onApply, t }) {
  const [text, setText] = React.useState(() => JSON.stringify(component, null, 2));
  const [err, setErr] = React.useState(null);
  React.useEffect(() => { setText(JSON.stringify(component, null, 2)); setErr(null); }, [component.id]);
  return (
    <>
      <textarea className="input" rows={14}
                style={{ fontFamily: 'var(--font-mono)', fontSize: 11 }}
                value={text}
                onChange={(e) => {
                  setText(e.target.value);
                  try {
                    const obj = JSON.parse(e.target.value);
                    setErr(null);
                    onApply(obj);
                  } catch (ex) {
                    setErr(ex.message);
                  }
                }} />
      {err && <div style={{ color: 'var(--danger)', fontSize: 10 }}>{(t && t.tx ? t.tx : ((zh) => zh))('JSON 解析失败：','JSON parse failed: ')}{err} · {(t && t.tx ? t.tx : ((zh) => zh))('不会应用','not applied')}</div>}
      <div className="text-faint" style={{ fontSize: 10 }}>{(t && t.tx ? t.tx : ((zh) => zh))('整组件原文 · 改 id 风险自担','Whole-component source · changing id is risky')}</div>
    </>
  );
}

function HistoryPanel({ pageId, push, t, catalog, onRollbackLoaded }) {
  const [list, setList] = React.useState([]);
  const [open, setOpen] = React.useState(false);
  const [viewing, setViewing] = React.useState(null);
  const load = React.useCallback(() => {
    fetch(`${API_BASE}/api/layouts/${pageId}/history`).then(r => r.json()).then(d => setList(d.history || []));
  }, [pageId]);
  React.useEffect(() => { load(); }, [load]);

  const adminFetch = (url, opts) => fetch(url, { ...opts, headers: { ...window.NebulaAdmin.authHeaders(), ...(opts?.headers || {}) } });

  const view = async (v) => {
    const r = await fetch(`${API_BASE}/api/layouts/${pageId}/history/${v}`);
    if (r.ok) setViewing(await r.json());
  };
  const tx = (zh, en) => t ? t.tx(zh, en) : zh;
  const rollback = async (v) => {
    if (!confirm(tx(`回滚到 v${v}？\n会把此版本载入为新草稿，需要再次「提交发布审批」才会生效。`, `Roll back to v${v}?\nThe version will be loaded as a new draft and you must submit again for approval.`))) return;
    const r = await adminFetch(`${API_BASE}/api/layouts/${pageId}/rollback/${v}`, { method: 'POST' });
    if (!r.ok) { push(tx('回滚失败：','Rollback failed: ') + r.status + (r.status === 401 ? tx(' 未登录',' not logged in') : '')); return; }
    const j = await r.json();
    push(tx(`已载入 v${v} 为草稿，请走审批发布`, `Loaded v${v} as draft — please submit for approval`));
    onRollbackLoaded?.(j.draft);
    setOpen(false);
  };

  return (
    <div className="card">
      <div className="card-h" onClick={() => setOpen(o => !o)} style={{ cursor: 'pointer' }}>
        <h3>{tx('发布历史','Publish history')} · {pageId}</h3>
        <span className="meta">{list.length} {tx('个版本','versions')} · {open ? tx('收起','Collapse') : tx('展开','Expand')} {open ? '▾' : '▸'}</span>
      </div>
      {open && (
        <div style={{ display: 'grid', gridTemplateColumns: viewing ? '1fr 1fr' : '1fr', gap: 12 }}>
          <div className="tbl-scroll">
          <table className="tbl tbl-stack">
            <thead><tr><th>{tx('版本','Version')}</th><th>{tx('发布时间','Published at')}</th><th>{tx('组件数','Components')}</th><th>{tx('发布人','Published by')}</th><th>{tx('操作','Actions')}</th></tr></thead>
            <tbody>
              {list.map(h => (
                <tr key={h.version}>
                  <td data-label={tx('版本','Version')} className="text-mono">v{h.version}</td>
                  <td data-label={tx('发布时间','Published at')} className="text-mono" style={{ fontSize: 11 }}>{new Date(h.published_at).toLocaleString('zh-CN', { hour12: false })}</td>
                  <td data-label={tx('组件数','Components')}>{h.components_count}</td>
                  <td data-label={tx('发布人','Published by')} className="muted">{h.published_by}</td>
                  <td data-label={tx('操作','Actions')}>
                    <button className="btn btn-sm" style={{ marginRight: 4, fontSize: 10 }} onClick={() => view(h.version)}>{tx('查看','View')}</button>
                    <button className="btn btn-sm" style={{ fontSize: 10 }} onClick={() => rollback(h.version)}>{tx('回滚','Roll back')}</button>
                  </td>
                </tr>
              ))}
              {!list.length && (
                <tr><td colSpan={5} className="text-faint" style={{ textAlign: 'center', padding: 14 }}>{tx('暂无发布历史 · 第一次「提交发布审批 → 通过」后会自动入库','No history yet · first approved publish will land here')}</td></tr>
              )}
            </tbody>
          </table>
          </div>
          {viewing && (
            <div style={{ position: 'relative' }}>
              <div className="card-h" style={{ position: 'sticky', top: 0 }}>
                <h3>v{viewing.version} {tx('快照','snapshot')}</h3>
                <button className="btn btn-sm" style={{ fontSize: 10 }} onClick={() => setViewing(null)}>{tx('关闭','Close')}</button>
              </div>
              <div style={{ padding: 10, display: 'flex', flexDirection: 'column', gap: 4, fontSize: 11 }}>
                {(viewing.components || []).map((c, i) => (
                  <div key={c.id} style={{ display: 'flex', gap: 6, alignItems: 'center', padding: '4px 8px', background: 'var(--bg-inset)', borderRadius: 4 }}>
                    <span className="text-faint" style={{ fontFamily: 'var(--font-mono)' }}>{i + 1}</span>
                    <span style={{ flex: 1 }}>{(catalog || COMPONENT_CATALOG(t)).find(x => x.type === c.type)?.name || c.type}</span>
                    <span className="text-faint" style={{ fontSize: 9 }}>{(c.audience?.tiers || []).length}/{TIER_LIST.length}</span>
                  </div>
                ))}
              </div>
            </div>
          )}
        </div>
      )}
    </div>
  );
}

// ─── Props editor — schema-driven visual form for the Inspector ─────────────

function PropsEditor({ component, catalog, onChange, openMedia, t }) {
  const entry = catalog.find(c => c.type === component.type);
  const schema = entry?.schema || {};
  const keys = Object.keys(schema);
  if (keys.length === 0) {
    return <div className="text-faint" style={{ fontSize: 11, padding: '6px 0' }}>{(t && t.tx ? t.tx : ((zh) => zh))('该组件无可视化属性','This component has no visual properties')}</div>;
  }
  const apply = (key, value) => {
    onChange({ ...component.props, [key]: value });
  };
  return (
    <div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
      {keys.map(key => (
        <PropField key={key} keyName={key} schema={schema[key]} value={component.props?.[key]}
                   onChange={(v) => apply(key, v)} openMedia={openMedia} t={t} />
      ))}
    </div>
  );
}

function PropField({ keyName, schema, value, onChange, openMedia, t }) {
  const label = schema.label || keyName;
  if (schema.type === 'text') {
    return (
      <Field label={label}>
        <input className="input" value={value || ''} onChange={(e) => onChange(e.target.value)} />
      </Field>
    );
  }
  if (schema.type === 'textarea') {
    return (
      <Field label={label}>
        <textarea className="input" rows={schema.rows || 4}
                  style={{ fontFamily: 'var(--font-mono)', fontSize: 11, resize: 'vertical' }}
                  value={value || ''} onChange={(e) => onChange(e.target.value)} />
      </Field>
    );
  }
  if (schema.type === 'number') {
    return (
      <Field label={label}>
        <input className="input" type="number" value={value ?? ''} min={schema.min} max={schema.max}
               onChange={(e) => onChange(e.target.value === '' ? undefined : Number(e.target.value))} />
      </Field>
    );
  }
  if (schema.type === 'select') {
    return (
      <Field label={label}>
        <select className="select" value={value || ''} onChange={(e) => onChange(e.target.value)}>
          {schema.options.map(opt => <option key={opt.value} value={opt.value}>{opt.label}</option>)}
        </select>
      </Field>
    );
  }
  if (schema.type === 'boolean') {
    return (
      <Field label={label}>
        <label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 12 }}>
          <input type="checkbox" checked={!!value} onChange={(e) => onChange(e.target.checked)} />
          <span className="text-muted">{value ? (t && t.tx ? t.tx : ((zh) => zh))('启用','On') : (t && t.tx ? t.tx : ((zh) => zh))('关闭','Off')}</span>
        </label>
      </Field>
    );
  }
  if (schema.type === 'image') {
    return (
      <Field label={label}>
        <div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
          <div style={{ width: 44, height: 44, borderRadius: 4, border: '1px solid var(--border)', background: value ? `url("${value}") center/cover` : 'var(--bg-inset)' }} />
          <input className="input" style={{ flex: 1, fontSize: 11, fontFamily: 'var(--font-mono)' }} value={value || ''} placeholder={(t && t.tx ? t.tx : ((zh) => zh))('https://… 或 /media/…','https://… or /media/…')} onChange={(e) => onChange(e.target.value)} />
          <button className="btn btn-sm" disabled={!openMedia} onClick={() => openMedia && openMedia((url) => onChange(url))}>{(t && t.tx ? t.tx : ((zh) => zh))('选图','Pick')}</button>
        </div>
      </Field>
    );
  }
  if (schema.type === 'items') {
    return <ItemsField label={label} value={value || []} itemFields={schema.itemFields} onChange={onChange} openMedia={openMedia} t={t} />;
  }
  if (schema.type === 'byTier') {
    return <ByTierField label={label} value={value || {}} tiers={schema.tiers} fields={schema.fields} onChange={onChange} openMedia={openMedia} t={t} />;
  }
  // unknown — JSON fallback
  return (
    <Field label={label + ' (JSON)'}>
      <textarea className="input" rows={3}
                style={{ fontFamily: 'var(--font-mono)', fontSize: 11 }}
                defaultValue={JSON.stringify(value || null, null, 2)}
                onChange={(e) => { try { onChange(JSON.parse(e.target.value)); } catch (err) {} }} />
    </Field>
  );
}

function ItemsField({ label, value, itemFields, onChange, openMedia, t }) {
  const arr = Array.isArray(value) ? value : [];
  const updateItem = (i, patch) => {
    const next = arr.map((it, idx) => idx === i ? { ...it, ...patch } : it);
    onChange(next);
  };
  const addItem = () => {
    const blank = Object.keys(itemFields).reduce((acc, k) => ({ ...acc, [k]: itemFields[k].type === 'select' ? itemFields[k].options[0].value : '' }), {});
    onChange([...arr, blank]);
  };
  const removeItem = (i) => { onChange(arr.filter((_, idx) => idx !== i)); };
  const moveItem = (i, delta) => {
    const j = i + delta;
    if (j < 0 || j >= arr.length) return;
    const next = [...arr];
    [next[i], next[j]] = [next[j], next[i]];
    onChange(next);
  };
  return (
    <Field label={`${label} (${arr.length})`}>
      <div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
        {arr.map((it, i) => (
          <div key={i} style={{ padding: 8, background: 'var(--bg-inset)', borderRadius: 6, display: 'flex', flexDirection: 'column', gap: 5 }}>
            <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 2 }}>
              <span className="text-faint" style={{ fontSize: 10 }}>#{i + 1}</span>
              <div style={{ display: 'flex', gap: 3 }}>
                <button className="btn btn-sm" style={{ padding: '1px 5px', fontSize: 10 }} disabled={i === 0} onClick={() => moveItem(i, -1)}>↑</button>
                <button className="btn btn-sm" style={{ padding: '1px 5px', fontSize: 10 }} disabled={i === arr.length - 1} onClick={() => moveItem(i, +1)}>↓</button>
                <button className="btn btn-sm btn-danger" style={{ padding: '1px 5px', fontSize: 10 }} onClick={() => removeItem(i)}>×</button>
              </div>
            </div>
            {Object.entries(itemFields).map(([k, f]) => (
              <PropField key={k} keyName={k} schema={f} value={it[k]} onChange={(v) => updateItem(i, { [k]: v })} openMedia={openMedia} t={t} />
            ))}
          </div>
        ))}
        <button className="btn btn-sm" style={{ fontSize: 11 }} onClick={addItem}>+ {(t && t.tx ? t.tx : ((zh) => zh))('新增一项','Add item')}</button>
      </div>
    </Field>
  );
}

function ByTierField({ label, value, tiers, fields, onChange, openMedia, t }) {
  const [activeTier, setActiveTier] = React.useState(tiers[0]);
  const tierData = value[activeTier] || {};
  const updateField = (k, v) => { onChange({ ...value, [activeTier]: { ...tierData, [k]: v } }); };
  const _tt = t || { tx: (zh) => zh };
  const TIER_NAMES_LOCAL = TIER_NAMES(_tt);
  const tierName = (tg) => TIER_NAMES_LOCAL[tg] || tg;
  return (
    <Field label={label}>
      <div style={{ display: 'flex', gap: 4, marginBottom: 6, flexWrap: 'wrap' }}>
        {tiers.map(tg => (
          <span key={tg} className={'chip ' + (activeTier === tg ? 'active' : '')}
                style={{ fontSize: 10, cursor: 'pointer' }} onClick={() => setActiveTier(tg)}>
            {tierName(tg)}
            {value[tg] ? '' : ' ⚠'}
          </span>
        ))}
      </div>
      <div style={{ padding: 8, background: 'var(--bg-inset)', borderRadius: 6, display: 'flex', flexDirection: 'column', gap: 5 }}>
        {Object.entries(fields).map(([k, f]) => (
          <PropField key={k + activeTier} keyName={k} schema={f} value={tierData[k]} onChange={(v) => updateField(k, v)} openMedia={openMedia} t={t} />
        ))}
      </div>
      <div className="text-faint" style={{ fontSize: 10, marginTop: 4 }}>{_tt.tx('同一组件按等级展示不同内容','Same component shows different content per tier')}</div>
    </Field>
  );
}

// ─── APPROVALS / AUDIT (special section) ────────────────────────────────────
function ApprovalsScreen({ t, push }) {
  const [tab, setTab] = React.useState('queue');
  const [liveApprovals, setLiveApprovals] = React.useState([]);
  const refreshLive = React.useCallback(() => {
    fetch(`${API_BASE}/api/approvals`).then(r => r.json()).then(d => setLiveApprovals(d.approvals || []));
  }, []);
  React.useEffect(() => { refreshLive(); const i = setInterval(refreshLive, 5000); return () => clearInterval(i); }, [refreshLive]);

  // Merge live (layout) approvals with the static mock approvals for a unified queue.
  const allApprovals = [
    ...liveApprovals.filter(a => a.status === 'pending').map(a => ({
      id: a.id, type: 'layout', who: a.who, amount: 0, currency: '—',
      tenant: '—', site: '—', risk: a.risk || 8, age: t.tx('刚刚','just now'),
      rule: a.target, priority: a.priority || 'mid',
      _live: a,
    })),
    ...DATA.approvals,
  ];
  const [selectedAp, setSelectedAp] = React.useState(allApprovals[0]);
  React.useEffect(() => { if (!selectedAp && allApprovals[0]) setSelectedAp(allApprovals[0]); }, [allApprovals, selectedAp]);

  const handleAct = async (action) => {
    if (!selectedAp || !selectedAp._live) {
      push(t.tx('已 ','') + (action === 'approve' ? t.tx('通过','Approved ') : t.tx('驳回','Rejected ')) + ' ' + (selectedAp?.id || '') + ' (mock)');
      return;
    }
    const r = await fetch(`${API_BASE}/api/approvals/${selectedAp.id}/${action}`, { method: 'POST', headers: window.NebulaAdmin.authHeaders() });
    if (!r.ok) { push(t.tx('操作失败：','Action failed: ') + r.status + (r.status === 401 ? t.tx(' 未登录',' not logged in') : '')); return; }
    push((action === 'approve' ? t.tx('已通过并发布 ','Approved and published ') : t.tx('已驳回 ','Rejected ')) + selectedAp.id);
    refreshLive();
    setSelectedAp(null);
  };

  return (
    <div className="stack">
      <div className="page-h">
        <div>
          <h1>{t.p_approvals}</h1>
          <div className="sub">{t.tx('审批流 · 审计日志 · 高危操作的最后一道闸门','Approval flow · audit log · the last gate for high-risk operations')}</div>
        </div>
        <div className="actions">
          <button className="btn btn-sm">{t.tx('规则配置','Rules')}</button>
          <button className="btn btn-sm"><Icons.download /> {t.tx('审计导出','Export audit')}</button>
        </div>
      </div>

      <div className="row-4">
        <KPI label={t.tx('队列中','In queue')} value="24" delta={t.tx('+6 高优','+6 high priority')} spark={<MiniBars data={[2,3,4,4,5,5,6]} w={100} h={24} color="oklch(0.68 0.16 75)" />} />
        <KPI label={t.tx('今日处理','Today processed')} value="142" delta={t.tx('98% 4h 内','98% within 4h')} up spark={<Sparkline data={[80,100,110,120,130,140,142]} w={100} h={24} stroke="oklch(0.6 0.16 145)" />} />
        <KPI label={t.tx('拒绝率','Reject rate')} value="8.4%" delta="-0.2%" spark={<Sparkline data={[10,9.5,9,8.8,8.6,8.5,8.4]} w={100} h={24} stroke="oklch(0.65 0.14 30)" />} />
        <KPI label={t.tx('平均时长','Avg duration')} value="14m" delta="-2m" up spark={<Sparkline data={[20,18,17,16,15,15,14]} w={100} h={24} stroke="oklch(0.62 0.14 230)" />} />
      </div>

      <div className="tabs">
        {[['queue',t.tx('审批队列','Queue')],['flow',t.tx('审批流配置','Flow config')],['log',t.tx('审计日志','Audit log')]].map(([k,l]) => (
          <div key={k} className={'tab' + (tab === k ? ' active' : '')} onClick={() => setTab(k)}>{l}</div>
        ))}
      </div>

      {tab === 'queue' && (
        <div style={{ display: 'grid', gridTemplateColumns: '1.4fr 1fr', gap: 16 }}>
          <div className="card">
            <div className="filter-bar">
              <span className="chip active">{t.tx('全部','All')} 24</span>
              <span className="chip">{t.tx('高优','High priority')} 8</span>
              <span className="chip">{t.tx('提现','Withdraw')} 12</span>
              <span className="chip">{t.tx('调账','Adjust')} 4</span>
              <span className="chip">{t.tx('敏感','Sensitive')} 5</span>
              <span className="chip">{t.tx('导出','Export')} 3</span>
            </div>
            <div className="tbl-scroll">
            <table className="tbl tbl-stack">
              <thead><tr><th>{t.tx('单号','Ticket')}</th><th>{t.tx('类型','Type')}</th><th>{t.tx('对象','Target')}</th><th className="right">{t.tx('金额','Amount')}</th><th>{t.tx('风险','Risk')}</th><th>{t.tx('优先','Priority')}</th><th>{t.tx('时长','Age')}</th></tr></thead>
              <tbody>
                {allApprovals.map(a => {
                  const typeMap = {
                    withdraw:  [t.tx('提现','Withdraw'),'warning'],
                    adjust:    [t.tx('调账','Adjust'),'outline'],
                    bonus:     [t.tx('活动','Promo'),'accent'],
                    patch:     [t.tx('补单','Patch'),'info'],
                    export:    [t.tx('导出','Export'),'outline'],
                    sensitive: [t.tx('敏感','Sensitive'),'purple'],
                    layout:    [t.tx('装修','Layout'),'accent'],
                  };
                  const [label, cls] = typeMap[a.type] || ['—',''];
                  return (
                    <tr key={a.id} onClick={() => setSelectedAp(a)} style={{ background: selectedAp?.id === a.id ? 'var(--bg-inset)' : '', cursor: 'default' }}>
                      <td data-label={t.tx('单号','Ticket')} className="text-mono" style={{ fontSize: 11 }}>{a.id}</td>
                      <td data-label={t.tx('类型','Type')}><span className={`badge ${cls}`}>{label}</span></td>
                      <td data-label={t.tx('对象','Target')}>{a.who}</td>
                      <td data-label={t.tx('金额','Amount')} className="right tabular">{a.amount === 0 ? '—' : fmtCN(Math.abs(a.amount))}</td>
                      <td data-label={t.tx('风险','Risk')}><RiskDot score={a.risk} /></td>
                      <td data-label={t.tx('优先','Priority')}>{a.priority === 'high' ? <span className="badge danger" style={{ fontSize: 10 }}>{t.tx('高','High')}</span> : a.priority === 'mid' ? <span className="badge warning" style={{ fontSize: 10 }}>{t.tx('中','Mid')}</span> : <span className="badge" style={{ fontSize: 10 }}>{t.tx('低','Low')}</span>}</td>
                      <td data-label={t.tx('时长','Age')} className="muted">{a.age}</td>
                    </tr>
                  );
                })}
              </tbody>
            </table>
            </div>
          </div>

          {selectedAp && <ApprovalDetail ap={selectedAp} t={t} push={push} onAct={handleAct} />}
        </div>
      )}

      {tab === 'flow' && <ApprovalFlows t={t} />}

      {tab === 'log' && <AuditLog t={t} />}
    </div>
  );
}

function ApprovalDetail({ ap, t, push, onAct }) {
  const typeLabel = {
    withdraw:  t.tx('提现','Withdraw'),
    adjust:    t.tx('调账','Adjustment'),
    bonus:     t.tx('活动派发','Promo payout'),
    patch:     t.tx('补单','Patch'),
    export:    t.tx('导出','Export'),
    sensitive: t.tx('敏感信息','Sensitive info'),
    layout:    t.tx('页面装修发布','Page builder publish'),
  }[ap.type];
  return (
    <div className="card">
      <div className="card-h">
        <h3>{ap.id}</h3>
        <span className={'badge ' + (ap.priority === 'high' ? 'danger' : 'warning')}>{ap.priority === 'high' ? t.tx('高优','High') : t.tx('常规','Normal')}</span>
      </div>
      <div style={{ padding: 16, display: 'flex', flexDirection: 'column', gap: 12 }}>
        <div>
          <div className="text-muted" style={{ fontSize: 11, marginBottom: 3 }}>{typeLabel}</div>
          <div style={{ fontSize: 18, fontWeight: 600 }}>{ap.amount !== 0 ? (ap.amount > 0 ? '+' : '-') + fmtCN(Math.abs(ap.amount)) : ap.rule}</div>
          <div className="text-muted" style={{ fontSize: 12 }}>{ap.who}</div>
        </div>

        <dl className="kv">
          <dt>{t.tx('触发规则','Trigger rule')}</dt><dd>{ap.rule}</dd>
          <dt>{t.tx('租户 / 站点','Tenant / Site')}</dt><dd className="text-mono">{ap.tenant} · {ap.site}</dd>
          <dt>{t.tx('风险评分','Risk score')}</dt><dd><RiskDot score={ap.risk} /></dd>
          <dt>{t.tx('提交时间','Submitted')}</dt><dd>{ap.age}{t.tx('前',' ago')}</dd>
        </dl>

        <div className="divider" />
        <div className="text-muted" style={{ fontSize: 11, fontWeight: 600, letterSpacing: '.05em', textTransform: 'uppercase' }}>{t.tx('审批进度','Approval progress')}</div>
        <div style={{ display: 'flex', flexDirection: 'column', gap: 0, position: 'relative' }}>
          <ApSteps steps={[
            { who: t.tx('系统检测','System check'),   t: '14:02', done: true, note: t.tx('触发规则 ','Rule ') + ap.rule },
            { who: t.tx('客服 · Liu','Support · Liu'), t: '14:04', done: true, note: t.tx('已提交','Submitted') },
            { who: t.tx('财务复核','Finance review'), t: t.tx('当前','now'), done: false, current: true, note: t.tx('等待审批 (你)','Awaiting your review') },
            ...(ap.priority === 'high' ? [{ who: t.tx('风控终审','Risk final'), t: '—', done: false, note: t.tx('大额复审','Large-amount review') }] : []),
            { who: t.tx('执行','Execute'), t: '—', done: false, note: t.tx('自动入账','Auto-post') },
          ]} />
        </div>

        <textarea className="textarea" rows={2} placeholder={t.tx('审批备注 (必填)...','Approval notes (required)...')} />

        <div style={{ display: 'flex', gap: 6, justifyContent: 'space-between' }}>
          <button className="btn btn-sm btn-danger" onClick={() => (onAct ? onAct('reject') : push(ap.id + ' ' + t.tx('已驳回','rejected')))}><Icons.x /> {t.reject}</button>
          <div style={{ display: 'flex', gap: 6 }}>
            <button className="btn btn-sm"><Icons.bell /> {t.tx('退回补充','Return for info')}</button>
            <button className="btn btn-pri btn-sm" onClick={() => (onAct ? onAct('approve') : push(ap.id + ' ' + t.tx('已通过','approved')))}><Icons.check /> {t.approve}</button>
          </div>
        </div>

        <div style={{ background: 'var(--bg-inset)', padding: 10, borderRadius: 7, fontSize: 11, color: 'var(--text-muted)' }}>
          <div style={{ display: 'flex', alignItems: 'center', gap: 5, marginBottom: 3 }}>
            <Icons.shield /><span style={{ fontWeight: 600, color: 'var(--text)' }}>{t.tx('双人复核','Dual review')}</span>
          </div>
          {t.tx('此操作需 2 人审批 · 当前 1/2 · 已通过会自动留痕至 AUDIT-','Requires 2 approvers · currently 1/2 · approval is logged as AUDIT-')}{ap.id.replace('AP-','')}
        </div>
      </div>
    </div>
  );
}

function ApSteps({ steps }) {
  return (
    <div style={{ position: 'relative' }}>
      <div style={{ position: 'absolute', left: 9, top: 8, bottom: 8, width: 2, background: 'var(--border)' }} />
      {steps.map((s, i) => (
        <div key={i} style={{ display: 'flex', alignItems: 'flex-start', gap: 12, padding: '6px 0', position: 'relative' }}>
          <div style={{
            width: 20, height: 20, borderRadius: '50%',
            background: s.done ? 'var(--success)' : s.current ? 'var(--accent)' : 'var(--bg-inset)',
            border: '2px solid ' + (s.done ? 'var(--success)' : s.current ? 'var(--accent)' : 'var(--border-strong)'),
            display: 'flex', alignItems: 'center', justifyContent: 'center',
            color: 'white', flexShrink: 0, zIndex: 1
          }}>
            {s.done && <svg width="11" height="11" viewBox="0 0 14 14"><path d="M3 7.2 5.8 10 11 4.2" fill="none" stroke="white" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round" /></svg>}
            {s.current && <span style={{ width: 6, height: 6, borderRadius: '50%', background: 'white' }} />}
          </div>
          <div style={{ flex: 1, minWidth: 0, paddingTop: 1 }}>
            <div style={{ display: 'flex', alignItems: 'baseline', gap: 6 }}>
              <span style={{ fontSize: 12.5, fontWeight: 500, color: s.done ? 'var(--text)' : s.current ? 'var(--accent)' : 'var(--text-muted)' }}>{s.who}</span>
              <span className="text-faint" style={{ fontSize: 10.5 }}>{s.t}</span>
            </div>
            <div style={{ fontSize: 11, color: 'var(--text-muted)' }}>{s.note}</div>
          </div>
        </div>
      ))}
    </div>
  );
}

function ApprovalFlows({ t }) {
  const tx = (zh, en) => t ? t.tx(zh, en) : zh;
  const flows = [
    { name: tx('提现审批','Withdraw approval'),    cond: '≥ ¥1,000',   steps: [tx('客服','Support'), tx('财务','Finance'), tx('风控','Risk')], time: '< 30m', vol: 84 },
    { name: tx('大额提现复审','Large withdraw review'), cond: '≥ ¥100,000', steps: [tx('客服','Support'), tx('财务','Finance'), tx('风控','Risk'), tx('终审','Final')], time: '< 2h',  vol: 12 },
    { name: tx('人工调账','Manual adjust'),       cond: tx('任意金额','Any amount'), steps: [tx('提交人','Submitter'), tx('财务','Finance'), tx('部门负责人','Department head')], time: '< 1h', vol: 28 },
    { name: tx('导出会员','Export members'),      cond: tx('≥ 100 条','≥ 100 rows'),  steps: [tx('提交人','Submitter'), tx('部门负责人','Department head'), tx('安全','Security')], time: '< 4h', vol: 6 },
    { name: tx('查看完整资料','View full profile'),cond: tx('敏感字段','Sensitive fields'), steps: [tx('提交人','Submitter'), tx('直属上级','Direct manager'), tx('安全','Security')], time: '< 30m', vol: 18 },
    { name: tx('活动派发','Promo payout'),        cond: '≥ ¥10,000',  steps: [tx('运营','Ops'), tx('财务','Finance')], time: '< 30m', vol: 32 },
  ];
  return (
    <div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 16 }}>
      {flows.map((f) => (
        <div key={f.name} className="card">
          <div className="card-h">
            <h3>{f.name}</h3>
            <span className="meta">{f.cond}</span>
          </div>
          <div style={{ padding: 16 }}>
            <div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 14, flexWrap: 'wrap' }}>
              {f.steps.map((s, i) => (
                <React.Fragment key={s}>
                  <span style={{ padding: '6px 10px', background: i === 0 ? 'var(--accent-soft)' : 'var(--bg-inset)', color: i === 0 ? 'var(--accent-text)' : 'var(--text-muted)', borderRadius: 6, fontSize: 12, fontWeight: 500 }}>{s}</span>
                  {i < f.steps.length - 1 && <Icons.arrowR />}
                </React.Fragment>
              ))}
            </div>
            <div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 11, color: 'var(--text-muted)' }}>
              <span>{tx('平均处理 ','Avg ')}<strong style={{ color: 'var(--text)' }}>{f.time}</strong></span>
              <span>{tx('本月 ','This month ')}<strong style={{ color: 'var(--text)' }}>{f.vol}</strong> {tx('单','tickets')}</span>
            </div>
          </div>
        </div>
      ))}
    </div>
  );
}

function AuditLog({ t }) {
  const tx = (zh, en) => t.tx(zh, en);
  const logs = [
    { t: '14:02:14', who: tx('财务·周明','Finance · Zhou'),  act: tx('审批通过','Approved'),            target: 'AP-30421 ' + tx('提现 ¥88,200','withdraw ¥88,200'),  ip: '10.0.1.42', risk: 'low' },
    { t: '13:58:09', who: tx('系统','System'),               act: tx('自动触发风控','Auto risk trigger'), target: 'R-22041 ' + tx('充值后即提现','recharge → withdraw'), ip: 'AUTO',     risk: 'high' },
    { t: '13:42:21', who: tx('客服·Liu','Support · Liu'),    act: tx('查看敏感信息','View sensitive info'), target: 'U-882104 ' + tx('完整手机号','full phone number'), ip: '10.0.1.18', risk: 'mid' },
    { t: '13:38:02', who: tx('运营·Wang','Ops · Wang'),      act: tx('导出申请','Export request'),     target: tx('会员列表 12,402 条','Member list 12,402 rows'), ip: '10.0.1.22', risk: 'mid' },
    { t: '13:21:18', who: tx('风控·Liu','Risk · Liu'),       act: tx('黑名单 + 设备','Blacklist + device'), target: 'fp_a8f29e21',     ip: '10.0.1.18', risk: 'high' },
    { t: '13:11:09', who: tx('客服·Liu','Support · Liu'),    act: tx('修改用户标签','Edit user tags'),  target: 'U-892310',          ip: '10.0.1.18', risk: 'low' },
    { t: '12:48:42', who: tx('财务·周明','Finance · Zhou'),  act: tx('调账 -¥12,000','Adjust -¥12,000'), target: 'AP-30420',         ip: '10.0.1.42', risk: 'high' },
    { t: '12:31:18', who: tx('系统','System'),               act: tx('通道熔断','Channel circuit-break'), target: 'PC-Card-INT',       ip: 'AUTO',     risk: 'mid' },
    { t: '12:18:09', who: tx('运营·Wang','Ops · Wang'),      act: tx('发布页面草稿','Publish page draft'), target: tx('首页 v12','Home v12'), ip: '10.0.1.22', risk: 'low' },
    { t: '11:02:09', who: tx('风控·Wang','Risk · Wang'),     act: tx('冻结账户','Freeze account'),     target: 'U-941024 BurnerKey',ip: '10.0.1.20', risk: 'high' },
  ];
  return (
    <div className="card">
      <div className="filter-bar">
        <span className="chip active">{tx('全部','All')}</span>
        <span className="chip">{tx('资金','Funds')}</span>
        <span className="chip">{tx('敏感','Sensitive')}</span>
        <span className="chip">{tx('配置','Config')}</span>
        <span className="chip">{tx('风控','Risk')}</span>
        <div style={{ flex: 1 }} />
        <input className="input" placeholder={tx('搜索操作人 / 目标 / IP','Search operator / target / IP')} style={{ width: 280 }} />
      </div>
      <div className="tbl-scroll">
      <table className="tbl tbl-stack">
        <thead><tr><th>{tx('时间','Time')}</th><th>{tx('操作人','Operator')}</th><th>{tx('动作','Action')}</th><th>{tx('目标','Target')}</th><th>IP</th><th>{tx('等级','Risk')}</th><th></th></tr></thead>
        <tbody>
          {logs.map((l, i) => (
            <tr key={i}>
              <td data-label={tx('时间','Time')} className="text-mono muted" style={{ fontSize: 11 }}>{l.t}</td>
              <td data-label={tx('操作人','Operator')}><div style={{ display: 'flex', gap: 6, alignItems: 'center' }}><Avatar name={l.who} size={20} /><span style={{ fontWeight: 500 }}>{l.who}</span></div></td>
              <td data-label={tx('动作','Action')}>{l.act}</td>
              <td data-label={tx('目标','Target')} className="text-mono" style={{ fontSize: 11 }}>{l.target}</td>
              <td data-label="IP" className="text-mono muted">{l.ip}</td>
              <td data-label={tx('等级','Risk')}>{l.risk === 'high' ? <span className="badge danger">{tx('高','High')}</span> : l.risk === 'mid' ? <span className="badge warning">{tx('中','Mid')}</span> : <span className="badge">{tx('低','Low')}</span>}</td>
              <td data-label={tx('操作','Actions')} className="tbl-actions"><button className="btn btn-xs btn-ghost">{tx('详情','Detail')}</button></td>
            </tr>
          ))}
        </tbody>
      </table>
      </div>
    </div>
  );
}

// ─── 02 TENANTS LIST (real backend) ──────────────────────────────────────────
const _tenantsApi   = window.NebulaAdmin.adminFetch;
const _tenantsPoll  = window.NebulaAdmin.useAdminPoll;

const TIER_OPTIONS = [
  { value: 'starter',    label: 'Starter' },
  { value: 'standard',   label: 'Standard' },
  { value: 'enterprise', label: 'Enterprise' },
];
const TIER_CLS = { starter: 'outline', standard: 'info', enterprise: 'purple' };

const CATEGORY_OPTIONS = (t) => {
  const tx = (t && t.tx) ? t.tx : ((zh) => zh);
  return [
    { value: 'lottery', label: tx('彩票', 'Lottery') },
    { value: 'live',    label: tx('直播', 'Live') },
    { value: 'drama',   label: tx('短剧', 'Drama') },
    { value: 'multi',   label: tx('综合', 'Multi') },
  ];
};
const CATEGORY_CLS = { lottery: 'warning', live: 'danger', drama: 'purple', multi: 'info' };

const REGION_OPTIONS = (t) => {
  const tx = (t && t.tx) ? t.tx : ((zh) => zh);
  return [
    { value: 'CN',  label: tx('中国', 'China') },
    { value: 'HK',  label: tx('香港', 'Hong Kong') },
    { value: 'SEA', label: tx('东南亚', 'Southeast Asia') },
  ];
};

function TenantsScreen({ t, push }) {
  const [rkey, setRkey] = React.useState(0);
  const refetch = () => setRkey(k => k + 1);
  const [data, err] = _tenantsPoll('/api/tenants?_k=' + rkey, 10000, true);
  const tenants = (data && data.tenants) || [];

  const [showNew, setShowNew] = React.useState(false);
  const [editing, setEditing] = React.useState(null);

  const totalSites = tenants.reduce((acc, x) => acc + (x.sites_count || 0), 0);
  const byStatus = tenants.reduce((acc, x) => { acc[x.status] = (acc[x.status] || 0) + 1; return acc; }, {});

  const act = async (tn, action) => {
    try {
      if (action === 'delete') {
        if (!confirm(t.tx(`软删除租户 ${tn.id} ？\n（标记 status=expired，可恢复）`, `Soft-delete tenant ${tn.id}?\n(marks status=expired, recoverable)`))) return;
        await _tenantsApi('/api/admin/tenants/' + tn.id, { method: 'DELETE' });
        push(t.tx('已删除 ','Deleted ') + tn.id);
      } else {
        await _tenantsApi('/api/admin/tenants/' + tn.id + '/' + action, { method: 'POST' });
        push((action === 'pause' ? t.tx('已暂停 ','Paused ') : t.tx('已恢复 ','Resumed ')) + tn.id);
      }
      refetch();
    } catch (e) { push(t.tx('操作失败：','Action failed: ') + (e.body?.error || e.message)); }
  };

  return (
    <div className="stack">
      <div className="page-h">
        <div>
          <h1>{t.p_tenants}</h1>
          <div className="sub">{tenants.length} {t.tx('个租户','tenants')} · {totalSites} {t.tx('个站点','sites')} · {t.tx('多品牌矩阵','multi-brand matrix')}</div>
        </div>
        <div className="actions">
          <button className="btn btn-sm" onClick={refetch}><Icons.refresh /> {t.tx('刷新','Refresh')}</button>
          <button className="btn btn-pri btn-sm" onClick={() => setShowNew(true)}><Icons.plus /> {t.tx('新建租户','New tenant')}</button>
        </div>
      </div>

      <div className="row-4">
        <KPI label={t.tx('租户总数','Total tenants')} value={String(tenants.length)} />
        <KPI label="Active" value={String(byStatus.active || 0)} />
        <KPI label="Paused" value={String(byStatus.paused || 0)} />
        <KPI label="Expired" value={String(byStatus.expired || 0)} delta={t.tx(`累计 ${totalSites} 站点`, `${totalSites} sites total`)} />
      </div>

      {err && <div className="card" style={{ padding: 16, color: 'var(--danger)', fontSize: 12 }}>{t.tx('加载失败：','Load failed: ')}{String(err.message || err)} {err.status === 401 && t.tx('· 请重新登录','· please re-login')}</div>}
      {!data && !err && <div className="card" style={{ padding: 32, textAlign: 'center', color: 'var(--text-muted)', fontSize: 12 }}>{t.tx('加载中…','Loading…')}</div>}

      {data && tenants.length === 0 && <div className="empty">{t.tx('还没有租户 · 点击「新建租户」','No tenants yet · click "New tenant"')}</div>}
      {data && tenants.length > 0 && (
        <div className="card">
          <div className="tbl-scroll">
          <table className="tbl tbl-stack">
            <thead><tr>
              <th>{t.tx('租户','Tenant')}</th><th>{t.tx('等级','Tier')}</th><th>{t.tx('状态','Status')}</th>
              <th className="right">{t.tx('站点','Sites')}</th><th className="right">{t.tx('用户','Users')}</th>
              <th>{t.tx('计费邮箱','Billing email')}</th><th>{t.tx('创建','Created')}</th><th></th>
            </tr></thead>
            <tbody>
              {tenants.map(tn => {
                const tierCls = TIER_CLS[tn.tier] || 'outline';
                const tierLabel = (TIER_OPTIONS.find(o => o.value === tn.tier) || {}).label || tn.tier;
                const owner = tn.meta?.owner_name;
                const color = tn.meta?.primary_color || 'oklch(0.6 0.14 264)';
                return (
                  <tr key={tn.id}>
                    <td data-label={t.tx('租户','Tenant')}>
                      <div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
                        <div style={{ width: 28, height: 28, borderRadius: 7, background: color, display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'white', fontWeight: 600, fontSize: 12 }}>
                          {(tn.name || '?').slice(0, 1)}
                        </div>
                        <div>
                          <div style={{ fontWeight: 500 }}>{tn.name}</div>
                          <div className="text-mono text-faint" style={{ fontSize: 10 }}>{tn.id}{owner ? ' · ' + owner : ''}</div>
                        </div>
                      </div>
                    </td>
                    <td data-label={t.tx('等级','Tier')}><span className={'badge ' + tierCls} style={{ fontSize: 10 }}>{tierLabel}</span></td>
                    <td data-label={t.tx('状态','Status')}><StatusBadge status={tn.status === 'active' ? 'normal' : tn.status} t={t} /></td>
                    <td data-label={t.tx('站点','Sites')} className="right tabular">{tn.sites_count || 0}</td>
                    <td data-label={t.tx('用户','Users')} className="right tabular">{fmtNum(tn.users_count || 0)}</td>
                    <td data-label={t.tx('计费邮箱','Billing email')} className="text-mono" style={{ fontSize: 11 }}>{tn.billing_email || '—'}</td>
                    <td data-label={t.tx('创建','Created')} className="muted" style={{ fontSize: 11 }}>{tn.created_at ? new Date(tn.created_at).toLocaleDateString('zh-CN') : '—'}</td>
                    <td data-label={t.tx('操作','Actions')} className="tbl-actions">
                      <button className="btn btn-xs" onClick={() => setEditing(tn)}>{t.tx('编辑','Edit')}</button>
                      {tn.status === 'active'
                        ? <button className="btn btn-xs" onClick={() => act(tn, 'pause')}>{t.tx('暂停','Pause')}</button>
                        : <button className="btn btn-xs" onClick={() => act(tn, 'resume')}>{t.tx('启用','Enable')}</button>}
                      <button className="btn btn-xs btn-danger" onClick={() => act(tn, 'delete')}>{t.tx('删除','Delete')}</button>
                    </td>
                  </tr>
                );
              })}
            </tbody>
          </table>
          </div>
        </div>
      )}

      {showNew && <TenantFormModal mode="create" t={t} onClose={() => setShowNew(false)} push={push} onSaved={() => { setShowNew(false); refetch(); }} />}
      {editing && <TenantFormModal mode="edit" t={t} tenant={editing} onClose={() => setEditing(null)} push={push} onSaved={() => { setEditing(null); refetch(); }} />}
    </div>
  );
}

function TenantFormModal({ mode, tenant, onClose, push, onSaved, t }) {
  const tx = (zh, en) => t ? t.tx(zh, en) : zh;
  const isEdit = mode === 'edit';
  const [form, setForm] = React.useState(() => ({
    id:            tenant?.id || '',
    name:          tenant?.name || '',
    tier:          tenant?.tier || 'starter',
    billing_email: tenant?.billing_email || '',
    owner_name:    tenant?.meta?.owner_name || '',
    primary_color: tenant?.meta?.primary_color || '#7B5BFF',
    logo_url:      tenant?.meta?.logo_url || '',
    contact:       tenant?.meta?.contact || '',
  }));
  const [busy, setBusy] = React.useState(false);
  const set = (k, v) => setForm(f => ({ ...f, [k]: v }));

  const submit = async () => {
    if (!isEdit && !form.id.trim()) { push(tx('id 必填，例如 T-NEW','id is required, e.g. T-NEW')); return; }
    if (!form.name.trim()) { push(tx('name 必填','name is required')); return; }
    setBusy(true);
    try {
      const body = {
        name:          form.name.trim(),
        tier:          form.tier,
        billing_email: form.billing_email.trim(),
        meta: {
          owner_name:    form.owner_name.trim() || undefined,
          primary_color: form.primary_color || undefined,
          logo_url:      form.logo_url.trim() || undefined,
          contact:       form.contact.trim() || undefined,
        },
      };
      if (isEdit) {
        await _tenantsApi('/api/admin/tenants/' + tenant.id, { method: 'PUT', body: JSON.stringify(body) });
        push(tx('已更新 ','Updated ') + tenant.id);
      } else {
        await _tenantsApi('/api/admin/tenants', { method: 'POST', body: JSON.stringify({ ...body, id: form.id.trim() }) });
        push(tx('已创建 ','Created ') + form.id);
      }
      onSaved();
    } catch (e) { push((isEdit ? tx('更新失败：','Update failed: ') : tx('创建失败：','Create failed: ')) + (e.body?.error || e.message)); }
    finally { setBusy(false); }
  };

  return (
    <Modal open={true} onClose={onClose} title={isEdit ? tx(`编辑租户 · ${tenant.id}`, `Edit tenant · ${tenant.id}`) : tx('新建租户','New tenant')} width={560}
           footer={<>
             <button className="btn btn-sm" onClick={onClose}>{tx('取消','Cancel')}</button>
             <button className="btn btn-pri btn-sm" disabled={busy} onClick={submit}>{busy ? tx('提交中…','Submitting…') : (isEdit ? tx('保存','Save') : tx('创建','Create'))}</button>
           </>}>
      <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
        <Field label={tx('租户 ID','Tenant ID')}>
          <input className="input" value={form.id} disabled={isEdit}
                 placeholder={tx('如 T-AURORA','e.g. T-AURORA')} onChange={(e) => set('id', e.target.value)} />
        </Field>
        <Field label={tx('名称','Name')}>
          <input className="input" value={form.name} onChange={(e) => set('name', e.target.value)} />
        </Field>
        <Field label={tx('等级','Tier')}>
          <select className="select" value={form.tier} onChange={(e) => set('tier', e.target.value)}>
            {TIER_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
          </select>
        </Field>
        <Field label={tx('计费邮箱','Billing email')}>
          <input className="input" value={form.billing_email}
                 placeholder="ops@example.com" onChange={(e) => set('billing_email', e.target.value)} />
        </Field>
        <Field label={tx('Owner 公司','Owner company')}>
          <input className="input" value={form.owner_name} onChange={(e) => set('owner_name', e.target.value)} />
        </Field>
        <Field label={tx('联系方式','Contact')}>
          <input className="input" value={form.contact} placeholder="WeChat / Tel" onChange={(e) => set('contact', e.target.value)} />
        </Field>
        <Field label={tx('主色','Primary color')}>
          <div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
            <input type="color" value={form.primary_color} onChange={(e) => set('primary_color', e.target.value)}
                   style={{ width: 38, height: 32, border: '1px solid var(--border)', borderRadius: 4, background: 'transparent' }} />
            <input className="input" style={{ flex: 1, fontFamily: 'var(--font-mono)' }} value={form.primary_color} onChange={(e) => set('primary_color', e.target.value)} />
          </div>
        </Field>
        <Field label="Logo URL">
          <input className="input" value={form.logo_url} placeholder="/assets/brand/xxx.svg" onChange={(e) => set('logo_url', e.target.value)} />
        </Field>
      </div>
    </Modal>
  );
}

// ─── 03 SITES (real backend) ─────────────────────────────────────────────────
function SitesScreen({ t, push }) {
  const [rkey, setRkey] = React.useState(0);
  const refetch = () => setRkey(k => k + 1);
  const [tenantId, setTenantId] = React.useState('');
  const [statusFilter, setStatusFilter] = React.useState('');

  const sitesPath = '/api/sites?_k=' + rkey
    + (tenantId ? '&tenant_id=' + encodeURIComponent(tenantId) : '')
    + (statusFilter ? '&status=' + encodeURIComponent(statusFilter) : '');
  const [data, err] = _tenantsPoll(sitesPath, 10000, true);
  const [tenantsData] = _tenantsPoll('/api/tenants', 30000, true);
  const sites = (data && data.sites) || [];
  const tenants = (tenantsData && tenantsData.tenants) || [];

  const [showNew, setShowNew] = React.useState(false);
  const [editing, setEditing] = React.useState(null);

  const act = async (s, action) => {
    try {
      await _tenantsApi('/api/admin/sites/' + s.id + '/' + action, { method: 'POST' });
      push((action === 'pause' ? t.tx('已暂停 ','Paused ') : t.tx('已恢复 ','Resumed ')) + s.id);
      refetch();
    } catch (e) { push(t.tx('操作失败：','Action failed: ') + (e.body?.error || e.message)); }
  };

  const catLabel = (val) => {
    const o = CATEGORY_OPTIONS(t).find(x => x.value === val);
    return o ? o.label : val;
  };
  const regionLabel = (val) => {
    const o = REGION_OPTIONS(t).find(x => x.value === val);
    return o ? o.label : val;
  };

  const byCat = sites.reduce((acc, x) => { acc[x.category] = (acc[x.category] || 0) + 1; return acc; }, {});

  return (
    <div className="stack">
      <div className="page-h">
        <div>
          <h1>{t.p_sites}</h1>
          <div className="sub">{sites.length} {t.tx('个站点','sites')} · {t.tx('综合','Multi')} {byCat.multi || 0} / {t.tx('彩票','Lottery')} {byCat.lottery || 0} / {t.tx('直播','Live')} {byCat.live || 0} / {t.tx('短剧','Drama')} {byCat.drama || 0}</div>
        </div>
        <div className="actions">
          <button className="btn btn-sm" onClick={refetch}><Icons.refresh /> {t.tx('刷新','Refresh')}</button>
          <button className="btn btn-pri btn-sm" onClick={() => setShowNew(true)}><Icons.plus /> {t.tx('新建站点','New site')}</button>
        </div>
      </div>

      <div className="card">
        <div className="filter-bar" style={{ flexWrap: 'wrap', gap: 8 }}>
          <span style={{ fontSize: 11, color: 'var(--text-muted)' }}>{t.tx('租户','Tenant')}</span>
          <select className="select" style={{ minWidth: 160 }} value={tenantId} onChange={(e) => setTenantId(e.target.value)}>
            <option value="">{t.tx('全部租户','All tenants')}</option>
            {tenants.map(tn => <option key={tn.id} value={tn.id}>{tn.id} · {tn.name}</option>)}
          </select>
          <div style={{ width: 1, height: 18, background: 'var(--border)', margin: '0 4px' }} />
          <span style={{ fontSize: 11, color: 'var(--text-muted)' }}>{t.tx('状态','Status')}</span>
          {[['', t.tx('全部','All')], ['live', t.tx('在线','Live')], ['paused', t.tx('暂停','Paused')], ['draft', t.tx('草稿','Draft')], ['expired', t.tx('过期','Expired')]].map(([v, l]) => (
            <span key={v || 'all'} className={'chip ' + (statusFilter === v ? 'active' : '')}
                  style={{ cursor: 'pointer' }} onClick={() => setStatusFilter(v)}>{l}</span>
          ))}
        </div>

        {err && <div style={{ padding: 16, color: 'var(--danger)', fontSize: 12 }}>{t.tx('加载失败：','Load failed: ')}{String(err.message || err)} {err.status === 401 && t.tx('· 请重新登录','· please re-login')}</div>}
        {!data && !err && <div style={{ padding: 32, textAlign: 'center', color: 'var(--text-muted)', fontSize: 12 }}>{t.tx('加载中…','Loading…')}</div>}
        {data && sites.length === 0 && <div style={{ padding: 32, textAlign: 'center', color: 'var(--text-muted)', fontSize: 12 }}>{t.tx('无站点','No sites')}</div>}

        {data && sites.length > 0 && (
          <div className="tbl-scroll">
          <table className="tbl tbl-stack">
            <thead><tr>
              <th>{t.tx('站点','Site')}</th><th>{t.tx('租户','Tenant')}</th><th>{t.tx('分类','Category')}</th><th>{t.tx('区域','Region')}</th>
              <th>{t.tx('域名','Domain')}</th><th>{t.tx('状态','Status')}</th><th className="right">{t.tx('用户','Users')}</th><th></th>
            </tr></thead>
            <tbody>
              {sites.map(s => {
                const catLabelText = catLabel(s.category);
                const catCls = CATEGORY_CLS[s.category] || 'outline';
                const domainUrl = s.domain && (s.domain.startsWith('http') ? s.domain : 'https://' + s.domain);
                return (
                  <tr key={s.id}>
                    <td data-label={t.tx('站点','Site')}>
                      <div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
                        <div style={{ width: 26, height: 26, borderRadius: 6, background: 'linear-gradient(135deg, oklch(0.6 0.14 ' + (s.id.charCodeAt(2) * 13 % 360) + '), oklch(0.4 0.16 ' + (s.id.charCodeAt(3) * 23 % 360) + '))' }} />
                        <div>
                          <div style={{ fontWeight: 500 }}>{s.name}</div>
                          <div className="text-mono text-faint" style={{ fontSize: 10 }}>{s.id}</div>
                        </div>
                      </div>
                    </td>
                    <td data-label={t.tx('租户','Tenant')} className="text-mono muted" style={{ fontSize: 11 }}>{s.tenant_id}</td>
                    <td data-label={t.tx('分类','Category')}><span className={'badge ' + catCls} style={{ fontSize: 10 }}>{catLabelText}</span></td>
                    <td data-label={t.tx('区域','Region')}><span className="badge outline" style={{ fontSize: 10 }}>{regionLabel(s.region)}</span></td>
                    <td data-label={t.tx('域名','Domain')} className="text-mono" style={{ fontSize: 11 }}>
                      {domainUrl
                        ? <a href={domainUrl} target="_blank" rel="noreferrer" style={{ color: 'var(--accent)' }}>{s.domain} ↗</a>
                        : '—'}
                    </td>
                    <td data-label={t.tx('状态','Status')}><StatusBadge status={s.status} t={t} /></td>
                    <td data-label={t.tx('用户','Users')} className="right tabular">{fmtNum(s.users_count || 0)}</td>
                    <td data-label={t.tx('操作','Actions')} className="tbl-actions">
                      <button className="btn btn-xs" onClick={() => setEditing(s)}>{t.tx('编辑','Edit')}</button>
                      {s.status === 'paused'
                        ? <button className="btn btn-xs" onClick={() => act(s, 'resume')}>{t.tx('启用','Enable')}</button>
                        : <button className="btn btn-xs" onClick={() => act(s, 'pause')}>{t.tx('暂停','Pause')}</button>}
                    </td>
                  </tr>
                );
              })}
            </tbody>
          </table>
          </div>
        )}
      </div>

      {showNew && <SiteFormModal mode="create" t={t} tenants={tenants} defaultTenantId={tenantId} onClose={() => setShowNew(false)} push={push} onSaved={() => { setShowNew(false); refetch(); }} />}
      {editing && <SiteFormModal mode="edit" t={t} site={editing} tenants={tenants} onClose={() => setEditing(null)} push={push} onSaved={() => { setEditing(null); refetch(); }} />}
    </div>
  );
}

function SiteFormModal({ mode, site, tenants, defaultTenantId, onClose, push, onSaved, t }) {
  const tx = (zh, en) => t ? t.tx(zh, en) : zh;
  const isEdit = mode === 'edit';
  const [form, setForm] = React.useState(() => ({
    id:        site?.id || '',
    tenant_id: site?.tenant_id || defaultTenantId || (tenants[0]?.id || ''),
    name:      site?.name || '',
    domain:    site?.domain || '',
    category:  site?.category || 'lottery',
    region:    site?.region || 'CN',
    settings:  JSON.stringify(site?.settings || {}, null, 2),
  }));
  const [busy, setBusy] = React.useState(false);
  const [jsonErr, setJsonErr] = React.useState(null);
  const set = (k, v) => setForm(f => ({ ...f, [k]: v }));

  const submit = async () => {
    if (!isEdit && !form.id.trim()) { push(tx('id 必填，例如 S-NEW','id is required, e.g. S-NEW')); return; }
    if (!form.name.trim()) { push(tx('name 必填','name is required')); return; }
    if (!form.tenant_id) { push(tx('请选择租户','Please choose a tenant')); return; }
    let settings = {};
    try { settings = form.settings.trim() ? JSON.parse(form.settings) : {}; }
    catch (e) { setJsonErr(tx('settings 不是合法 JSON','settings is not valid JSON')); return; }
    setJsonErr(null);
    setBusy(true);
    try {
      const body = {
        tenant_id: form.tenant_id,
        name:      form.name.trim(),
        domain:    form.domain.trim(),
        category:  form.category,
        region:    form.region,
        settings,
      };
      if (isEdit) {
        await _tenantsApi('/api/admin/sites/' + site.id, { method: 'PUT', body: JSON.stringify(body) });
        push(tx('已更新 ','Updated ') + site.id);
      } else {
        await _tenantsApi('/api/admin/sites', { method: 'POST', body: JSON.stringify({ ...body, id: form.id.trim() }) });
        push(tx('已创建 ','Created ') + form.id);
      }
      onSaved();
    } catch (e) { push((isEdit ? tx('更新失败：','Update failed: ') : tx('创建失败：','Create failed: ')) + (e.body?.error || e.message)); }
    finally { setBusy(false); }
  };

  const catLabelLocal = (o) => o.label;
  const regionLabelLocal = (o) => o.label;

  return (
    <Modal open={true} onClose={onClose} title={isEdit ? tx(`编辑站点 · ${site.id}`, `Edit site · ${site.id}`) : tx('新建站点','New site')} width={620}
           footer={<>
             <button className="btn btn-sm" onClick={onClose}>{tx('取消','Cancel')}</button>
             <button className="btn btn-pri btn-sm" disabled={busy} onClick={submit}>{busy ? tx('提交中…','Submitting…') : (isEdit ? tx('保存','Save') : tx('创建','Create'))}</button>
           </>}>
      <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
        <Field label={tx('站点 ID','Site ID')}>
          <input className="input" value={form.id} disabled={isEdit}
                 placeholder={tx('如 S-AURORA-CN','e.g. S-AURORA-CN')} onChange={(e) => set('id', e.target.value)} />
        </Field>
        <Field label={tx('归属租户','Owner tenant')}>
          <select className="select" value={form.tenant_id} onChange={(e) => set('tenant_id', e.target.value)}>
            <option value="">{tx('— 选择 —','— Choose —')}</option>
            {tenants.map(tn => <option key={tn.id} value={tn.id}>{tn.id} · {tn.name}</option>)}
          </select>
        </Field>
        <Field label={tx('名称','Name')}>
          <input className="input" value={form.name} onChange={(e) => set('name', e.target.value)} />
        </Field>
        <Field label={tx('域名','Domain')}>
          <input className="input" value={form.domain} placeholder="aurora.cn" onChange={(e) => set('domain', e.target.value)} />
        </Field>
        <Field label={tx('分类','Category')}>
          <select className="select" value={form.category} onChange={(e) => set('category', e.target.value)}>
            {CATEGORY_OPTIONS(t).map(o => <option key={o.value} value={o.value}>{catLabelLocal(o)}</option>)}
          </select>
        </Field>
        <Field label={tx('区域','Region')}>
          <select className="select" value={form.region} onChange={(e) => set('region', e.target.value)}>
            {REGION_OPTIONS(t).map(o => <option key={o.value} value={o.value}>{regionLabelLocal(o)}</option>)}
          </select>
        </Field>
        <div style={{ gridColumn: '1 / -1' }}>
          <Field label="settings (JSON)">
            <textarea className="input" rows={6}
                      style={{ fontFamily: 'var(--font-mono)', fontSize: 11 }}
                      value={form.settings} onChange={(e) => set('settings', e.target.value)} />
            {jsonErr && <div style={{ color: 'var(--danger)', fontSize: 11, marginTop: 4 }}>{jsonErr}</div>}
            <div className="text-faint" style={{ fontSize: 10, marginTop: 4 }}>{tx('主题、CDN、语言、币种等站点级配置都塞这里','Theme, CDN, language, currency and other site-level config goes here')}</div>
          </Field>
        </div>
      </div>
    </Modal>
  );
}

Object.assign(window, { BuilderScreen, ApprovalsScreen, TenantsScreen, SitesScreen });
