十一节后工作日记

3297 字
16 分钟
十一节后工作日记

十一假期之后的第一项工作,是给智慧科协补一套运营位管理功能。需求看起来很集中:管理端配置移动端首页轮播图,移动端按配置展示图片,需要时再跳转到指定链接。

真正做下来,工作并没有停在“画一个列表,再放一个轮播组件”。页面需要延续旧系统的布局和交互,接口要接入现有网关与请求封装,上传后的图片还经历了临时文件、业务关联和 OSS 文件三个阶段。最后遇到的图片无法预览问题,也正是出在这些 ID 的语义没有分清。

这篇日记按最终保留下来的功能记录实现过程,也把接口阅读和文件链路多展开一些。

1. 先确定最终页面链路#

运营位管理的入口放在业务管理工作台中,位置紧跟科技馆人流监控统计。入口沿用工作台原有卡片结构,图标放在项目统一的资源目录中,尺寸、文字和悬停效果都由原组件控制。点击后进入独立的运营位管理路由。

最终页面只保留一项业务:移动端首页轮播图。页面分成筛选区、操作区、数据表格和分页区:

区域功能
筛选区图片标题、启用时间、启用状态
操作区新增、批量删除
表格标题、跳转类型、状态、启用时间、操作
行操作启用/禁用、编辑、查看
分页总数、页码切换

业务规则也在页面设计阶段一起确定:最多启用六张图片;启用项排在禁用项之前;禁用项不显示启用时间;两种状态都能编辑;移动端每六秒轮播一次。

这一步让我再次意识到,原型只描述页面长什么样,真正决定代码结构的是状态、动作和接口之间的关系。

2. 用页面模型隔离接口字段#

接口使用字符串表示业务枚举:状态 0/1,跳转类型 1/2。模板若直接消费这些值,会出现很多重复判断,所以页面先把接口对象转换成适合展示的 CarouselItem。

export interface CarouselItem {
id: string
title: string
jumpType: "link" | "none"
link: string
imageUrl: string
fileId: string
enabled: boolean
enabledAt: string
}

转换后,模板只需要判断 record.enabled 和 record.jumpType,不再到处比较魔法字符串:

const mapBaseFields = (item: AppBanner) => ({
id: String(item.id),
title: item.title || "",
enabled: item.status === "1",
enabledAt:
item.status === "1" && item.enableTime
? String(item.enableTime)
: "",
jumpType: item.jumpType === "2" ? "link" : "none",
link: item.jumpUrl || "",
})

提交时再做一次反向转换。接口字段的变化被限制在适配函数中,表格和弹窗保持稳定。

3. 新增与编辑共用一个弹窗#

新增、编辑和查看使用同一个 CarouselEditor,通过 mode 区分行为。这样可以复用表单结构、校验规则和图片预览,又不会为三个弹窗复制相同代码。

表单约束包括:

  • 图片名称必填,最多五十个字符;
  • 图片只允许 JPG、PNG,最大 10MB;
  • 跳转类型默认“无跳转”;
  • 选择“跳转”后才展示跳转地址;
  • 跳转地址最多五百个字符,并校验 http/https 协议;
  • 查看模式禁用输入,只保留图片和字段回显。

跳转地址的校验没有只判断是否为空,而是交给 URL 解析:

const validateLink = async (_rule: unknown, value: string) => {
if (form.jumpType !== "link") return
try {
const url = new URL(value.trim())
if (!["http:", "https:"].includes(url.protocol)) {
throw new Error("Unsupported protocol")
}
} catch {
throw new Error("请输入以 http:// 或 https:// 开头的有效地址")
}
}

这里的重点不是校验写得多复杂,而是让表单状态和业务规则一致。用户选了无跳转,页面就不应提交上一次残留的链接。

4. 从 Knife4j 文档整理接口边界#

拿到后端文档后,我先没有急着把 URL 写进页面,而是把轮播配置接口按职责整理了一遍:

方法路径用途
POST/manager/banner新增配置
PUT/manager/banner修改配置、切换状态
GET/manager/banner/{id}查询详情
DELETE/manager/banner/{ids}批量删除
GET/manager/banner/list查询全部匹配记录
GET/manager/banner/page分页查询

Knife4j 展示的是网关下的完整服务路径。Portal 的 requestClient 已经配置了 API 基础地址,Vite 代理也会去掉前端代理前缀,所以业务 API 层只保留后端实际控制器路径。

import { requestClient } from "#/api/request"
const ROOT = "/manager/banner"
export function addAppBanner(data: AppBannerForm) {
return requestClient.post<void>(ROOT, data)
}
export function updateAppBanner(data: AppBannerForm) {
return requestClient.put<void>(ROOT, data)
}
export function getAppBanner(id: number | string) {
return requestClient.get<AppBanner>(`${ROOT}/${id}`)
}
export function removeAppBanners(ids: Array<number | string>) {
return requestClient.delete<void>(`${ROOT}/${ids.join(",")}`)
}
export function getAppBannerPage(params: AppBannerQuery) {
return requestClient.get<AppBannerPage>(`${ROOT}/page`, { params })
}

API 文件同时声明了 AppBanner、AppBannerForm、AppBannerQuery 和分页响应类型。页面因此能清楚地区分:哪些字段来自列表,哪些字段用于提交,哪些字段只是查询条件。

5. 分页响应为什么没有 data#

项目请求拦截器会统一处理 { code, msg, data }。但分页接口返回的是 { code, msg, rows, total },没有 data 字段。

请求封装已经考虑了这种差异:响应中有 data 时返回 data;没有 data 时,将 rows、total 等剩余字段作为结果返回。因此页面调用分页接口后可以直接读取:

const result = await getAppBannerPage({
pageNum: page.value,
pageSize,
title: applied.title || undefined,
status,
orderByColumn: "status",
isAsc: "desc",
})
records.value = result.rows
total.value = Number(result.total || 0)

调试接口时,我会先确认三个层次:浏览器实际请求地址、HTTP 状态、业务响应结构。只看控制台中有没有报错,很容易把网关路径和响应适配混在一起。

6. 六张启用上限放在状态动作前#

“最多启用六张”不是列表展示规则,而是状态变更的前置条件。点击启用前,页面先查询当前启用记录:

const toggleStatus = async (item: CarouselItem) => {
if (!item.enabled) {
const enabledItems = await getAppBannerList({ status: "1" })
if (enabledItems.length >= MAX_ENABLED_IMAGES) {
message.warning("最多可以启用6张图片")
return
}
}
const enabled = !item.enabled
await updateAppBanner(
toBannerForm({
...item,
enabled,
enabledAt: enabled ? dayjs().format("YYYY-MM-DD HH:mm:ss") : "",
}),
)
}

页面还用一个 Set 记录正在切换状态的记录 ID,避免用户连续点击同一行。前端检查改善交互,真正的并发上限仍应由后端保证,因为多个管理人员可能同时操作。

7. 图片预览故障:fileId 不是 ossId#

页面与接口接通后出现了一个很有代表性的问题:列表有图片标题,查看弹窗没有预览,移动端首页也不显示配置图片。

当时两端都使用了下面的写法:

const imageUrl = `${apiURL}/resource/oss/preview/${item.fileId}`

从字段名看,这似乎很合理,但直接请求后,资源接口返回“文件数据不存在”。继续查 Knife4j 和项目已有文件代码后,我发现上传与业务保存并不是只有一个 ID。

文件链路实际是:

上传图片
↓ 返回临时附件 ID
保存轮播配置并提交 fileIds
↓ 后端完成业务关联
轮播记录中的 fileId 作为附件业务 bizId
↓ 通过 bizId 查询附件
得到真正的 ossId
↓ 调用资源预览接口

我用现有轮播记录做了逐段验证:

  1. 分页接口返回轮播记录和 fileId;
  2. 将这个 fileId 直接传给预览接口,返回 JSON 错误;
  3. 调用 /manager/fileData/list?bizId={fileId},可以查到附件记录;
  4. 附件记录中的 ossId 与原 fileId 不同;
  5. 使用真实 ossId 调用预览接口,响应类型是 image/jpeg。

这次排查最关键的结论是:字段类型都是字符串或长整型,并不代表语义相同。bizId 负责说明文件属于哪个业务,ossId 才是资源服务预览文件时需要的主键。

8. Review:管理端如何恢复图片回显#

管理端将原来的同步映射改成异步映射。先按业务 ID 查询附件,再使用查询结果中的真实 OSS ID 生成预览地址。项目现有的 fileData API 已经会补全 url,页面只消费第一张附件即可。

const mapBanner = async (item: AppBanner): Promise<CarouselItem> => {
let imageUrl = ""
if (item.fileId !== null && item.fileId !== undefined) {
const files = await fileData({ bizId: item.fileId })
imageUrl = files.rows?.[0]?.url || ""
}
return {
id: String(item.id),
title: item.title || "",
fileId: item.fileId ? String(item.fileId) : "",
imageUrl,
enabled: item.status === "1",
enabledAt:
item.status === "1" && item.enableTime
? String(item.enableTime)
: "",
jumpType: item.jumpType === "2" ? "link" : "none",
link: item.jumpUrl || "",
}
}

分页一次返回多条记录,所以列表使用 Promise.all 并行补全图片:

const result = await getAppBannerPage(query)
records.value = await Promise.all((result.rows || []).map(mapBanner))

新增上传后的即时预览仍然使用上传接口刚返回的 ossId。此时附件还没有完成业务关联,它就是资源预览所需的真实 ID:

const result = await uploadApi(file)
form.fileId = String(result.ossId)
form.imageUrl = `${apiURL}/resource/oss/preview/${result.ossId}`

编辑已有记录时还要处理另一个细节:如果用户只改标题或跳转地址,就不应把记录中的业务 fileId 再次作为新附件 ID 提交。现在只有图片发生变化时才发送 fileIds:

const imageChanged =
!currentRecord.value || value.fileId !== currentRecord.value.fileId
const attachmentIds =
imageChanged && value.fileId ? [value.fileId] : undefined
await updateAppBanner(
toBannerForm({ ...currentRecord.value, ...value }, attachmentIds),
)

这样,状态切换和普通字段编辑不会意外改变附件关联,重新上传图片时又能把新的 OSS 文件交给后端绑定。

9. WST 首页的 API 封装#

移动端没有直接复用管理端 API,而是在自己的 src/api/banner.js 中封装首页所需的两个请求。

import request from "@/utils/request"
export function queryEnabledBanners(params = {}) {
return request({
url: "/manager/banner/page",
method: "get",
params: {
pageNum: 1,
pageSize: 6,
status: "1",
orderByColumn: "enableTime",
isAsc: "desc",
...params,
},
})
}
export function queryBannerFiles(bizId) {
return request({
url: "/manager/fileData/list",
method: "get",
params: { bizId },
})
}

第一个接口只取启用数据,限制六条并按启用时间倒序;第二个接口完成业务附件 ID 到真实 OSS ID 的转换。页面不再知道完整网关地址,也不重复处理客户端标识、Token 和错误提示。

首页加载时并行补全图片地址:

const fetchBannerList = async () => {
const { rows = [] } = await queryEnabledBanners()
const enabledBanners = rows
.filter(item => item.status === "1" && item.fileId)
.slice(0, 6)
const resolvedBanners = await Promise.all(
enabledBanners.map(async item => {
const { rows: files = [] } = await queryBannerFiles(item.fileId)
const ossId = files[0]?.ossId
return ossId
? {
...item,
imageUrl: `${apiURL}/resource/oss/preview/${ossId}`,
}
: null
}),
)
bannerList.value = resolvedBanners.filter(Boolean)
}

模板使用 Vant Swipe,保持原首页 180px 的轮播区域。只有一张时隐藏指示点,多张时每六秒切换:

<van-swipe
v-if="bannerList.length"
class="home-banner"
:autoplay="6000"
:show-indicators="bannerList.length > 1"
indicator-color="#ffffff"
>
<van-swipe-item v-for="item in bannerList" :key="item.id">
<van-image
width="100%"
height="180"
fit="cover"
:src="item.imageUrl"
:alt="item.title"
@error="handleBannerImageError(item)"
@click="handleBannerClick(item)"
/>
</van-swipe-item>
</van-swipe>

点击行为也严格跟随配置:只有 jumpType === "2" 且地址存在时才跳转。接口为空、请求失败或单张图片失效时,页面保留原首页图片作为兜底,不让首屏出现空白区域。

10. 调试时如何证明链路已经恢复#

这次没有停在“代码看起来正确”。我按真实请求顺序检查了运行结果:

轮播分页接口 200 application/json
附件关联查询(第一条) 200 application/json
附件关联查询(第二条) 200 application/json
图片预览(第一张) 200 image/jpeg
图片预览(第二张) 200 image/jpeg

然后用移动端尺寸打开首页,等待网络空闲,确认轮播区域显示后端配置的图片。最后分别完成 Portal 与 WST 的构建检查。

这里的验证顺序很重要。若只看到 <img> 没显示就开始改 CSS,很可能越改越远;先看图片请求返回的是图片还是 JSON,往往几分钟就能判断问题属于样式、鉴权、路径还是数据关联。

11. 本次工作的几个记录#

第一,API 文档中的字段名只是入口。遇到文件、用户、组织等关联数据时,还要顺着已有代码和真实响应确认字段语义。

第二,API 层应表达业务动作。页面调用 queryEnabledBanners、queryBannerFiles,比在组件里散落 URL、状态值和分页参数更容易阅读和修改。

第三,上传和业务保存是两个阶段。上传接口返回 OSS ID,保存接口负责把附件和业务关联;查看时再从业务 ID 找回真实文件。忽略中间这一层,就会出现“数据库有配置、对象存储也有文件,但页面永远找不到图片”的情况。

第四,状态更新要控制提交范围。切换启用状态时只提交状态相关字段,图片没有变化就不重新绑定附件。数据越复杂,越不能把整个页面对象不加区分地塞回接口。

这次工作从一个运营位入口开始,最后落到管理端页面、移动端轮播、请求封装和附件模型四条链路。页面实现本身并不算最难,真正有价值的是把一个模糊的“图片不显示”拆成可验证的步骤,并最终找到 bizId 与 ossId 之间那一层容易被忽略的关系。

支持与分享

如果这篇文章对你有帮助,欢迎分享给更多人或打赏支持!

打赏
十一节后工作日记
https://firefly.cuteleaf.cn/posts/post-national-day-work-diary/
作者
Yore
发布于
2026-10-09
许可协议
CC BY-NC-SA 4.0

评论区

Profile Image of the Author
Yore
Hello, I'm Yore.
公告
欢迎来到Yore的博客!本站基于原创作者夏叶的博客网站模版创作。
分类
标签
最新动态
站点统计
文章
51
分类
2
标签
124
总字数
94,115
运行时长
0 天
最后活动
0 天前
站点信息
构建平台
EdgeOne Pages
博客版本
Firefly v6.15.0
文章许可
CC BY-NC-SA 4.0