拾光·极简相册2.2.2.更新

拾光 · 极简相册 (ShiGuang)

Typecho 独立相册插件,双列瀑布流与三列网格一键切换,零配置开箱即用。
适配Cuteen主题/其余模板可用独立路由或者改路径
适配后的OneBlog主题的代码在最下面
演示:linyu.live · 作者:Lin · 下载:GitHub


特性

  • 双列瀑布流 — 小红书风格,图片按原始比例自然错落,无白边、无强制裁剪
  • 三列大图网格 — 边缘贴合,圆角裁切,最大化利用屏幕空间
  • 现代灯箱 — 深色沉浸背景、缩略图导航、键盘/触摸/手势滑动、淡入淡出过渡
  • 暗色模式 — 自动适配主题暗色变量,无缝切换
  • 自动部署 — 启用插件即自动写入主题模板与样式文件,无需手动复制
  • 双入口访问 — 支持独立页面模板(/photo.html)与独立路由(/shiguang
  • 侧边栏兼容 — 自动识别 Cuteen 主题 Context::SidebarEcho(),PC 端左侧边栏正常显示
  • 零依赖 — 纯原生 CSS/JS,无第三方库

安装

  1. 下载插件,解压至 usr/plugins/ShiGuang/
  2. 确保目录结构如下:
    ShiGuang/
    ├── Plugin.php
    ├── Action.php
    └── views/ (自动生成,无需手动创建)

    1. 后台 → 控制台 → 插件 → 启用「拾光·极简相册」
  3. 插件会自动将 page-photo.php 部署到当前主题目录,并将 CSS/视图写入 views/

使用

方式一:独立页面(推荐)

后台 → 管理 → 独立页面 → 新建页面 → 模板选择「拾光·极简相册」→ 发布。

方式二:独立路由

直接访问 https://你的域名/shiguang,手机端自带返回顶栏。


后台设置

选项说明
相册标题首图大标题与页面标题
相册副标题首图小字/英文
默认分类ID支持多分类,英文逗号分隔;填 0 或留空显示全部
首图背景相对于插件目录的路径或完整 URL
网格布局三列密集网格 / 双列瀑布流卡片

目录结构(启用后)

usr/plugins/ShiGuang/
├── Plugin.php # 核心:路由、数据抓取、自动部署
├── Action.php # /shiguang 路由控制器

└── views/ ├── shiguang.css # 样式(自动写入) └── shiguang.php # /shiguang 路由视图(自动写入)
usr/themes/你的主题/ └── page-photo.php # 独立页面模板(自动写入)


技术细节

  • 图片提取:自动解析文章 Markdown 中的 ![alt](url) 与引用式图片语法
  • 瀑布流:CSS column-count 实现,图片 height: auto 保持原始比例,无 JS 计算
  • 灯箱:原生 JS 实现,支持 ← → 键盘导航、触摸滑动、缩略图点击跳转
  • 自动部署activate() 时通过 file_put_contents 将模板与样式写入对应位置,升级时禁用再启用即可刷新

更新日志

v2.2.2

  • 修复 PC 端侧边栏布局,兼容 Cuteen 主题原生 Bootstrap 结构
  • 路由视图增加手机端 sticky 返回顶栏
  • PC 端相册整体缩小,更精致
  • 双排瀑布流改为自然高度(按图片原始比例)
  • 灯箱沉浸感优化:缩略图放大、箭头半透明、底部毛玻璃
  • 路由首图高度压缩,更扁平
  • 增加 PC 端左侧边栏支持
  • 三列模式图片边缘贴合,零内边距
  • 统一圆角与轻微阴影
  • 重写双列模式为真瀑布流,支持 3/4、4/3、1/1 错落比例
  • 增加滚动入场动画(IntersectionObserver)

v1.2.1

  • 初始版本,基础双列/三列布局与灯箱
  • ·page-photo.php(手动复制粘贴到主题(如果没有)page-photo.php自己建立,然后禁用/开启插件)

    <?php
    /**
     * 拾光·极简相册
     *
     * @package custom
     * @template Photo
     */
    
    if (!defined('__TYPECHO_ROOT_DIR__')) exit;
    $this->need('header.php');
    
    $opts = $this->options->plugin('ShiGuang');
    $gridColumns = intval($opts->gridColumns) ?: 3;
    
    $allArticles = [];
    try {
      if (class_exists('TypechoPlugin\ShiGuang\Plugin')) {
          $allArticles = \TypechoPlugin\ShiGuang\Plugin::getAlbumData();
      }
    } catch (\Throwable $e) { $allArticles = []; }
    
    $pluginUrl = $this->options->pluginUrl . '/ShiGuang';
    ?>
    
    <link rel="stylesheet" href="<?= $pluginUrl ?>/views/shiguang.css?v=3">
    <style>
    /* 灯箱覆盖:标题+关闭按钮在下方胶囊 */
    .sg-lightbox-header { display: none !important; }
    .sg-lightbox-stage { padding-top: 0 !important; height: calc(100% - 140px) !important; }
    .sg-lightbox-imgbox { padding: 8px !important; background: rgba(255,255,255,0.04) !important; border-radius: 16px !important; box-shadow: 0 16px 48px rgba(0,0,0,0.4) !important; }
    .sg-lightbox-img { border-radius: 12px !important; max-height: 64vh !important; }
    .sg-lightbox-bar { display: flex !important; flex-direction: column !important; align-items: center !important; gap: 6px !important; padding: 8px 20px 20px !important; }
    .sg-lightbox-meta { display: inline-flex !important; align-items: center !important; gap: 8px !important; padding: 5px 14px 5px 16px !important; background: rgba(255,255,255,0.08) !important; border-radius: 24px !important; backdrop-filter: blur(8px) !important; }
    .sg-lightbox-title { position: static !important; transform: none !important; left: auto !important; font-size: 0.78rem !important; color: rgba(255,255,255,0.7) !important; background: transparent !important; padding: 0 !important; max-width: 200px !important; overflow: hidden !important; text-overflow: ellipsis !important; }
    .sg-lightbox-close { position: static !important; width: 22px !important; height: 22px !important; margin-left: 0 !important; background: rgba(255,255,255,0.1) !important; color: rgba(255,255,255,0.6) !important; }
    .sg-lightbox-close svg { stroke: currentColor !important; }
    .sg-lightbox-counter { font-size: 0.7rem !important; color: rgba(255,255,255,0.35) !important; }
    .sg-lb-thumb { width: 44px !important; height: 32px !important; opacity: 0.45 !important; }
    .sg-lb-thumb.active { opacity: 1 !important; transform: scale(1.06) !important; border-color: rgba(255,255,255,0.5) !important; }
    </style>
    
    <div class="main">
      <?php $this->need('module/head2.php');?>
      
      <!-- 顶部大图(复用原相册页结构) -->
      <div class="page_thumb blur">
          <div class="post_bg lazy-load" data-src="<?php echo $this->fields->thumb ? $this->fields->thumb : Helper::options()->themeUrl . '/static/img/photo.jpg'; ?>"></div>
          <div class="pc">
              <i class="iconfont icon-nav menu-button"></i>
              <div class="page-head">
                  <?php if ($this->options->logoStyle == 'text') {?>
                  <h1><a href="<?php $this->options->siteUrl(); ?>"><?php $this->options->title();?></a><span class="soul">生活志</span></h1>
                  <?php }else{ ?>
                  <a class="logo" href="<?php $this->options->siteUrl(); ?>">
                      <img src="<?php echo $this->options->logoWhite ? $this->options->logoWhite : Helper::options()->themeUrl . '/static/img/logoWhite.svg'; ?>">
                  </a>
                  <?php }?>
              </div>
          </div>
          <div class="m">
              <h1 class="page-head"><?php $this->archiveTitle(' &raquo; ', ''); ?><span>Scenery along the way</span></h1> 
          </div>
      </div>
      
      <!-- PC 标题 -->
      <div class="page-title animate__animated animate__fadeIn pc">
          <h1><?php $this->title(); ?></h1>   
      </div>
      
      <!-- 相册内容区 -->
      <div class="photo-contain blur animate__animated animate__fadeIn">
          <div class="sg-wrap">
              <main>
    <?php if (empty($allArticles)): ?>
                  <div class="sg-empty">
                      <div class="icon">📷</div>
                      <div class="txt">暂无图片</div>
                  </div>
    <?php else: ?>
    
    <?php if ($gridColumns == 2): ?>
    <!-- 双列瀑布流 -->
    <div class="sg-waterfall" id="sgWaterfall">
    <?php $articleIndex = 0; foreach ($allArticles as $article):
      $isSingle = $article['count'] <= 1;
      $firstImg = $article['images'][0];
      $delay = min($articleIndex * 0.08, 0.8);
    ?>
    <div class="sg-wf-item sg-reveal"
         data-index="<?= $articleIndex ?>"
         data-cid="<?= $article['cid'] ?>"
         style="animation-delay: <?= $delay ?>s">
      <div class="sg-wf-visual">
        <img src="<?= htmlspecialchars($firstImg['thumb']) ?>"
             alt="<?= htmlspecialchars($article['title']) ?>"
             loading="lazy">
        <?php if (!$isSingle): ?>
        <div class="sg-wf-count"><?= $article['count'] ?></div>
        <?php endif; ?>
      </div>
      <div class="sg-wf-body">
        <div class="sg-wf-title"><?= htmlspecialchars($article['title']) ?></div>
        <div class="sg-wf-date"><?= htmlspecialchars($article['date']) ?></div>
      </div>
    </div>
    <?php $articleIndex++; endforeach; ?>
    </div>
    
    <?php else: ?>
    <!-- 三列大图 -->
    <?php
    $flatImages = [];
    foreach ($allArticles as $article) {
      foreach ($article['images'] as $img) {
          $flatImages[] = [
              'url'       => $img['url'],
              'thumb'     => $img['thumb'],
              'title'     => $article['title'],
              'permalink' => $article['permalink']
          ];
      }
    }
    ?>
    <div class="sg-3col" id="sg3col">
    <?php $globalIndex = 0; foreach ($flatImages as $img): ?>
    <div class="sg-photo sg-reveal"
         data-index="<?= $globalIndex ?>"
         data-url="<?= htmlspecialchars($img['url']) ?>"
         data-title="<?= htmlspecialchars($img['title']) ?>"
         data-thumb="<?= htmlspecialchars($img['thumb']) ?>"
         data-permalink="<?= htmlspecialchars($img['permalink']) ?>"
         style="animation-delay: <?= min(($globalIndex % 9) * 0.06, 0.5) ?>s">
      <div class="sg-photo-inner">
        <img src="<?= htmlspecialchars($img['thumb']) ?>" alt="" loading="lazy">
      </div>
    </div>
    <?php $globalIndex++; endforeach; ?>
    </div>
    <?php endif; ?>
    
    <?php endif; ?>
              </main>
    
              <!-- 灯箱 -->
              <div class="sg-lightbox" id="sgLightbox">
                  <div class="sg-lightbox-backdrop" id="sgLbBackdrop"></div>
                  <div class="sg-lightbox-stage" id="sgLbStage">
                      <button class="sg-lightbox-arrow prev" id="sgLbPrev">
                          <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg>
                      </button>
                      <div class="sg-lightbox-imgbox" id="sgLbImgbox">
                          <img class="sg-lightbox-img" id="sgLbImg" src="" alt="">
                      </div>
                      <button class="sg-lightbox-arrow next" id="sgLbNext">
                          <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>
                      </button>
                  </div>
                  <div class="sg-lightbox-bar">
                      <div class="sg-lightbox-meta">
                          <a class="sg-lightbox-title" id="sgLbTitle" href="#" target="_blank" title="">图片</a>
                          <button class="sg-lightbox-close" id="sgLbClose">
                              <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
                          </button>
                      </div>
                      <div class="sg-lightbox-counter" id="sgLbCounter">1 / 1</div>
                      <div class="sg-lightbox-thumbs" id="sgLbThumbs"></div>
                  </div>
              </div>
          </div>
      </div>
    </div>
    
    <a id="gototop" class="hidden"><i class="iconfont icon-up"></i></a>
    
    <script>
    const sgArticles = <?= json_encode($allArticles) ?>;
    let sgFlatImages = [];
    let sgMode = '<?= $gridColumns == 2 ? 'article' : 'single' ?>';
    
    if (sgMode === 'single') {
      sgArticles.forEach(article => {
          article.images.forEach(img => {
              sgFlatImages.push({ url: img.url, thumb: img.thumb, title: article.title, permalink: article.permalink });
          });
      });
    }
    
    const revealObserver = new IntersectionObserver((entries) => {
      entries.forEach(entry => {
          if (entry.isIntersecting) {
              entry.target.classList.add('visible');
              revealObserver.unobserve(entry.target);
          }
      });
    }, { threshold: 0.05, rootMargin: '0px 0px -30px 0px' });
    
    document.querySelectorAll('.sg-reveal').forEach(el => revealObserver.observe(el));
    
    let sgCurrentImages = [];
    let sgCurrentIndex = 0;
    const sgLightbox = document.getElementById('sgLightbox');
    const sgLbImg = document.getElementById('sgLbImg');
    const sgLbTitle = document.getElementById('sgLbTitle');
    const sgLbCounter = document.getElementById('sgLbCounter');
    const sgLbThumbs = document.getElementById('sgLbThumbs');
    const sgLbPrev = document.getElementById('sgLbPrev');
    const sgLbNext = document.getElementById('sgLbNext');
    const sgLbClose = document.getElementById('sgLbClose');
    
    function sgOpen(images, index, permalink, title) {
      sgCurrentImages = images;
      sgCurrentIndex = index;
      sgLbTitle.textContent = title || '图片';
      sgLbTitle.href = permalink || '#';
      sgLbTitle.title = title || '';
      sgUpdateLb();
      sgLightbox.classList.add('active');
      document.body.style.overflow = 'hidden';
    }
    
    function sgUpdateLb() {
      if (!sgCurrentImages.length) return;
      const img = sgCurrentImages[sgCurrentIndex];
      sgLbImg.style.opacity = '0';
      sgLbImg.style.transform = 'scale(0.92)';
      setTimeout(() => {
          sgLbImg.src = img.url;
          sgLbImg.onload = () => {
              sgLbImg.style.opacity = '1';
              sgLbImg.style.transform = 'scale(1)';
          };
      }, 120);
      sgLbCounter.textContent = (sgCurrentIndex + 1) + ' / ' + sgCurrentImages.length;
      sgLbThumbs.innerHTML = sgCurrentImages.map((item, idx) =>
          '<div class="sg-lb-thumb ' + (idx === sgCurrentIndex ? 'active' : '') + '" data-index="' + idx + '">' +
          '<img src="' + item.thumb + '" alt="" loading="lazy">' +
          '</div>'
      ).join('');
      sgLbThumbs.querySelectorAll('.sg-lb-thumb').forEach(item => {
          item.addEventListener('click', () => { sgCurrentIndex = parseInt(item.dataset.index); sgUpdateLb(); });
      });
      sgLbPrev.style.opacity = sgCurrentIndex === 0 ? '0.2' : '1';
      sgLbNext.style.opacity = sgCurrentIndex === sgCurrentImages.length - 1 ? '0.2' : '1';
    }
    
    function sgPrev() { if (sgCurrentIndex > 0) { sgCurrentIndex--; sgUpdateLb(); } }
    function sgNext() { if (sgCurrentIndex < sgCurrentImages.length - 1) { sgCurrentIndex++; sgUpdateLb(); } }
    function sgClose() { sgLightbox.classList.remove('active'); document.body.style.overflow = ''; }
    
    document.querySelectorAll('.sg-wf-item').forEach((card, idx) => {
      card.addEventListener('click', () => {
          const article = sgArticles[idx];
          if (article && article.images) {
              sgOpen(article.images, 0, article.permalink, article.title);
          }
      });
    });
    
    document.querySelectorAll('.sg-photo').forEach((item, idx) => {
      item.addEventListener('click', () => {
          if (sgFlatImages[idx]) {
              sgOpen(sgFlatImages, idx, sgFlatImages[idx].permalink, sgFlatImages[idx].title);
          }
      });
    });
    
    sgLbClose.addEventListener('click', sgClose);
    sgLbPrev.addEventListener('click', sgPrev);
    sgLbNext.addEventListener('click', sgNext);
    
    const sgLbBackdrop = document.getElementById('sgLbBackdrop');
    const sgLbImgbox = document.getElementById('sgLbImgbox');
    
    sgLbBackdrop.addEventListener('click', sgClose);
    
    sgLbStage.addEventListener('click', (e) => {
      if (e.target === sgLbStage || e.target === sgLbImgbox) {
          sgClose();
      }
    });
    
    document.addEventListener('keydown', (e) => {
      if (sgLightbox.classList.contains('active')) {
          if (e.key === 'ArrowLeft') sgPrev();
          if (e.key === 'ArrowRight') sgNext();
          if (e.key === 'Escape') sgClose();
      }
    });
    
    let sgTouchX = 0;
    const lbStage = document.getElementById('sgLbStage');
    if (lbStage) {
      lbStage.addEventListener('touchstart', (e) => {
          sgTouchX = e.changedTouches[0].screenX;
      }, { passive: true });
      lbStage.addEventListener('touchend', (e) => {
          const diff = sgTouchX - e.changedTouches[0].screenX;
          if (Math.abs(diff) > 60) { diff > 0 ? sgNext() : sgPrev(); }
      }, { passive: true });
    }
    </script>
    
    <?php $this->need('footer.php'); ?>
评论区
头像