拾光 · 极简相册 (ShiGuang)
Typecho 独立相册插件,双列瀑布流与三列网格一键切换,零配置开箱即用。
适配Cuteen主题/其余模板可用独立路由或者改路径
适配后的OneBlog主题的代码在最下面
演示:linyu.live · 作者:Lin · 下载:GitHub
特性
- 双列瀑布流 — 小红书风格,图片按原始比例自然错落,无白边、无强制裁剪
- 三列大图网格 — 边缘贴合,圆角裁切,最大化利用屏幕空间
- 现代灯箱 — 深色沉浸背景、缩略图导航、键盘/触摸/手势滑动、淡入淡出过渡
- 暗色模式 — 自动适配主题暗色变量,无缝切换
- 自动部署 — 启用插件即自动写入主题模板与样式文件,无需手动复制
- 双入口访问 — 支持独立页面模板(
/photo.html)与独立路由(/shiguang) - 侧边栏兼容 — 自动识别 Cuteen 主题
Context::SidebarEcho(),PC 端左侧边栏正常显示 - 零依赖 — 纯原生 CSS/JS,无第三方库
安装
- 下载插件,解压至
usr/plugins/ShiGuang/ 确保目录结构如下:
ShiGuang/
├── Plugin.php
├── Action.php
└── views/ (自动生成,无需手动创建)- 后台 → 控制台 → 插件 → 启用「拾光·极简相册」
- 插件会自动将
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 中的
与引用式图片语法 - 瀑布流: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(' » ', ''); ?><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'); ?>