十一节后工作日记

十一假期之后的第一项工作,是给智慧科协补一套运营位管理功能。需求看起来很集中:管理端配置移动端首页轮播图,移动端按配置展示图片,需要时再跳转到指定链接。
真正做下来,工作并没有停在“画一个列表,再放一个轮播组件”。页面需要延续旧系统的布局和交互,接口要接入现有网关与请求封装,上传后的图片还经历了临时文件、业务关联和 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.rowstotal.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 ↓ 调用资源预览接口我用现有轮播记录做了逐段验证:
- 分页接口返回轮播记录和
fileId; - 将这个
fileId直接传给预览接口,返回 JSON 错误; - 调用
/manager/fileData/list?bizId={fileId},可以查到附件记录; - 附件记录中的
ossId与原fileId不同; - 使用真实
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 之间那一层容易被忽略的关系。
支持与分享
如果这篇文章对你有帮助,欢迎分享给更多人或打赏支持!





























