屏蔽空内容;接入搜索引擎;新增详情页面
This commit is contained in:
@@ -2,8 +2,11 @@
|
|||||||
|
|
||||||
> 基于原作者v1.4.3代码修改
|
> 基于原作者v1.4.3代码修改
|
||||||
|
|
||||||
#### v1.4.3-4-补丁
|
#### v1.4.3-5
|
||||||
1. 补充ui/package.json内"@rsbuild/core": "^1.4.3"依赖
|
1. 补充ui/package.json内"@rsbuild/core": "^1.4.3"依赖
|
||||||
|
2. 新增单条朋友圈页面(前端模板friend-post.html;访问地址/friends/friend-post-xxxxx)
|
||||||
|
3. 朋友圈内容可搜索并返回单条朋友圈页面链接
|
||||||
|
4. 内容(description)为空的项目将不再存入数据库
|
||||||
|
|
||||||
#### v1.4.3-4
|
#### v1.4.3-4
|
||||||
1. 控制台新增并行处理数量、图片下载开关、图片下载位置、图片下载代理等配置
|
1. 控制台新增并行处理数量、图片下载开关、图片下载位置、图片下载代理等配置
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
version=1.4.3-4
|
version=1.4.3-5
|
||||||
|
|||||||
Generated
+3
@@ -126,6 +126,9 @@ importers:
|
|||||||
'@iconify/json':
|
'@iconify/json':
|
||||||
specifier: ^2.2.224
|
specifier: ^2.2.224
|
||||||
version: 2.2.446
|
version: 2.2.446
|
||||||
|
'@rsbuild/core':
|
||||||
|
specifier: ^1.4.3
|
||||||
|
version: 1.7.3
|
||||||
'@rspack/cli':
|
'@rspack/cli':
|
||||||
specifier: 1.4.0-beta.0
|
specifier: 1.4.0-beta.0
|
||||||
version: 1.4.0-beta.0(@rspack/core@1.4.0-beta.0(@swc/helpers@0.5.19))(@types/express@4.17.25)(tslib@2.8.1)(webpack@5.105.4)
|
version: 1.4.0-beta.0(@rspack/core@1.4.0-beta.0(@swc/helpers@0.5.19))(@types/express@4.17.25)(tslib@2.8.1)(webpack@5.105.4)
|
||||||
|
|||||||
@@ -242,44 +242,40 @@ public class RssSyncReconciler implements Reconciler<RssSyncReconciler.Request>,
|
|||||||
friendPostSpec.setAuthorUrl(link.getSpec().getUrl());
|
friendPostSpec.setAuthorUrl(link.getSpec().getUrl());
|
||||||
friendPostSpec.setLinkName(linkName);
|
friendPostSpec.setLinkName(linkName);
|
||||||
String description = friendPost.getSpec().getDescription();
|
String description = friendPost.getSpec().getDescription();
|
||||||
|
String pureText = normalizeDescription(description);
|
||||||
// 先提取图片和视频 poster 文件名覆盖标题,再将 HTML 描述转换为纯文本
|
if (StringUtils.isBlank(pureText)) {
|
||||||
if (StringUtils.isNotEmpty(description)) {
|
continue;
|
||||||
List<String> imageFileNames = new ArrayList<>();
|
|
||||||
Matcher matcher = IMG_SRC_PATTERN.matcher(description);
|
|
||||||
while (matcher.find()) {
|
|
||||||
String imgSrc = matcher.group(2);
|
|
||||||
Matcher mw2000Matcher = MW2000_IMAGE_FILE_PATTERN.matcher(imgSrc);
|
|
||||||
if (mw2000Matcher.matches()) {
|
|
||||||
imageFileNames.add(mw2000Matcher.group(1));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Matcher posterMatcher = VIDEO_POSTER_PATTERN.matcher(description);
|
|
||||||
while (posterMatcher.find()) {
|
|
||||||
String posterSrc = posterMatcher.group(2);
|
|
||||||
String posterFileName = extractFileNameFromUrl(posterSrc);
|
|
||||||
if (StringUtils.isNotBlank(posterFileName)) {
|
|
||||||
imageFileNames.add(posterFileName);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (enableWeiboImageRecordAndDownload && !imageFileNames.isEmpty()) {
|
|
||||||
String imageFiles = String.join(",", imageFileNames);
|
|
||||||
friendPostSpec.setImageFiles(imageFiles);
|
|
||||||
// 通知图片处理服务
|
|
||||||
notifyImageService(imageFiles, imageSavePath, imageBaseUrl,
|
|
||||||
imageReferer);
|
|
||||||
}
|
|
||||||
|
|
||||||
String pureText = CommonUtils.parseAndTruncateHtml2Text(description, 100000);
|
|
||||||
String regexp = "[ *|\\s*]*";
|
|
||||||
pureText = pureText.replaceFirst(regexp, "").trim();
|
|
||||||
String weiboVideoRegex = "\\s[^\\s]*的微博视频 视频无法显示,请前往微博视频观看。";
|
|
||||||
pureText = pureText.replaceAll(weiboVideoRegex, "");
|
|
||||||
friendPostSpec.setDescription(pureText);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 提取图片和视频 poster 文件名,并保存过滤后的纯文本描述
|
||||||
|
List<String> imageFileNames = new ArrayList<>();
|
||||||
|
Matcher matcher = IMG_SRC_PATTERN.matcher(description);
|
||||||
|
while (matcher.find()) {
|
||||||
|
String imgSrc = matcher.group(2);
|
||||||
|
Matcher mw2000Matcher = MW2000_IMAGE_FILE_PATTERN.matcher(imgSrc);
|
||||||
|
if (mw2000Matcher.matches()) {
|
||||||
|
imageFileNames.add(mw2000Matcher.group(1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Matcher posterMatcher = VIDEO_POSTER_PATTERN.matcher(description);
|
||||||
|
while (posterMatcher.find()) {
|
||||||
|
String posterSrc = posterMatcher.group(2);
|
||||||
|
String posterFileName = extractFileNameFromUrl(posterSrc);
|
||||||
|
if (StringUtils.isNotBlank(posterFileName)) {
|
||||||
|
imageFileNames.add(posterFileName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (enableWeiboImageRecordAndDownload && !imageFileNames.isEmpty()) {
|
||||||
|
String imageFiles = String.join(",", imageFileNames);
|
||||||
|
friendPostSpec.setImageFiles(imageFiles);
|
||||||
|
// 通知图片处理服务
|
||||||
|
notifyImageService(imageFiles, imageSavePath, imageBaseUrl,
|
||||||
|
imageReferer);
|
||||||
|
}
|
||||||
|
|
||||||
|
friendPostSpec.setDescription(pureText);
|
||||||
client.create(friendPost);
|
client.create(friendPost);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -287,6 +283,17 @@ public class RssSyncReconciler implements Reconciler<RssSyncReconciler.Request>,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private String normalizeDescription(String description) {
|
||||||
|
if (StringUtils.isEmpty(description)) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
String pureText = CommonUtils.parseAndTruncateHtml2Text(description, 100000);
|
||||||
|
String regexp = "[ *|\\s*]*";
|
||||||
|
pureText = pureText.replaceFirst(regexp, "").trim();
|
||||||
|
String weiboVideoRegex = "\\s[^\\s]*的微博视频 视频无法显示,请前往微博视频观看。";
|
||||||
|
return pureText.replaceAll(weiboVideoRegex, "").trim();
|
||||||
|
}
|
||||||
|
|
||||||
public List<FriendPost> fetchFriendPost(String rssAddress, int postsLimit, RssFeedSyncLog sync) {
|
public List<FriendPost> fetchFriendPost(String rssAddress, int postsLimit, RssFeedSyncLog sync) {
|
||||||
List<FriendPost> friendPostList = new ArrayList<>();
|
List<FriendPost> friendPostList = new ArrayList<>();
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ public class FriendRouter {
|
|||||||
RouterFunction<ServerResponse> friendTemplateRoute() {
|
RouterFunction<ServerResponse> friendTemplateRoute() {
|
||||||
return RouterFunctions.route().GET("/friends",this::handlerFunction)
|
return RouterFunctions.route().GET("/friends",this::handlerFunction)
|
||||||
.GET("/friends/page/{page:\\d+}",this::handlerFunction)
|
.GET("/friends/page/{page:\\d+}",this::handlerFunction)
|
||||||
|
.GET("/friends/{friendPostName:\\S+}", this::friendPostDetail)
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,6 +55,16 @@ public class FriendRouter {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private Mono<ServerResponse> friendPostDetail(ServerRequest request) {
|
||||||
|
String friendPostName = request.pathVariable("friendPostName");
|
||||||
|
return templateNameResolver.resolveTemplateNameOrDefault(request.exchange(), "friend-post")
|
||||||
|
.flatMap(templateName -> ServerResponse.ok().render(templateName,
|
||||||
|
Map.of(ModelConst.TEMPLATE_ID, templateName,
|
||||||
|
"friend", friendFinder.getByName(friendPostName),
|
||||||
|
"title", getFriendTitle())
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
Mono<String> getFriendTitle() {
|
Mono<String> getFriendTitle() {
|
||||||
return this.settingFetcher.get("base").map(
|
return this.settingFetcher.get("base").map(
|
||||||
setting -> setting.get("title").asText("友链朋友圈")).defaultIfEmpty(
|
setting -> setting.get("title").asText("友链朋友圈")).defaultIfEmpty(
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
package la.moony.friends.search;
|
||||||
|
|
||||||
|
import static la.moony.friends.search.FriendPostHaloDocumentsProvider.FRIEND_POST_DOCUMENT_TYPE;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import la.moony.friends.extension.FriendPost;
|
||||||
|
import la.moony.friends.util.CommonUtils;
|
||||||
|
import lombok.NonNull;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.apache.commons.lang3.StringUtils;
|
||||||
|
import org.springframework.core.convert.converter.Converter;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
import run.halo.app.infra.ExternalUrlSupplier;
|
||||||
|
import run.halo.app.search.HaloDocument;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class FriendPostDocumentConverter implements Converter<FriendPost, Mono<HaloDocument>> {
|
||||||
|
|
||||||
|
private static final String EMPTY_CONTENT = "无内容";
|
||||||
|
|
||||||
|
private final ExternalUrlSupplier externalUrlSupplier;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@NonNull
|
||||||
|
public Mono<HaloDocument> convert(FriendPost friendPost) {
|
||||||
|
var spec = friendPost.getSpec();
|
||||||
|
var author = authorName(friendPost);
|
||||||
|
var title = defaultIfBlank(spec == null ? null : spec.getTitle(), EMPTY_CONTENT);
|
||||||
|
var description = defaultIfBlank(plainDescription(spec), EMPTY_CONTENT);
|
||||||
|
var haloDoc = new HaloDocument();
|
||||||
|
haloDoc.setMetadataName(friendPost.getMetadata().getName());
|
||||||
|
haloDoc.setType(FRIEND_POST_DOCUMENT_TYPE);
|
||||||
|
haloDoc.setId(haloDocId(friendPost));
|
||||||
|
haloDoc.setTitle(title);
|
||||||
|
haloDoc.setDescription(description);
|
||||||
|
haloDoc.setContent(content(title, author, description));
|
||||||
|
haloDoc.setExposed(true);
|
||||||
|
haloDoc.setTags(tags(author));
|
||||||
|
haloDoc.setOwnerName(author);
|
||||||
|
haloDoc.setUpdateTimestamp(updateTimestamp(friendPost));
|
||||||
|
haloDoc.setCreationTimestamp(creationTimestamp(friendPost));
|
||||||
|
haloDoc.setPermalink(permalink(friendPost));
|
||||||
|
haloDoc.setPublished(true);
|
||||||
|
return Mono.just(haloDoc);
|
||||||
|
}
|
||||||
|
|
||||||
|
String haloDocId(FriendPost friendPost) {
|
||||||
|
return FRIEND_POST_DOCUMENT_TYPE + '-' + friendPost.getMetadata().getName();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String authorName(FriendPost friendPost) {
|
||||||
|
var author = Optional.ofNullable(friendPost.getSpec())
|
||||||
|
.map(FriendPost.FriendPostSpec::getAuthor)
|
||||||
|
.orElse(null);
|
||||||
|
return StringUtils.defaultIfBlank(author, EMPTY_CONTENT);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String plainDescription(FriendPost.FriendPostSpec spec) {
|
||||||
|
if (spec == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return CommonUtils.parseAndTruncateHtml2Text(defaultString(spec.getDescription()), 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String content(String title, String author, String description) {
|
||||||
|
return String.join(" ", title, author, description).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<String> tags(String author) {
|
||||||
|
return StringUtils.isBlank(author) ? List.of() : List.of(author);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Instant updateTimestamp(FriendPost friendPost) {
|
||||||
|
var spec = friendPost.getSpec();
|
||||||
|
return Optional.ofNullable(spec)
|
||||||
|
.map(FriendPost.FriendPostSpec::getPubDate)
|
||||||
|
.orElse(creationTimestamp(friendPost));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Instant creationTimestamp(FriendPost friendPost) {
|
||||||
|
return Optional.ofNullable(friendPost.getMetadata().getCreationTimestamp())
|
||||||
|
.orElse(Instant.EPOCH);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String permalink(FriendPost friendPost) {
|
||||||
|
var externalUrl = externalUrlSupplier.get();
|
||||||
|
return externalUrl.resolve("friends/" + friendPost.getMetadata().getName()).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String defaultString(String value) {
|
||||||
|
return StringUtils.defaultString(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String defaultIfBlank(String value, String defaultValue) {
|
||||||
|
return StringUtils.defaultIfBlank(value, defaultValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package la.moony.friends.search;
|
||||||
|
|
||||||
|
import la.moony.friends.extension.FriendPost;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.data.domain.Sort;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import reactor.core.publisher.Flux;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
import run.halo.app.extension.ListOptions;
|
||||||
|
import run.halo.app.extension.ListResult;
|
||||||
|
import run.halo.app.extension.PageRequest;
|
||||||
|
import run.halo.app.extension.PageRequestImpl;
|
||||||
|
import run.halo.app.extension.ReactiveExtensionClient;
|
||||||
|
import run.halo.app.extension.router.selector.FieldSelector;
|
||||||
|
import run.halo.app.search.HaloDocument;
|
||||||
|
import run.halo.app.search.HaloDocumentsProvider;
|
||||||
|
|
||||||
|
import static run.halo.app.extension.index.query.QueryFactory.isNull;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class FriendPostHaloDocumentsProvider implements HaloDocumentsProvider {
|
||||||
|
|
||||||
|
public static final String FRIEND_POST_DOCUMENT_TYPE = "friendpost.friend.moony.la";
|
||||||
|
|
||||||
|
private static final int PAGE_SIZE = 200;
|
||||||
|
|
||||||
|
private final ReactiveExtensionClient client;
|
||||||
|
|
||||||
|
private final FriendPostDocumentConverter converter;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Flux<HaloDocument> fetchAll() {
|
||||||
|
var options = new ListOptions();
|
||||||
|
var notDeleted = isNull("metadata.deletionTimestamp");
|
||||||
|
options.setFieldSelector(FieldSelector.of(notDeleted));
|
||||||
|
var pageRequest = createPageRequest();
|
||||||
|
return client.listBy(FriendPost.class, options, pageRequest)
|
||||||
|
.expand(result -> result.hasNext()
|
||||||
|
? client.listBy(FriendPost.class, options, nextPage(result, pageRequest.getSort()))
|
||||||
|
: Mono.empty())
|
||||||
|
.flatMap(result -> Flux.fromStream(result.get()))
|
||||||
|
.flatMap(converter::convert);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getType() {
|
||||||
|
return FRIEND_POST_DOCUMENT_TYPE;
|
||||||
|
}
|
||||||
|
|
||||||
|
private PageRequest createPageRequest() {
|
||||||
|
return PageRequestImpl.of(1, PAGE_SIZE,
|
||||||
|
Sort.by("metadata.creationTimestamp", "metadata.name"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PageRequest nextPage(ListResult<FriendPost> result, Sort sort) {
|
||||||
|
return PageRequestImpl.of(result.getPage() + 1, result.getSize(), sort);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package la.moony.friends.search;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
import la.moony.friends.extension.FriendPost;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.context.ApplicationEventPublisher;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import run.halo.app.extension.ExtensionClient;
|
||||||
|
import run.halo.app.extension.ExtensionUtil;
|
||||||
|
import run.halo.app.extension.controller.Controller;
|
||||||
|
import run.halo.app.extension.controller.ControllerBuilder;
|
||||||
|
import run.halo.app.extension.controller.Reconciler;
|
||||||
|
import run.halo.app.search.event.HaloDocumentAddRequestEvent;
|
||||||
|
import run.halo.app.search.event.HaloDocumentDeleteRequestEvent;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class FriendPostSearchReconciler implements Reconciler<Reconciler.Request> {
|
||||||
|
|
||||||
|
private static final String FINALIZER = "friend-post-search-protection";
|
||||||
|
|
||||||
|
private final ApplicationEventPublisher eventPublisher;
|
||||||
|
|
||||||
|
private final ExtensionClient client;
|
||||||
|
|
||||||
|
private final FriendPostDocumentConverter converter;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Result reconcile(Request request) {
|
||||||
|
client.fetch(FriendPost.class, request.name()).ifPresent(friendPost -> {
|
||||||
|
if (ExtensionUtil.isDeleted(friendPost)) {
|
||||||
|
if (ExtensionUtil.removeFinalizers(friendPost.getMetadata(), Set.of(FINALIZER))) {
|
||||||
|
eventPublisher.publishEvent(
|
||||||
|
new HaloDocumentDeleteRequestEvent(this,
|
||||||
|
List.of(converter.haloDocId(friendPost)))
|
||||||
|
);
|
||||||
|
client.update(friendPost);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ExtensionUtil.addFinalizers(friendPost.getMetadata(), Set.of(FINALIZER));
|
||||||
|
|
||||||
|
var haloDoc = converter.convert(friendPost)
|
||||||
|
.blockOptional().orElseThrow();
|
||||||
|
eventPublisher.publishEvent(
|
||||||
|
new HaloDocumentAddRequestEvent(this, List.of(haloDoc)));
|
||||||
|
|
||||||
|
client.update(friendPost);
|
||||||
|
});
|
||||||
|
return Result.doNotRetry();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Controller setupWith(ControllerBuilder builder) {
|
||||||
|
return builder
|
||||||
|
.extension(new FriendPost())
|
||||||
|
.workerCount(1)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta name="description" th:content="${friend.spec.description ?: '无内容'}">
|
||||||
|
<title th:text="${site.title + '-' + (friend.spec.title ?: friend.spec.author)}"></title>
|
||||||
|
<link data-n-head="true" rel="icon" type="image/x-icon" th:href="${site.favicon}">
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
color: #242424;
|
||||||
|
background: #f7f7f7;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue",
|
||||||
|
Arial, "Noto Sans", sans-serif;
|
||||||
|
line-height: 1.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: #666;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
color: #111;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrapper {
|
||||||
|
max-width: 820px;
|
||||||
|
margin: 60px auto 32px;
|
||||||
|
padding: 56px;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 10px 20px 0 hsla(0, 0%, 93%, .86);
|
||||||
|
}
|
||||||
|
|
||||||
|
.back {
|
||||||
|
display: inline-block;
|
||||||
|
margin-bottom: 28px;
|
||||||
|
color: #888;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: 0 0 18px;
|
||||||
|
color: #1f1f1f;
|
||||||
|
font-size: 28px;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px 18px;
|
||||||
|
margin-bottom: 36px;
|
||||||
|
color: #888;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.author {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.author img {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 50%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
color: #333;
|
||||||
|
font-size: 16px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
margin-top: 40px;
|
||||||
|
padding-top: 24px;
|
||||||
|
border-top: 1px solid #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
footer {
|
||||||
|
margin: 0 auto 40px;
|
||||||
|
color: #999;
|
||||||
|
font-size: 13px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.wrapper {
|
||||||
|
margin: 0;
|
||||||
|
padding: 32px 22px;
|
||||||
|
min-height: 100vh;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<article class="wrapper">
|
||||||
|
<a class="back" href="/friends">返回朋友圈</a>
|
||||||
|
<h1 th:text="${friend.spec.title ?: '无内容'}"></h1>
|
||||||
|
<div class="meta">
|
||||||
|
<span class="author">
|
||||||
|
<img th:if="${friend.spec.logo != null and not #strings.isEmpty(friend.spec.logo)}"
|
||||||
|
th:src="${friend.spec.logo}" alt="">
|
||||||
|
<a th:if="${friend.spec.authorUrl != null and not #strings.isEmpty(friend.spec.authorUrl)}"
|
||||||
|
th:href="${friend.spec.authorUrl}" target="_blank" rel="noopener noreferrer"
|
||||||
|
th:text="${friend.spec.author}"></a>
|
||||||
|
<span th:unless="${friend.spec.authorUrl != null and not #strings.isEmpty(friend.spec.authorUrl)}"
|
||||||
|
th:text="${friend.spec.author}"></span>
|
||||||
|
</span>
|
||||||
|
<time th:if="${friend.spec.pubDate != null}"
|
||||||
|
th:datetime="${#dates.format(friend.spec.pubDate,'yyyy-MM-dd')}"
|
||||||
|
th:text="${#dates.format(friend.spec.pubDate,'yyyy.MM.dd')}"></time>
|
||||||
|
</div>
|
||||||
|
<div class="content" th:text="${friend.spec.description ?: '无内容'}"></div>
|
||||||
|
<div class="actions" th:if="${friend.spec.postLink != null and not #strings.isEmpty(friend.spec.postLink)}">
|
||||||
|
<a th:href="${friend.spec.postLink}" target="_blank" rel="noopener noreferrer">查看原文</a>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
<footer>
|
||||||
|
<span>©2024 [[${site.title}]]</span>
|
||||||
|
</footer>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -1511,9 +1511,10 @@
|
|||||||
<main>
|
<main>
|
||||||
<section class="article-list">
|
<section class="article-list">
|
||||||
<article th:each="friend : ${friends.items}">
|
<article th:each="friend : ${friends.items}">
|
||||||
<h2><a th:href="${friend.spec.postLink}" target="_blank" th:text="${friend.spec.title}"></a></h2>
|
<h2><a th:href="@{/friends/{name}(name=${friend.metadata.name})}"
|
||||||
|
th:text="${friend.spec.title ?: '无内容'}"></a></h2>
|
||||||
<div class="excerpt">
|
<div class="excerpt">
|
||||||
<p th:text="${friend.spec.description}"></p>
|
<p th:text="${friend.spec.description ?: '无内容'}"></p>
|
||||||
</div>
|
</div>
|
||||||
<div class="meta">
|
<div class="meta">
|
||||||
<span class="item">
|
<span class="item">
|
||||||
|
|||||||
Reference in New Issue
Block a user