3 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
anian 18aa1199ec 修改路由、控制台类别限制;修复异常 2026-04-29 16:24:40 +08:00
21 changed files with 5389 additions and 3326 deletions
+20
View File
@@ -1,3 +1,23 @@
# halo-plugin-douban
> 基于原作者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
1. 修改前台页面路由为/books
2. 控制台类别value不再限制
3. 修复构建失败等异常
# plugin-douban # plugin-douban
* 豆瓣管理插件, 支持在 Console 进行管理以及为主题端提供 `/douban` 页面路由。 * 豆瓣管理插件, 支持在 Console 进行管理以及为主题端提供 `/douban` 页面路由。
+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
@@ -16,7 +16,7 @@ repositories {
} }
dependencies { dependencies {
implementation platform('run.halo.tools.platform:plugin:2.20.0-SNAPSHOT') implementation platform('run.halo.tools.platform:plugin:2.20.11')
compileOnly 'run.halo.app:api' compileOnly 'run.halo.app:api'
testImplementation 'run.halo.app:api' testImplementation 'run.halo.app:api'
+1 -1
View File
@@ -1 +1 @@
version=1.2.2 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(
@@ -28,10 +28,19 @@ public class DoubanRouter {
@Bean @Bean
RouterFunction<ServerResponse> friendTemplateRoute() { RouterFunction<ServerResponse> friendTemplateRoute() {
return RouterFunctions.route().GET("/douban",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" ]
--- ---
+4 -4
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>`;
}) })
@@ -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>
+1 -1
View File
@@ -83,7 +83,7 @@
</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>
+1
View File
@@ -2,6 +2,7 @@
"name": "@kunkunyu/plugin-douban", "name": "@kunkunyu/plugin-douban",
"version": "1.0.0", "version": "1.0.0",
"private": true, "private": true,
"type": "module",
"scripts": { "scripts": {
"dev": "vite build --watch --mode=development", "dev": "vite build --watch --mode=development",
"build": "vite build", "build": "vite build",
+5124 -3316
View File
File diff suppressed because it is too large Load Diff
+18 -31
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();
@@ -218,32 +226,12 @@ const handleSaveFriend = async () => {
></FormKit> ></FormKit>
<FormKit <FormKit
v-if="formState.spec.dataType=='halo'" v-if="formState.spec.dataType=='halo'"
:options="[ type="text"
{
label: '电影',
value: 'movie',
},
{
label: '图书',
value: 'book',
},
{
label: '音乐',
value: 'music',
},
{
label: '游戏',
value: 'game',
},
{
label: '舞台剧',
value: 'drama',
},
]"
label="类型"
v-model="formState.spec.type" v-model="formState.spec.type"
name="type" name="type"
type="select" label="类型"
placeholder="输入类型 value,例如 movie"
validation="required"
></FormKit> ></FormKit>
<FormKit <FormKit
v-if="formState.spec.dataType=='halo'" v-if="formState.spec.dataType=='halo'"
@@ -252,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"
@@ -273,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();
} }
+4 -27
View File
@@ -332,35 +332,12 @@ const onEditingModalClose = async () => {
}, },
]" ]"
/> />
<FilterDropdown <FormKit
v-model="selectedType" v-model="selectedType"
label="类型" label="类型"
:items="[ placeholder="输入类型 value 过滤,例如 movie"
{ type="text"
label: '全部', outer-class="!moments-p-0"
value: undefined,
},
{
label: '电影',
value: 'movie',
},
{
label: '图书',
value: 'book',
},
{
label: '音乐',
value: 'music',
},
{
label: '游戏',
value: 'game',
},
{
label: '舞台剧',
value: 'drama',
},
]"
/> />
<FilterDropdown <FilterDropdown
v-model="selectedSort" v-model="selectedSort"
+15 -1
View File
@@ -1,9 +1,23 @@
{ {
"extends": "@vue/tsconfig/tsconfig.web.json",
"include": ["./env.d.ts", "./src/**/*", "./src/**/*.vue"], "include": ["./env.d.ts", "./src/**/*", "./src/**/*.vue"],
"exclude": ["./src/**/__tests__/*"], "exclude": ["./src/**/__tests__/*"],
"compilerOptions": { "compilerOptions": {
"composite": true, "composite": true,
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "preserve",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".", "baseUrl": ".",
"paths": { "paths": {
"@/*": ["./src/*"] "@/*": ["./src/*"]
+1 -1
View File
@@ -1,5 +1,5 @@
{ {
"extends": "@vue/tsconfig/tsconfig.node.json", "extends": "@tsconfig/node20/tsconfig.json",
"include": ["vite.config.*", "vitest.config.*", "cypress.config.*"], "include": ["vite.config.*", "vitest.config.*", "cypress.config.*"],
"compilerOptions": { "compilerOptions": {
"composite": true, "composite": true,