智能协同云图库项目 第 5 期 用户传图 前端笔记
审核信息相关
1、定义审核常量 位置src/constants/picture.ts
▼ts复制代码export const PIC_REVIEW_STATUS_ENUM = { REVIEWING: 0, PASS: 1, REJECT: 2, } export const PIC_REVIEW_STATUS_MAP = { 0: '待审核', 1: '通过', 2: '拒绝', } export const PIC_REVIEW_STATUS_OPTIONS = Object.keys(PIC_REVIEW_STATUS_MAP).map((key) => { return { label: PIC_REVIEW_STATUS_MAP[key], value: key, } })
2、在 PictureManagePage 增加审核字段
放到 用户 id 和 创建时间 之间
▼text复制代码const columns = [ // ... { title: '审核信息', dataIndex: 'reviewMessage', }, // ... ]
3、增加之自定义要展示的内容
src/pages/admin/PictureManagePage.vue
▼vue复制代码<!-- 审核信息 --> <template v-if="column.dataIndex === 'reviewMessage'"> <div>审核状态:{{ PIC_REVIEW_STATUS_MAP[record.reviewStatus] }}</div> <div>审核信息:{{ record.reviewMessage }}</div> <div>审核人:{{ record.reviewerId }}</div> </template>
4、新增过审和拒绝操作
1)增加通过和拒绝按钮
▼vue复制代码<template v-else-if="column.key === 'action'"> <a-space wrap> <a-button v-if="record.reviewStatus !== PIC_REVIEW_STATUS_ENUM.PASS" type="link" @click="handleReview(record, PIC_REVIEW_STATUS_ENUM.PASS)" > 通过 </a-button> <a-button v-if="record.reviewStatus !== PIC_REVIEW_STATUS_ENUM.REJECT" type="link" danger @click="handleReview(record, PIC_REVIEW_STATUS_ENUM.REJECT)" > 拒绝 </a-button> <a-button type="link" :href="`/add_picture?id=${record.id}`" target="_blank" >编辑 </a-button> <a-button type="link" danger @click="doDelete(record.id)">删除</a-button> </a-space> </template>
2)编写 handleReview 方法
▼ts复制代码const handleReview = async (record: API.Picture, reviewStatus: number) => { const reviewMessage = reviewStatus === PIC_REVIEW_STATUS_ENUM.PASS ? '管理员操作通过' : '管理员操作拒绝' const res = await doPictureReviewUsingPost({ id: record.id, reviewStatus, reviewMessage, }) if (res.data.code === 0) { message.success('审核操作成功') // 重新获取列表 fetchData() } else { message.error('审核操作失败,' + res.data.message) } }
5、按照需求审核状态筛选
▼vue复制代码<a-form-item label="审核状态" name="reviewStatus"> <a-select v-model:value="searchParams.reviewStatus" :options="PIC_REVIEW_STATUS_OPTIONS" placeholder="请输入审核状态" style="min-width: 180px" allow-clear /> </a-form-item>
修改完成后最终的代码 PictureManagePage.vue
▼vue复制代码<template> <div id="userManagePage"> <a-flex justify="space-between"> <h2>图片管理</h2> <a-button type="primary" href="/add_picture" target="_blank">+ 创建图片</a-button> </a-flex> <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-input v-model:value="searchParams.category" placeholder="请输入类型" allow-clear /> </a-form-item> <a-form-item label="标签" name="tags"> <a-select v-model:value="searchParams.tags" mode="tags" placeholder="请输入标签" style="min-width: 180px" allow-clear /> </a-form-item> <a-form-item label="审核状态" name="reviewStatus"> <a-select v-model:value="searchParams.reviewStatus" :options="PIC_REVIEW_STATUS_OPTIONS" placeholder="请输入审核状态" style="min-width: 180px" allow-clear /> </a-form-item> <a-form-item> <a-button type="primary" html-type="submit">搜索</a-button> </a-form-item> </a-form> <div style="margin-bottom: 16px" /> <!-- 表格 --> <a-table :columns="columns" :data-source="dataList" :pagination="pagination" @change="doTableChange" > <template #bodyCell="{ column, record }"> <template v-if="column.dataIndex === 'url'"> <a-image :src="record.url" :width="120" /> </template> <!-- 标签 --> <template v-if="column.dataIndex === 'tags'"> <a-space wrap> <a-tag v-for="tag in JSON.parse(record.tags || '[]')" :key="tag">{{ tag }}</a-tag> </a-space> </template> <!-- 图片信息 --> <template v-if="column.dataIndex === 'picInfo'"> <div>格式:{{ record.picFormat }}</div> <div>宽度:{{ record.picWidth }}</div> <div>高度:{{ record.picHeight }}</div> <div>宽高比:{{ record.picScale }}</div> <div>大小:{{ (record.picSize / 1024).toFixed(2) }}KB</div> </template> <!-- 审核信息 --> <template v-if="column.dataIndex === 'reviewMessage'"> <div>审核状态:{{ PIC_REVIEW_STATUS_MAP[record.reviewStatus] }}</div> <div>审核信息:{{ record.reviewMessage }}</div> <div>审核人:{{ record.reviewerId }}</div> </template> <template v-else-if="column.dataIndex === 'createTime'"> {{ dayjs(record.createTime).format('YYYY-MM-DD HH:mm:ss') }} </template> <template v-else-if="column.dataIndex === 'editTime'"> {{ dayjs(record.editTime).format('YYYY-MM-DD HH:mm:ss') }} </template> <template v-else-if="column.key === 'action'"> <a-space wrap> <a-button v-if="record.reviewStatus !== PIC_REVIEW_STATUS_ENUM.PASS" type="link" @click="handleReview(record, PIC_REVIEW_STATUS_ENUM.PASS)" > 通过 </a-button> <a-button v-if="record.reviewStatus !== PIC_REVIEW_STATUS_ENUM.REJECT" type="link" danger @click="handleReview(record, PIC_REVIEW_STATUS_ENUM.REJECT)" > 拒绝 </a-button> <a-button type="link" :href="`/add_picture?id=${record.id}`" target="_blank" >编辑 </a-button> <a-button type="link" danger @click="doDelete(record.id)">删除</a-button> </a-space> </template> </template> </a-table> </div> </template> <script lang="ts" setup> import { computed, onMounted, reactive, ref } from 'vue' import { message } from 'ant-design-vue' import dayjs from 'dayjs' import {deletePictureUsingPost, doPictureReviewUsingPost, listPictureByPageUsingPost} from '@/api/pictureController.ts' import {PIC_REVIEW_STATUS_ENUM, PIC_REVIEW_STATUS_MAP, PIC_REVIEW_STATUS_OPTIONS} from "@/constants/picture.ts"; const columns = [ { title: 'id', dataIndex: 'id', width: 80, }, { title: '图片', dataIndex: 'url', }, { title: '名称', dataIndex: 'name', }, { title: '简介', dataIndex: 'introduction', ellipsis: true, }, { title: '类型', dataIndex: 'category', }, { title: '标签', dataIndex: 'tags', }, { title: '图片信息', dataIndex: 'picInfo', }, { title: '用户 id', dataIndex: 'userId', width: 80, }, { title: '审核信息', dataIndex: 'reviewMessage', }, { title: '创建时间', dataIndex: 'createTime', }, { title: '编辑时间', dataIndex: 'editTime', }, { title: '操作', key: 'action', }, ] // 数据 const dataList = ref([]) const total = ref(0) // 搜索条件 const searchParams = reactive<API.PictureQueryRequest>({ current: 1, pageSize: 10, sortField: 'createTime', sortOrder: 'descend', }) // 分页参数 const pagination = computed(() => { return { current: searchParams.current ?? 1, pageSize: searchParams.pageSize ?? 10, total: total.value, showSizeChanger: true, showTotal: (total) => `共 ${total} 条`, } }) // 获取数据 const fetchData = async () => { const res = await listPictureByPageUsingPost({ ...searchParams, }) if (res.data.data) { dataList.value = res.data.data.records ?? [] total.value = res.data.data.total ?? 0 } else { message.error('获取数据失败,' + res.data.message) } } // 获取数据 const doSearch = () => { // 重置搜索条件 searchParams.current = 1 fetchData() } // 表格变化处理 const doTableChange = (page: any) => { searchParams.current = page.current searchParams.pageSize = page.pageSize fetchData() } const doDelete = async (id: number) => { if (id == null) { message.error('id 为空') return } const deleteResponse = await deletePictureUsingPost({ id }) if (deleteResponse.data.data && deleteResponse.data.code === 0) { await fetchData() message.success('删除成功') } else { message.error('删除失败') } } const handleReview = async (record: API.Picture, reviewStatus: number) => { const reviewMessage = reviewStatus === PIC_REVIEW_STATUS_ENUM.PASS ? '管理员操作通过' : '管理员操作拒绝' const res = await doPictureReviewUsingPost({ id: record.id, reviewStatus, reviewMessage, }) if (res.data.code === 0) { message.success('审核操作成功') // 重新获取列表 fetchData() } else { message.error('审核操作失败,' + res.data.message) } } // 页面加载时请求一次 onMounted(() => { fetchData() }) </script> <style scoped> .picture-upload :deep(.ant-upload) { width: 100% !important; height: 100% !important; min-height: 152px; min-width: 152px; } .picture-upload img { max-width: 100%; max-height: 480px; } .ant-upload-select-picture-card i { font-size: 32px; color: #999; } .ant-upload-select-picture-card .ant-upload-text { margin-top: 8px; color: #666; } </style>
URL 上传组件
来源:https://antdv.com/components/input-cn#components-input-demo-group
1、在 src/components/UrlPictureUpload.vue 创建一下组件
▼vue复制代码<template> <div class="url-picture-upload"> <a-input-group compact> <a-input v-model:value="fileUrl" style="width: calc(100% - 120px)" placeholder="请输入图片地址" /> <a-button type="primary" style="width: 120px" :loading="loading" @click="handleUpload"> 提交 </a-button> </a-input-group> <div class="img-wrapper"> <img v-if="picture?.url" :src="picture?.url" alt="avatar" /> </div> </div> </template> <script lang="ts" setup> import { ref } from 'vue' import { message } from 'ant-design-vue' import { uploadPictureByUrlUsingPost } from '@/api/pictureController.ts' interface Props { picture?: API.PictureVO spaceId?: number onSuccess?: (newPicture: API.PictureVO) => void } const props = defineProps<Props>() const fileUrl = ref<string>() const loading = ref<boolean>(false) /** * 上传图片 */ const handleUpload = async () => { loading.value = true try { const params: API.PictureUploadRequest = { fileUrl: fileUrl.value } if (props.picture) { params.id = props.picture.id } const res = await uploadPictureByUrlUsingPost(params) if (res.data.code === 0 && res.data.data) { message.success('图片上传成功') // 将上传成功的图片信息传递给父组件 props.onSuccess?.(res.data.data) } else { message.error('图片上传失败,' + res.data.message) } } catch (error) { console.error('图片上传失败', error) message.error('图片上传失败,' + error.message) } loading.value = false } </script> <style scoped> .url-picture-upload { margin-bottom: 16px; } .url-picture-upload img { max-width: 100%; max-height: 480px; } .url-picture-upload .img-wrapper { text-align: center; margin-top: 16px; } </style>
2、修改 src/pages/AddPicturePage.vue
▼vue复制代码<!-- 选择上传方式 --> <a-tabs v-model:activeKey="uploadType" >> <a-tab-pane key="file" tab="文件上传"> <PictureUpload :picture="picture" :onSuccess="onSuccess" /> </a-tab-pane> <a-tab-pane key="url" tab="URL 上传" force-render> <UrlPictureUpload :picture="picture" :onSuccess="onSuccess" /> </a-tab-pane> </a-tabs>
在 script 上添加一下代码
▼ts复制代码const uploadType = ref<'file' | 'url'>('file')
src/pages/AddPicturePage.vue 具体位置
▼vue复制代码<template> <div id="addPicturePage"> <h2 style="margin-bottom: 16px"> {{ route.query?.id ? '修改图片' : '创建图片' }} </h2> <!-- 选择上传方式 --> <a-tabs v-model:activeKey="uploadType" >> <a-tab-pane key="file" tab="文件上传"> <PictureUpload :picture="picture" :onSuccess="onSuccess" /> </a-tab-pane> <a-tab-pane key="url" tab="URL 上传" force-render> <UrlPictureUpload :picture="picture" :onSuccess="onSuccess" /> </a-tab-pane> </a-tabs> <!-- 图片信息表单 --> <a-form v-if="picture" name="pictureForm" layout="vertical" :model="pictureForm" @finish="handleSubmit" > <a-form-item name="name" label="名称"> <a-input v-model:value="pictureForm.name" placeholder="请输入名称" allow-clear /> </a-form-item> <a-form-item name="introduction" label="简介"> <a-textarea v-model:value="pictureForm.introduction" placeholder="请输入简介" :auto-size="{ minRows: 2, maxRows: 5 }" allow-clear /> </a-form-item> <a-form-item name="category" label="分类"> <a-auto-complete v-model:value="pictureForm.category" placeholder="请输入分类" :options="categoryOptions" allow-clear /> </a-form-item> <a-form-item name="tags" label="标签"> <a-select v-model:value="pictureForm.tags" mode="tags" placeholder="请输入标签" :options="tagOptions" allow-clear /> </a-form-item> <a-form-item> <a-button type="primary" html-type="submit" style="width: 100%">创建</a-button> </a-form-item> </a-form> </div> </template> <script setup lang="ts"> import PictureUpload from '@/components/PictureUpload.vue' import { onMounted, reactive, ref } from 'vue' import { message } from 'ant-design-vue' import { editPictureUsingPost, getPictureVoByIdUsingGet, listPictureTagCategoryUsingGet, } from '@/api/pictureController.ts' import { useRoute, useRouter } from 'vue-router' import UrlPictureUpload from '@/components/UrlPictureUpload.vue' const picture = ref<API.PictureVO>() const pictureForm = reactive<API.PictureEditRequest>({}) const uploadType = ref<'file' | 'url'>('file') /** * 图片上传成功 * @param newPicture */ const onSuccess = (newPicture: API.PictureVO) => { picture.value = newPicture pictureForm.name = newPicture.name } const router = useRouter() /** * 提交表单 * @param values */ const handleSubmit = async (values: any) => { console.log(values) const pictureId = picture.value.id if (!pictureId) { return } const res = await editPictureUsingPost({ id: pictureId, ...values, }) // 操作成功 if (res.data.code === 0 && res.data.data) { message.success('创建成功') // 跳转到图片详情页 router.push({ path: `/picture/${pictureId}`, }) } else { message.error('创建失败,' + res.data.message) } } const categoryOptions = ref<string[]>([]) const tagOptions = ref<string[]>([]) /** * 获取标签和分类选项 * @param values */ 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) } } const route = useRoute() // 获取老数据 const getOldPicture = async () => { // 获取到 id const id = route.query?.id if (id) { const res = await getPictureVoByIdUsingGet({ id, }) if (res.data.code === 0 && res.data.data) { const data = res.data.data picture.value = data pictureForm.name = data.name pictureForm.introduction = data.introduction pictureForm.category = data.category pictureForm.tags = data.tags } } } onMounted(() => { getOldPicture() getTagCategoryOptions() }) </script> <style scoped> #addPicturePage { max-width: 720px; margin: 0 auto; } </style>
批量创建页面
新增管理员 批量创建图片
1、新增页面
▼VUE复制代码<template> <div id="addPictureBatchPage"> <h2 style="margin-bottom: 16px">批量创建图片</h2> <a-form layout="vertical" :model="formData" @finish="handleSubmit"> <a-form-item label="关键词" name="searchText"> <a-input v-model:value="formData.searchText" placeholder="请输入关键词" /> </a-form-item> <a-form-item label="抓取数量" name="count"> <a-input-number v-model:value="formData.count" placeholder="请输入数量" style="min-width: 180px" :min="1" :max="30" allow-clear /> </a-form-item> <a-form-item label="名称前缀" name="namePrefix"> <a-input v-model:value="formData.namePrefix" placeholder="请输入名称前缀,会自动补充序号" /> </a-form-item> <a-form-item> <a-button type="primary" html-type="submit" style="width: 100%" :loading="loading"> 执行任务 </a-button> </a-form-item> </a-form> </div> </template> <script setup lang="ts"> import { reactive, ref } from 'vue' import { message } from 'ant-design-vue' import { uploadPictureByBatchUsingPost } from '@/api/pictureController.ts' import { useRouter } from 'vue-router' const formData = reactive<API.PictureUploadByBatchRequest>({ count: 10, }) // 提交任务状态 const loading = ref(false) const router = useRouter() /** * 提交表单 * @param values */ const handleSubmit = async (values: any) => { loading.value = true const res = await uploadPictureByBatchUsingPost({ ...formData, }) // 操作成功 if (res.data.code === 0 && res.data.data) { message.success(`创建成功,共 ${res.data.data} 条`) // 跳转到主页 router.push({ path: `/`, }) } else { message.error('创建失败,' + res.data.message) } loading.value = false } </script> <style scoped> #addPictureBatchPage { max-width: 720px; margin: 0 auto; } </style>
2、增加 router.ts 文件
▼ts复制代码{ path: '/add_picture/batch', name: '批量创建图片', component: AddPictureBatchPage, },
3、在管理员页面 src/pages/admin/PictureManagePage.vue ,添加
▼vue复制代码<a-space> <a-button type="primary" href="/add_picture" target="_blank">+ 创建图片</a-button> <a-button type="primary" href="/add_picture/batch" target="_blank" ghost>+ 批量创建图片</a-button> </a-space>
4、测试

相关专栏
评论
问答助学
相关内容
0个评论
全部评论
点击登录,快来和大家讨论吧~
表情
图片
暂无评论
作者分享
DeepSeek Harness 缓存命中率太惊人了,有时候竟然能到 99%,看鱼皮哥的视频竟然还出现过 100% 😱 https://www.bilibili.com/video/BV1VkgK6NEZS
4
ARTS 0809: 反转链表、Shopify 如何用 MySQL 解决超卖与 AI 时代程序员的价值
5
译文:《SwiftUI 七年:平庸的故事》
SwiftUI 在 2019 年高调发布,本应成为苹果全平台成熟、可量产的 UI 未来。七年过去,到了 2026 年,它仍像一场永不结束的 beta:布局难预期、性能不稳、数据流混乱,还几乎没有可靠的向后兼容,开发者被迫写一堆 shim 和 workaround。
作者用苹果官方教程(甚至是有问题的)以及与 UIKit 的对比说明:SwiftUI 用“看起来方便”换掉了精确的工程控制。更深一层,他认为这反映了苹果从 Cocoa、Aqua、Auto Layout 那种不妥协的工艺,转向“够用就行”的企业文化。
SwiftUI 为何存在
苹果并非单纯想提供更好工具,而是不得不应对竞争:React、React Native、Flutter 让“一套代码多端跑”变得诱人;Mac 上原生应用又日渐被网页和 Electron 吃掉。SwiftUI 要同时拴住原生生态、并降低移植到 Mac 的成本。卖点是:响应式数据流、声明式布局、跨平台复用。
数据流
“单一数据源”听起来很美,实际却是 @State、@Binding、ObservedObject,再到 Observation / @Observable 的不断换代。你很难确定视图会更新几次、为何更新;它该忽略的变化会反应,该关心的变化又可能忽略 - - 像个黑盒。
布局系统
基于尺寸协商的布局在 Keynote 里很合理,做浮动视图、自定义侧边栏时却极度不稳定。官方教程里一个很普通的侧边栏,多年仍有问题。布局脆弱到最后往往只能上 GeometryReader - - 一旦用了,声明式优势就没了,还要手算坐标,而且下一版布局规则一变,数学还得重写。
API 稳定与功能对等
代码里满是 if #available。滚动收起键盘要到 iOS 16;工具栏定制很晚才来;网络图片 AsyncImage 要到 iOS 15,缓存相关 API 到 2026 年 7 月仍在 beta。旧 API 常被换掉(如 NavigationView → NavigationStack),开发者要维护多套实现,等于替苹果做 QA。对比 Android 的 Jetpack Compose 可作为依赖打包回退到旧设备,SwiftUI 做不到“写最新 API、稳定回退”。
性能
在真实对比里,即便做了后台解码等优化,SwiftUI 图片网格滚动仍明显不如 UIKit。若展示一堆 JPEG 都得靠顶级芯片撑,架构本身就有问题。
跨平台神话
苹果说的是“学一次、到处用”,不是“写一次、到处跑”。iOS 上学到的布局很少直接适用 Mac;同一套 view 跨平台实现也不一致。结果常变成:学一次、再学一次、某处能用、处处要调。
哲学转向
最大的问题是“够用就行”:覆盖 90% 用例就算成功,用 velocity 掩盖质量下降。作者列举系统与一线应用中的各种瑕疵,认为这不是偶然,而是苹果主动降低质量门槛 - - 所以即使过了七年,他仍不信任 SwiftUI。
结论
对构建稳定、高性能、可维护系统真正重要的部分,SwiftUI 几乎都有问题。它不是“极差”,而是平庸 - - 用假便利换真精度,要么你花时间给框架打补丁,要么把半成品发出去。作者更宁愿继续用“遗留”的 UIKit / AppKit。
最后小总结:
最让人不能接受的不是“SwiftUI 还有 bug”,而是它把“看起来很快”当成了工程上的完成态。声明式、预览、跨平台,每一项都在秀高级感,可真正写进业务后,你面对的是难预测的重绘、脆弱的布局、层层 #available,以及把兼容和排错外包给业务方的现实。七年够长了,若还靠“框架还年轻”解释,那更像是对标准的侮辱。技术选型从来不只是语法偏好,而是你选的是可预期性、可维护成本,以及对用户体验的态度。平庸的“成功”往往比明显失败更危险 ,你说它能上线、能 demo、能交差,但是却在细节里一点点磨损信任。工具可以换代,但对质量的要求不该跟着一起降级。
4
ARTS 0802: 合并有序链表、AI 时代的技术断层与 TCP 200ms 延迟之谜
5
Spring 团队开发者布道师 Josh Long,从 2011 年起每周二坚持写 This Week in Spring https://spring.io/authors/joshlong,大概 15 年半从未间断,到现在大概写了 800 期以上😱
大佬在采访里他说,写博客不是额外负担,而是逼自己整理每周所学的「强制机制」——反正本来就会刷社区动态,写出来既方便自己,也帮到别人。更重要的是 Spring 一直在变:微服务、AI……永远有新东西可聊,停一周就容易掉队。一旦养成习惯,坚持往往比重新开始更容易。
这种级别的大佬都还在用周更逼自己不掉队,我更没理由再拖了。还有之前左耳朵耗子大佬说的 ARTS 打卡,我老实说只撑了两周,真的需要捡起来了,加油✊
10

