为了吸引用户使用我们平台的私有空间作为个人相册,需要提供更多功能。
本节我们重点对图片功能进行扩展,包括:
图片搜索
基础属性搜索
以图搜图
颜色搜索
图片分享
链接分享
扫码分享
图片批量管理
批量修改信息
批量重命名
有了这些功能,用户能够更高效地管理和分享平台上的图片资源,进一步提升使用体验。
我们可以提供多种搜索维度,帮用户更快地找到自己空间的图片。
将搜索维度按优先级进行排序,优先级高的会展示在靠前的位置:
后端可以直接复用原有的分页获取图片列表接口,并在此基础上增加相应的搜索条件,以支持更灵活的筛选。
前端可以针对不同类型的搜索维度选用特定的表单项组件,来提高搜索的体验。
参考其他网站,日期的选择最好能够提供预设的时间范围:
其他的搜索条件基本都已经有了,还需要支持按照编辑时间搜索。
1)为了支持按编辑时间进行搜索,需要在请求类 PictureQueryRequest 中添加开始和结束编辑时间字段:
private Date startEditTime; private Date endEditTime;
2)更新图片服务的 getQueryWrapper 方法
在处理查询时,补充按编辑时间筛选的逻辑:
Date startEditTime = pictureQueryRequest.getStartEditTime(); Date endEditTime = pictureQueryRequest.getEndEditTime(); queryWrapper.ge(ObjUtil.isNotEmpty(startEditTime), "editTime", startEditTime); queryWrapper.lt(ObjUtil.isNotEmpty(endEditTime), "editTime", endEditTime);
由于空间详情页的代码量较大,我们可以将所有图片搜索逻辑单独封装为图片搜索表单组件 PictureSearchForm.vue。为提高效率,该组件可以从图片管理页面的搜索表单复制而来。
注意,该组件仅负责修改搜索条件,不负责数据获取与存储。
1)定义组件属性:
interface Props {
onSearch?: (searchParams: PictureQueryRequest) => void
}
const props = defineProps<Props>()
2)编写搜索条件和搜索函数:使用 reactive 变量存储搜索条件,并触发父组件的 onSearch 方法。
const searchParams = reactive<API.PictureQueryRequest>({})
const doSearch = () => {
props.onSearch?.(searchParams)
}
3)开发页面结构
其中:
代码如下:
<div>
<!-- 搜索表单 -->
<a-form layout="inline" :model="searchParams" @finish="doSearch">
<a-form-item label="关键词" name="searchText">
<a-input
v-model:value="searchParams.searchText"
placeholder="从名称和简介搜索"
allow-clear
/>
</a-form-item>
<a-form-item label="分类" name="category">
<a-auto-complete
v-model:value="searchParams.category"
:options="categoryOptions"
placeholder="请输入分类"
allowClear
/>
</a-form-item>
<a-form-item label="标签" name="tags">
<a-select
v-model:value="searchParams.tags"
:options="tagOptions"
mode="tags"
placeholder="请输入标签"
allowClear
/>
</a-form-item>
<a-form-item label="日期" name="">
<a-range-picker
show-time
v-model:value="dateRange"
:placeholder="['编辑开始日期', '编辑结束时间']"
format="YYYY/MM/DD HH:mm:ss"
:presets="rangePresets"
@change="onRangeChange"
/>
</a-form-item>
<a-form-item label="名称" name="name">
<a-input v-model:value="searchParams.name" placeholder="请输入名称" allow-clear />
</a-form-item>
<a-form-item label="简介" name="introduction">
<a-input v-model:value="searchParams.introduction" placeholder="请输入简介" allow-clear />
</a-form-item>
<a-form-item label="宽度" name="picWidth">
<a-input-number v-model:value="searchParams.picWidth" />
</a-form-item>
<a-form-item label="高度" name="picHeight">
<a-input-number v-model:value="searchParams.picHeight" />
</a-form-item>
<a-form-item label="格式" name="picFormat">
<a-input v-model:value="searchParams.picFormat" placeholder="请输入格式" allow-clear />
</a-form-item>
<a-form-item>
<a-button type="primary" html-type="submit">搜索</a-button>
</a-form-item>
</a-form>
</div>
日期表单项所需的变量:
const dateRange = ref<[]>([])
const onRangeChange = (dates: any[], dateStrings: string[]) => {
if (dates.length < 2) {
searchParams.startEditTime = undefined
searchParams.endEditTime = undefined
} else {
searchParams.startEditTime = dates[0].toDate()
searchParams.endEditTime = dates[1].toDate()
}
}
const rangePresets = ref([
{ label: '过去 7 天', value: [dayjs().add(-7, 'd'), dayjs()] },
{ label: '过去 14 天', value: [dayjs().add(-14, 'd'), dayjs()] },
{ label: '过去 30 天', value: [dayjs().add(-30, 'd'), dayjs()] },
{ label: '过去 90 天', value: [dayjs().add(-90, 'd'), dayjs()] },
])
4)获取分类和标签表单项的默认选项列表,这段代码可以直接复用创建图片页面的:
const categoryOptions = ref<string[]>([])
const tagOptions = ref<string[]>([])
const getTagCategoryOptions = async () => {
const res = await listPictureTagCategoryUsingGet()
if (res.data.code === 0 && res.data.data) {
tagOptions.value = (res.data.data.tagList ?? []).map((data: string) => {
return {
value: data,
label: data,
}
})
categoryOptions.value = (res.data.data.categoryList ?? []).map((data: string) => {
return {
value: data,
label: data,
}
})
} else {
message.error('加载选项失败,' + res.data.message)
}
}
onMounted(() => {
getTagCategoryOptions()
})
5)空间详情页使用组件:
<!-- 搜索表单 --> <PictureSearchForm />
浏览效果,目前表单项都挤在一起,不太好看:
可以添加 CSS 样式,增加上边距:
.picture-search-form .ant-form-item {
margin-top: 16px;
}
6)由于数字输入框的值无法直接通过 allow-clear 清理,所以给表单增加一个重置按钮。
页面代码:
<a-form-item>
<a-space>
<a-button type="primary" html-type="submit">搜索</a-button>
<a-button html-type="reset" @click="doClear">重置</a-button>
</a-space>
</a-form-item>
需要编写一个重置函数,将所有搜索条件的值清空。由于我们使用了 reactive 响应式变量,无法直接整体赋值为一个空对象,而是需要将其中的字段全部设置为空。此外,不要忘了日期组件的值也需要重置为空数组。
const doClear = () => {
Object.keys(searchParams).forEach((key) => {
searchParams[key] = undefined
})
dateRange.value = []
props.onSearch?.(searchParams)
}
💡 学前端的同学可以去了解下 lodash 工具库,类似后端的 HuTool,提供了很多快捷操作对象的函数。
效果如图:
1)给组件传递 onSearch 搜索函数:
<!-- 搜索表单 --> <PictureSearchForm :onSearch="onSearch" />
2)编写搜索函数
由于搜索参数可能被重置,为了方便,将 searchParams 从 reactive 变量改为 ref 变量,这样可以整体给 searchParams 赋值为空的对象。
要修改的代码如下:
const searchParams = ref<API.PictureQueryRequest>({
current: 1,
pageSize: 12,
sortField: 'createTime',
sortOrder: 'descend',
})
const onPageChange = (page, pageSize) => {
searchParams.value.current = page
searchParams.value.pageSize = pageSize
fetchData()
}
const onSearch = (newSearchParams: API.PictureQueryRequest) => {
searchParams.value = {
...searchParams.value,
...newSearchParams,
current: 1,
}
fetchData()
}
const fetchData = async () => {
loading.value = true
const params = {
spaceId: props.id,
...searchParams.value,
}
const res = await listPictureVoByPageUsingPost(params)
if (res.data.data) {
dataList.value = res.data.data.records ?? []
total.value = res.data.data.total ?? 0
} else {
message.error('获取数据失败,' + res.data.message)
}
loading.value = false
}
1、图片尺寸搜索优化
按照图片的尺寸提供几个预设选项,如 “小”、“中”、“大” 和“特大”。每个选项对应一个最大宽度和最大高度,用户只需选择对应的尺寸,系统即可自动进行匹配和过滤。
2、图片格式选择
为了方便用户筛选不同格式的图片,前端可以提供常见的图片格式下拉选项,如 JPG、PNG、GIF 等。
3、基础搜索与详细搜索的切换
前端可以支持基础搜索和详细搜索的功能。通过使用 折叠组件,用户可以在基础搜索框中快速输入常见的搜索条件(如关键词、分类和标签);点击展开后可以显示更多详细的搜索条件(比如图片宽度和格式)。这样既保留了简单快捷的搜索体验,又不失灵活性。
用户可以使用一张图片来搜索相似的图片,相比传统的关键词搜索,能够更精确地找到与上传图片内容相似的图片。
为了获得更多的搜索结果,我们的需求是从 全网搜索图片,而不是只在自己的图库中搜索。
注意,该功能不用局限于私有空间,公共图库也可以使用。
主要有 2 种方案:第三方 API 以及数据抓取(爬虫)
如果想从自建的图库中搜索:可以使用百度 AI 提供的图片搜索 API,参考官方文档
Bing 以图搜图 API: 利用必应的图库,可以从全网进行搜索,而且可以免费使用,参考官方文档
利用已有的以图搜图网站,通过数据抓取的方式实时查询搜图网站的返回结果。
为了让大家学习到更多知识,此处我们选择这种方案。
以百度搜图网站为例,我们可以先体验一遍流程,并且对接口进行分析:
1)进到百度图片搜索,通过 url 上传图片,发现接口:https://graph.baidu.com/upload?uptime= ,该接口的返回值为 “以图搜图的页面地址”
2)访问上一步得到的 页面地址,可以在返回值中找到 firstUrl:
3)访问 firstUrl,就能得到 JSON 格式的相似图片列表,里面包含了图片的缩略图和原图地址:
💡 友情提示,这种方式只适合学习使用!注意不要给目标网站带来压力!!否则后果自负!!!
新建 api 包,由于项目可能会用到多个 api,可以将每个 api 都放在 api 目录下的一个包中。比如图片搜索 api 的相关代码,全部放在 api.imagesearch 包下。
在 imagesearch.model 包中,新建一个图片搜索结果类,用于接受 API 的返回值:
@Data
public class ImageSearchResult {
private String thumbUrl;
private String fromUrl;
}
根据方案,我们要调用多个 API,每个子 API 可以作为一个静态类来实现,统一放在 imagesearch.sub 包中,并且每个类都包含一个 main 方法,用于进行本地测试。
1)获取以图搜图的页面地址
通过向百度发送 POST 请求,获取给定图片 URL 的相似图片页面地址。
@Slf4j
public class GetImagePageUrlApi {
public static String getImagePageUrl(String imageUrl) {
Map<String, Object> formData = new HashMap<>();
formData.put("image", imageUrl);
formData.put("tn", "pc");
formData.put("from", "pc");
formData.put("image_source", "PC_UPLOAD_URL");
long uptime = System.currentTimeMillis();
String url = "https://graph.baidu.com/upload?uptime=" + uptime;
try {
HttpResponse response = HttpRequest.post(url)
.form(formData)
.timeout(5000)
.execute();
if (HttpStatus.HTTP_OK != response.getStatus()) {
throw new BusinessException(ErrorCode.OPERATION_ERROR, "接口调用失败");
}
String responseBody = response.body();
Map<String, Object> result = JSONUtil.toBean(responseBody, Map.class);
if (result == null || !Integer.valueOf(0).equals(result.get("status"))) {
throw new BusinessException(ErrorCode.OPERATION_ERROR, "接口调用失败");
}
Map<String, Object> data = (Map<String, Object>) result.get("data");
String rawUrl = (String) data.get("url");
String searchResultUrl = URLUtil.decode(rawUrl, StandardCharsets.UTF_8);
if (searchResultUrl == null) {
throw new BusinessException(ErrorCode.OPERATION_ERROR, "未返回有效结果");
}
return searchResultUrl;
} catch (Exception e) {
log.error("搜索失败", e);
throw new BusinessException(ErrorCode.OPERATION_ERROR, "搜索失败");
}
}
public static void main(String[] args) {
String imageUrl = "https://www.codefather.cn/logo.png";
String result = getImagePageUrl(imageUrl);
System.out.println("搜索成功,结果 URL:" + result);
}
}
2)获取图片列表页面地址
通过 jsoup 爬取 HTML 页面,提取其中包含 firstUrl 的 JavaScript 脚本,并返回图片列表的页面地址。
@Slf4j
public class GetImageFirstUrlApi {
public static String getImageFirstUrl(String url) {
try {
Document document = Jsoup.connect(url)
.timeout(5000)
.get();
Elements scriptElements = document.getElementsByTag("script");
for (Element script : scriptElements) {
String scriptContent = script.html();
if (scriptContent.contains("\"firstUrl\"")) {
Pattern pattern = Pattern.compile("\"firstUrl\"\\s*:\\s*\"(.*?)\"");
Matcher matcher = pattern.matcher(scriptContent);
if (matcher.find()) {
String firstUrl = matcher.group(1);
firstUrl = firstUrl.replace("\\/", "/");
return firstUrl;
}
}
}
throw new BusinessException(ErrorCode.OPERATION_ERROR, "未找到 url");
} catch (Exception e) {
log.error("搜索失败", e);
throw new BusinessException(ErrorCode.OPERATION_ERROR, "搜索失败");
}
}
public static void main(String[] args) {
String url = "https://graph.baidu.com/s?card_key=&entrance=GENERAL&extUiData[isLogoShow]=1&f=all&isLogoShow=1&session_id=16250747570487381669&sign=1265ce97cd54acd88139901733452612&tpl_from=pc";
String imageFirstUrl = getImageFirstUrl(url);
System.out.println("搜索成功,结果 URL:" + imageFirstUrl);
}
}
3)获取图片列表
通过调用百度接口返回的 JSON 数据,提取出其中的图片列表并返回。
@Slf4j
public class GetImageListApi {
public static List<ImageSearchResult> getImageList(String url) {
try {
HttpResponse response = HttpUtil.createGet(url).execute();
int statusCode = response.getStatus();
String body = response.body();
if (statusCode == 200) {
return processResponse(body);
} else {
throw new BusinessException(ErrorCode.OPERATION_ERROR, "接口调用失败");
}
} catch (Exception e) {
log.error("获取图片列表失败", e);
throw new BusinessException(ErrorCode.OPERATION_ERROR, "获取图片列表失败");
}
}
private static List<ImageSearchResult> processResponse(String responseBody) {
JSONObject jsonObject = new JSONObject(responseBody);
if (!jsonObject.containsKey("data")) {
throw new BusinessException(ErrorCode.OPERATION_ERROR, "未获取到图片列表");
}
JSONObject data = jsonObject.getJSONObject("data");
if (!data.containsKey("list")) {
throw new BusinessException(ErrorCode.OPERATION_ERROR, "未获取到图片列表");
}
JSONArray list = data.getJSONArray("list");
return JSONUtil.toList(list, ImageSearchResult.class);
}
public static void main(String[] args) {
String url = "https://graph.baidu.com/ajax/pcsimi?carousel=503&entrance=GENERAL&extUiData%5BisLogoShow%5D=1&inspire=general_pc&limit=30&next=2&render_type=card&session_id=16250747570487381669&sign=1265ce97cd54acd88139901733452612&tk=4caaa&tpl_from=pc";
List<ImageSearchResult> imageList = getImageList(url);
System.out.println("搜索成功" + imageList);
}
}
这里我们运用一种设计模式来提供图片搜索服务。门面模式通过提供一个统一的接口来简化多个接口的调用,使得客户端不需要关注内部的具体实现。
我们可以将多个 API 整合到一个门面类中,简化调用过程。在 imagesearch 包下新建门面类,整合几个接口的调用:
@Slf4j
public class ImageSearchApiFacade {
public static List<ImageSearchResult> searchImage(String imageUrl) {
String imagePageUrl = GetImagePageUrlApi.getImagePageUrl(imageUrl);
String imageFirstUrl = GetImageFirstUrlApi.getImageFirstUrl(imagePageUrl);
List<ImageSearchResult> imageList = GetImageListApi.getImageList(imageFirstUrl);
return imageList;
}
public static void main(String[] args) {
String imageUrl = "https://www.codefather.cn/logo.png";
List<ImageSearchResult> resultList = searchImage(imageUrl);
System.out.println("结果列表" + resultList);
}
}
开发请求类:
@Data
public class SearchPictureByPictureRequest implements Serializable {
private Long pictureId;
private static final long serialVersionUID = 1L;
}
开发接口:
@PostMapping("/search/picture")
public BaseResponse<List<ImageSearchResult>> searchPictureByPicture(@RequestBody SearchPictureByPictureRequest searchPictureByPictureRequest) {
ThrowUtils.throwIf(searchPictureByPictureRequest == null, ErrorCode.PARAMS_ERROR);
Long pictureId = searchPictureByPictureRequest.getPictureId();
ThrowUtils.throwIf(pictureId == null || pictureId <= 0, ErrorCode.PARAMS_ERROR);
Picture oldPicture = pictureService.getById(pictureId);
ThrowUtils.throwIf(oldPicture == null, ErrorCode.NOT_FOUND_ERROR);
List<ImageSearchResult> resultList = ImageSearchApiFacade.searchImage(oldPicture.getUrl());
return ResultUtils.success(resultList);
}
1)修改图片列表页面的代码,给图片操作栏增加一个搜索按钮:
<template v-if="showOp" #actions>
<a-space @click="(e) => doSearch(picture, e)">
<search-outlined />
搜索
</a-space>
<a-space @click="(e) => doEdit(picture, e)">
<edit-outlined />
编辑
</a-space>
<a-space @click="(e) => doDelete(picture, e)">
<delete-outlined />
删除
</a-space>
</template>
效果如图:
2)点击搜索后打开新页面,进入到以图搜图结果页:
const doSearch = (picture, e) => {
e.stopPropagation()
window.open(`/search_picture?pictureId=${picture.id}`)
}
1)新建图片搜索页面文件 SearchPicturePage.vue。可以复制创建图片页面,这样可以复用获取 url 查询参数并查询老数据的逻辑。
添加路由:
{
path: '/search_picture',
name: '图片搜索',
component: SearchPicturePage,
}
2)开发页面,上方展示页面标题和原始图片,下方展示搜索结果图片列表。可以参考图片列表组件来展示图片列表:
<template>
<div>
<h2>以图搜图</h2>
<h3>原图</h3>
<a-card>
<template #cover>
<img
:alt="picture.name"
:src="picture.thumbnailUrl ?? picture.url"
/>
</template>
</a-card>
<h3>识图结果</h3>
<!-- 图片列表 -->
<a-list
:grid="{ gutter: 16, xs: 1, sm: 2, md: 3, lg: 4, xl: 5, xxl: 6 }"
:data-source="dataList"
>
<template #renderItem="{ item }">
<a-list-item>
<a :href="item.fromUrl" target="_blank">
<a-card>
<template #cover>
<img :src="item.thumbUrl" />
</template>
</a-card>
</a>
</a-list-item>
</template>
</a-list>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import { getPictureVoByIdUsingGet, searchPictureByPictureUsingPost } from '@/api/pictureController'
import { message } from 'ant-design-vue'
const route = useRoute()
// 图片 id
const pictureId = computed(() => {
return route.query?.pictureId
})
const picture = ref<API.PictureVO>({})
// 获取老数据
const getOldPicture = async () => {
// 获取数据
const id = route.query?.pictureId
if (id) {
const res = await getPictureVoByIdUsingGet({
id: id,
})
if (res.data.code === 0 && res.data.data) {
const data = res.data.data
picture.value = data
}
}
}
onMounted(() => {
getOldPicture()
})
</script>
3)获取图片搜索结果:
const dataList = ref<API.ImageSearchResult[]>([])
const fetchData = async () => {
const res = await searchPictureByPictureUsingPost({
pictureId: pictureId.value,
})
if (res.data.code === 0 && res.data.data) {
dataList.value = res.data.data ?? []
} else {
message.error('获取数据失败,' + res.data.message)
}
}
onMounted(() => {
fetchData()
})
经过测试发现,百度搜索对于 webp 格式图片的支持度并不好(改文件的后缀也没有用),估计是平台不支持该格式的算法。
但是使用 png 图片去测试,就能正常看到结果了:
解决 webp 格式图片无法搜索的问题
如果想解决上述问题,有几种方案:
能够按照颜色搜索空间内 主色调 最相似的图片,在设计、创意和电商领域有广泛应用。
参考其他网站的颜色搜图效果:
此处我们将该功能限定在空间内使用,主要是考虑到公共图库的图片数量可能非常庞大,直接进行颜色匹配会导致搜索速度较慢,影响用户体验。
需要思考几个问题:
为了提升性能,避免每次搜索都实时计算图片主色调,建议在图片上传成功后立即提取主色调并存储到数据库的独立字段中。
完整流程如下:
我们存储图片使用的 COS 对象存储服务已经帮我们整合了数据万象,自带获取图片主色调的功能,参考文档。
💡 在使用云服务功能前,我们可以详细了解下服务的相关限制,比如 数据万象的限制,一般情况下达不到限制。
除了方便之外,这个功能属于基础图片处理,官方提供的免费额度较高,适合学习测试:
💡 一般我们做项目时,尽可能减少新依赖或服务的引入,会让成本更可控。比如看到腾讯云 COS 有现成的支持和免费额度,就已经是我们的首选解决方案,无需考虑第三方 API,可能会带来的额外限制和兼容性问题(比如我们的图片开启防盗链,可能就解析不到)。
数据库不支持直接按照颜色检索,用 like 检索又不符合颜色的特性。所以可以使用一些算法来解决。
此处使用 欧几里得距离 算法:颜色可以用 RGB 值表示,可以通过计算两种颜色 RGB 值之间的欧几里得距离来判断它们的相似度。
公式:
解释:
距离越小,表示颜色越相似;距离越大,表示颜色越不同。
还有一些其他的方法,需要用到时自己在网上调研即可:
1)图片表新增字段,执行 SQL:
ALTER TABLE picture
ADD COLUMN picColor varchar(16) null comment '图片主色调';
2)每次新增字段时,都要修改 PictureMapper.xml 以支持新字段的查询。
Picture 实体类、PictureVO 包装类、UploadPictureResult 上传图片结果类也需要补充新字段:
private String picColor;
1)修改 PictureUploadTemplate 的 buildResult 方法,直接从 ImageInfo 对象中就能获得主色调:
uploadPictureResult.setPicColor(imageInfo.getAve());
注意两个 buildResult 方法都要修改,其中一个 buildResult 方法要补充 imageInfo 参数,修改的代码如下:
private UploadPictureResult buildResult(String originFilename, CIObject compressedCiObject, CIObject thumbnailCiObject, ImageInfo imageInfo) {
UploadPictureResult uploadPictureResult = new UploadPictureResult();
int picWidth = compressedCiObject.getWidth();
int picHeight = compressedCiObject.getHeight();
double picScale = NumberUtil.round(picWidth * 1.0 / picHeight, 2).doubleValue();
uploadPictureResult.setPicName(FileUtil.mainName(originFilename));
uploadPictureResult.setPicWidth(picWidth);
uploadPictureResult.setPicHeight(picHeight);
uploadPictureResult.setPicScale(picScale);
uploadPictureResult.setPicFormat(compressedCiObject.getFormat());
uploadPictureResult.setPicColor(imageInfo.getAve());
uploadPictureResult.setPicSize(compressedCiObject.getSize().longValue());
uploadPictureResult.setUrl(cosClientConfig.getHost() + "/" + compressedCiObject.getKey());
uploadPictureResult.setThumbnailUrl(cosClientConfig.getHost() + "/" + thumbnailCiObject.getKey());
return uploadPictureResult;
}
获取到的值格式为十六进制,如图:
2)图片服务的 uploadPicture 中补充设置 picColor,从而将该字段保存到数据库中:
picture.setPicColor(uploadPictureResult.getPicColor());
新建 utils 包,直接利用 AI 来编写工具类:
public class ColorSimilarUtils {
private ColorSimilarUtils() {
}
public static double calculateSimilarity(Color color1, Color color2) {
int r1 = color1.getRed();
int g1 = color1.getGreen();
int b1 = color1.getBlue();
int r2 = color2.getRed();
int g2 = color2.getGreen();
int b2 = color2.getBlue();
double distance = Math.sqrt(Math.pow(r1 - r2, 2) + Math.pow(g1 - g2, 2) + Math.pow(b1 - b2, 2));
return 1 - distance / Math.sqrt(3 * Math.pow(255, 2));
}
public static double calculateSimilarity(String hexColor1, String hexColor2) {
Color color1 = Color.decode(hexColor1);
Color color2 = Color.decode(hexColor2);
return calculateSimilarity(color1, color2);
}
public static void main(String[] args) {
Color color1 = Color.decode("0xFF0000");
Color color2 = Color.decode("0xFE0101");
double similarity = calculateSimilarity(color1, color2);
System.out.println("颜色相似度为:" + similarity);
double hexSimilarity = calculateSimilarity("0xFF0000", "0xFE0101");
System.out.println("十六进制颜色相似度为:" + hexSimilarity);
}
}
为了让大家学习更清晰,在图片服务中新编写按颜色查询图片的方法 searchPictureByColor,不和其他的搜索条件放在一起。
按照方案设计中的流程开发,代码如下:
@Override
public List<PictureVO> searchPictureByColor(Long spaceId, String picColor, User loginUser) {
ThrowUtils.throwIf(spaceId == null || StrUtil.isBlank(picColor), ErrorCode.PARAMS_ERROR);
ThrowUtils.throwIf(loginUser == null, ErrorCode.NO_AUTH_ERROR);
Space space = spaceService.getById(spaceId);
ThrowUtils.throwIf(space == null, ErrorCode.NOT_FOUND_ERROR, "空间不存在");
if (!loginUser.getId().equals(space.getUserId())) {
throw new BusinessException(ErrorCode.NO_AUTH_ERROR, "没有空间访问权限");
}
List<Picture> pictureList = this.lambdaQuery()
.eq(Picture::getSpaceId, spaceId)
.isNotNull(Picture::getPicColor)
.list();
if (CollUtil.isEmpty(pictureList)) {
return Collections.emptyList();
}
Color targetColor = Color.decode(picColor);
List<Picture> sortedPictures = pictureList.stream()
.sorted(Comparator.comparingDouble(picture -> {
String hexColor = picture.getPicColor();
if (StrUtil.isBlank(hexColor)) {
return Double.MAX_VALUE;
}
Color pictureColor = Color.decode(hexColor);
return -ColorSimilarUtils.calculateSimilarity(targetColor, pictureColor);
}))
.limit(12)
.collect(Collectors.toList());
return sortedPictures.stream()
.map(PictureVO::objToVo)
.collect(Collectors.toList());
}
上述代码有 2 个小细节:
1)请求封装类 SearchPictureByColorRequest,需要传入空间 id 和主色调:
@Data
public class SearchPictureByColorRequest implements Serializable {
private String picColor;
private Long spaceId;
private static final long serialVersionUID = 1L;
}
2)开发接口:
@PostMapping("/search/color")
public BaseResponse<List<PictureVO>> searchPictureByColor(@RequestBody SearchPictureByColorRequest searchPictureByColorRequest, HttpServletRequest request) {
ThrowUtils.throwIf(searchPictureByColorRequest == null, ErrorCode.PARAMS_ERROR);
String picColor = searchPictureByColorRequest.getPicColor();
Long spaceId = searchPictureByColorRequest.getSpaceId();
User loginUser = userService.getLoginUser(request);
List<PictureVO> result = pictureService.searchPictureByColor(spaceId, picColor, loginUser);
return ResultUtils.success(result);
}
1)选择颜色选择器组件 vue3-colorpicker,参考文档 来了解使用方法和参数。
安装组件:
npm install vue3-colorpicker
2)在空间详情页新增颜色搜索。因为跟其他搜索不是联动的,所以独立出来,放到搜索框下面。
<!-- 按颜色搜索 --> <a-form-item label="按颜色搜索"> <color-picker format="hex" @pureColorChange="onColorChange" /> </a-form-item>
注意,format 要设置为 hex,得到十六进制的颜色值。
3)编写切换颜色事件函数,切换颜色时会触发搜索:
const onColorChange = async (color: string) => {
const res = await searchPictureByColorUsingPost({
picColor: color,
spaceId: props.id,
})
if (res.data.code === 0 && res.data.data) {
const data = res.data.data ?? [];
dataList.value = data;
total.value = data.length;
} else {
message.error('获取数据失败,' + res.data.message)
}
}
效果如图:
图片详情页补充颜色主色调的展示,可以使用一个小色块让颜色展示效果更明显:
<a-descriptions-item label="主色调">
<a-space>
{{ picture.picColor ?? '-' }}
<div
v-if="picture.picColor"
:style="{
backgroundColor: toHexColor(picture.picColor),
width: '16px',
height: '16px',
}"
/>
</a-space>
</a-descriptions-item>
由于后端数据万象计算出的色值格式不是标准的,存在类似 0x080e0 的色值,需要转换为标准 16 进制色值:
export function toHexColor(input) {
const colorValue = input.startsWith('0x') ? input.slice(2) : input
const hexColor = parseInt(colorValue, 16).toString(16).padStart(6, '0')
return `#${hexColor}`
}
效果如图:
1、刷新历史数据,让所有的图片都有主色调。
2、将颜色搜索和其他的搜索相结合,比如先用其他的搜索条件过滤数据,再运用相似度算法排序。
3、将颜色搜索应用到主页公共图库、图片管理页面等。
4、使用 ES 分词搜索图片的名称和简介,鱼皮编程导航的聚合搜索项目、面试刷题平台项目 都有从 0 开始的 ES 讲解。
5、多模态搜索:可以用文字搜索图片内容,一般使用第三方云服务实现。比如 智能检索 MetaInsight,可以通过自然语言或结构化的检索条件,分析存储在对象存储 COS 中的文件,满足对存储数据的管理、分析、检索需求。
智能检索利用数据万象已有的图片、视频、语音、文档等数据处理能力,提取文件的特征或元数据并索引到数据集中,提供文件的聚合统计查询、人脸图像检索、图片内容检索等能力。
使用它能实现更智能的搜索,比如:
6、自动按日期将图片分类到不同的文件夹中
7、颜色检索时,定义一个阈值范围,过滤掉不相似颜色。
为了提升网站的用户数量,我们需要添加多个引导用户分享网站的按钮,并且确保个人用户能够更方便地通过手机访问和分享网站内容。
支持两种分享形式:移动端扫码分享和复制链接分享,同时兼容移动端和 PC 端。
该功能的实现以前端为主,不涉及后端开发。
由于网站多个位置都可以触发分享。可以开发一个通用的弹窗分享组件,并在网站的各个页面(或组件)中引入。
用户点击分享按钮时,分享弹窗会弹出,并展示多种不同的分享方式,引导用户顺利完成分享。
可以在图片详情页、个人空间的图片卡片操作栏中增加分享入口。当然也可以在主页等其他合适的位置分享~
我们可以直接使用 Ant Design 的 可复制文本组件,也可以采用第三方库如 copy-text-to-clipboard 来实现复制链接功能。
移动端扫码分享可以使用 组件库的 qrcode 组件,也可以使用第三方的 qrcode 组件。其原理是将分享链接转化为二维码图片,用户扫描二维码后即可访问链接。
可以给网站接入微信 js-sdk 实现微信卡片分享能力。用户在网页内分享到微信时,用户看到的不再是一个干巴巴的链接,而是可以自定义展示的标题和图片。参考文档
比如我们的老鱼简历,就是这么做的,分享效果如图:
1)新增 ShareModal 组件,使用 Modal 弹窗组件,支持传入 title 标题和 link 分享链接属性,可以由父组件决定要分享的信息。
代码如下:
<template>
<a-modal v-model:visible="visible" title="分享图片" :footer="false" @cancel="closeModal">
<h4>复制分享链接</h4>
<a-typography-link copyable>
{{ link }}
</a-typography-link>
<div />
<h4>手机扫码查看</h4>
<a-qrcode :value="link" />
</a-modal>
</template>
<script setup lang="ts">
import { defineProps, ref, withDefaults, defineExpose } from 'vue'
/**
* 定义组件属性类型
*/
interface Props {
title: string
link: string
}
/**
* 给组件指定初始值
*/
const props = withDefaults(defineProps<Props>(), {
title: () => '分享',
link: () => 'https://laoyujianli.com/share/yupi',
})
</script>
2)定义 visible 变量和弹窗打开关闭的函数,用于控制弹窗是否可见:
const visible = ref(false)
const openModal = () => {
visible.value = true
}
const closeModal = () => {
visible.value = false
}
3)为了方便其他页面使用组件,需要暴露出 openModal 函数:
import { defineExpose } from "vue";
defineExpose({
openModal,
});
1)图片列表组件中引入弹窗分享组件,注意要在 for 循环外引入:
<ShareModal ref="shareModalRef" :link="shareLink" />
2)编写触发分享的入口,点击分享图标后打开弹窗。可以移除操作栏的文字,让图片卡片看起来更精简:
<template v-if="showOp" #actions> <search-outlined @click="(e) => doSearch(picture, e)" /> <share-alt-outlined @click="(e) => doShare(picture, e)" /> <edit-outlined @click="(e) => doEdit(picture, e)" /> <delete-outlined @click="(e) => doDelete(picture, e)" /> </template>
3)编写分享函数,打开分享弹窗。注意不要硬编码分享链接的路径,而是可以获取当前的网址,代码如下:
const shareModalRef = ref()
const shareLink = ref<string>()
const doShare = (picture: API.PictureVO, e: Event) => {
e.stopPropagation()
shareLink.value = `${window.location.protocol}//${window.location.host}/picture/${picture.id}`
if (shareModalRef.value) {
shareModalRef.value.openModal()
}
}
效果如图:
1)在免费下载按钮的右边增加分享按钮:
<a-button type="primary" ghost @click="doShare">
分享
<template #icon>
<share-alt-outlined />
</template>
</a-button>
2)分享函数就很简单了,复制图片列表组件的分享函数代码后,略作修改即可。
效果如图:
1、后端记录分享次数
后端可以记录点击分享按钮的次数、以及分享链接的点击次数,以便进行数据分析和优化。
2、生成自定义邀请码
可以为每个用户生成自己的邀请码,还可以支持自助修改,参考鱼皮的 面试刷题平台面试鸭,提高用户的分享意愿。
3、微信卡片分享功能
接入微信 JS-SDK,实现微信卡片分享功能。通过该功能,用户分享时可以展示自定义的标题、图片等内容,而非简单的链接,提高点击率。
用户可以对私有空间内的图片进行批量修改,包括:
批量操作的实现并不难,首先查询出空间内所有的图片,然后最简单的方式就是 for 循环遍历一下嘛!
但如果想让批量操作更快、更稳定地完成,我们需要注意几点:
updateBatchById 方法进行批量更新,而不是 for 循环多次操作数据库,从而提高性能并降低操作时间。此外,如果要处理的数据量非常大(上千条),为了进一步优化性能,还可以结合使用线程池、分批处理和并发编程,提升大规模操作的效率。还可以通过添加日志来记录批处理操作的执行情况,提高可观测性。
最简单的实现是将所有图片都修改为同一个名称,但这样不够有区分度。所以我们可以定义一个动态生成规则,允许用户在重命名时填写动态变量(占位符)。比如用户输入图片_{序号},其中{序号}就是动态变量,每个图片的序号都不同,会从 1 开始持续递增。
后端可以使用字符串替换方法来处理 {序号} 占位符,适用于比较简单的场景,如果动态生成规则很复杂,可以使用模板引擎技术,鱼皮在 编程导航的代码生成器平台项目 中讲解过。
提到动态替换内容,这里顺便分享一下 Spring 表达式技术。
Spring 表达式语言(Spring Expression Language,简称 SpEL)用于在 Spring 配置文件或 Java 代码中动态地查询和操作对象。SpEL 可以在运行时解析表达式,并执行对 Java 对象的访问、操作和计算,支持丰富的功能,如条件判断、方法调用、属性访问、集合处理、正则表达式等。
举一些语法示例:
#{user.name}
#{person.address.city}
#{user.getFullName()}
#{user.age > 18 ? 'Adult' : 'Child'}
举例一个应用场景,比如缓存注解中,使用表达式根据方法参数动态生成缓存的 key:
@Cacheable(value = "users", key = "#userId + ':' + #locale")
public String getUserInfo(Long userId, String locale) {
System.out.println("Fetching user info from DB...");
return "User " + userId + " info in " + locale + " language";
}
它的实现方式可就不是字符串替换这么简单了,而是用到了 AST 抽象语法树来对字符串进行解析,大家要对这种思路有个印象。
1)开发请求类,接受图片 id 列表等字段:
@Data
public class PictureEditByBatchRequest implements Serializable {
private List<Long> pictureIdList;
private Long spaceId;
private String category;
private List<String> tags;
private static final long serialVersionUID = 1L;
}
2)开发批量修改图片服务,依次完成参数校验、空间权限校验、图片查询、批量更新操作:
@Override
@Transactional(rollbackFor = Exception.class)
public void editPictureByBatch(PictureEditByBatchRequest pictureEditByBatchRequest, User loginUser) {
List<Long> pictureIdList = pictureEditByBatchRequest.getPictureIdList();
Long spaceId = pictureEditByBatchRequest.getSpaceId();
String category = pictureEditByBatchRequest.getCategory();
List<String> tags = pictureEditByBatchRequest.getTags();
ThrowUtils.throwIf(spaceId == null || CollUtil.isEmpty(pictureIdList), ErrorCode.PARAMS_ERROR);
ThrowUtils.throwIf(loginUser == null, ErrorCode.NO_AUTH_ERROR);
Space space = spaceService.getById(spaceId);
ThrowUtils.throwIf(space == null, ErrorCode.NOT_FOUND_ERROR, "空间不存在");
if (!loginUser.getId().equals(space.getUserId())) {
throw new BusinessException(ErrorCode.NO_AUTH_ERROR, "没有空间访问权限");
}
List<Picture> pictureList = this.lambdaQuery()
.select(Picture::getId, Picture::getSpaceId)
.eq(Picture::getSpaceId, spaceId)
.in(Picture::getId, pictureIdList)
.list();
if (pictureList.isEmpty()) {
return;
}
pictureList.forEach(picture -> {
if (StrUtil.isNotBlank(category)) {
picture.setCategory(category);
}
if (CollUtil.isNotEmpty(tags)) {
picture.setTags(JSONUtil.toJsonStr(tags));
}
});
boolean result = this.updateBatchById(pictureList);
ThrowUtils.throwIf(!result, ErrorCode.OPERATION_ERROR);
}
💡 对于我们的项目来说,由于用户要处理的数据量不大,上述代码已经能够满足需求。但如果要处理大量数据,可以使用线程池 + 分批 + 并发进行优化,参考代码如下:
@Resource
private ThreadPoolExecutor customExecutor;
@Override
@Transactional(rollbackFor = Exception.class)
public void batchEditPictureMetadata(PictureBatchEditRequest request, Long spaceId, Long loginUserId) {
validateBatchEditRequest(request, spaceId, loginUserId);
List<Picture> pictureList = this.lambdaQuery()
.eq(Picture::getSpaceId, spaceId)
.in(Picture::getId, request.getPictureIds())
.list();
if (pictureList.isEmpty()) {
throw new BusinessException(ErrorCode.NOT_FOUND_ERROR, "指定的图片不存在或不属于该空间");
}
int batchSize = 100;
List<CompletableFuture<Void>> futures = new ArrayList<>();
for (int i = 0; i < pictureList.size(); i += batchSize) {
List<Picture> batch = pictureList.subList(i, Math.min(i + batchSize, pictureList.size()));
CompletableFuture<Void> future = CompletableFuture.runAsync(() -> {
batch.forEach(picture -> {
if (request.getCategory() != null) {
picture.setCategory(request.getCategory());
}
if (request.getTags() != null) {
picture.setTags(String.join(",", request.getTags()));
}
});
boolean result = this.updateBatchById(batch);
if (!result) {
throw new BusinessException(ErrorCode.OPERATION_ERROR, "批量更新图片失败");
}
}, customExecutor);
futures.add(future);
}
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
}
此外,还可以多记录日志,或者让返回结果更加详细,比如更新成功了多少条数据之类的。
3)开发接口
@PostMapping("/edit/batch")
public BaseResponse<Boolean> editPictureByBatch(@RequestBody PictureEditByBatchRequest pictureEditByBatchRequest, HttpServletRequest request) {
ThrowUtils.throwIf(pictureEditByBatchRequest == null, ErrorCode.PARAMS_ERROR);
User loginUser = userService.getLoginUser(request);
pictureService.editPictureByBatch(pictureEditByBatchRequest, loginUser);
return ResultUtils.success(true);
}
直接复用批量修改信息的方法,在这基础上做增强,补充对图片名称的修改。
1)批量编辑请求类 PictureEditByBatchRequest 补充字段:
private String nameRule;
2)批量修改方法补充图片名称:
String nameRule = pictureEditByBatchRequest.getNameRule(); fillPictureWithNameRule(pictureList, nameRule);
编写填充图片名称的方法,使用字符串的 replaceAll 方法替换动态变量:
private void fillPictureWithNameRule(List<Picture> pictureList, String nameRule) {
if (CollUtil.isEmpty(pictureList) || StrUtil.isBlank(nameRule)) {
return;
}
long count = 1;
try {
for (Picture picture : pictureList) {
String pictureName = nameRule.replaceAll("\\{序号}", String.valueOf(count++));
picture.setName(pictureName);
}
} catch (Exception e) {
log.error("名称解析错误", e);
throw new BusinessException(ErrorCode.OPERATION_ERROR, "名称解析错误");
}
}
为了实现方便,仅对空间详情页当前页号查询出的图片进行批量管理。
1)直接复用之前的弹窗分享组件 + 搜索图片表单组件,就能快速完成开发。
代码如下:
<template>
<a-modal v-model:visible="visible" title="批量编辑图片" :footer="false" @cancel="closeModal">
<a-typography-paragraph type="secondary">* 只对当前页面的图片生效</a-typography-paragraph>
<!-- 表单项 -->
<a-form layout="vertical" :model="formData" @finish="handleSubmit">
<a-form-item label="分类" name="category">
<a-auto-complete
v-model:value="formData.category"
:options="categoryOptions"
placeholder="请输入分类"
allowClear
/>
</a-form-item>
<a-form-item label="标签" name="tags">
<a-select
v-model:value="formData.tags"
:options="tagOptions"
mode="tags"
placeholder="请输入标签"
allowClear
/>
</a-form-item>
<a-form-item label="命名规则" name="nameRule">
<a-input v-model:value="formData.nameRule" placeholder="请输入命名规则,输入 {序号} 可动态生成" />
</a-form-item>
<a-form-item>
<a-button type="primary" html-type="submit">提交</a-button>
</a-form-item>
</a-form>
</a-modal>
</template>
<script setup lang="ts">
import { defineProps, ref, withDefaults, defineExpose, reactive, onMounted } from 'vue'
import {
editPictureByBatchUsingPost,
listPictureTagCategoryUsingGet,
} from '@/api/pictureController'
import { message } from 'ant-design-vue'
// 定义组件属性类型
interface Props {
pictureList: API.PictureVO[]
spaceId: number
onSuccess: () => void
}
// 给组件指定初始值
const props = withDefaults(defineProps<Props>(), {})
// 控制弹窗可见性
const visible = ref(false)
// 打开弹窗
const openModal = () => {
visible.value = true
}
// 关闭弹窗
const closeModal = () => {
visible.value = false
}
// 暴露函数给父组件
defineExpose({
openModal,
})
// 初始化表单数据
const formData = reactive({
category: '', // 分类
tags: [], // 标签
nameRule: '', // 命名规则
})
const categoryOptions = ref<string[]>([])
const tagOptions = ref<string[]>([])
// 获取标签和分类选项
const getTagCategoryOptions = async () => {
const res = await listPictureTagCategoryUsingGet()
if (res.data.code === 0 && res.data.data) {
// 转换成下拉选项组件接受的格式
tagOptions.value = (res.data.data.tagList ?? []).map((data: string) => {
return {
value: data,
label: data,
}
})
categoryOptions.value = (res.data.data.categoryList ?? []).map((data: string) => {
return {
value: data,
label: data,
}
})
} else {
message.error('加载选项失败,' + res.data.message)
}
}
onMounted(() => {
getTagCategoryOptions()
})
</script>
2)编写提交表单的函数,调用后端批量编辑接口:
const handleSubmit = async (values: any) => {
if (!props.pictureList) {
return
}
const res = await editPictureByBatchUsingPost({
pictureIdList: props.pictureList.map((picture) => picture.id),
spaceId: props.spaceId,
...values,
})
if (res.data.code === 0 && res.data.data) {
message.success('操作成功')
closeModal()
props.onSuccess?.()
} else {
message.error('操作失败,' + res.data.message)
}
}
1)空间详情页引入弹窗组件:
<BatchEditPictureModal ref="batchEditPictureModalRef" :spaceId="id" :pictureList="dataList" :onSuccess="onBatchEditPictureSuccess" />
2)通过 ref 获取到弹窗的引用,在批量编辑成功后要刷新图片列表数据:
const batchEditPictureModalRef = ref()
const onBatchEditPictureSuccess = () => {
fetchData()
}
在创建图片按钮的右侧补充 “批量编辑” 按钮,点击后打开弹窗表单:
<a-button :icon="h(EditOutlined)" @click="doBatchEdit"> 批量编辑</a-button>
按钮点击函数:
const doBatchEdit = () => {
if (batchEditPictureModalRef.value) {
batchEditPictureModalRef.value.openModal()
}
}
效果如图:
1、管理员可以对公共图库进行批量编辑
实现思路:接口需要兼容不传空间 id 的情况(也就对应了公共图库),前端图片管理页面可以使用 表格组件 提供的多选功能,自由选择图片进行批量操作。
2、空间详情页面支持对图片进行自由多选
实现思路:建议增加一种展示图片列表的方式,改为表格,这样就可以使用 表格组件 提供的多选功能,自由选择图片进行批量操作。
3、重命名规则可以增加更多的动态变量,前端可以通过点击快速填充表达式字符串,而不用让用户自己编写 {xxx} 的语法。
4、对于前端同学,可以尝试将弹窗组件改为受控组件。
受控组件是指其状态由外部(通常是父组件或状态管理系统)控制的组件。在受控组件中,组件的值始终由父组件或状态控制,用户对组件的输入会通过事件通知父组件或状态管理,并更新相应的值,父组件再通过更新传递给子组件,从而反映新的状态。
我们之前使用 Ant Design 的分页组件时,就是把它当做受控组件使用的:
<a-pagination
v-model:current="searchParams.current"
v-model:pageSize="searchParams.pageSize"
:total="total"
:show-total="() => `图片总数 ${total} / ${space.maxCount}`"
@change="onPageChange"
/>
const searchParams = ref<API.PictureQueryRequest>({
current: 1,
pageSize: 12,
})
const onPageChange = (page, pageSize) => {
searchParams.value.current = page
searchParams.value.pageSize = pageSize
fetchData()
}