2 Commits

Author SHA1 Message Date
anian 1a8e81bb5b 接入搜索引擎; 修改控制台配置 2026-07-26 06:54:23 +08:00
anian 0e4184057a 新增类别接口 2026-06-15 20:22:20 +08:00
15 changed files with 289 additions and 18 deletions
+7
View File
@@ -2,6 +2,13 @@
> 基于原作者v1.2.2代码修改 > 基于原作者v1.2.2代码修改
#### v1.2.2-3
1. 标题、描述接入搜索引擎;新增详情页
2. 控制台新增必填项;修改字数上限、时间格式
#### v1.2.2-2
1. 新增类别接口`/apis/api.douban.moony.la/v1alpha1/doubanmovies/-/types`(基于原作者v1.2.5代码)
#### v1.2.2-1 #### v1.2.2-1
1. 修改前台页面路由为/books 1. 修改前台页面路由为/books
2. 控制台类别value不再限制 2. 控制台类别value不再限制
+37
View File
@@ -144,6 +144,28 @@
"tags" : [ "api.douban.moony.la/v1alpha1/DoubanMovie" ] "tags" : [ "api.douban.moony.la/v1alpha1/DoubanMovie" ]
} }
}, },
"/apis/api.douban.moony.la/v1alpha1/doubanmovies/-/types" : {
"get" : {
"description" : "List all douban types.",
"operationId" : "ListTypes",
"responses" : {
"default" : {
"content" : {
"*/*" : {
"schema" : {
"type" : "array",
"items" : {
"$ref" : "#/components/schemas/DoubanTypeVo"
}
}
}
},
"description" : "default response"
}
},
"tags" : [ "api.douban.moony.la/v1alpha1/DoubanMovie" ]
}
},
"/apis/api.douban.moony.la/v1alpha1/doubanmovies/-/getDoubanDetail" : { "/apis/api.douban.moony.la/v1alpha1/doubanmovies/-/getDoubanDetail" : {
"get" : { "get" : {
"description" : "getDoubanDetail.", "description" : "getDoubanDetail.",
@@ -851,6 +873,21 @@
} }
} }
}, },
"DoubanTypeVo" : {
"type" : "object",
"properties" : {
"doubanCount" : {
"type" : "integer",
"format" : "int32"
},
"key" : {
"type" : "string"
},
"name" : {
"type" : "string"
}
}
},
"JsonPatch" : { "JsonPatch" : {
"minItems" : 1, "minItems" : 1,
"uniqueItems" : true, "uniqueItems" : true,
+1 -1
View File
@@ -1 +1 @@
version=1.2.2-1 version=1.2.2-3
@@ -2,8 +2,10 @@ package la.moony.douban;
import io.swagger.v3.oas.annotations.enums.ParameterIn; import io.swagger.v3.oas.annotations.enums.ParameterIn;
import la.moony.douban.extension.DoubanMovie; import la.moony.douban.extension.DoubanMovie;
import la.moony.douban.finders.DoubanFinder;
import la.moony.douban.service.DoubanService; import la.moony.douban.service.DoubanService;
import la.moony.douban.vo.DoubanMovieVo; import la.moony.douban.vo.DoubanMovieVo;
import la.moony.douban.vo.DoubanTypeVo;
import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.StringUtils;
import org.springdoc.webflux.core.fn.SpringdocRouteBuilder; import org.springdoc.webflux.core.fn.SpringdocRouteBuilder;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
@@ -25,9 +27,12 @@ public class DoubanEndpoint implements CustomEndpoint {
private final DoubanService doubanService; private final DoubanService doubanService;
private final DoubanFinder doubanFinder;
public DoubanEndpoint(DoubanService doubanService) {
public DoubanEndpoint(DoubanService doubanService, DoubanFinder doubanFinder) {
this.doubanService = doubanService; this.doubanService = doubanService;
this.doubanFinder = doubanFinder;
} }
@Override @Override
@@ -43,6 +48,13 @@ public class DoubanEndpoint implements CustomEndpoint {
); );
DoubanMovieQuery.buildParameters(builder); DoubanMovieQuery.buildParameters(builder);
}) })
.GET("doubanmovies/-/types", this::ListTypes,
builder -> builder.operationId("ListTypes")
.description("List all douban types.")
.tag(doubanMovieTag)
.response(responseBuilder()
.implementationArray(DoubanTypeVo.class)
))
.GET("doubanmovies/-/genres", this::ListGenres, .GET("doubanmovies/-/genres", this::ListGenres,
builder -> builder.operationId("ListGenres") builder -> builder.operationId("ListGenres")
.description("List all douban genres.") .description("List all douban genres.")
@@ -87,6 +99,12 @@ public class DoubanEndpoint implements CustomEndpoint {
.flatMap(result -> ServerResponse.ok().bodyValue(result)); .flatMap(result -> ServerResponse.ok().bodyValue(result));
} }
private Mono<ServerResponse> ListTypes(ServerRequest request) {
return doubanFinder.listAllType()
.collectList()
.flatMap(result -> ServerResponse.ok().bodyValue(result));
}
private Mono<ServerResponse> getDoubanDetail(ServerRequest request) { private Mono<ServerResponse> getDoubanDetail(ServerRequest request) {
String url = request.queryParam("url").orElse(null); String url = request.queryParam("url").orElse(null);
return doubanService.getDoubanDetail(url) return doubanService.getDoubanDetail(url)
@@ -51,6 +51,10 @@ public class DoubanPlugin extends BasePlugin {
.setName("spec.name") .setName("spec.name")
.setIndexFunc( .setIndexFunc(
simpleAttribute(DoubanMovie.class, doubanMovie -> doubanMovie.getSpec().getName()))); simpleAttribute(DoubanMovie.class, doubanMovie -> doubanMovie.getSpec().getName())));
indexSpecs.add(new IndexSpec()
.setName("spec.cardSubtitle")
.setIndexFunc(simpleAttribute(DoubanMovie.class,
doubanMovie -> doubanMovie.getSpec().getCardSubtitle())));
indexSpecs.add(new IndexSpec() indexSpecs.add(new IndexSpec()
.setName("spec.id") .setName("spec.id")
.setIndexFunc( .setIndexFunc(
@@ -29,9 +29,18 @@ public class DoubanRouter {
RouterFunction<ServerResponse> friendTemplateRoute() { RouterFunction<ServerResponse> friendTemplateRoute() {
return RouterFunctions.route().GET("/books",this::handlerFunction) return RouterFunctions.route().GET("/books",this::handlerFunction)
.GET("/books/{doubanMovieName:\\S+}", this::doubanMovieDetail)
.build(); .build();
} }
private Mono<ServerResponse> doubanMovieDetail(ServerRequest request) {
String doubanMovieName = request.pathVariable("doubanMovieName");
return templateNameResolver.resolveTemplateNameOrDefault(request.exchange(), "douban-detail")
.flatMap(templateName -> ServerResponse.ok().render(templateName,
java.util.Map.of("douban", doubanFinder.get(doubanMovieName),
"title", getDoubanTitle())));
}
private Mono<ServerResponse> handlerFunction(ServerRequest request) { private Mono<ServerResponse> handlerFunction(ServerRequest request) {
String type = request.queryParam("type") String type = request.queryParam("type")
.filter(StringUtils::isNotBlank) .filter(StringUtils::isNotBlank)
@@ -0,0 +1,59 @@
package la.moony.douban.search;
import static la.moony.douban.search.DoubanMovieHaloDocumentsProvider.DOUBAN_MOVIE_DOCUMENT_TYPE;
import java.time.Instant;
import java.util.List;
import la.moony.douban.extension.DoubanMovie;
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 DoubanMovieDocumentConverter implements Converter<DoubanMovie, Mono<HaloDocument>> {
private final ExternalUrlSupplier externalUrlSupplier;
@Override
@NonNull
public Mono<HaloDocument> convert(DoubanMovie movie) {
var spec = movie.getSpec();
String title = StringUtils.defaultIfBlank(spec == null ? null : spec.getName(), "豆瓣记录");
String description = StringUtils.defaultIfBlank(
spec == null ? null : spec.getCardSubtitle(), "无描述");
var document = new HaloDocument();
document.setMetadataName(movie.getMetadata().getName());
document.setType(DOUBAN_MOVIE_DOCUMENT_TYPE);
document.setId(haloDocId(movie));
document.setTitle(title);
document.setDescription(description);
document.setContent(String.join(" ", title, description));
document.setExposed(true);
document.setTags(List.of());
document.setOwnerName("豆瓣");
document.setPublished(true);
document.setPermalink(externalUrlSupplier.get()
.resolve("books/" + movie.getMetadata().getName()).toString());
document.setCreationTimestamp(creationTimestamp(movie));
document.setUpdateTimestamp(creationTimestamp(movie));
return Mono.just(document);
}
String haloDocId(DoubanMovie movie) {
return DOUBAN_MOVIE_DOCUMENT_TYPE + '-' + movie.getMetadata().getName();
}
private Instant creationTimestamp(DoubanMovie movie) {
if (movie.getFaves() != null && movie.getFaves().getCreateTime() != null) {
return movie.getFaves().getCreateTime();
}
return movie.getMetadata().getCreationTimestamp() == null
? Instant.EPOCH : movie.getMetadata().getCreationTimestamp();
}
}
@@ -0,0 +1,49 @@
package la.moony.douban.search;
import la.moony.douban.extension.DoubanMovie;
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 DoubanMovieHaloDocumentsProvider implements HaloDocumentsProvider {
public static final String DOUBAN_MOVIE_DOCUMENT_TYPE = "doubanmovie.douban.moony.la";
private static final int PAGE_SIZE = 200;
private final ReactiveExtensionClient client;
private final DoubanMovieDocumentConverter converter;
@Override
public Flux<HaloDocument> fetchAll() {
var options = new ListOptions();
options.setFieldSelector(FieldSelector.of(isNull("metadata.deletionTimestamp")));
var pageRequest = PageRequestImpl.of(1, PAGE_SIZE,
Sort.by("metadata.creationTimestamp", "metadata.name"));
return client.listBy(DoubanMovie.class, options, pageRequest)
.expand(result -> result.hasNext()
? client.listBy(DoubanMovie.class, options,
PageRequestImpl.of(result.getPage() + 1, result.getSize(), pageRequest.getSort()))
: Mono.empty())
.flatMap(result -> Flux.fromStream(result.get()))
.flatMap(converter::convert);
}
@Override
public String getType() {
return DOUBAN_MOVIE_DOCUMENT_TYPE;
}
}
@@ -0,0 +1,49 @@
package la.moony.douban.search;
import java.util.List;
import java.util.Set;
import la.moony.douban.extension.DoubanMovie;
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 DoubanMovieSearchReconciler implements Reconciler<Reconciler.Request> {
private static final String FINALIZER = "douban-movie-search-protection";
private final ApplicationEventPublisher eventPublisher;
private final ExtensionClient client;
private final DoubanMovieDocumentConverter converter;
@Override
public Result reconcile(Request request) {
client.fetch(DoubanMovie.class, request.name()).ifPresent(movie -> {
if (ExtensionUtil.isDeleted(movie)) {
if (ExtensionUtil.removeFinalizers(movie.getMetadata(), Set.of(FINALIZER))) {
eventPublisher.publishEvent(new HaloDocumentDeleteRequestEvent(this,
List.of(converter.haloDocId(movie))));
client.update(movie);
}
return;
}
ExtensionUtil.addFinalizers(movie.getMetadata(), Set.of(FINALIZER));
var document = converter.convert(movie).blockOptional().orElseThrow();
eventPublisher.publishEvent(new HaloDocumentAddRequestEvent(this, List.of(document)));
client.update(movie);
});
return Result.doNotRetry();
}
@Override
public Controller setupWith(ControllerBuilder builder) {
return builder.extension(new DoubanMovie()).workerCount(1).build();
}
}
@@ -10,7 +10,7 @@ metadata:
rbac.authorization.halo.run/display-name: "查看朋友圈提交模板" rbac.authorization.halo.run/display-name: "查看朋友圈提交模板"
rules: rules:
- apiGroups: [ "api.douban.moony.la" ] - apiGroups: [ "api.douban.moony.la" ]
resources: [ "doubanmovies","doubanmovies/genres","doubanmovies/getDoubanDetail"] resources: [ "doubanmovies","doubanmovies/types","doubanmovies/genres","doubanmovies/getDoubanDetail"]
verbs: [ "get", "list" ] verbs: [ "get", "list" ]
--- ---
+5 -5
View File
@@ -182,8 +182,8 @@ class HALO_DOUBAN {
}${ }${
movie.spec.year > 0 ? " · " + movie.spec.year : "" movie.spec.year > 0 ? " · " + movie.spec.year : ""
}</div><div class="db--title"><a href="${ }</div><div class="db--title"><a href="${
movie.spec.link `/books/${movie.metadata.name}`
}" target="_blank">${movie.spec.name}</a></div> }">${movie.spec.name}</a></div>
</div>`; </div>`;
}) })
@@ -212,8 +212,8 @@ class HALO_DOUBAN {
}${ }${
item.spec.year > 0 ? " · " + item.spec.year : "" item.spec.year > 0 ? " · " + item.spec.year : ""
}</div><div class="db--title"><a href="${ }</div><div class="db--title"><a href="${
item.spec.link `/books/${item.metadata.name}`
}" target="_blank">${item.spec.name}</a></div> }">${item.spec.name}</a></div>
</div> </div>
</div>`; </div>`;
}) })
@@ -294,4 +294,4 @@ class HALO_DOUBAN {
} }
} }
new HALO_DOUBAN(); new HALO_DOUBAN();
@@ -0,0 +1,25 @@
<!DOCTYPE html>
<html lang="zh-CN" xmlns:th="https://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title th:text="${douban != null ? douban.spec.name : '豆瓣记录'}">豆瓣记录</title>
<link rel="stylesheet" th:href="@{/plugins/plugin-douban/assets/static/db.min.css}">
</head>
<body>
<main class="layoutSingleColumn layoutSingleColumn--wide u-paddingTop50">
<section class="doulist-item" th:if="${douban != null}">
<div class="doulist-subject">
<div class="doulist-post"><img class="db--image" th:src="${douban.spec.poster}" alt=""></div>
<div class="doulist-content">
<div class="doulist-title" th:text="${douban.spec.name}">标题</div>
<div class="abstract" th:text="${douban.spec.cardSubtitle}">描述</div>
<p th:text="${douban.faves.remark}">备注</p>
<a th:href="${douban.spec.link}" target="_blank" rel="external nofollow">查看豆瓣原页</a>
</div>
</div>
</section>
<p th:if="${douban == null}">未找到该条豆瓣记录。</p>
</main>
</body>
</html>
+2 -2
View File
@@ -83,9 +83,9 @@
</main> </main>
<footer class="site--footer"> <footer class="site--footer">
<script th:src="@{/plugins/plugin-douban/assets/static/db.min.js?v={version}(version=${version})}"></script> <script th:src="@{/plugins/plugin-douban/assets/static/db.js?v={version}(version=${version})}"></script>
</footer> </footer>
</body> </body>
</html> </html>
+15 -7
View File
@@ -4,7 +4,7 @@ import { ref, computed, nextTick, watch} from "vue";
import cloneDeep from "lodash.clonedeep"; import cloneDeep from "lodash.clonedeep";
import {doubanCoreApiClient} from "@/api"; import {doubanCoreApiClient} from "@/api";
import type { DoubanMovie } from "@/api/generated"; import type { DoubanMovie } from "@/api/generated";
import {toDatetimeLocal, toISOString} from "@/utils/date"; import {toDateInput, toISOString} from "@/utils/date";
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
@@ -55,6 +55,8 @@ const saving = ref<boolean>(false);
const formVisible = ref(false); const formVisible = ref(false);
const createTime = ref<string | undefined>(undefined); const createTime = ref<string | undefined>(undefined);
const getToday = () => toDateInput(new Date());
const isUpdateMode = computed(() => { const isUpdateMode = computed(() => {
return !!formState.value.metadata.creationTimestamp; return !!formState.value.metadata.creationTimestamp;
@@ -73,6 +75,9 @@ const onVisibleChange = (visible: boolean) => {
const handleResetForm = () => { const handleResetForm = () => {
formState.value = cloneDeep(initialFormState); formState.value = cloneDeep(initialFormState);
const today = getToday();
createTime.value = today;
formState.value.faves.createTime = toISOString(today);
}; };
watch( watch(
@@ -94,9 +99,9 @@ watch(
(doubanMovie) => { (doubanMovie) => {
if (doubanMovie) { if (doubanMovie) {
formState.value = cloneDeep(doubanMovie); formState.value = cloneDeep(doubanMovie);
createTime.value = toDatetimeLocal(formState.value.faves.createTime); createTime.value = toDateInput(formState.value.faves.createTime) || getToday();
}else { }else {
createTime.value = undefined; createTime.value = getToday();
} }
}, },
{ {
@@ -108,6 +113,9 @@ watch(
() => createTime.value, () => createTime.value,
(value) => { (value) => {
formState.value.faves.createTime = value ? toISOString(value) : undefined; formState.value.faves.createTime = value ? toISOString(value) : undefined;
},
{
immediate: true,
} }
); );
const annotationsFormRef = ref(); const annotationsFormRef = ref();
@@ -223,6 +231,7 @@ const handleSaveFriend = async () => {
name="type" name="type"
label="类型" label="类型"
placeholder="输入类型 value,例如 movie" placeholder="输入类型 value,例如 movie"
validation="required"
></FormKit> ></FormKit>
<FormKit <FormKit
v-if="formState.spec.dataType=='halo'" v-if="formState.spec.dataType=='halo'"
@@ -231,12 +240,10 @@ const handleSaveFriend = async () => {
name="cardSubtitle" name="cardSubtitle"
label="描述" label="描述"
:rows="4" :rows="4"
validation="required|length:0,300" validation="required|length:0,1000"
></FormKit> ></FormKit>
<FormKit <FormKit
type="datetime-local" type="date"
min="0000-01-01T00:00"
max="9999-12-31T23:59"
v-model="createTime" v-model="createTime"
name="createTime" name="createTime"
validation="required" validation="required"
@@ -252,6 +259,7 @@ const handleSaveFriend = async () => {
v-model="formState.faves.status" v-model="formState.faves.status"
name="status" name="status"
type="select" type="select"
validation="required"
></FormKit> ></FormKit>
<FormKit <FormKit
type="textarea" type="textarea"
+7 -1
View File
@@ -47,10 +47,16 @@ export function toDatetimeLocal(date: string | Date | undefined | null): string
return dayjs(date).format("YYYY-MM-DDTHH:mm"); return dayjs(date).format("YYYY-MM-DDTHH:mm");
} }
export function toDateInput(date: string | Date | undefined | null): string {
if (!date) {
return "";
}
return dayjs(date).format("YYYY-MM-DD");
}
export function toISOString(date: string | Date | undefined | null): string { export function toISOString(date: string | Date | undefined | null): string {
if (!date) { if (!date) {
return ""; return "";
} }
return dayjs(date).utc(false).toISOString(); return dayjs(date).utc(false).toISOString();
} }