Files
YwxAppThink/addon/articles/view/frontend/article/detail.html
T
yangwx 18101cf11a feat: 上线前安全加固与发布工程化;appmall 由插件迁移为独立应用
安全(P0)
- 发布包签名:新增 scripts/package_keygen.php、scripts/package_verify.php、
  ywxapp\service\PackageVerifier;FrameworkService::apply() 解压前强制验签
  (ywxapp.framework_package_verify,默认开启)
- JWT 收紧:config/jwt.php 移除公开默认密钥,JwtService::assertStrongSecret()
  校验非空 / 长度≥32 / 已知弱值;.env.production.example 键名由 JWT_SECRET_KEY 修正为 JWT_SECRET
- /api/v1/Article:免登录白名单收窄为只读接口,写操作字段白名单 + assertOwner() 越权校验
- 开发者令牌:查找接口不再回显明文、统一失败文案 + 限流
- TemplateInstaller:文件名白名单 + realpath 归一化 + 目录内断言,修复 Zip Slip;补 use ZipArchive
- 后台登录:接入验证码、失败锁定与登录限流(config/ratelimit.php)
- 缓存 / Session 驱动可由 .env 覆盖(CACHE_DRIVER / SESSION_TYPE),集群部署改 redis
- paycenter 支付日志开关改为 PAY_LOG,不再跟随 APP_DEBUG

工程化 / 交付
- 新增 CHANGELOG.md 与 .env.production.example;composer.lock 入库锁定依赖版本
- 插件离线安装 CLI 增加 --uid,后台上传安装透传 uid,付费插件授权校验可通过
- 授权拒绝文案区分「未携带 uid」与「未找到有效授权」,便于排障
- .gitignore 放行 .env.production.example / .env.addon.example / .gitattributes / public/.htaccess

结构
- appmall 由 addon/appmall 迁移为独立应用 app/appmall(市场 / 开发者中心 / 审核 / 分润)
2026-10-22 00:53:39 +08:00

320 lines
14 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>{$article.title} - 文章CMS</title>
<meta name="keywords" content="{$article.category_name|default=''}">
<meta name="description" content="{$article.summary|default=''|substr=0,150}">
<link rel="canonical" href="{:url('articles/read/' . $article.id)}">
{notempty name='$jsonLd'}
<script type="application/ld+json">{$jsonLd|raw}</script>
<script type="application/ld+json">{$breadcrumbLd|raw}</script>
{/notempty}
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=0">
<link rel="stylesheet" type="text/css" href="/assets/layui/css/layui.css">
<link rel="stylesheet" type="text/css" href="/static/articles/css/main.css">
</head>
<body class="micronews">
<!-- 阅读进度条 + 继续上次阅读 -->
<div id="readProgressBar" style="position:fixed;top:0;left:0;height:3px;background:#5FB878;width:0;z-index:9999;"></div>
<div id="readResume" style="display:none;position:fixed;right:16px;bottom:16px;background:#fff;border:1px solid #eee;padding:8px 12px;box-shadow:0 2px 8px rgba(0,0,0,.12);z-index:9999;font-size:13px;">
上次读到 <b id="readResumePct">0</b>% · <a href="javascript:;" id="readResumeGo" style="color:#5FB878;">继续阅读</a>
<a href="javascript:;" id="readResumeClose" style="margin-left:8px;color:#999;">×</a>
</div>
<div class="micronews-header-wrap">
<div class="micronews-header w1000 layui-clear">
<h1 class="logo">
<a href="{:url('articles/index/index')}">
<img src="/static/articles/img/LOGO.png" alt="logo">
<span class="layui-hide">LOGO</span>
</a>
</h1>
<p class="nav">
<a href="{:url('articles/index/index')}" {eq name='nav_active' value='index'}class="active"{/eq}>最新</a>
{volist name='categories' id='c'}
<a href="{:url('articles/category/index', ['id'=>$c.id])}" {eq name='nav_active' value='$c.id'}class="active"{/eq}>{$c.name}</a>
{/volist}
</p>
<div class="search-bar">
<form class="layui-form" action="{:url('articles/search/index')}">
<div class="layui-form-item">
<div class="layui-input-block">
<input type="text" name="keyword" placeholder="搜索你要的内容" autocomplete="off" class="layui-input">
<button class="layui-btn search-btn" formnovalidate><i class="layui-icon layui-icon-search"></i></button>
</div>
</div>
</form>
</div>
<div class="login">
{if $is_login}
<a href="{:url('member/index/index')}"><img src="{:isset($member['avatar']) && $member['avatar'] ? $member['avatar'] : '/static/articles/img/header.png'}" style="width: 36px; height: 36px;"></a>
{else/}
<a href="{:url('member/login/index')}">登录</a>
{/if}
</div>
<div class="menu-icon">
<i class="layui-icon layui-icon-more-vertical"></i>
</div>
<div class="mobile-nav">
<ul class="layui-nav" lay-filter="">
<li class="layui-nav-item {eq name='nav_active' value='index'}layui-this{/eq}"><a href="{:url('articles/index/index')}">最新</a></li>
{volist name='categories' id='c'}
<li class="layui-nav-item {eq name='nav_active' value='$c.id'}layui-this{/eq}"><a href="{:url('articles/category/index', ['id'=>$c.id])}">{$c.name}</a></li>
{/volist}
</ul>
</div>
</div>
</div>
<div class="micronews-container micronews-details-container w1000">
<div class="layui-fluid">
<div class="layui-row">
<div class="layui-col-xs12 layui-col-sm12 layui-col-md8">
<div class="main">
<div class="title">
<h3>{$article.title}</h3>
<div class="b-txt">
<span class="label">{$article.category_name|default='未分类'}</span>
<span class="icon">
<i class="layui-icon layui-icon-radio"></i>
<b>{$article.view_count}</b>人
</span>
<a href="#message">
<span class="icon message">
<i class="layui-icon layui-icon-dialogue"></i>
<b>{$article.comment_count}</b>条
</span>
</a>
<a href="javascript:;" id="btnLike" class="icon like {if $liked}liked{/if}" title="点赞">
<i class="layui-icon layui-icon-praise"></i>
<b id="likeCount">{$article.like_count|default='0'}</b>
</a>
<a href="javascript:;" id="btnFav" class="icon fav {if $favorited}on{/if}" title="收藏">
<i class="layui-icon layui-icon-star"></i>
<span id="favText">{if $favorited}已收藏{else/}收藏{/if}</span>
</a>
<span class="icon time">
<i class="layui-icon layui-icon-log"></i>
{$article.create_at}
</span>
</div>
</div>
<div class="article">
{if isset($contentHtml) && $contentHtml}{$contentHtml|raw}{else/}{$article.content|raw}{/if}
</div>
<div class="leave-message" id="message">
<div class="tit-box">
<span class="tit">网友跟帖</span>
<span class="num"><b>{$article.comment_count}</b>条</span>
</div>
<div class="content-box">
<div class="tear-box">
<a href="#"><img src="/static/articles/img/header_img1.png"></a>
<form class="layui-form">
<div class="layui-form-item layui-form-text">
<div class="layui-input-block">
<textarea id="onInput" placeholder="请输入内容" class="layui-textarea"></textarea>
</div>
</div>
<div class="layui-form-item">
<div class="layui-input-block" style="text-align: right;">
<div class="message-text">
<div class="txt"></div>
</div>
<button type="button" class="layui-btn micronews-details-Publish">发表</button>
</div>
</div>
</form>
</div>
<!-- 楼层回复框:点击某条评论的「回复」后展开 -->
<div class="reply-box" id="replyBox" style="display:none;margin:10px 0;padding:10px;border:1px solid #eee;">
<div class="reply-tip" id="replyTip" style="margin-bottom:6px;color:#888;"></div>
<textarea id="replyContent" class="layui-textarea" placeholder="回复内容"></textarea>
<div style="text-align:right;margin-top:8px;">
<button type="button" class="layui-btn layui-btn-sm layui-btn-primary" id="replyCancel">取消</button>
<button type="button" class="layui-btn layui-btn-sm" id="replySubmit">提交回复</button>
</div>
</div>
<div class="ulCommentList">
{volist name='comments.data' id='cm'}
<div class="liCont">
<a href="#"><img src="/static/articles/img/header_img1.png"></a>
<div class="item-cont">
<div class="cont">
<p><span class="name">{$cm.nickname|default='游客'}</span><span class="time">{$cm.create_at}</span></p>
<p class="text">{$cm.content}</p>
<p><a href="javascript:;" class="btn-reply" data-id="{$cm.id}" data-name="{$cm.nickname|default='游客'}">回复</a></p>
</div>
{notempty name='$cm.replies'}
<div class="reply-list" style="margin-left:52px;border-left:2px solid #f0f0f0;padding-left:10px;">
{volist name='$cm.replies' id='rp'}
<div class="reply-item">
<p><span class="name">{$rp.nickname|default='游客'}</span><span class="time">{$rp.create_at}</span></p>
<p class="text">{$rp.content}</p>
</div>
{/volist}
</div>
{/notempty}
</div>
</div>
{/volist}
</div>
<div class="page-wrap">{$comments.render|raw}</div>
</div>
</div>
</div>
</div>
<div class="layui-col-xs12 layui-col-sm12 layui-col-md4">
<div class="popular-info popular-info-tog">
<div class="layui-card">
<div class="layui-card-header">
<h3>资讯推荐</h3>
</div>
<div class="layui-card-body">
<ul class="list-box">
{volist name='related' id='r'}
<li class="list">
<a href="{:url('articles/Article/read', ['id'=>$r.id])}">{$r.title}</a>
</li>
{/volist}
{volist name='hot' id='h'}
<li class="list">
<a href="{:url('articles/Article/read', ['id'=>$h.id])}">{$h.title}</a>
</li>
{/volist}
</ul>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="micronews-footer-wrap">
<div class="micronews-footer w1000">
<div class="ft-nav">
<a href="#">关于我们</a>
<a href="#">合作伙伴</a>
<a href="#">广告服务</a>
<a href="#">常见问题</a>
</div>
<div class="Copyright">
<span>Copyright&nbsp;</span>&nbsp;&copy;<span>文章CMS&nbsp;&nbsp;</span><span>Powered by YwxApp</span>
</div>
<div class="f-icon">
<a href="#" class="w-icon">
<img src="/static/articles/img/wechat_ic.png">
</a>
<a href="#" class="wb-icon">
<img src="/static/articles/img/qq_ic.png">
</a>
</div>
</div>
</div>
<script type="text/javascript" src="/assets/layui/layui.js"></script>
<script type="text/javascript" src="/assets/ywxapp/ywxapp.js"></script>
<script>
layui.config({
base: '/assets/layui/js/'
}).use('index', function () {
var index = layui.index;
index.EnterMessage();
index.seachBtn();
index.onInput();
index.arrowutil();
});
</script>
<script>
// 点赞 / 楼层回复(独立于模板自带 index 模块,避免冲突)
layui.use(['jquery', 'layer'], function () {
var $ = layui.jquery, layer = layui.layer;
var aid = "{$article.id}";
var likeUrl = "{:url('articles/article/like')}";
var commentUrl = "{:url('articles/article/comment')}";
$('#btnLike').on('click', function () {
$.post(likeUrl, { id: aid, target: 1 }, function (r) {
if (!r || !r.code) { layer.msg((r && r.message) || '操作失败'); return; }
var d = r.data || {};
$('#likeCount').text(d.count || 0);
$('#btnLike').toggleClass('liked', !!d.liked);
layer.msg(r.message || (d.liked ? '已点赞' : '已取消点赞'));
});
return false;
});
// 收藏
$('#btnFav').on('click', function () {
$.post("{:url('articles/article/favorite')}", { id: aid }, function (r) {
if (!r || !r.code) { layer.msg((r && r.message) || '请先登录'); return; }
var on = !!(r.data && r.data.favorited);
$('#btnFav').toggleClass('on', on);
$('#favText').text(on ? '已收藏' : '收藏');
layer.msg(r.message || '');
});
return false;
});
// 阅读进度(本地记录,无需登录)
var bar = document.getElementById('readProgressBar');
var pkey = 'articles_progress_' + aid;
function progress() {
var h = document.documentElement;
var top = h.scrollTop || document.body.scrollTop || 0;
var max = (h.scrollHeight - h.clientHeight) || 0;
return max > 0 ? Math.min(100, Math.round(top / max * 100)) : 0;
}
window.addEventListener('scroll', function () {
var p = progress();
if (bar) { bar.style.width = p + '%'; }
try { localStorage.setItem(pkey, p); } catch (e) {}
});
var saved = 0;
try { saved = parseInt(localStorage.getItem(pkey) || '0', 10); } catch (e) {}
if (saved >= 5 && saved <= 90) {
$('#readResumePct').text(saved);
$('#readResume').show();
$('#readResumeGo').on('click', function () {
var h = document.documentElement;
var max = (h.scrollHeight - h.clientHeight) || 0;
window.scrollTo({ top: Math.round(max * saved / 100), behavior: 'smooth' });
$('#readResume').hide();
});
$('#readResumeClose').on('click', function () { $('#readResume').hide(); });
}
var replyPid = 0;
$(document).on('click', '.btn-reply', function () {
replyPid = $(this).data('id');
$('#replyTip').text('回复 @' + ($(this).data('name') || ''));
$('#replyBox').show();
$('#replyContent').focus();
});
$('#replyCancel').on('click', function () {
replyPid = 0;
$('#replyBox').hide();
$('#replyContent').val('');
});
$('#replySubmit').on('click', function () {
var content = $.trim($('#replyContent').val());
if (!content) { layer.msg('请输入回复内容'); return; }
$.post(commentUrl, { aid: aid, pid: replyPid, content: content }, function (r) {
layer.msg((r && r.message) || '已提交');
if (r && r.code) { setTimeout(function () { location.reload(); }, 600); }
});
});
});
</script>
</body>
</html>