Showing
35 changed files
with
552 additions
and
1507 deletions
This diff is collapsed. Click to expand it.
| ... | @@ -204,7 +204,7 @@ Vue 官方建议在 SFC + Composition API 场景使用 `<script setup>`,因为 | ... | @@ -204,7 +204,7 @@ Vue 官方建议在 SFC + Composition API 场景使用 `<script setup>`,因为 |
| 204 | 4) 清理“重复/并存”的实现:避免同名组件在不同目录各自演进 | 204 | 4) 清理“重复/并存”的实现:避免同名组件在不同目录各自演进 |
| 205 | 205 | ||
| 206 | - 目标:减少认知负担与误用风险(例如存在两个 `AppLayout`) | 206 | - 目标:减少认知负担与误用风险(例如存在两个 `AppLayout`) |
| 207 | -- 涉及文件:[layouts/AppLayout.vue](file:///Users/huyirui/program/itomix/git/mlaj/src/layouts/AppLayout.vue)、[components/layout/AppLayout.vue](file:///Users/huyirui/program/itomix/git/mlaj/src/components/layout/AppLayout.vue) | 207 | +- 涉及文件:[AppLayout.vue](file:///Users/huyirui/program/itomix/git/mlaj/src/components/layout/AppLayout.vue)(已完成归一,移除 /src/layouts) |
| 208 | 208 | ||
| 209 | 5) 降低“语法形态”混用成本:能不用 JSX 就别用 | 209 | 5) 降低“语法形态”混用成本:能不用 JSX 就别用 |
| 210 | 210 | ... | ... |
docs/ARCHITECTURE.md
0 → 100644
| 1 | +# 架构实现与工程配置 | ||
| 2 | + | ||
| 3 | +## 入口与初始化 | ||
| 4 | + | ||
| 5 | +- 应用入口:[/src/main.js](file:///Users/huyirui/program/itomix/git/mlaj/src/main.js) | ||
| 6 | + - 创建 App、注册全局 Icon 组件、挂载路由 | ||
| 7 | + - 全局注入 axios 到 app.config.globalProperties.$http | ||
| 8 | +- 根组件:[/src/App.vue](file:///Users/huyirui/program/itomix/git/mlaj/src/App.vue) | ||
| 9 | + - 初始化全局认证与购物车:provideAuth / provideCart | ||
| 10 | + - 生产环境 + 微信环境:初始化微信 JSSDK(配置签名 URL) | ||
| 11 | + - 生产环境:版本更新探测(弹窗提示刷新) | ||
| 12 | + | ||
| 13 | +## 路由与权限 | ||
| 14 | + | ||
| 15 | +- 路由入口:[/src/router/index.js](file:///Users/huyirui/program/itomix/git/mlaj/src/router/index.js) | ||
| 16 | + - Hash 路由:createWebHashHistory(import.meta.env.VITE_BASE || '/') | ||
| 17 | + - beforeEach:统一登录页回跳处理,并在必要时探测“是否已登录” | ||
| 18 | +- 鉴权策略:[/src/router/guards.js](file:///Users/huyirui/program/itomix/git/mlaj/src/router/guards.js) | ||
| 19 | + - 白名单 + meta.requiresAuth 双策略判断 | ||
| 20 | + - 未登录时重定向 /login 并带 redirect | ||
| 21 | +- 微信授权策略:[/src/router/guards.js](file:///Users/huyirui/program/itomix/git/mlaj/src/router/guards.js) | ||
| 22 | + - 不在路由守卫自动触发授权,避免循环 | ||
| 23 | + - 仅在用户触发(如点击微信图标/购买流程探测)时调用 startWxAuth | ||
| 24 | + | ||
| 25 | +## 请求与登录态注入 | ||
| 26 | + | ||
| 27 | +- Axios 封装:[/src/utils/axios.js](file:///Users/huyirui/program/itomix/git/mlaj/src/utils/axios.js) | ||
| 28 | + - 请求拦截:动态读取本地 user_info 并注入 User-Id / User-Token | ||
| 29 | + - 响应拦截:code=401 时,仅当当前路由确实需要登录才跳转登录(公开页面不强制跳转) | ||
| 30 | +- 登录态管理:[/src/contexts/auth.js](file:///Users/huyirui/program/itomix/git/mlaj/src/contexts/auth.js) | ||
| 31 | + - provide/inject 维护 currentUser/loading/login/logout | ||
| 32 | + - localStorage 持久化 currentUser | ||
| 33 | + - 初始化流程中会探测授权/登录态并拉取用户信息 | ||
| 34 | + | ||
| 35 | +## 购物车与结算 | ||
| 36 | + | ||
| 37 | +- 购物车上下文:[/src/contexts/cart.js](file:///Users/huyirui/program/itomix/git/mlaj/src/contexts/cart.js) | ||
| 38 | + - 单品/多品两种模式(App.vue 默认使用单品模式) | ||
| 39 | + - localStorage 带时间戳的过期策略(默认一天过期) | ||
| 40 | + - handleCheckout 负责构建订单数据并提交订单 | ||
| 41 | + | ||
| 42 | +## 上传与预览 | ||
| 43 | + | ||
| 44 | +- 上传工具:[/src/utils/upload.js](file:///Users/huyirui/program/itomix/git/mlaj/src/utils/upload.js)、[/src/utils/qiniuFileHash.js](file:///Users/huyirui/program/itomix/git/mlaj/src/utils/qiniuFileHash.js) | ||
| 45 | + - 前端计算文件 Hash,支持“秒传检测 + 直传对象存储” | ||
| 46 | +- 关键业务复用:[/src/composables/useCheckin.js](file:///Users/huyirui/program/itomix/git/mlaj/src/composables/useCheckin.js) | ||
| 47 | + - 打卡/作业提交流程:校验、上传、提交、编辑回填等 | ||
| 48 | +- 预览组件:[/src/components/media](file:///Users/huyirui/program/itomix/git/mlaj/src/components/media) | ||
| 49 | + - 视频/音频播放器、PDF/Office 预览等 | ||
| 50 | + | ||
| 51 | +## Vite 配置与环境变量 | ||
| 52 | + | ||
| 53 | +- Vite 配置:[/vite.config.js](file:///Users/huyirui/program/itomix/git/mlaj/vite.config.js) | ||
| 54 | + - 自动按需引入 Vant 组件(unplugin-auto-import / unplugin-vue-components) | ||
| 55 | + - 别名:@ / @components / @utils / @api 等 | ||
| 56 | + - 本地代理:createProxy(viteEnv.VITE_PROXY_PREFIX, viteEnv.VITE_PROXY_TARGET) | ||
| 57 | +- 环境变量示例:[.env](file:///Users/huyirui/program/itomix/git/mlaj/.env) | ||
| 58 | + - VITE_PORT:开发端口 | ||
| 59 | + - VITE_PROXY_TARGET / VITE_PROXY_PREFIX:接口代理目标与前缀 | ||
| 60 | + - VITE_OUTDIR:构建输出目录 | ||
| 61 | + - VITE_CONSOLE:调试开关 | ||
| 62 | + | ||
| 63 | +## 目录结构(详细) | ||
| 64 | + | ||
| 65 | +``` | ||
| 66 | +mlaj/ | ||
| 67 | +├── build/ # Vite 代理封装 | ||
| 68 | +├── docs/ # 项目文档(本目录) | ||
| 69 | +├── public/ # 静态资源 | ||
| 70 | +├── src/ | ||
| 71 | +│ ├── api/ # 按业务域拆分的接口封装(auth/course/checkin/teacher/...) | ||
| 72 | +│ ├── assets/ # 图片等资源 | ||
| 73 | +│ ├── common/ # 常量 | ||
| 74 | +│ ├── components/ # 组件(按业务域归类) | ||
| 75 | +│ ├── composables/ # 组合式函数(逻辑复用,含单测) | ||
| 76 | +│ ├── contexts/ # 全局状态(provide/inject:auth/cart) | ||
| 77 | +│ ├── router/ # 路由定义与守卫 | ||
| 78 | +│ ├── utils/ # 工具层(axios、上传、鉴权存储、版本更新等) | ||
| 79 | +│ └── views/ # 页面(按业务域归类) | ||
| 80 | +├── tailwind.config.js # Tailwind 配置 | ||
| 81 | +└── vite.config.js # Vite 配置 | ||
| 82 | +``` |
docs/CHANGELOG.md
0 → 100644
| 1 | +# 功能更新记录(Recent Changes) | ||
| 2 | + | ||
| 3 | +说明:该章节从 README 迁移到本文件,避免 README 过长。后续新增变更建议追加在文件顶部。 | ||
| 4 | + | ||
| 5 | +## 打卡详情页重构(/checkin/detail) | ||
| 6 | + | ||
| 7 | +- 统一了文本、媒体上传和计数打卡的入口 | ||
| 8 | +- 实现了基于 composables 的通用提交流程:[/src/composables/useCheckin.js](file:///Users/huyirui/program/itomix/git/mlaj/src/composables/useCheckin.js) | ||
| 9 | +- 页面入口:[/src/views/checkin/CheckinDetailPage.vue](file:///Users/huyirui/program/itomix/git/mlaj/src/views/checkin/CheckinDetailPage.vue) | ||
| 10 | +- 优化了附件预览与编辑回填逻辑(音频/视频/图片预览能力) | ||
| 11 | + | ||
| 12 | +## 教师端功能完善(/teacher) | ||
| 13 | + | ||
| 14 | +- 新增作业管理页面:/teacher/tasks(列表展示:名称、开始/截止时间) | ||
| 15 | +- 新增作业主页:/teacher/tasks/:id(统计 + 日历视图) | ||
| 16 | +- 新增学员作业记录页:/teacher/student-record(作业帖子 + 点赞/点评) | ||
| 17 | + | ||
| 18 | +## 基础体验优化 | ||
| 19 | + | ||
| 20 | +- 登录逻辑调整:仅在登录页点击微信图标时触发授权(避免路由守卫自动授权导致的循环) | ||
| 21 | + - 关键文件:[/src/router/guards.js](file:///Users/huyirui/program/itomix/git/mlaj/src/router/guards.js)、[/src/router/index.js](file:///Users/huyirui/program/itomix/git/mlaj/src/router/index.js)、[/src/views/auth/LoginPage.vue](file:///Users/huyirui/program/itomix/git/mlaj/src/views/auth/LoginPage.vue) | ||
| 22 | +- 搜索栏优化:提升 iOS 软键盘“搜索”键触发稳定性 | ||
| 23 | +- 课程详情页:增加动态 Open Graph 标签,优化分享体验 | ||
| 24 | + | ||
| 25 | +## 课程详情页动态 Open Graph 元标签 | ||
| 26 | + | ||
| 27 | +- 行为:进入课程详情页时,在 head 中插入 og:title / og:description / og:image / og:url;离开页面时移除 | ||
| 28 | +- CDN 规则:图片域名为 cdn.ipadbiz.cn 时,追加 ?imageMogr2/thumbnail/200x/strip/quality/70 | ||
| 29 | +- 位置:[/src/views/courses/CourseDetailPage.vue](file:///Users/huyirui/program/itomix/git/mlaj/src/views/courses/CourseDetailPage.vue) | ||
| 30 | + | ||
| 31 | +## 购买流程环境校验与微信授权探测 | ||
| 32 | + | ||
| 33 | +- 行为:仅对非免费课程在详情页点击“购买”时进行校验;生产环境必须为微信内置浏览器 | ||
| 34 | +- 微信环境内:若未完成微信授权(openid_has=false),会自动发起一次微信授权并中止本次购买,授权后再次点击进入结算 | ||
| 35 | +- 位置:[/src/views/courses/CourseDetailPage.vue](file:///Users/huyirui/program/itomix/git/mlaj/src/views/courses/CourseDetailPage.vue) | ||
| 36 | + | ||
| 37 | +## 401 拦截策略优化(公开页面不再跳登录) | ||
| 38 | + | ||
| 39 | +- 行为:接口返回 code=401 时,仅当当前路由确实需要登录时才重定向登录 | ||
| 40 | +- 位置:[/src/utils/axios.js](file:///Users/huyirui/program/itomix/git/mlaj/src/utils/axios.js) | ||
| 41 | + | ||
| 42 | +## 搜索栏回车搜索兼容性提升 | ||
| 43 | + | ||
| 44 | +- 行为:输入框类型改为 search,并可选开启 form submit 机制,同时保留 keyup.enter | ||
| 45 | +- 位置:[/src/components/common/SearchBar.vue](file:///Users/huyirui/program/itomix/git/mlaj/src/components/common/SearchBar.vue) | ||
| 46 | + | ||
| 47 | +## 分享海报弹窗(可复用) | ||
| 48 | + | ||
| 49 | +- 入口:课程详情页底部操作栏“分享”按钮 | ||
| 50 | +- 组件:[/src/components/poster/SharePoster.vue](file:///Users/huyirui/program/itomix/git/mlaj/src/components/poster/SharePoster.vue) | ||
| 51 | +- 能力:弹窗打开时通过 Canvas 合成海报(封面、二维码、文案),生成 dataURL 展示,用户长按保存 | ||
| 52 | + | ||
| 53 | +## 打卡弹窗与列表组件(可复用) | ||
| 54 | + | ||
| 55 | +- 打卡弹窗:[/src/components/checkin/CheckInDialog.vue](file:///Users/huyirui/program/itomix/git/mlaj/src/components/checkin/CheckInDialog.vue) | ||
| 56 | +- 打卡列表:[/src/components/checkin/CheckInList.vue](file:///Users/huyirui/program/itomix/git/mlaj/src/components/checkin/CheckInList.vue) |
docs/COMPONENTS.md
0 → 100644
| 1 | +# /src/components 组件目录索引 | ||
| 2 | + | ||
| 3 | +## 目录划分 | ||
| 4 | + | ||
| 5 | +| 目录 | 代表组件 | 说明 | | ||
| 6 | +| --- | --- | --- | | ||
| 7 | +| activity/ | ActivityCard.vue、ActivityApplyHistoryPopup.vue | 活动卡片、报名/历史相关弹窗 | | ||
| 8 | +| calendar/ | CollapsibleCalendar.vue、TaskCalendar.vue | 日历与任务日历组件 | | ||
| 9 | +| checkin/ | CheckInDialog.vue、CheckInList.vue、CheckinCard.vue、UploadVideoPopup.vue | 打卡/作业相关组件(弹窗、列表、卡片、上传) | | ||
| 10 | +| common/ | ConfirmDialog.vue、GradientHeader.vue、SearchBar.vue、UserAgreement.vue | 通用基础组件(确认、头部、搜索、协议) | | ||
| 11 | +| count/ | AddTargetDialog.vue、CheckinTargetList.vue、postCountModel.vue | 计数型打卡相关组件 | | ||
| 12 | +| courses/ | CourseCard.vue、CourseList.vue、LiveStreamCard.vue、ReviewPopup.vue | 课程展示与列表、直播卡片、评价弹窗等 | | ||
| 13 | +| effects/ | FrostedGlass.vue、StarryBackground.vue | 视觉效果组件 | | ||
| 14 | +| homePage/ | FeaturedCoursesSection.vue、LatestActivitiesSection.vue | 首页区块组件(精选/活动/推荐等) | | ||
| 15 | +| infoEntry/ | formPage.vue | 信息录入相关组件 | | ||
| 16 | +| layout/ | AppLayout.vue、BottomNav.vue | 页面布局与底部导航 | | ||
| 17 | +| media/ | AudioPlayer.vue、VideoPlayer.vue、PdfPreview.vue、OfficeViewer.vue | 音视频播放器与文档预览 | | ||
| 18 | +| payment/ | WechatPayment.vue | 微信支付相关组件 | | ||
| 19 | +| poster/ | RecallPoster.vue、SharePoster.vue | 海报生成与分享相关组件 | | ||
| 20 | +| studyDetail/ | StudyCatalogPopup.vue、StudyCommentsSection.vue、StudyMaterialsPopup.vue | 学习详情页的弹窗与评论区 | | ||
| 21 | +| teacher/ | TaskFilter.vue、TaskCascaderFilter.vue | 教师端筛选与任务相关组件 | | ||
| 22 | + | ||
| 23 | +## 备注 | ||
| 24 | + | ||
| 25 | +- 布局目录已归一:统一使用 [/src/components/layout](file:///Users/huyirui/program/itomix/git/mlaj/src/components/layout),已移除 /src/layouts |
| ... | @@ -130,7 +130,7 @@ export const getUploadTaskInfoAPI = (params) => fn(fetch.get(Api.TASK_UPLOAD_IN | ... | @@ -130,7 +130,7 @@ export const getUploadTaskInfoAPI = (params) => fn(fetch.get(Api.TASK_UPLOAD_IN |
| 130 | * @param gratitude_form_list 感恩表单数据 [{id,name,city,unit,其他信息字段}] | 130 | * @param gratitude_form_list 感恩表单数据 [{id,name,city,unit,其他信息字段}] |
| 131 | * @returns | 131 | * @returns |
| 132 | */ | 132 | */ |
| 133 | -export const editUploadTaskInfoAPI = (params) => fn(fetch.get(Api.TASK_UPLOAD_EDIT, params)) | 133 | +export const editUploadTaskInfoAPI = (params) => fn(fetch.post(Api.TASK_UPLOAD_EDIT, params)) |
| 134 | 134 | ||
| 135 | /** | 135 | /** |
| 136 | * @description: 删除打卡动态详情 | 136 | * @description: 删除打卡动态详情 | ... | ... |
| ... | @@ -69,7 +69,6 @@ declare module 'vue' { | ... | @@ -69,7 +69,6 @@ declare module 'vue' { |
| 69 | VanConfigProvider: typeof import('vant/es')['ConfigProvider'] | 69 | VanConfigProvider: typeof import('vant/es')['ConfigProvider'] |
| 70 | VanDatePicker: typeof import('vant/es')['DatePicker'] | 70 | VanDatePicker: typeof import('vant/es')['DatePicker'] |
| 71 | VanDialog: typeof import('vant/es')['Dialog'] | 71 | VanDialog: typeof import('vant/es')['Dialog'] |
| 72 | - VanDivider: typeof import('vant/es')['Divider'] | ||
| 73 | VanDropdownItem: typeof import('vant/es')['DropdownItem'] | 72 | VanDropdownItem: typeof import('vant/es')['DropdownItem'] |
| 74 | VanDropdownMenu: typeof import('vant/es')['DropdownMenu'] | 73 | VanDropdownMenu: typeof import('vant/es')['DropdownMenu'] |
| 75 | VanEmpty: typeof import('vant/es')['Empty'] | 74 | VanEmpty: typeof import('vant/es')['Empty'] |
| ... | @@ -95,8 +94,6 @@ declare module 'vue' { | ... | @@ -95,8 +94,6 @@ declare module 'vue' { |
| 95 | VanSwipe: typeof import('vant/es')['Swipe'] | 94 | VanSwipe: typeof import('vant/es')['Swipe'] |
| 96 | VanSwipeItem: typeof import('vant/es')['SwipeItem'] | 95 | VanSwipeItem: typeof import('vant/es')['SwipeItem'] |
| 97 | VanTab: typeof import('vant/es')['Tab'] | 96 | VanTab: typeof import('vant/es')['Tab'] |
| 98 | - VanTabbar: typeof import('vant/es')['Tabbar'] | ||
| 99 | - VanTabbarItem: typeof import('vant/es')['TabbarItem'] | ||
| 100 | VanTabs: typeof import('vant/es')['Tabs'] | 97 | VanTabs: typeof import('vant/es')['Tabs'] |
| 101 | VanTag: typeof import('vant/es')['Tag'] | 98 | VanTag: typeof import('vant/es')['Tag'] |
| 102 | VanTimePicker: typeof import('vant/es')['TimePicker'] | 99 | VanTimePicker: typeof import('vant/es')['TimePicker'] | ... | ... |
| ... | @@ -11,7 +11,7 @@ | ... | @@ -11,7 +11,7 @@ |
| 11 | <!-- Activity Image --> | 11 | <!-- Activity Image --> |
| 12 | <div class="w-1/3 h-28 relative"> | 12 | <div class="w-1/3 h-28 relative"> |
| 13 | <img | 13 | <img |
| 14 | - :src="activity.imageUrl" | 14 | + :src="buildCdnImageUrl(activity.imageUrl)" |
| 15 | :alt="activity.title" | 15 | :alt="activity.title" |
| 16 | class="w-full h-full object-cover" | 16 | class="w-full h-full object-cover" |
| 17 | /> | 17 | /> |
| ... | @@ -76,6 +76,7 @@ | ... | @@ -76,6 +76,7 @@ |
| 76 | 76 | ||
| 77 | <script setup> | 77 | <script setup> |
| 78 | import FrostedGlass from '@/components/effects/FrostedGlass.vue' | 78 | import FrostedGlass from '@/components/effects/FrostedGlass.vue' |
| 79 | +import { buildCdnImageUrl } from '@/utils/tools' | ||
| 79 | 80 | ||
| 80 | const props = defineProps({ | 81 | const props = defineProps({ |
| 81 | activity: { | 82 | activity: { | ... | ... |
| ... | @@ -9,7 +9,7 @@ | ... | @@ -9,7 +9,7 @@ |
| 9 | <router-link :to="linkTo || `/courses/${course.id}`" class="flex bg-white rounded-lg overflow-hidden shadow-sm"> | 9 | <router-link :to="linkTo || `/courses/${course.id}`" class="flex bg-white rounded-lg overflow-hidden shadow-sm"> |
| 10 | <div class="w-1/3 h-28 relative"> | 10 | <div class="w-1/3 h-28 relative"> |
| 11 | <img | 11 | <img |
| 12 | - :src="course.cover || 'https://cdn.ipadbiz.cn/mlaj/images/default_block.png'" | 12 | + :src="buildCdnImageUrl(course.cover || 'https://cdn.ipadbiz.cn/mlaj/images/default_block.png')" |
| 13 | :alt="course.title" | 13 | :alt="course.title" |
| 14 | class="w-full h-full object-cover" | 14 | class="w-full h-full object-cover" |
| 15 | /> | 15 | /> |
| ... | @@ -44,6 +44,8 @@ | ... | @@ -44,6 +44,8 @@ |
| 44 | </template> | 44 | </template> |
| 45 | 45 | ||
| 46 | <script setup> | 46 | <script setup> |
| 47 | +import { buildCdnImageUrl } from '@/utils/tools' | ||
| 48 | + | ||
| 47 | /** | 49 | /** |
| 48 | * @description 课程卡片组件 | 50 | * @description 课程卡片组件 |
| 49 | * @property {Object} course 课程对象 | 51 | * @property {Object} course 课程对象 | ... | ... |
| ... | @@ -15,7 +15,7 @@ | ... | @@ -15,7 +15,7 @@ |
| 15 | 15 | ||
| 16 | <router-link :to="`/courses/${stream.id}`" class="block rounded-lg overflow-hidden shadow-sm relative"> | 16 | <router-link :to="`/courses/${stream.id}`" class="block rounded-lg overflow-hidden shadow-sm relative"> |
| 17 | <img | 17 | <img |
| 18 | - :src="stream.imageUrl" | 18 | + :src="buildCdnImageUrl(stream.imageUrl)" |
| 19 | :alt="stream.title" | 19 | :alt="stream.title" |
| 20 | class="w-full h-28 object-cover" | 20 | class="w-full h-28 object-cover" |
| 21 | /> | 21 | /> |
| ... | @@ -43,6 +43,8 @@ | ... | @@ -43,6 +43,8 @@ |
| 43 | </template> | 43 | </template> |
| 44 | 44 | ||
| 45 | <script setup> | 45 | <script setup> |
| 46 | +import { buildCdnImageUrl } from '@/utils/tools' | ||
| 47 | + | ||
| 46 | defineProps({ | 48 | defineProps({ |
| 47 | /** | 49 | /** |
| 48 | * 直播流数据对象 | 50 | * 直播流数据对象 | ... | ... |
| ... | @@ -16,7 +16,7 @@ | ... | @@ -16,7 +16,7 @@ |
| 16 | > | 16 | > |
| 17 | <div class="relative rounded-xl overflow-hidden shadow-lg h-48"> | 17 | <div class="relative rounded-xl overflow-hidden shadow-lg h-48"> |
| 18 | <img | 18 | <img |
| 19 | - :src="course.cover || 'https://cdn.ipadbiz.cn/mlaj/images/default_block.png'" | 19 | + :src="buildCdnImageUrl(course.cover || 'https://cdn.ipadbiz.cn/mlaj/images/default_block.png', 400)" |
| 20 | :alt="course.title" | 20 | :alt="course.title" |
| 21 | class="w-full h-full object-cover" | 21 | class="w-full h-full object-cover" |
| 22 | /> | 22 | /> |
| ... | @@ -79,6 +79,7 @@ | ... | @@ -79,6 +79,7 @@ |
| 79 | <script setup> | 79 | <script setup> |
| 80 | import { ref, onMounted, onUnmounted } from 'vue' | 80 | import { ref, onMounted, onUnmounted } from 'vue' |
| 81 | import { getCourseListAPI } from '@/api/course' | 81 | import { getCourseListAPI } from '@/api/course' |
| 82 | +import { buildCdnImageUrl } from '@/utils/tools' | ||
| 82 | 83 | ||
| 83 | const courses = ref([]) | 84 | const courses = ref([]) |
| 84 | const carouselRef = ref(null) | 85 | const carouselRef = ref(null) | ... | ... |
| ... | @@ -117,7 +117,9 @@ const fetch_external_activities = async () => { | ... | @@ -117,7 +117,9 @@ const fetch_external_activities = async () => { |
| 117 | const mapped = list.map((item) => { | 117 | const mapped = list.map((item) => { |
| 118 | const xs_price = number_or_null(item?.xs_price) | 118 | const xs_price = number_or_null(item?.xs_price) |
| 119 | const sc_price = number_or_null(item?.sc_price) | 119 | const sc_price = number_or_null(item?.sc_price) |
| 120 | - const imageUrl = item?.sl_img || item?.fx_img || item?.banner_img || 'https://cdn.ipadbiz.cn/mlaj/images/default_block.png' | 120 | + let imageUrl = item?.sl_img || item?.fx_img || item?.banner_img || 'https://cdn.ipadbiz.cn/mlaj/images/default_block.png' |
| 121 | + // 压缩阿里云图片资源 | ||
| 122 | + imageUrl = imageUrl+ '?x-oss-process=image/quality,q_70/resize,w_100' | ||
| 121 | const period = format_period(item?.act_start_at, item?.act_end_at) | 123 | const period = format_period(item?.act_start_at, item?.act_end_at) |
| 122 | 124 | ||
| 123 | const upper = number_or_null(item?.stu_num_upper) | 125 | const upper = number_or_null(item?.stu_num_upper) | ... | ... |
| ... | @@ -21,7 +21,7 @@ | ... | @@ -21,7 +21,7 @@ |
| 21 | <div class="flex flex-col h-full" @click="go_to_course_detail(item)"> | 21 | <div class="flex flex-col h-full" @click="go_to_course_detail(item)"> |
| 22 | <div class="h-28 mb-2 rounded-lg overflow-hidden relative"> | 22 | <div class="h-28 mb-2 rounded-lg overflow-hidden relative"> |
| 23 | <img | 23 | <img |
| 24 | - :src="item.cover || 'https://cdn.ipadbiz.cn/mlaj/images/default_block.png'" | 24 | + :src="buildCdnImageUrl(item.cover || 'https://cdn.ipadbiz.cn/mlaj/images/default_block.png')" |
| 25 | :alt="item.title" | 25 | :alt="item.title" |
| 26 | class="w-full h-full object-cover" | 26 | class="w-full h-full object-cover" |
| 27 | /> | 27 | /> |
| ... | @@ -45,6 +45,7 @@ import { ref, onMounted } from 'vue' | ... | @@ -45,6 +45,7 @@ import { ref, onMounted } from 'vue' |
| 45 | import { useRouter } from 'vue-router' | 45 | import { useRouter } from 'vue-router' |
| 46 | import FrostedGlass from '@/components/effects/FrostedGlass.vue' | 46 | import FrostedGlass from '@/components/effects/FrostedGlass.vue' |
| 47 | import { getCourseListAPI } from '@/api/course' | 47 | import { getCourseListAPI } from '@/api/course' |
| 48 | +import { buildCdnImageUrl } from '@/utils/tools' | ||
| 48 | 49 | ||
| 49 | const router = useRouter() | 50 | const router = useRouter() |
| 50 | 51 | ... | ... |
| ... | @@ -116,8 +116,9 @@ | ... | @@ -116,8 +116,9 @@ |
| 116 | </template> | 116 | </template> |
| 117 | 117 | ||
| 118 | <script setup> | 118 | <script setup> |
| 119 | -import { ref, watch, nextTick, onUnmounted } from 'vue'; | 119 | +import { defineAsyncComponent, ref, watch, nextTick, onUnmounted } from 'vue'; |
| 120 | -import PDF from "pdf-vue3"; | 120 | + |
| 121 | +const PDF = defineAsyncComponent(() => import("pdf-vue3")); | ||
| 121 | 122 | ||
| 122 | /** | 123 | /** |
| 123 | * 组件属性定义 | 124 | * 组件属性定义 | ... | ... |
| ... | @@ -62,11 +62,18 @@ | ... | @@ -62,11 +62,18 @@ |
| 62 | </template> | 62 | </template> |
| 63 | 63 | ||
| 64 | <script setup> | 64 | <script setup> |
| 65 | -import { ref } from "vue"; | 65 | +import { defineAsyncComponent, ref } from "vue"; |
| 66 | -import { VideoPlayer } from "@videojs-player/vue"; | ||
| 67 | -import "video.js/dist/video-js.css"; | ||
| 68 | import { useVideoPlayer } from "@/composables/useVideoPlayer"; | 66 | import { useVideoPlayer } from "@/composables/useVideoPlayer"; |
| 69 | 67 | ||
| 68 | +const VideoPlayer = defineAsyncComponent(async () => { | ||
| 69 | + await import("video.js/dist/video-js.css"); | ||
| 70 | + await import("videojs-hls-quality-selector/dist/videojs-hls-quality-selector.css"); | ||
| 71 | + await import("videojs-contrib-quality-levels"); | ||
| 72 | + await import("videojs-hls-quality-selector"); | ||
| 73 | + const mod = await import("@videojs-player/vue"); | ||
| 74 | + return mod.VideoPlayer; | ||
| 75 | +}); | ||
| 76 | + | ||
| 70 | const props = defineProps({ | 77 | const props = defineProps({ |
| 71 | options: { | 78 | options: { |
| 72 | type: Object, | 79 | type: Object, | ... | ... |
| 1 | import { describe, expect, it, vi, beforeEach } from 'vitest' | 1 | import { describe, expect, it, vi, beforeEach } from 'vitest' |
| 2 | 2 | ||
| 3 | +let mock_query = {} | ||
| 4 | + | ||
| 3 | vi.mock('vue-router', () => { | 5 | vi.mock('vue-router', () => { |
| 6 | + const router = { | ||
| 7 | + push: vi.fn(), | ||
| 8 | + back: vi.fn() | ||
| 9 | + } | ||
| 4 | return { | 10 | return { |
| 5 | useRoute: () => ({ | 11 | useRoute: () => ({ |
| 6 | - query: {} | 12 | + query: mock_query |
| 7 | }), | 13 | }), |
| 8 | - useRouter: () => ({ | 14 | + useRouter: () => router |
| 9 | - push: vi.fn() | ||
| 10 | - }) | ||
| 11 | } | 15 | } |
| 12 | }) | 16 | }) |
| 13 | 17 | ||
| ... | @@ -51,10 +55,23 @@ vi.mock('@/contexts/auth', async () => { | ... | @@ -51,10 +55,23 @@ vi.mock('@/contexts/auth', async () => { |
| 51 | 55 | ||
| 52 | import { useCheckin } from '../useCheckin' | 56 | import { useCheckin } from '../useCheckin' |
| 53 | import { showToast } from 'vant' | 57 | import { showToast } from 'vant' |
| 58 | +import { addUploadTaskAPI } from '@/api/checkin' | ||
| 54 | 59 | ||
| 55 | describe('useCheckin 上传大小限制', () => { | 60 | describe('useCheckin 上传大小限制', () => { |
| 56 | beforeEach(() => { | 61 | beforeEach(() => { |
| 57 | vi.clearAllMocks() | 62 | vi.clearAllMocks() |
| 63 | + mock_query = { enable_multi: '0' } | ||
| 64 | + if (!globalThis.sessionStorage) { | ||
| 65 | + let store = {} | ||
| 66 | + globalThis.sessionStorage = { | ||
| 67 | + getItem: (key) => store[key] ?? null, | ||
| 68 | + setItem: (key, value) => { store[key] = String(value) }, | ||
| 69 | + removeItem: (key) => { delete store[key] }, | ||
| 70 | + clear: () => { store = {} }, | ||
| 71 | + } | ||
| 72 | + } else { | ||
| 73 | + sessionStorage.clear() | ||
| 74 | + } | ||
| 58 | }) | 75 | }) |
| 59 | 76 | ||
| 60 | it('setMaxFileSizeMbMap 能更新并保留其他类型默认值', () => { | 77 | it('setMaxFileSizeMbMap 能更新并保留其他类型默认值', () => { |
| ... | @@ -119,3 +136,71 @@ describe('useCheckin 上传大小限制', () => { | ... | @@ -119,3 +136,71 @@ describe('useCheckin 上传大小限制', () => { |
| 119 | }) | 136 | }) |
| 120 | }) | 137 | }) |
| 121 | 138 | ||
| 139 | +describe('useCheckin 提交兼容', () => { | ||
| 140 | + beforeEach(() => { | ||
| 141 | + vi.clearAllMocks() | ||
| 142 | + mock_query = { enable_multi: '0' } | ||
| 143 | + if (globalThis.sessionStorage?.clear) sessionStorage.clear() | ||
| 144 | + }) | ||
| 145 | + | ||
| 146 | + it('新结构失败会回退旧结构提交', async () => { | ||
| 147 | + addUploadTaskAPI | ||
| 148 | + .mockResolvedValueOnce({ code: 0, msg: '参数错误' }) | ||
| 149 | + .mockResolvedValueOnce({ code: 1, data: { id: 1 } }) | ||
| 150 | + | ||
| 151 | + const { activeType, fileList, message, onSubmit } = useCheckin() | ||
| 152 | + | ||
| 153 | + activeType.value = 'audio' | ||
| 154 | + message.value = '随便写点内容' | ||
| 155 | + fileList.value = [ | ||
| 156 | + { status: 'done', meta_id: 11, file_type: 'audio' } | ||
| 157 | + ] | ||
| 158 | + | ||
| 159 | + await onSubmit({ subtask_id: 's1' }) | ||
| 160 | + | ||
| 161 | + expect(addUploadTaskAPI).toHaveBeenCalledTimes(2) | ||
| 162 | + expect(addUploadTaskAPI.mock.calls[0][0]).toHaveProperty('files') | ||
| 163 | + expect(addUploadTaskAPI.mock.calls[0][0]).toHaveProperty('meta_id') | ||
| 164 | + expect(addUploadTaskAPI.mock.calls[1][0]).toHaveProperty('meta_id') | ||
| 165 | + expect(addUploadTaskAPI.mock.calls[1][0]).not.toHaveProperty('files') | ||
| 166 | + expect(showToast).toHaveBeenCalledWith('提交成功') | ||
| 167 | + }) | ||
| 168 | + | ||
| 169 | + it('混合附件且新结构失败时会提示分别提交', async () => { | ||
| 170 | + const { activeType, fileList, message, onSubmit } = useCheckin() | ||
| 171 | + | ||
| 172 | + activeType.value = 'image' | ||
| 173 | + message.value = '随便写点内容' | ||
| 174 | + fileList.value = [ | ||
| 175 | + { status: 'done', meta_id: 11, file_type: 'image' }, | ||
| 176 | + { status: 'done', meta_id: 12, file_type: 'audio' }, | ||
| 177 | + ] | ||
| 178 | + | ||
| 179 | + await onSubmit({ subtask_id: 's1' }) | ||
| 180 | + | ||
| 181 | + expect(addUploadTaskAPI).toHaveBeenCalledTimes(0) | ||
| 182 | + expect(showToast).toHaveBeenCalledWith('当前接口暂不支持多类型附件,请分别提交') | ||
| 183 | + }) | ||
| 184 | + | ||
| 185 | + it('开启多附件开关时允许 mixed files 提交', async () => { | ||
| 186 | + mock_query = { enable_multi: '1' } | ||
| 187 | + addUploadTaskAPI.mockResolvedValueOnce({ code: 1, data: { id: 1 } }) | ||
| 188 | + | ||
| 189 | + const { activeType, fileList, message, onSubmit } = useCheckin() | ||
| 190 | + | ||
| 191 | + activeType.value = 'image' | ||
| 192 | + message.value = '随便写点内容' | ||
| 193 | + fileList.value = [ | ||
| 194 | + { status: 'done', meta_id: 11, file_type: 'image' }, | ||
| 195 | + { status: 'done', meta_id: 12, file_type: 'audio' }, | ||
| 196 | + ] | ||
| 197 | + | ||
| 198 | + await onSubmit({ subtask_id: 's1' }) | ||
| 199 | + | ||
| 200 | + expect(addUploadTaskAPI).toHaveBeenCalledTimes(1) | ||
| 201 | + expect(addUploadTaskAPI.mock.calls[0][0]).toHaveProperty('files') | ||
| 202 | + expect(addUploadTaskAPI.mock.calls[0][0]).not.toHaveProperty('meta_id') | ||
| 203 | + expect(addUploadTaskAPI.mock.calls[0][0]).toHaveProperty('file_type', 'mixed') | ||
| 204 | + expect(showToast).toHaveBeenCalledWith('提交成功') | ||
| 205 | + }) | ||
| 206 | +}) | ... | ... |
This diff is collapsed. Click to expand it.
| ... | @@ -15,7 +15,7 @@ export function useImageLoader() { | ... | @@ -15,7 +15,7 @@ export function useImageLoader() { |
| 15 | /** | 15 | /** |
| 16 | * 默认头像 URL | 16 | * 默认头像 URL |
| 17 | */ | 17 | */ |
| 18 | - const DEFAULT_AVATAR = 'https://cdn.ipadbiz.cn/mlaj/images/default-avatar.jpeg' | 18 | + const DEFAULT_AVATAR = 'https://cdn.ipadbiz.cn/mlaj/images/default-avatar.jpeg?imageMogr2/thumbnail/200x/strip/quality/70' |
| 19 | 19 | ||
| 20 | /** | 20 | /** |
| 21 | * 处理图片加载错误 | 21 | * 处理图片加载错误 | ... | ... |
| 1 | import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'; | 1 | import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'; |
| 2 | import { wxInfo } from "@/utils/tools"; | 2 | import { wxInfo } from "@/utils/tools"; |
| 3 | -import videojs from "video.js"; | ||
| 4 | import { buildVideoSources, canPlayHlsNatively } from "./videoPlayerSource"; | 3 | import { buildVideoSources, canPlayHlsNatively } from "./videoPlayerSource"; |
| 5 | import { useVideoProbe } from "./useVideoProbe"; | 4 | import { useVideoProbe } from "./useVideoProbe"; |
| 6 | import { useVideoPlaybackOverlays } from "./useVideoPlaybackOverlays"; | 5 | import { useVideoPlaybackOverlays } from "./useVideoPlaybackOverlays"; |
| 7 | -// 新增:引入多码率切换插件 | 6 | + |
| 8 | -import 'videojs-contrib-quality-levels'; // 用于读取 m3u8 中的多码率信息 | 7 | +const is_safari_browser = () => { |
| 9 | -import 'videojs-hls-quality-selector'; // 用于在播放器控制条显示“清晰度”切换菜单(支持 Auto/720p/480p 等)。 | 8 | + if (typeof navigator === 'undefined') return false; |
| 10 | -import 'videojs-hls-quality-selector/dist/videojs-hls-quality-selector.css'; | 9 | + const ua = navigator.userAgent || ''; |
| 10 | + const is_safari = /safari/i.test(ua) && !/chrome|crios|android|fxios|edg/i.test(ua); | ||
| 11 | + return is_safari; | ||
| 12 | +}; | ||
| 11 | 13 | ||
| 12 | /** | 14 | /** |
| 13 | * - 使用方法 :您无需修改业务代码。只要传入的视频 URL 是七牛云生成的多码率 .m3u8 地址,播放器控制条右下角会自动出现“齿轮”图标,用户点击即可切换清晰度(或选择 Auto 自动切换)。 | 15 | * - 使用方法 :您无需修改业务代码。只要传入的视频 URL 是七牛云生成的多码率 .m3u8 地址,播放器控制条右下角会自动出现“齿轮”图标,用户点击即可切换清晰度(或选择 Auto 自动切换)。 |
| ... | @@ -271,7 +273,7 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) { | ... | @@ -271,7 +273,7 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) { |
| 271 | // 5. Video.js 播放器逻辑 (PC/Android) | 273 | // 5. Video.js 播放器逻辑 (PC/Android) |
| 272 | const shouldOverrideNativeHls = computed(() => { | 274 | const shouldOverrideNativeHls = computed(() => { |
| 273 | if (!isM3U8.value) return false; | 275 | if (!isM3U8.value) return false; |
| 274 | - if (videojs.browser.IS_SAFARI) return false; | 276 | + if (is_safari_browser()) return false; |
| 275 | // 非 Safari 且不具备原生 HLS 时,强制 video.js 的 VHS 来解 m3u8 | 277 | // 非 Safari 且不具备原生 HLS 时,强制 video.js 的 VHS 来解 m3u8 |
| 276 | return !canPlayHlsNatively(); | 278 | return !canPlayHlsNatively(); |
| 277 | }); | 279 | }); | ... | ... |
src/layouts/AppLayout.vue
deleted
100644 → 0
| 1 | -<!-- src/layouts/AppLayout.vue --> | ||
| 2 | -<template> | ||
| 3 | - <div class="app-layout"> | ||
| 4 | - <!-- Header --> | ||
| 5 | - <header class="app-header" v-if="hasTitle"> | ||
| 6 | - <div v-if="showBack" class="header-back" @click="goBack"> | ||
| 7 | - <van-icon name="arrow-left" size="20" /> | ||
| 8 | - </div> | ||
| 9 | - <h1 class="header-title">{{ title }}</h1> | ||
| 10 | - <div class="header-right"> | ||
| 11 | - <slot name="header-right"></slot> | ||
| 12 | - </div> | ||
| 13 | - </header> | ||
| 14 | - | ||
| 15 | - <!-- Main Content --> | ||
| 16 | - <main class="app-content" :class="{ 'has-bottom-nav': showBottomNav, 'no-header': !hasTitle }"> | ||
| 17 | - <slot></slot> | ||
| 18 | - </main> | ||
| 19 | - | ||
| 20 | - <!-- Bottom Navigation --> | ||
| 21 | - <van-tabbar v-if="showBottomNav" route safe-area-inset-bottom> | ||
| 22 | - <van-tabbar-item to="/home" icon="home-o">首页</van-tabbar-item> | ||
| 23 | - <van-tabbar-item to="/courses" icon="orders-o">课程</van-tabbar-item> | ||
| 24 | - <van-tabbar-item to="/activities" icon="friends-o">活动</van-tabbar-item> | ||
| 25 | - <van-tabbar-item to="/community" icon="chat-o">社区</van-tabbar-item> | ||
| 26 | - <van-tabbar-item to="/profile" icon="user-o">我的</van-tabbar-item> | ||
| 27 | - </van-tabbar> | ||
| 28 | - </div> | ||
| 29 | -</template> | ||
| 30 | - | ||
| 31 | -<script> | ||
| 32 | -import { ref, onMounted, computed } from 'vue' | ||
| 33 | -import { useRouter, useRoute } from 'vue-router' | ||
| 34 | - | ||
| 35 | -export default { | ||
| 36 | - name: 'AppLayout', | ||
| 37 | - props: { | ||
| 38 | - title: { | ||
| 39 | - type: String, | ||
| 40 | - default: '美乐爱觉教育' | ||
| 41 | - }, | ||
| 42 | - showBack: { | ||
| 43 | - type: Boolean, | ||
| 44 | - default: false | ||
| 45 | - }, | ||
| 46 | - showBottomNav: { | ||
| 47 | - type: Boolean, | ||
| 48 | - default: true | ||
| 49 | - }, | ||
| 50 | - hasTitle: { | ||
| 51 | - type: Boolean, | ||
| 52 | - default: true | ||
| 53 | - }, | ||
| 54 | - }, | ||
| 55 | - setup(props) { | ||
| 56 | - const router = useRouter() | ||
| 57 | - const route = useRoute() | ||
| 58 | - | ||
| 59 | - const goBack = () => { | ||
| 60 | - if (window.history.length > 1) { | ||
| 61 | - router.back() | ||
| 62 | - } else { | ||
| 63 | - router.push('/') | ||
| 64 | - } | ||
| 65 | - } | ||
| 66 | - | ||
| 67 | - return { | ||
| 68 | - goBack, | ||
| 69 | - } | ||
| 70 | - } | ||
| 71 | -} | ||
| 72 | -</script> | ||
| 73 | - | ||
| 74 | -<style scoped> | ||
| 75 | -.app-layout { | ||
| 76 | - display: flex; | ||
| 77 | - flex-direction: column; | ||
| 78 | - min-height: 100vh; | ||
| 79 | - background-color: var(--background-color); | ||
| 80 | -} | ||
| 81 | - | ||
| 82 | -.app-header { | ||
| 83 | - position: sticky; | ||
| 84 | - top: 0; | ||
| 85 | - z-index: 100; | ||
| 86 | - display: flex; | ||
| 87 | - align-items: center; | ||
| 88 | - justify-content: center; | ||
| 89 | - height: 46px; | ||
| 90 | - padding: 0 16px; | ||
| 91 | - background-color: var(--white); | ||
| 92 | - box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1); | ||
| 93 | -} | ||
| 94 | - | ||
| 95 | -.header-back { | ||
| 96 | - position: absolute; | ||
| 97 | - left: 16px; | ||
| 98 | - display: flex; | ||
| 99 | - align-items: center; | ||
| 100 | - justify-content: center; | ||
| 101 | -} | ||
| 102 | - | ||
| 103 | -.header-title { | ||
| 104 | - font-size: 18px; | ||
| 105 | - font-weight: 600; | ||
| 106 | - margin: 0; | ||
| 107 | -} | ||
| 108 | - | ||
| 109 | -.header-right { | ||
| 110 | - position: absolute; | ||
| 111 | - right: 16px; | ||
| 112 | - display: flex; | ||
| 113 | - align-items: center; | ||
| 114 | -} | ||
| 115 | - | ||
| 116 | -.app-content { | ||
| 117 | - flex: 1; | ||
| 118 | - overflow-y: auto; | ||
| 119 | - padding-bottom: 20px; | ||
| 120 | - -webkit-overflow-scrolling: touch; | ||
| 121 | -} | ||
| 122 | - | ||
| 123 | -.app-content.has-bottom-nav { | ||
| 124 | - padding-bottom: 50px; | ||
| 125 | -} | ||
| 126 | - | ||
| 127 | -.app-content.no-header { | ||
| 128 | - padding-top: 0; | ||
| 129 | -} | ||
| 130 | -</style> |
| 1 | /* | 1 | /* |
| 2 | * @Date: 2025-03-21 13:28:30 | 2 | * @Date: 2025-03-21 13:28:30 |
| 3 | * @LastEditors: hookehuyr hookehuyr@gmail.com | 3 | * @LastEditors: hookehuyr hookehuyr@gmail.com |
| 4 | - * @LastEditTime: 2025-11-17 13:54:31 | 4 | + * @LastEditTime: 2026-01-24 15:29:54 |
| 5 | * @FilePath: /mlaj/src/router/checkin.js | 5 | * @FilePath: /mlaj/src/router/checkin.js |
| 6 | * @Description: 文件描述 | 6 | * @Description: 文件描述 |
| 7 | */ | 7 | */ |
| ... | @@ -61,42 +61,6 @@ export default [ | ... | @@ -61,42 +61,6 @@ export default [ |
| 61 | } | 61 | } |
| 62 | }, | 62 | }, |
| 63 | { | 63 | { |
| 64 | - path: '/checkin/image', | ||
| 65 | - name: 'ImageCheckIn', | ||
| 66 | - component: () => import('@/views/checkin/upload/image.vue'), | ||
| 67 | - meta: { | ||
| 68 | - title: '打卡图片', | ||
| 69 | - requiresAuth: true | ||
| 70 | - } | ||
| 71 | - }, | ||
| 72 | - { | ||
| 73 | - path: '/checkin/video', | ||
| 74 | - name: 'VideoCheckIn', | ||
| 75 | - component: () => import('@root/src/views/checkin/upload/video.vue'), | ||
| 76 | - meta: { | ||
| 77 | - title: '打卡视频', | ||
| 78 | - requiresAuth: true | ||
| 79 | - } | ||
| 80 | - }, | ||
| 81 | - { | ||
| 82 | - path: '/checkin/audio', | ||
| 83 | - name: 'AudioCheckIn', | ||
| 84 | - component: () => import('@root/src/views/checkin/upload/audio.vue'), | ||
| 85 | - meta: { | ||
| 86 | - title: '打卡音频', | ||
| 87 | - requiresAuth: true | ||
| 88 | - } | ||
| 89 | - }, | ||
| 90 | - { | ||
| 91 | - path: '/checkin/text', | ||
| 92 | - name: 'TextCheckIn', | ||
| 93 | - component: () => import('@root/src/views/checkin/upload/text.vue'), | ||
| 94 | - meta: { | ||
| 95 | - title: '打卡文本', | ||
| 96 | - requiresAuth: true | ||
| 97 | - } | ||
| 98 | - }, | ||
| 99 | - { | ||
| 100 | path: '/checkin/join', | 64 | path: '/checkin/join', |
| 101 | name: 'JoinCheckIn', | 65 | name: 'JoinCheckIn', |
| 102 | component: () => import('@root/src/views/checkin/JoinCheckInPage.vue'), | 66 | component: () => import('@root/src/views/checkin/JoinCheckInPage.vue'), | ... | ... |
| 1 | /* | 1 | /* |
| 2 | * @Date: 2022-04-18 15:59:42 | 2 | * @Date: 2022-04-18 15:59:42 |
| 3 | * @LastEditors: hookehuyr hookehuyr@gmail.com | 3 | * @LastEditors: hookehuyr hookehuyr@gmail.com |
| 4 | - * @LastEditTime: 2026-01-21 13:33:36 | 4 | + * @LastEditTime: 2026-01-24 14:35:27 |
| 5 | * @FilePath: /mlaj/src/utils/tools.js | 5 | * @FilePath: /mlaj/src/utils/tools.js |
| 6 | * @Description: 文件描述 | 6 | * @Description: 文件描述 |
| 7 | */ | 7 | */ |
| ... | @@ -127,6 +127,35 @@ const formatDuration = (seconds) => { | ... | @@ -127,6 +127,35 @@ const formatDuration = (seconds) => { |
| 127 | }; | 127 | }; |
| 128 | 128 | ||
| 129 | /** | 129 | /** |
| 130 | + * @description 为 CDN 图片追加七牛压缩参数,降低首页等场景的图片体积。 | ||
| 131 | + * @param {string} src 原始图片地址 | ||
| 132 | + * @param {number} [width=200] 缩略图宽度(像素) | ||
| 133 | + * @param {number} [quality=70] 图片质量(1-100) | ||
| 134 | + * @returns {string} 处理后的图片地址 | ||
| 135 | + */ | ||
| 136 | +const buildCdnImageUrl = (src, width = 200, quality = 70) => { | ||
| 137 | + const url = typeof src === 'string' ? src : ''; | ||
| 138 | + if (!url) return ''; | ||
| 139 | + | ||
| 140 | + // 已包含七牛处理参数时不重复追加,避免不确定行为 | ||
| 141 | + if (url.includes('imageMogr2')) return url; | ||
| 142 | + | ||
| 143 | + try { | ||
| 144 | + const u = new URL(url, window.location.origin); | ||
| 145 | + // 兼容多个 CDN 域名:只要域名前缀以 cdn 开头,就允许追加七牛参数 | ||
| 146 | + // 例如:cdn.ipadbiz.cn / cdn.xxx.com / cdn1.xxx.com | ||
| 147 | + if (!/^cdn/i.test(u.hostname)) return url; | ||
| 148 | + | ||
| 149 | + const [base, hash] = url.split('#'); | ||
| 150 | + const param = `imageMogr2/thumbnail/${width}x/strip/quality/${quality}`; | ||
| 151 | + const next = base + (base.includes('?') ? '&' : '?') + param; | ||
| 152 | + return hash ? `${next}#${hash}` : next; | ||
| 153 | + } catch (e) { | ||
| 154 | + return url; | ||
| 155 | + } | ||
| 156 | +}; | ||
| 157 | + | ||
| 158 | +/** | ||
| 130 | * @description 归一化“打卡任务列表”字段,避免各页面散落 map 导致漏改。 | 159 | * @description 归一化“打卡任务列表”字段,避免各页面散落 map 导致漏改。 |
| 131 | * @param {Array<any>} list 原始任务列表(通常为接口返回 task_list/timeout_task_list) | 160 | * @param {Array<any>} list 原始任务列表(通常为接口返回 task_list/timeout_task_list) |
| 132 | * @returns {Array<{id: number|string, name: string, task_type: string, is_gray: boolean, is_finish: boolean, checkin_subtask_id: number|string|undefined}>} | 161 | * @returns {Array<{id: number|string, name: string, task_type: string, is_gray: boolean, is_finish: boolean, checkin_subtask_id: number|string|undefined}>} |
| ... | @@ -289,6 +318,7 @@ export { | ... | @@ -289,6 +318,7 @@ export { |
| 289 | getUrlParams, | 318 | getUrlParams, |
| 290 | stringifyQuery, | 319 | stringifyQuery, |
| 291 | formatDuration, | 320 | formatDuration, |
| 321 | + buildCdnImageUrl, | ||
| 292 | normalizeCheckinTaskItems, | 322 | normalizeCheckinTaskItems, |
| 293 | normalizeAttachmentTypeConfig, | 323 | normalizeAttachmentTypeConfig, |
| 294 | }; | 324 | }; | ... | ... |
| ... | @@ -38,7 +38,7 @@ | ... | @@ -38,7 +38,7 @@ |
| 38 | <div class="flex items-center"> | 38 | <div class="flex items-center"> |
| 39 | <div class="w-10 h-10 rounded-full overflow-hidden mr-3"> | 39 | <div class="w-10 h-10 rounded-full overflow-hidden mr-3"> |
| 40 | <img | 40 | <img |
| 41 | - :src="currentUser?.avatar || 'https://cdn.ipadbiz.cn/mlaj/images/icon_1.jpeg'" | 41 | + :src="buildCdnImageUrl(currentUser?.avatar || 'https://cdn.ipadbiz.cn/mlaj/images/icon_1.jpeg')" |
| 42 | class="w-full h-full object-cover" | 42 | class="w-full h-full object-cover" |
| 43 | @error="handleImageError" /> | 43 | @error="handleImageError" /> |
| 44 | </div> | 44 | </div> |
| ... | @@ -231,7 +231,7 @@ | ... | @@ -231,7 +231,7 @@ |
| 231 | <div class="flex items-center"> | 231 | <div class="flex items-center"> |
| 232 | <div class="w-12 h-12 bg-green-100 rounded-lg overflow-hidden mr-3 flex-shrink-0"> | 232 | <div class="w-12 h-12 bg-green-100 rounded-lg overflow-hidden mr-3 flex-shrink-0"> |
| 233 | <img | 233 | <img |
| 234 | - :src="item.image" | 234 | + :src="buildCdnImageUrl(item.image)" |
| 235 | :alt="item.title" | 235 | :alt="item.title" |
| 236 | class="w-full h-full object-cover" | 236 | class="w-full h-full object-cover" |
| 237 | @error="handleImageError" | 237 | @error="handleImageError" |
| ... | @@ -287,7 +287,7 @@ | ... | @@ -287,7 +287,7 @@ |
| 287 | > | 287 | > |
| 288 | <template v-if="activeVideoIndex !== index"> | 288 | <template v-if="activeVideoIndex !== index"> |
| 289 | <img | 289 | <img |
| 290 | - :src="item.image" | 290 | + :src="buildCdnImageUrl(item.image, 400)" |
| 291 | :alt="item.title" | 291 | :alt="item.title" |
| 292 | class="w-full h-full object-cover" | 292 | class="w-full h-full object-cover" |
| 293 | @error="handleImageError" | 293 | @error="handleImageError" |
| ... | @@ -350,7 +350,7 @@ import { useImageLoader } from '@/composables/useImageLoader' | ... | @@ -350,7 +350,7 @@ import { useImageLoader } from '@/composables/useImageLoader' |
| 350 | 350 | ||
| 351 | // 导入接口 | 351 | // 导入接口 |
| 352 | import { getTaskListAPI } from "@/api/checkin"; | 352 | import { getTaskListAPI } from "@/api/checkin"; |
| 353 | -import { normalizeCheckinTaskItems } from '@/utils/tools' | 353 | +import { normalizeCheckinTaskItems, buildCdnImageUrl } from '@/utils/tools' |
| 354 | 354 | ||
| 355 | // 图片加载错误处理 | 355 | // 图片加载错误处理 |
| 356 | const { handleImageError } = useImageLoader() | 356 | const { handleImageError } = useImageLoader() | ... | ... |
| 1 | <!-- | 1 | <!-- |
| 2 | * @Date: 2025-09-30 17:05 | 2 | * @Date: 2025-09-30 17:05 |
| 3 | * @LastEditors: hookehuyr hookehuyr@gmail.com | 3 | * @LastEditors: hookehuyr hookehuyr@gmail.com |
| 4 | - * @LastEditTime: 2026-01-22 22:11:29 | 4 | + * @LastEditTime: 2026-01-24 15:32:45 |
| 5 | * @FilePath: /mlaj/src/views/checkin/CheckinDetailPage.vue | 5 | * @FilePath: /mlaj/src/views/checkin/CheckinDetailPage.vue |
| 6 | * @Description: 用户打卡详情页 | 6 | * @Description: 用户打卡详情页 |
| 7 | --> | 7 | --> |
| ... | @@ -84,19 +84,22 @@ | ... | @@ -84,19 +84,22 @@ |
| 84 | <div class="tab-title">{{ taskType === 'count' ? '附件类型(可选)' : '附件类型' }}</div> | 84 | <div class="tab-title">{{ taskType === 'count' ? '附件类型(可选)' : '附件类型' }}</div> |
| 85 | <div class="tabs-nav"> | 85 | <div class="tabs-nav"> |
| 86 | <div v-for="option in attachmentTypeOptions" :key="option.key" | 86 | <div v-for="option in attachmentTypeOptions" :key="option.key" |
| 87 | - @click="switchType(option.key)" :class="['tab-item', { | 87 | + @click="switchType(option.key)" :class="['tab-item', 'relative', { |
| 88 | - active: activeType === option.key | 88 | + active: activeType === option.key |
| 89 | - }]"> | 89 | + }]"> |
| 90 | - <van-icon :name="getIconName(option.key)" size="1.2rem" /> | 90 | + <van-icon :name="getIconName(option.key)" size="1.2rem" /> |
| 91 | - <span class="tab-text">{{ option.value }}</span> | 91 | + <span class="tab-text">{{ option.value }}</span> |
| 92 | - </div> | 92 | + <div v-if="getTypeCount(option.key) > 0" class="absolute -top-2 -right-2 bg-red-500 text-white text-[10px] rounded-full min-w-[16px] h-[16px] flex items-center justify-center px-1"> |
| 93 | + {{ getTypeCount(option.key) }} | ||
| 94 | + </div> | ||
| 95 | + </div> | ||
| 93 | </div> | 96 | </div> |
| 94 | </div> | 97 | </div> |
| 95 | 98 | ||
| 96 | <!-- 文件上传区域 --> | 99 | <!-- 文件上传区域 --> |
| 97 | <div v-if="activeType !== '' && activeType !== 'text'" class="upload-area"> | 100 | <div v-if="activeType !== '' && activeType !== 'text'" class="upload-area"> |
| 98 | - <van-uploader v-model="fileList" :max-count="maxCount" :max-size="maxFileSizeBytes" | 101 | + <van-uploader v-model="displayFileList" :max-count="maxCount" :max-size="maxFileSizeBytes" |
| 99 | - :before-read="beforeRead" :after-read="afterRead" @delete="onDelete" | 102 | + :before-read="beforeReadGuard" :after-read="afterRead" @delete="onDelete" |
| 100 | @click-preview="onClickPreview" multiple :accept="getAcceptType()" result-type="file" | 103 | @click-preview="onClickPreview" multiple :accept="getAcceptType()" result-type="file" |
| 101 | :deletable="true" upload-icon="plus" /> | 104 | :deletable="true" upload-icon="plus" /> |
| 102 | 105 | ||
| ... | @@ -188,7 +191,7 @@ import AudioPlayer from '@/components/media/AudioPlayer.vue' | ... | @@ -188,7 +191,7 @@ import AudioPlayer from '@/components/media/AudioPlayer.vue' |
| 188 | import VideoPlayer from '@/components/media/VideoPlayer.vue' | 191 | import VideoPlayer from '@/components/media/VideoPlayer.vue' |
| 189 | import AddTargetDialog from '@/components/count/AddTargetDialog.vue' | 192 | import AddTargetDialog from '@/components/count/AddTargetDialog.vue' |
| 190 | import CheckinTargetList from '@/components/count/CheckinTargetList.vue' | 193 | import CheckinTargetList from '@/components/count/CheckinTargetList.vue' |
| 191 | -import { showToast, showLoadingToast } from 'vant' | 194 | +import { showToast, showLoadingToast, showDialog } from 'vant' |
| 192 | import dayjs from 'dayjs' | 195 | import dayjs from 'dayjs' |
| 193 | 196 | ||
| 194 | const route = useRoute() | 197 | const route = useRoute() |
| ... | @@ -221,6 +224,115 @@ const { | ... | @@ -221,6 +224,115 @@ const { |
| 221 | gratitudeFormList | 224 | gratitudeFormList |
| 222 | } = useCheckin() | 225 | } = useCheckin() |
| 223 | 226 | ||
| 227 | +const beforeReadGuard = (file) => { | ||
| 228 | + const files = Array.isArray(file) ? file : [file] | ||
| 229 | + if (activeType.value === 'video') { | ||
| 230 | + const hasMov = files.some(item => { | ||
| 231 | + const fileName = String(item?.name || '').toLowerCase() | ||
| 232 | + const fileType = String(item?.type || '').toLowerCase() | ||
| 233 | + return fileName.endsWith('.mov') || fileType.includes('quicktime') | ||
| 234 | + }) | ||
| 235 | + | ||
| 236 | + if (hasMov) { | ||
| 237 | + showDialog({ | ||
| 238 | + title: '不支持 MOV 格式', | ||
| 239 | + message: 'MOV(QuickTime)在非苹果系统/部分播放器兼容性较差,可能出现无法打开、黑屏、无声等问题。\n\n请将视频导出/转换为 MP4(更通用)后再上传。', | ||
| 240 | + confirmButtonText: '我知道了', | ||
| 241 | + }) | ||
| 242 | + return false | ||
| 243 | + } | ||
| 244 | + | ||
| 245 | + return beforeRead(file) | ||
| 246 | + } | ||
| 247 | + | ||
| 248 | + if (activeType.value === 'audio') { | ||
| 249 | + const supportedExt = ['mp3', 'm4a', 'aac', 'wav'] | ||
| 250 | + const unsupportedFiles = files.filter(item => { | ||
| 251 | + const fileName = String(item?.name || '').toLowerCase() | ||
| 252 | + const ext = fileName.includes('.') ? fileName.split('.').pop() : '' | ||
| 253 | + if (supportedExt.includes(ext)) return false | ||
| 254 | + const fileType = String(item?.type || '').toLowerCase() | ||
| 255 | + if (!fileType) return true | ||
| 256 | + if (!fileType.startsWith('audio/')) return true | ||
| 257 | + return true | ||
| 258 | + }) | ||
| 259 | + | ||
| 260 | + if (unsupportedFiles.length > 0) { | ||
| 261 | + showDialog({ | ||
| 262 | + title: '不支持的音频格式', | ||
| 263 | + message: '当前音频播放基于系统浏览器能力,不同机型/系统对音频格式支持差异较大(例如 .wma 等常见无法播放)。\n\n为避免上传后无法播放,请使用 .mp3 或 .m4a(推荐)重新导出/转换后再上传。', | ||
| 264 | + confirmButtonText: '我知道了', | ||
| 265 | + }) | ||
| 266 | + return false | ||
| 267 | + } | ||
| 268 | + | ||
| 269 | + return beforeRead(file) | ||
| 270 | + } | ||
| 271 | + | ||
| 272 | + return beforeRead(file) | ||
| 273 | +} | ||
| 274 | + | ||
| 275 | +/** | ||
| 276 | + * 获取指定类型的文件数量 | ||
| 277 | + * @param {string} type - 文件类型 | ||
| 278 | + * @returns {number} 文件数量 | ||
| 279 | + */ | ||
| 280 | +const getTypeCount = (type) => { | ||
| 281 | + return fileList.value.filter(item => { | ||
| 282 | + if (item.file_type) { | ||
| 283 | + return item.file_type === type | ||
| 284 | + } | ||
| 285 | + // 处理刚选择但未上传完成的文件(尝试推断类型) | ||
| 286 | + if (item.file && item.file.type) { | ||
| 287 | + if (type === 'image') return item.file.type.startsWith('image/') | ||
| 288 | + if (type === 'video') return item.file.type.startsWith('video/') | ||
| 289 | + if (type === 'audio') return item.file.type.startsWith('audio/') | ||
| 290 | + } | ||
| 291 | + return false | ||
| 292 | + }).length | ||
| 293 | +} | ||
| 294 | + | ||
| 295 | +/** | ||
| 296 | + * 当前显示的(经过类型过滤的)文件列表 | ||
| 297 | + * @description | ||
| 298 | + * 1. getter: 根据 activeType 过滤 fileList,只显示当前类型的文件 | ||
| 299 | + * 2. setter: 处理 van-uploader 的更新(添加/删除),同步回 fileList | ||
| 300 | + */ | ||
| 301 | +const displayFileList = computed({ | ||
| 302 | + get: () => { | ||
| 303 | + return fileList.value.filter(item => { | ||
| 304 | + if (item.file_type) { | ||
| 305 | + return item.file_type === activeType.value | ||
| 306 | + } | ||
| 307 | + if (item.file && item.file.type) { | ||
| 308 | + if (activeType.value === 'image') return item.file.type.startsWith('image/') | ||
| 309 | + if (activeType.value === 'video') return item.file.type.startsWith('video/') | ||
| 310 | + if (activeType.value === 'audio') return item.file.type.startsWith('audio/') | ||
| 311 | + } | ||
| 312 | + return false | ||
| 313 | + }) | ||
| 314 | + }, | ||
| 315 | + set: (val) => { | ||
| 316 | + // 找出不属于当前视图的其他文件(保留它们) | ||
| 317 | + const otherFiles = fileList.value.filter(item => { | ||
| 318 | + if (item.file_type) { | ||
| 319 | + return item.file_type !== activeType.value | ||
| 320 | + } | ||
| 321 | + if (item.file && item.file.type) { | ||
| 322 | + if (activeType.value === 'image') return !item.file.type.startsWith('image/') | ||
| 323 | + if (activeType.value === 'video') return !item.file.type.startsWith('video/') | ||
| 324 | + if (activeType.value === 'audio') return !item.file.type.startsWith('audio/') | ||
| 325 | + } | ||
| 326 | + // 如果无法判断类型,且 activeType 不是 text,保守起见保留它? | ||
| 327 | + // 或者:如果 activeType 是 image,那么所有 image/ 相关的都算当前视图,非 image/ 的算 other | ||
| 328 | + return true | ||
| 329 | + }) | ||
| 330 | + | ||
| 331 | + // 合并其他文件和当前视图的新文件列表 | ||
| 332 | + fileList.value = [...otherFiles, ...val] | ||
| 333 | + } | ||
| 334 | +}) | ||
| 335 | + | ||
| 224 | // 动态字段文字 | 336 | // 动态字段文字 |
| 225 | const dynamicFieldText = ref('感恩') | 337 | const dynamicFieldText = ref('感恩') |
| 226 | 338 | ||
| ... | @@ -521,7 +633,7 @@ const isSubmitDisabled = computed(() => { | ... | @@ -521,7 +633,7 @@ const isSubmitDisabled = computed(() => { |
| 521 | // 文本打卡:必须填写内容且长度不少于10个字符 | 633 | // 文本打卡:必须填写内容且长度不少于10个字符 |
| 522 | return !message.value.trim() || message.value.trim().length < 10 | 634 | return !message.value.trim() || message.value.trim().length < 10 |
| 523 | } else { | 635 | } else { |
| 524 | - // 其他类型:必须有文件 | 636 | + // 其他类型:必须有文件 (如果是混合模式,只要有文件就行) |
| 525 | return fileList.value.length === 0 | 637 | return fileList.value.length === 0 |
| 526 | } | 638 | } |
| 527 | }) | 639 | }) |
| ... | @@ -621,8 +733,8 @@ const getFileIcon = () => { | ... | @@ -621,8 +733,8 @@ const getFileIcon = () => { |
| 621 | const getAcceptType = () => { | 733 | const getAcceptType = () => { |
| 622 | const acceptMap = { | 734 | const acceptMap = { |
| 623 | 'image': 'image/*', | 735 | 'image': 'image/*', |
| 624 | - 'video': 'video/*', | 736 | + 'video': '.mp4,video/mp4', |
| 625 | - 'audio': '.mp3,.wav,.aac,.flac,.ogg,.wma,.m4a' | 737 | + 'audio': '.mp3,.m4a,.aac,.wav' |
| 626 | } | 738 | } |
| 627 | return acceptMap[activeType.value] || '*' | 739 | return acceptMap[activeType.value] || '*' |
| 628 | } | 740 | } |
| ... | @@ -634,8 +746,8 @@ const getAcceptType = () => { | ... | @@ -634,8 +746,8 @@ const getAcceptType = () => { |
| 634 | const getUploadTips = () => { | 746 | const getUploadTips = () => { |
| 635 | const tipsMap = { | 747 | const tipsMap = { |
| 636 | 'image': '支持格式:.jpg/.jpeg/.png', | 748 | 'image': '支持格式:.jpg/.jpeg/.png', |
| 637 | - 'video': '支持格式:视频文件', | 749 | + 'video': '支持格式:.mp4(不支持 .mov)', |
| 638 | - 'audio': '支持格式:.mp3/.wav/.aac/.flac/.ogg/.wma/.m4a' | 750 | + 'audio': '支持格式:.mp3/.m4a/.aac/.wav(不支持 .wma)' |
| 639 | } | 751 | } |
| 640 | return tipsMap[activeType.value] || '' | 752 | return tipsMap[activeType.value] || '' |
| 641 | } | 753 | } |
| ... | @@ -745,18 +857,20 @@ const onClickPreview = (file, detail) => { | ... | @@ -745,18 +857,20 @@ const onClickPreview = (file, detail) => { |
| 745 | } | 857 | } |
| 746 | 858 | ||
| 747 | // 根据打卡类型或文件扩展名判断文件类型 | 859 | // 根据打卡类型或文件扩展名判断文件类型 |
| 748 | - if (activeType.value === 'audio' || isAudioFile(fileName)) { | 860 | + const finalFileType = file.file_type || (isAudioFile(fileName) ? 'audio' : (isVideoFile(fileName) ? 'video' : 'image')) |
| 861 | + | ||
| 862 | + if (finalFileType === 'audio') { | ||
| 749 | console.log('准备播放音频:', fileName, fileUrl) | 863 | console.log('准备播放音频:', fileName, fileUrl) |
| 750 | showAudio(fileName, fileUrl) | 864 | showAudio(fileName, fileUrl) |
| 751 | - } else if (activeType.value === 'video' || isVideoFile(fileName)) { | 865 | + } else if (finalFileType === 'video') { |
| 752 | console.log('准备播放视频:', fileName, fileUrl) | 866 | console.log('准备播放视频:', fileName, fileUrl) |
| 753 | showVideo(fileName, fileUrl) | 867 | showVideo(fileName, fileUrl) |
| 754 | - } else if (activeType.value === 'image' || isImageFile(fileName)) { | 868 | + } else if (finalFileType === 'image') { |
| 755 | console.log('图片预览由van-uploader组件处理,跳过文件列表点击预览') | 869 | console.log('图片预览由van-uploader组件处理,跳过文件列表点击预览') |
| 756 | // 图片预览由van-uploader的@click-preview事件处理,避免重复弹出 | 870 | // 图片预览由van-uploader的@click-preview事件处理,避免重复弹出 |
| 757 | return | 871 | return |
| 758 | } else { | 872 | } else { |
| 759 | - console.log('该文件类型不支持预览,文件名:', fileName, '类型:', activeType.value) | 873 | + console.log('该文件类型不支持预览,文件名:', fileName, '类型:', finalFileType) |
| 760 | showToast('该文件类型不支持预览') | 874 | showToast('该文件类型不支持预览') |
| 761 | } | 875 | } |
| 762 | } | 876 | } | ... | ... |
| 1 | <!-- | 1 | <!-- |
| 2 | * @Date: 2025-05-29 15:34:17 | 2 | * @Date: 2025-05-29 15:34:17 |
| 3 | * @LastEditors: hookehuyr hookehuyr@gmail.com | 3 | * @LastEditors: hookehuyr hookehuyr@gmail.com |
| 4 | - * @LastEditTime: 2026-01-23 10:46:15 | 4 | + * @LastEditTime: 2026-01-24 15:29:28 |
| 5 | * @FilePath: /mlaj/src/views/checkin/IndexCheckInPage.vue | 5 | * @FilePath: /mlaj/src/views/checkin/IndexCheckInPage.vue |
| 6 | * @Description: 用户打卡主页 | 6 | * @Description: 用户打卡主页 |
| 7 | --> | 7 | --> |
| ... | @@ -460,45 +460,6 @@ const goToCheckinDetailPage = () => { | ... | @@ -460,45 +460,6 @@ const goToCheckinDetailPage = () => { |
| 460 | }) | 460 | }) |
| 461 | } | 461 | } |
| 462 | 462 | ||
| 463 | -// const goToCheckinTextPage = () => { | ||
| 464 | -// router.push({ | ||
| 465 | -// path: '/checkin/text', | ||
| 466 | -// query: { | ||
| 467 | -// id: route.query.id, | ||
| 468 | -// type: 'text' | ||
| 469 | -// } | ||
| 470 | -// }) | ||
| 471 | -// } | ||
| 472 | - | ||
| 473 | -// const goToCheckinImagePage = () => { | ||
| 474 | -// router.push({ | ||
| 475 | -// path: '/checkin/image', | ||
| 476 | -// query: { | ||
| 477 | -// id: route.query.id, | ||
| 478 | -// type: 'image' | ||
| 479 | -// } | ||
| 480 | -// }) | ||
| 481 | -// } | ||
| 482 | -// const goToCheckinVideoPage = (type) => { | ||
| 483 | -// router.push({ | ||
| 484 | -// path: '/checkin/video', | ||
| 485 | -// query: { | ||
| 486 | -// id: route.query.id, | ||
| 487 | -// type: 'video', | ||
| 488 | -// } | ||
| 489 | -// }) | ||
| 490 | -// } | ||
| 491 | - | ||
| 492 | -// const goToCheckinAudioPage = (type) => { | ||
| 493 | -// router.push({ | ||
| 494 | -// path: '/checkin/audio', | ||
| 495 | -// query: { | ||
| 496 | -// id: route.query.id, | ||
| 497 | -// type: 'audio', | ||
| 498 | -// } | ||
| 499 | -// }) | ||
| 500 | -// } | ||
| 501 | - | ||
| 502 | const handLike = async (post) => { | 463 | const handLike = async (post) => { |
| 503 | if (!post.is_liked) { | 464 | if (!post.is_liked) { |
| 504 | const { code, data } = await likeUploadTaskInfoAPI({ checkin_id: post.id, }) | 465 | const { code, data } = await likeUploadTaskInfoAPI({ checkin_id: post.id, }) |
| ... | @@ -716,29 +677,33 @@ const formatData = (data) => { | ... | @@ -716,29 +677,33 @@ const formatData = (data) => { |
| 716 | let images = []; | 677 | let images = []; |
| 717 | let audio = []; | 678 | let audio = []; |
| 718 | let videoList = []; | 679 | let videoList = []; |
| 719 | - if (item.file_type === 'image') { | 680 | + |
| 720 | - images = item.files.map(file => { | 681 | + // 支持多类型混合显示:遍历 files 数组根据 file_type 分类 |
| 721 | - return file.value; | 682 | + if (item.files && Array.isArray(item.files)) { |
| 722 | - }); | 683 | + item.files.forEach(file => { |
| 723 | - } else if (item.file_type === 'video') { | 684 | + // 优先使用文件自身的 file_type,如果没有则回退到 item.file_type |
| 724 | - videoList = item.files.map(file => { | 685 | + const type = file.file_type || item.file_type; |
| 725 | - return { | 686 | + |
| 726 | - id: file.meta_id, | 687 | + if (type === 'image') { |
| 727 | - video: file.value, | 688 | + images.push(file.value); |
| 728 | - videoCover: file.cover, | 689 | + } else if (type === 'video') { |
| 729 | - isPlaying: false, | 690 | + videoList.push({ |
| 730 | - } | 691 | + id: file.meta_id, |
| 731 | - }) | 692 | + video: file.value, |
| 732 | - } else if (item.file_type === 'audio') { | 693 | + videoCover: file.cover, |
| 733 | - audio = item.files.map(file => { | 694 | + isPlaying: false, |
| 734 | - return { | 695 | + }); |
| 735 | - title: file.name ? file.name : '打卡音频', | 696 | + } else if (type === 'audio') { |
| 736 | - artist: file.artist ? file.artist : '', | 697 | + audio.push({ |
| 737 | - url: file.value, | 698 | + title: file.name ? file.name : '打卡音频', |
| 738 | - cover: file.cover ? file.cover : '', | 699 | + artist: file.artist ? file.artist : '', |
| 700 | + url: file.value, | ||
| 701 | + cover: file.cover ? file.cover : '', | ||
| 702 | + }); | ||
| 739 | } | 703 | } |
| 740 | - }) | 704 | + }); |
| 741 | } | 705 | } |
| 706 | + | ||
| 742 | return { | 707 | return { |
| 743 | id: item.id, | 708 | id: item.id, |
| 744 | task_id: item.task_id, | 709 | task_id: item.task_id, | ... | ... |
src/views/checkin/upload/audio.vue
deleted
100644 → 0
| 1 | -<!-- | ||
| 2 | - * @Date: 2025-06-03 09:41:41 | ||
| 3 | - * @LastEditors: hookehuyr hookehuyr@gmail.com | ||
| 4 | - * @LastEditTime: 2025-06-13 14:34:57 | ||
| 5 | - * @FilePath: /mlaj/src/views/checkin/upload/audio.vue | ||
| 6 | - * @Description: 音视频文件上传组件 | ||
| 7 | ---> | ||
| 8 | -<template> | ||
| 9 | - <div class="checkin-upload-file p-4"> | ||
| 10 | - <div class="title text-center pb-4 font-bold">{{route.meta.title}}</div> | ||
| 11 | - <!-- 文件上传区域 --> | ||
| 12 | - <div class="mb-4"> | ||
| 13 | - <van-uploader | ||
| 14 | - v-model="fileList" | ||
| 15 | - :max-count="max_count" | ||
| 16 | - :max-size="20 * 1024 * 1024" | ||
| 17 | - :before-read="beforeRead" | ||
| 18 | - :after-read="afterRead" | ||
| 19 | - @delete="onDelete" | ||
| 20 | - multiple | ||
| 21 | - accept=".mp3,.wav,.aac" | ||
| 22 | - result-type="file" | ||
| 23 | - upload-icon="plus" | ||
| 24 | - :deletable="false" | ||
| 25 | - > | ||
| 26 | - </van-uploader> | ||
| 27 | - <van-row v-for="(item, index) in fileList" :key="index" class="mt-2 text-s text-gray-500"> | ||
| 28 | - <van-col span="22">{{ item.name }}</van-col> | ||
| 29 | - <van-col span="2" @click="delItem(item)"><van-icon name="clear" /></van-col> | ||
| 30 | - </van-row> | ||
| 31 | - <van-divider /> | ||
| 32 | - <div class="mt-2 text-xs text-gray-500">最多上传{{ max_count }}个文件,每个不超过20M</div> | ||
| 33 | - <div class="mt-2 text-xs text-gray-500">上传类型: .mp3,.wav,.aac 格式音频文件</div> | ||
| 34 | - </div> | ||
| 35 | - | ||
| 36 | - <!-- 文字留言区域 --> | ||
| 37 | - <div class="mb-4 border"> | ||
| 38 | - <van-field | ||
| 39 | - v-model="message" | ||
| 40 | - rows="4" | ||
| 41 | - autosize | ||
| 42 | - type="textarea" | ||
| 43 | - placeholder="请输入打卡留言" | ||
| 44 | - /> | ||
| 45 | - </div> | ||
| 46 | - | ||
| 47 | - <!-- 提交按钮 --> | ||
| 48 | - <div class="fixed bottom-0 left-0 right-0 p-4 bg-white"> | ||
| 49 | - <van-button | ||
| 50 | - type="primary" | ||
| 51 | - block | ||
| 52 | - :loading="uploading" | ||
| 53 | - :disabled="!canSubmit" | ||
| 54 | - @click="onSubmit" | ||
| 55 | - > | ||
| 56 | - 提交 | ||
| 57 | - </van-button> | ||
| 58 | - </div> | ||
| 59 | - | ||
| 60 | - <!-- 上传加载遮罩 --> | ||
| 61 | - <van-overlay :show="loading"> | ||
| 62 | - <div class="wrapper" @click.stop> | ||
| 63 | - <van-loading vertical color="#FFFFFF">上传中...</van-loading> | ||
| 64 | - </div> | ||
| 65 | - </van-overlay> | ||
| 66 | - </div> | ||
| 67 | -</template> | ||
| 68 | - | ||
| 69 | -<script setup> | ||
| 70 | -import { ref, computed } from 'vue' | ||
| 71 | -import { useRoute, useRouter } from 'vue-router' | ||
| 72 | -import { showToast, showLoadingToast } from 'vant' | ||
| 73 | -import { qiniuTokenAPI, qiniuUploadAPI, saveFileAPI } from '@/api/common' | ||
| 74 | -import { addUploadTaskAPI, getUploadTaskInfoAPI, editUploadTaskInfoAPI } from "@/api/checkin"; | ||
| 75 | -import { qiniuFileHash } from '@/utils/qiniuFileHash'; | ||
| 76 | -import _ from 'lodash' | ||
| 77 | -import { useTitle } from '@vueuse/core'; | ||
| 78 | -import { useAuth } from '@/contexts/auth' | ||
| 79 | - | ||
| 80 | -const route = useRoute() | ||
| 81 | -const router = useRouter() | ||
| 82 | -const { currentUser } = useAuth() | ||
| 83 | -useTitle(route.meta.title); | ||
| 84 | - | ||
| 85 | -const max_count = ref(5); | ||
| 86 | - | ||
| 87 | -// 文件列表 | ||
| 88 | -const fileList = ref([]) | ||
| 89 | -// 留言内容 | ||
| 90 | -const message = ref('') | ||
| 91 | -// 上传状态 | ||
| 92 | -const uploading = ref(false) | ||
| 93 | -// 上传loading | ||
| 94 | -const loading = ref(false) | ||
| 95 | - | ||
| 96 | -// 是否可以提交 | ||
| 97 | -const canSubmit = computed(() => { | ||
| 98 | - return fileList.value.length > 0 && message.value.trim() !== '' | ||
| 99 | -}) | ||
| 100 | - | ||
| 101 | -// 文件校验 | ||
| 102 | -const beforeRead = (file) => { | ||
| 103 | - let flag = true | ||
| 104 | - | ||
| 105 | - if (Array.isArray(file)) { | ||
| 106 | - // 多个文件 | ||
| 107 | - const invalidTypes = file.filter(item => { | ||
| 108 | - const fileType = item.type.toLowerCase(); | ||
| 109 | - return !fileType.startsWith('audio/'); | ||
| 110 | - }) | ||
| 111 | - if (invalidTypes.length) { | ||
| 112 | - flag = false | ||
| 113 | - showToast('请上传音频文件') | ||
| 114 | - } | ||
| 115 | - if (fileList.value.length + file.length > max_count.value) { | ||
| 116 | - flag = false | ||
| 117 | - showToast(`最大上传数量为${max_count.value}个`) | ||
| 118 | - } | ||
| 119 | - } else { | ||
| 120 | - const fileType = file.type.toLowerCase(); | ||
| 121 | - if (!fileType.startsWith('audio/')) { | ||
| 122 | - showToast('请上传音频文件') | ||
| 123 | - flag = false | ||
| 124 | - } | ||
| 125 | - if (fileList.value.length + 1 > max_count.value) { | ||
| 126 | - flag = false | ||
| 127 | - showToast(`最大上传数量为${max_count.value}个`) | ||
| 128 | - } | ||
| 129 | - if ((file.size / 1024 / 1024).toFixed(2) > 20) { | ||
| 130 | - flag = false | ||
| 131 | - showToast('最大文件体积为20MB') | ||
| 132 | - } | ||
| 133 | - } | ||
| 134 | - return flag | ||
| 135 | -} | ||
| 136 | - | ||
| 137 | -/** | ||
| 138 | - * 获取文件哈希(与七牛云ETag一致) | ||
| 139 | - * @param {File} file 文件对象 | ||
| 140 | - * @returns {Promise<string>} 哈希字符串 | ||
| 141 | - * 注释:使用 qiniuFileHash 进行计算,替代浏览器MD5方案。 | ||
| 142 | - */ | ||
| 143 | -const getFileMD5 = async (file) => { | ||
| 144 | - return await qiniuFileHash(file) | ||
| 145 | -} | ||
| 146 | - | ||
| 147 | -// 上传到七牛云 | ||
| 148 | -const uploadToQiniu = async (file, token, fileName) => { | ||
| 149 | - const formData = new FormData() | ||
| 150 | - formData.append('file', file) | ||
| 151 | - formData.append('token', token) | ||
| 152 | - formData.append('key', fileName) | ||
| 153 | - | ||
| 154 | - const config = { | ||
| 155 | - headers: { 'Content-Type': 'multipart/form-data' } | ||
| 156 | - } | ||
| 157 | - | ||
| 158 | - // 根据协议选择上传地址 | ||
| 159 | - const qiniuUploadUrl = window.location.protocol === 'https:' | ||
| 160 | - ? 'https://up.qbox.me' | ||
| 161 | - : 'http://upload.qiniu.com' | ||
| 162 | - | ||
| 163 | - return await qiniuUploadAPI(qiniuUploadUrl, formData, config) | ||
| 164 | -} | ||
| 165 | - | ||
| 166 | -// 处理单个文件上传 | ||
| 167 | -const handleUpload = async (file) => { | ||
| 168 | - loading.value = true | ||
| 169 | - try { | ||
| 170 | - // 获取MD5值 | ||
| 171 | - const md5 = await getFileMD5(file.file) | ||
| 172 | - | ||
| 173 | - // 获取七牛token | ||
| 174 | - const tokenResult = await qiniuTokenAPI({ | ||
| 175 | - name: file.file.name, | ||
| 176 | - hash: md5 | ||
| 177 | - }) | ||
| 178 | - | ||
| 179 | - // 文件已存在,直接返回 | ||
| 180 | - if (tokenResult.data) { | ||
| 181 | - return tokenResult.data | ||
| 182 | - } | ||
| 183 | - | ||
| 184 | - // 新文件上传 | ||
| 185 | - if (tokenResult.token) { | ||
| 186 | - const suffix = /.[^.]+$/.exec(file.file.name) || '' | ||
| 187 | - const fileName = `mlaj/upload/checkin/${currentUser.value.mobile}/file/${md5}${suffix}` | ||
| 188 | - | ||
| 189 | - const { filekey } = await uploadToQiniu( | ||
| 190 | - file.file, | ||
| 191 | - tokenResult.token, | ||
| 192 | - fileName | ||
| 193 | - ) | ||
| 194 | - | ||
| 195 | - if (filekey) { | ||
| 196 | - // 保存文件信息 | ||
| 197 | - const { data } = await saveFileAPI({ | ||
| 198 | - name: file.file.name, | ||
| 199 | - filekey, | ||
| 200 | - hash: md5 | ||
| 201 | - }) | ||
| 202 | - return data | ||
| 203 | - } | ||
| 204 | - } | ||
| 205 | - return null | ||
| 206 | - } catch (error) { | ||
| 207 | - console.error('Upload error:', error) | ||
| 208 | - return null | ||
| 209 | - } finally { | ||
| 210 | - loading.value = false | ||
| 211 | - } | ||
| 212 | -} | ||
| 213 | - | ||
| 214 | -// 文件读取后的处理 | ||
| 215 | -const afterRead = async (file) => { | ||
| 216 | - if (Array.isArray(file)) { | ||
| 217 | - // 多文件上传 | ||
| 218 | - for (const item of file) { | ||
| 219 | - item.status = 'uploading' | ||
| 220 | - item.message = '上传中...' | ||
| 221 | - const result = await handleUpload(item) | ||
| 222 | - if (result) { | ||
| 223 | - item.status = 'done' | ||
| 224 | - item.message = '上传成功' | ||
| 225 | - item.url = result.url | ||
| 226 | - item.meta_id = result.meta_id | ||
| 227 | - item.name = result.name | ||
| 228 | - } else { | ||
| 229 | - item.status = 'failed' | ||
| 230 | - item.message = '上传失败' | ||
| 231 | - showToast('上传失败,请重试') | ||
| 232 | - } | ||
| 233 | - } | ||
| 234 | - } else { | ||
| 235 | - // 单文件上传 | ||
| 236 | - file.status = 'uploading' | ||
| 237 | - file.message = '上传中...' | ||
| 238 | - const result = await handleUpload(file) | ||
| 239 | - if (result) { | ||
| 240 | - file.status = 'done' | ||
| 241 | - file.message = '上传成功' | ||
| 242 | - file.url = result.url | ||
| 243 | - file.meta_id = result.meta_id | ||
| 244 | - file.name = result.name | ||
| 245 | - } else { | ||
| 246 | - file.status = 'failed' | ||
| 247 | - file.message = '上传失败' | ||
| 248 | - showToast('上传失败,请重试') | ||
| 249 | - } | ||
| 250 | - } | ||
| 251 | -} | ||
| 252 | - | ||
| 253 | -// 删除文件 | ||
| 254 | -const onDelete = (file) => { | ||
| 255 | - const index = fileList.value.indexOf(file) | ||
| 256 | - if (index !== -1) { | ||
| 257 | - fileList.value.splice(index, 1) | ||
| 258 | - } | ||
| 259 | -} | ||
| 260 | - | ||
| 261 | -// 提交表单 | ||
| 262 | -const onSubmit = async () => { | ||
| 263 | - if (uploading.value) return | ||
| 264 | - | ||
| 265 | - // 检查是否所有文件都上传完成 | ||
| 266 | - const hasUploadingFiles = fileList.value.some(file => file.status === 'uploading') | ||
| 267 | - if (hasUploadingFiles) { | ||
| 268 | - showToast('请等待所有文件上传完成') | ||
| 269 | - return | ||
| 270 | - } | ||
| 271 | - | ||
| 272 | - uploading.value = true | ||
| 273 | - const toast = showLoadingToast({ | ||
| 274 | - message: '提交中...', | ||
| 275 | - forbidClick: true, | ||
| 276 | - }) | ||
| 277 | - | ||
| 278 | - try { | ||
| 279 | - if (route.query.status === 'edit') { | ||
| 280 | - // 编辑打卡接口 | ||
| 281 | - const { code, data } = await editUploadTaskInfoAPI({ | ||
| 282 | - i: route.query.post_id, | ||
| 283 | - note: message.value, | ||
| 284 | - meta_id: fileList.value.map(item => item.meta_id), | ||
| 285 | - file_type: route.query.type, | ||
| 286 | - }); | ||
| 287 | - if (code === 1) { | ||
| 288 | - showToast('提交成功') | ||
| 289 | - router.back() | ||
| 290 | - } | ||
| 291 | - } else { | ||
| 292 | - // 新增打卡接口 | ||
| 293 | - const { code, data } = await addUploadTaskAPI({ | ||
| 294 | - task_id: route.query.id, | ||
| 295 | - note: message.value, | ||
| 296 | - meta_id: fileList.value.map(item => item.meta_id), | ||
| 297 | - file_type: route.query.type, | ||
| 298 | - }); | ||
| 299 | - if (code === 1) { | ||
| 300 | - showToast('提交成功') | ||
| 301 | - router.back() | ||
| 302 | - } | ||
| 303 | - } | ||
| 304 | - } catch (error) { | ||
| 305 | - showToast('提交失败,请重试') | ||
| 306 | - } finally { | ||
| 307 | - toast.close() | ||
| 308 | - uploading.value = false | ||
| 309 | - } | ||
| 310 | -} | ||
| 311 | - | ||
| 312 | -onMounted(async () => { | ||
| 313 | - if (route.query.status === 'edit') { | ||
| 314 | - const { code, data } = await getUploadTaskInfoAPI({ i: route.query.post_id }); | ||
| 315 | - if (code === 1) { | ||
| 316 | - fileList.value = data.files.map(item => ({ | ||
| 317 | - name: item.name, | ||
| 318 | - url: item.value, | ||
| 319 | - status: 'done', | ||
| 320 | - message: '上传成功', | ||
| 321 | - meta_id: item.meta_id, | ||
| 322 | - })) | ||
| 323 | - message.value = data.note | ||
| 324 | - } | ||
| 325 | - } | ||
| 326 | -}); | ||
| 327 | - | ||
| 328 | -const delItem = (item) => { | ||
| 329 | - const index = fileList.value.indexOf(item) | ||
| 330 | - if (index!== -1) { | ||
| 331 | - fileList.value.splice(index, 1) | ||
| 332 | - } | ||
| 333 | -} | ||
| 334 | -</script> | ||
| 335 | - | ||
| 336 | -<style lang="less" scoped> | ||
| 337 | -.checkin-upload-file { | ||
| 338 | - min-height: 100vh; | ||
| 339 | - padding-bottom: 80px; | ||
| 340 | -} | ||
| 341 | - | ||
| 342 | -.wrapper { | ||
| 343 | - display: flex; | ||
| 344 | - align-items: center; | ||
| 345 | - justify-content: center; | ||
| 346 | - height: 100%; | ||
| 347 | -} | ||
| 348 | - | ||
| 349 | -.preview-cover { | ||
| 350 | - position: absolute; | ||
| 351 | - bottom: 0; | ||
| 352 | - box-sizing: border-box; | ||
| 353 | - width: 100%; | ||
| 354 | - padding: 4px; | ||
| 355 | - color: #fff; | ||
| 356 | - font-size: 12px; | ||
| 357 | - text-align: center; | ||
| 358 | - background: rgba(0, 0, 0, 0.3); | ||
| 359 | - } | ||
| 360 | -</style> |
src/views/checkin/upload/image.vue
deleted
100644 → 0
| 1 | -<template> | ||
| 2 | - <div class="checkin-upload-image p-4"> | ||
| 3 | - <div class="title text-center pb-4 font-bold">{{route.meta.title}}</div> | ||
| 4 | - <!-- 图片上传区域 --> | ||
| 5 | - <div class="mb-4"> | ||
| 6 | - <van-uploader | ||
| 7 | - v-model="fileList" | ||
| 8 | - :max-count="max_count" | ||
| 9 | - :max-size="20 * 1024 * 1024" | ||
| 10 | - :before-read="beforeRead" | ||
| 11 | - :after-read="afterRead" | ||
| 12 | - @delete="onDelete" | ||
| 13 | - multiple | ||
| 14 | - accept="image/*" | ||
| 15 | - result-type="file" | ||
| 16 | - > | ||
| 17 | - </van-uploader> | ||
| 18 | - <div class="mt-2 text-xs text-gray-500">最多上传{{ max_count }}张图片,每张不超过20M</div> | ||
| 19 | - <div class="mt-2 text-xs text-gray-500">上传类型: {{ type_text }}</div> | ||
| 20 | - </div> | ||
| 21 | - | ||
| 22 | - <!-- 文字留言区域 --> | ||
| 23 | - <div class="mb-4 border"> | ||
| 24 | - <van-field | ||
| 25 | - v-model="message" | ||
| 26 | - rows="4" | ||
| 27 | - autosize | ||
| 28 | - type="textarea" | ||
| 29 | - placeholder="请输入打卡留言" | ||
| 30 | - /> | ||
| 31 | - </div> | ||
| 32 | - | ||
| 33 | - <!-- 提交按钮 --> | ||
| 34 | - <div class="fixed bottom-0 left-0 right-0 p-4 bg-white"> | ||
| 35 | - <van-button | ||
| 36 | - type="primary" | ||
| 37 | - block | ||
| 38 | - :loading="uploading" | ||
| 39 | - :disabled="!canSubmit" | ||
| 40 | - @click="onSubmit" | ||
| 41 | - > | ||
| 42 | - 提交 | ||
| 43 | - </van-button> | ||
| 44 | - </div> | ||
| 45 | - | ||
| 46 | - <!-- 上传加载遮罩 --> | ||
| 47 | - <van-overlay :show="loading"> | ||
| 48 | - <div class="wrapper" @click.stop> | ||
| 49 | - <van-loading vertical color="#FFFFFF">上传中...</van-loading> | ||
| 50 | - </div> | ||
| 51 | - </van-overlay> | ||
| 52 | - </div> | ||
| 53 | -</template> | ||
| 54 | - | ||
| 55 | -<script setup> | ||
| 56 | -import { ref, computed } from 'vue' | ||
| 57 | -import { useRoute, useRouter } from 'vue-router' | ||
| 58 | -import { showToast, showLoadingToast } from 'vant' | ||
| 59 | -import { qiniuTokenAPI, qiniuUploadAPI, saveFileAPI } from '@/api/common' | ||
| 60 | -import { addUploadTaskAPI, getUploadTaskInfoAPI, editUploadTaskInfoAPI } from "@/api/checkin"; | ||
| 61 | -import { qiniuFileHash } from '@/utils/qiniuFileHash'; | ||
| 62 | -import _ from 'lodash' | ||
| 63 | -import { useTitle } from '@vueuse/core'; | ||
| 64 | -import { useAuth } from '@/contexts/auth' | ||
| 65 | - | ||
| 66 | -const route = useRoute() | ||
| 67 | -const router = useRouter() | ||
| 68 | -const { currentUser } = useAuth() | ||
| 69 | -useTitle(route.meta.title); | ||
| 70 | - | ||
| 71 | -const max_count = ref(5); | ||
| 72 | - | ||
| 73 | -// 文件列表 | ||
| 74 | -const fileList = ref([]) | ||
| 75 | -// 留言内容 | ||
| 76 | -const message = ref('') | ||
| 77 | -// 上传状态 | ||
| 78 | -const uploading = ref(false) | ||
| 79 | -// 上传loading | ||
| 80 | -const loading = ref(false) | ||
| 81 | - | ||
| 82 | -// 是否可以提交 | ||
| 83 | -const canSubmit = computed(() => { | ||
| 84 | - return fileList.value.length > 0 && message.value.trim() !== '' | ||
| 85 | -}) | ||
| 86 | - | ||
| 87 | -// 固定类型限制 | ||
| 88 | -const imageTypes = "jpg/jpeg/png"; | ||
| 89 | - | ||
| 90 | -// 文件类型中文页面显示 | ||
| 91 | -const type_text = computed(() => { | ||
| 92 | - // return props.item.component_props.image_type; | ||
| 93 | - return imageTypes; | ||
| 94 | -}); | ||
| 95 | - | ||
| 96 | -// 文件校验 | ||
| 97 | -const beforeRead = (file) => { | ||
| 98 | - const image_types = _.map(imageTypes.split("/"), (item) => `image/${item}`); | ||
| 99 | - let flag = true | ||
| 100 | - | ||
| 101 | - if (Array.isArray(file)) { | ||
| 102 | - // 多张图片 | ||
| 103 | - const invalidTypes = file.filter(item => { | ||
| 104 | - const fileType = item.type.toLowerCase(); | ||
| 105 | - return !image_types.some(type => fileType.includes(type.split('/')[1])); | ||
| 106 | - }) | ||
| 107 | - if (invalidTypes.length) { | ||
| 108 | - flag = false | ||
| 109 | - showToast('请上传指定格式图片') | ||
| 110 | - } | ||
| 111 | - if (fileList.value.length + file.length > max_count.value) { | ||
| 112 | - flag = false | ||
| 113 | - showToast(`最大上传数量为${max_count.value}张`) | ||
| 114 | - } | ||
| 115 | - } else { | ||
| 116 | - const fileType = file.type.toLowerCase(); | ||
| 117 | - if (!image_types.some(type => fileType.includes(type.split('/')[1]))) { | ||
| 118 | - showToast('请上传指定格式图片') | ||
| 119 | - flag = false | ||
| 120 | - } | ||
| 121 | - if (fileList.value.length + 1 > max_count.value) { | ||
| 122 | - flag = false | ||
| 123 | - showToast(`最大上传数量为${max_count.value}张`) | ||
| 124 | - } | ||
| 125 | - if ((file.size / 1024 / 1024).toFixed(2) > 20) { | ||
| 126 | - flag = false | ||
| 127 | - showToast('最大文件体积为20MB') | ||
| 128 | - } | ||
| 129 | - } | ||
| 130 | - return flag | ||
| 131 | -} | ||
| 132 | - | ||
| 133 | -/** | ||
| 134 | - * 获取文件哈希(与七牛云ETag一致) | ||
| 135 | - * @param {File} file 文件对象 | ||
| 136 | - * @returns {Promise<string>} 哈希字符串 | ||
| 137 | - * 注释:使用 qiniuFileHash 进行计算,替代浏览器MD5方案。 | ||
| 138 | - */ | ||
| 139 | -const getFileMD5 = async (file) => { | ||
| 140 | - return await qiniuFileHash(file) | ||
| 141 | -} | ||
| 142 | - | ||
| 143 | -// 上传到七牛云 | ||
| 144 | -const uploadToQiniu = async (file, token, fileName) => { | ||
| 145 | - const formData = new FormData() | ||
| 146 | - formData.append('file', file) | ||
| 147 | - formData.append('token', token) | ||
| 148 | - formData.append('key', fileName) | ||
| 149 | - | ||
| 150 | - const config = { | ||
| 151 | - headers: { 'Content-Type': 'multipart/form-data' } | ||
| 152 | - } | ||
| 153 | - | ||
| 154 | - // 根据协议选择上传地址 | ||
| 155 | - const qiniuUploadUrl = window.location.protocol === 'https:' | ||
| 156 | - ? 'https://up.qbox.me' | ||
| 157 | - : 'http://upload.qiniu.com' | ||
| 158 | - | ||
| 159 | - return await qiniuUploadAPI(qiniuUploadUrl, formData, config) | ||
| 160 | -} | ||
| 161 | - | ||
| 162 | -// 处理单个文件上传 | ||
| 163 | -const handleUpload = async (file) => { | ||
| 164 | - loading.value = true | ||
| 165 | - try { | ||
| 166 | - // 获取MD5值 | ||
| 167 | - const md5 = await getFileMD5(file.file) | ||
| 168 | - | ||
| 169 | - // 获取七牛token | ||
| 170 | - const tokenResult = await qiniuTokenAPI({ | ||
| 171 | - name: file.file.name, | ||
| 172 | - hash: md5 | ||
| 173 | - }) | ||
| 174 | - | ||
| 175 | - // 文件已存在,直接返回 | ||
| 176 | - if (tokenResult.data) { | ||
| 177 | - return tokenResult.data | ||
| 178 | - } | ||
| 179 | - | ||
| 180 | - // 新文件上传 | ||
| 181 | - if (tokenResult.token) { | ||
| 182 | - const suffix = /.[^.]+$/.exec(file.file.name) || '' | ||
| 183 | - const fileName = `mlaj/upload/checkin/${currentUser.value.mobile}/img/${md5}${suffix}` | ||
| 184 | - | ||
| 185 | - const { filekey, image_info } = await uploadToQiniu( | ||
| 186 | - file.file, | ||
| 187 | - tokenResult.token, | ||
| 188 | - fileName | ||
| 189 | - ) | ||
| 190 | - | ||
| 191 | - if (filekey) { | ||
| 192 | - // 保存文件信息 | ||
| 193 | - const { data } = await saveFileAPI({ | ||
| 194 | - name: file.file.name, | ||
| 195 | - filekey, | ||
| 196 | - hash: md5, | ||
| 197 | - height: image_info?.height, | ||
| 198 | - width: image_info?.width | ||
| 199 | - }) | ||
| 200 | - return data | ||
| 201 | - } | ||
| 202 | - } | ||
| 203 | - return null | ||
| 204 | - } catch (error) { | ||
| 205 | - console.error('Upload error:', error) | ||
| 206 | - return null | ||
| 207 | - } finally { | ||
| 208 | - loading.value = false | ||
| 209 | - } | ||
| 210 | -} | ||
| 211 | - | ||
| 212 | -// 文件读取后的处理 | ||
| 213 | -const afterRead = async (file) => { | ||
| 214 | - if (Array.isArray(file)) { | ||
| 215 | - // 多文件上传 | ||
| 216 | - for (const item of file) { | ||
| 217 | - item.status = 'uploading' | ||
| 218 | - item.message = '上传中...' | ||
| 219 | - const result = await handleUpload(item) | ||
| 220 | - if (result) { | ||
| 221 | - item.status = 'done' | ||
| 222 | - item.message = '上传成功' | ||
| 223 | - item.url = result.url | ||
| 224 | - item.meta_id = result.meta_id | ||
| 225 | - } else { | ||
| 226 | - item.status = 'failed' | ||
| 227 | - item.message = '上传失败' | ||
| 228 | - showToast('上传失败,请重试') | ||
| 229 | - } | ||
| 230 | - } | ||
| 231 | - } else { | ||
| 232 | - // 单文件上传 | ||
| 233 | - file.status = 'uploading' | ||
| 234 | - file.message = '上传中...' | ||
| 235 | - const result = await handleUpload(file) | ||
| 236 | - if (result) { | ||
| 237 | - file.status = 'done' | ||
| 238 | - file.message = '上传成功' | ||
| 239 | - file.url = result.url | ||
| 240 | - file.meta_id = result.meta_id | ||
| 241 | - } else { | ||
| 242 | - file.status = 'failed' | ||
| 243 | - file.message = '上传失败' | ||
| 244 | - showToast('上传失败,请重试') | ||
| 245 | - } | ||
| 246 | - } | ||
| 247 | -} | ||
| 248 | - | ||
| 249 | -// 删除文件 | ||
| 250 | -const onDelete = (file) => { | ||
| 251 | - const index = fileList.value.indexOf(file) | ||
| 252 | - if (index !== -1) { | ||
| 253 | - fileList.value.splice(index, 1) | ||
| 254 | - } | ||
| 255 | -} | ||
| 256 | - | ||
| 257 | -// 提交表单 | ||
| 258 | -const onSubmit = async () => { | ||
| 259 | - if (uploading.value) return | ||
| 260 | - | ||
| 261 | - // 检查是否所有文件都上传完成 | ||
| 262 | - const hasUploadingFiles = fileList.value.some(file => file.status === 'uploading') | ||
| 263 | - if (hasUploadingFiles) { | ||
| 264 | - showToast('请等待所有文件上传完成') | ||
| 265 | - return | ||
| 266 | - } | ||
| 267 | - | ||
| 268 | - uploading.value = true | ||
| 269 | - const toast = showLoadingToast({ | ||
| 270 | - message: '提交中...', | ||
| 271 | - forbidClick: true, | ||
| 272 | - }) | ||
| 273 | - | ||
| 274 | - try { | ||
| 275 | - if (route.query.status === 'edit') { | ||
| 276 | - // 编辑打卡接口 | ||
| 277 | - const { code, data } = await editUploadTaskInfoAPI({ | ||
| 278 | - i: route.query.post_id, | ||
| 279 | - note: message.value, | ||
| 280 | - meta_id: fileList.value.map(item => item.meta_id), | ||
| 281 | - file_type: route.query.type, | ||
| 282 | - }); | ||
| 283 | - if (code === 1) { | ||
| 284 | - showToast('提交成功') | ||
| 285 | - router.back() | ||
| 286 | - } | ||
| 287 | - } else { | ||
| 288 | - // 新增打卡接口 | ||
| 289 | - const { code, data } = await addUploadTaskAPI({ | ||
| 290 | - task_id: route.query.id, | ||
| 291 | - note: message.value, | ||
| 292 | - meta_id: fileList.value.map(item => item.meta_id), | ||
| 293 | - file_type: route.query.type, | ||
| 294 | - }); | ||
| 295 | - if (code === 1) { | ||
| 296 | - showToast('提交成功') | ||
| 297 | - router.back() | ||
| 298 | - } | ||
| 299 | - } | ||
| 300 | - } catch (error) { | ||
| 301 | - showToast('提交失败,请重试') | ||
| 302 | - } finally { | ||
| 303 | - toast.close() | ||
| 304 | - uploading.value = false | ||
| 305 | - } | ||
| 306 | -} | ||
| 307 | - | ||
| 308 | -onMounted(async () => { | ||
| 309 | - if (route.query.status === 'edit') { | ||
| 310 | - const { code, data } = await getUploadTaskInfoAPI({ i: route.query.post_id }); | ||
| 311 | - if (code === 1) { | ||
| 312 | - fileList.value = data.files.map(item => ({ | ||
| 313 | - url: item.value, | ||
| 314 | - status: 'done', | ||
| 315 | - message: '上传成功', | ||
| 316 | - meta_id: item.meta_id, | ||
| 317 | - })) | ||
| 318 | - message.value = data.note | ||
| 319 | - } | ||
| 320 | - } | ||
| 321 | -}) | ||
| 322 | -</script> | ||
| 323 | - | ||
| 324 | -<style lang="less" scoped> | ||
| 325 | -.checkin-upload-image { | ||
| 326 | - min-height: 100vh; | ||
| 327 | - padding-bottom: 80px; | ||
| 328 | -} | ||
| 329 | - | ||
| 330 | -.wrapper { | ||
| 331 | - display: flex; | ||
| 332 | - align-items: center; | ||
| 333 | - justify-content: center; | ||
| 334 | - height: 100%; | ||
| 335 | -} | ||
| 336 | -</style> |
src/views/checkin/upload/text.vue
deleted
100644 → 0
| 1 | -<template> | ||
| 2 | - <div class="checkin-upload-text p-4"> | ||
| 3 | - <div class="title text-center pb-4 font-bold">{{ route.meta.title }}</div> | ||
| 4 | - | ||
| 5 | - <!-- 文字输入区域 --> | ||
| 6 | - <div class="mb-4 border"> | ||
| 7 | - <van-field | ||
| 8 | - v-model="message" | ||
| 9 | - rows="8" | ||
| 10 | - autosize | ||
| 11 | - type="textarea" | ||
| 12 | - placeholder="请输入打卡内容, 至少需要10个字符" | ||
| 13 | - maxlength="500" | ||
| 14 | - show-word-limit | ||
| 15 | - /> | ||
| 16 | - </div> | ||
| 17 | - | ||
| 18 | - <!-- 提交按钮 --> | ||
| 19 | - <div class="fixed bottom-0 left-0 right-0 p-4 bg-white"> | ||
| 20 | - <van-button | ||
| 21 | - type="primary" | ||
| 22 | - block | ||
| 23 | - :loading="uploading" | ||
| 24 | - :disabled="!canSubmit" | ||
| 25 | - @click="onSubmit" | ||
| 26 | - > | ||
| 27 | - 提交 | ||
| 28 | - </van-button> | ||
| 29 | - </div> | ||
| 30 | - </div> | ||
| 31 | -</template> | ||
| 32 | - | ||
| 33 | -<script setup> | ||
| 34 | -import { ref, computed, onMounted } from 'vue' | ||
| 35 | -import { useRoute, useRouter } from 'vue-router' | ||
| 36 | -import { showToast, showLoadingToast } from 'vant' | ||
| 37 | -import { addUploadTaskAPI, getUploadTaskInfoAPI, editUploadTaskInfoAPI } from "@/api/checkin"; | ||
| 38 | -import { useTitle } from '@vueuse/core'; | ||
| 39 | - | ||
| 40 | -const route = useRoute() | ||
| 41 | -const router = useRouter() | ||
| 42 | -useTitle(route.meta.title); | ||
| 43 | - | ||
| 44 | -// 留言内容 | ||
| 45 | -const message = ref('') | ||
| 46 | -// 上传状态 | ||
| 47 | -const uploading = ref(false) | ||
| 48 | - | ||
| 49 | -/** | ||
| 50 | - * 是否可以提交 | ||
| 51 | - */ | ||
| 52 | -const canSubmit = computed(() => { | ||
| 53 | - return message.value.trim() !== '' && message.value.trim().length >= 10 | ||
| 54 | -}) | ||
| 55 | - | ||
| 56 | -/** | ||
| 57 | - * 提交表单 | ||
| 58 | - */ | ||
| 59 | -const onSubmit = async () => { | ||
| 60 | - if (uploading.value) return | ||
| 61 | - | ||
| 62 | - if (message.value.trim().length < 10) { | ||
| 63 | - showToast('打卡内容至少需要10个字符') | ||
| 64 | - return | ||
| 65 | - } | ||
| 66 | - | ||
| 67 | - uploading.value = true | ||
| 68 | - const toast = showLoadingToast({ | ||
| 69 | - message: '提交中...', | ||
| 70 | - forbidClick: true, | ||
| 71 | - }) | ||
| 72 | - | ||
| 73 | - try { | ||
| 74 | - if (route.query.status === 'edit') { | ||
| 75 | - // 编辑打卡接口 | ||
| 76 | - const { code, data } = await editUploadTaskInfoAPI({ | ||
| 77 | - i: route.query.post_id, | ||
| 78 | - note: message.value, | ||
| 79 | - meta_id: [], // 文本类型不需要文件 | ||
| 80 | - file_type: route.query.type, | ||
| 81 | - }); | ||
| 82 | - if (code === 1) { | ||
| 83 | - showToast('提交成功') | ||
| 84 | - router.back() | ||
| 85 | - } | ||
| 86 | - } else { | ||
| 87 | - // 新增打卡接口 | ||
| 88 | - const { code, data } = await addUploadTaskAPI({ | ||
| 89 | - task_id: route.query.id, | ||
| 90 | - note: message.value, | ||
| 91 | - meta_id: [], // 文本类型不需要文件 | ||
| 92 | - file_type: route.query.type, | ||
| 93 | - }); | ||
| 94 | - if (code === 1) { | ||
| 95 | - showToast('提交成功') | ||
| 96 | - router.back() | ||
| 97 | - } | ||
| 98 | - } | ||
| 99 | - } catch (error) { | ||
| 100 | - showToast('提交失败,请重试') | ||
| 101 | - } finally { | ||
| 102 | - uploading.value = false | ||
| 103 | - } | ||
| 104 | -} | ||
| 105 | - | ||
| 106 | -/** | ||
| 107 | - * 页面挂载时的初始化逻辑 | ||
| 108 | - */ | ||
| 109 | -onMounted(async () => { | ||
| 110 | - if (route.query.status === 'edit') { | ||
| 111 | - const { code, data } = await getUploadTaskInfoAPI({ i: route.query.post_id }); | ||
| 112 | - if (code === 1) { | ||
| 113 | - message.value = data.note | ||
| 114 | - } | ||
| 115 | - } | ||
| 116 | -}) | ||
| 117 | -</script> | ||
| 118 | - | ||
| 119 | -<style lang="less" scoped> | ||
| 120 | -.checkin-upload-text { | ||
| 121 | - min-height: 100vh; | ||
| 122 | - padding-bottom: 80px; | ||
| 123 | -} | ||
| 124 | - | ||
| 125 | -.van-field { | ||
| 126 | - border-radius: 8px; | ||
| 127 | - background-color: #f8f9fa; | ||
| 128 | -} | ||
| 129 | - | ||
| 130 | -.van-field__control { | ||
| 131 | - font-size: 16px; | ||
| 132 | - line-height: 1.5; | ||
| 133 | -} | ||
| 134 | -</style> |
src/views/checkin/upload/video.vue
deleted
100644 → 0
| 1 | -<!-- | ||
| 2 | - * @Date: 2025-06-03 09:41:41 | ||
| 3 | - * @LastEditors: hookehuyr hookehuyr@gmail.com | ||
| 4 | - * @LastEditTime: 2025-07-02 18:33:29 | ||
| 5 | - * @FilePath: /mlaj/src/views/checkin/upload/video.vue | ||
| 6 | - * @Description: 音视频文件上传组件 | ||
| 7 | ---> | ||
| 8 | -<template> | ||
| 9 | - <div class="checkin-upload-file p-4"> | ||
| 10 | - <div class="title text-center pb-4 font-bold">{{route.meta.title}}</div> | ||
| 11 | - <!-- 文件上传区域 --> | ||
| 12 | - <div class="mb-4"> | ||
| 13 | - <van-uploader | ||
| 14 | - v-model="fileList" | ||
| 15 | - :max-count="max_count" | ||
| 16 | - :max-size="20 * 1024 * 1024" | ||
| 17 | - :before-read="beforeRead" | ||
| 18 | - :after-read="afterRead" | ||
| 19 | - @delete="onDelete" | ||
| 20 | - multiple | ||
| 21 | - accept="video/*" | ||
| 22 | - result-type="file" | ||
| 23 | - upload-icon="plus" | ||
| 24 | - :deletable="false" | ||
| 25 | - > | ||
| 26 | - </van-uploader> | ||
| 27 | - <van-row v-for="(item, index) in fileList" :key="index" class="mt-2 text-s text-gray-500"> | ||
| 28 | - <van-col span="22">{{ item.name }}</van-col> | ||
| 29 | - <van-col span="2" @click="delItem(item)"><van-icon name="clear" /></van-col> | ||
| 30 | - </van-row> | ||
| 31 | - <van-divider /> | ||
| 32 | - <div class="mt-2 text-xs text-gray-500">最多上传{{ max_count }}个文件,每个不超过20M</div> | ||
| 33 | - <div class="mt-2 text-xs text-gray-500">上传类型: 视频文件</div> | ||
| 34 | - </div> | ||
| 35 | - | ||
| 36 | - <!-- 文字留言区域 --> | ||
| 37 | - <div class="mb-4 border"> | ||
| 38 | - <van-field | ||
| 39 | - v-model="message" | ||
| 40 | - rows="4" | ||
| 41 | - autosize | ||
| 42 | - type="textarea" | ||
| 43 | - placeholder="请输入打卡留言" | ||
| 44 | - /> | ||
| 45 | - </div> | ||
| 46 | - | ||
| 47 | - <!-- 提交按钮 --> | ||
| 48 | - <div class="fixed bottom-0 left-0 right-0 p-4 bg-white"> | ||
| 49 | - <van-button | ||
| 50 | - type="primary" | ||
| 51 | - block | ||
| 52 | - :loading="uploading" | ||
| 53 | - :disabled="!canSubmit" | ||
| 54 | - @click="onSubmit" | ||
| 55 | - > | ||
| 56 | - 提交 | ||
| 57 | - </van-button> | ||
| 58 | - </div> | ||
| 59 | - | ||
| 60 | - <!-- 上传加载遮罩 --> | ||
| 61 | - <van-overlay :show="loading"> | ||
| 62 | - <div class="wrapper" @click.stop> | ||
| 63 | - <van-loading vertical color="#FFFFFF">上传中...</van-loading> | ||
| 64 | - </div> | ||
| 65 | - </van-overlay> | ||
| 66 | - </div> | ||
| 67 | -</template> | ||
| 68 | - | ||
| 69 | -<script setup> | ||
| 70 | -import { ref, computed } from 'vue' | ||
| 71 | -import { useRoute, useRouter } from 'vue-router' | ||
| 72 | -import { showToast, showLoadingToast } from 'vant' | ||
| 73 | -import { qiniuTokenAPI, qiniuUploadAPI, saveFileAPI } from '@/api/common' | ||
| 74 | -import { addUploadTaskAPI, getUploadTaskInfoAPI, editUploadTaskInfoAPI } from "@/api/checkin"; | ||
| 75 | -import { qiniuFileHash } from '@/utils/qiniuFileHash'; | ||
| 76 | -import _ from 'lodash' | ||
| 77 | -import { useTitle } from '@vueuse/core'; | ||
| 78 | -import { useAuth } from '@/contexts/auth' | ||
| 79 | - | ||
| 80 | -const route = useRoute() | ||
| 81 | -const router = useRouter() | ||
| 82 | -const { currentUser } = useAuth() | ||
| 83 | -useTitle(route.meta.title); | ||
| 84 | - | ||
| 85 | -const max_count = ref(5); | ||
| 86 | - | ||
| 87 | -// 文件列表 | ||
| 88 | -const fileList = ref([]) | ||
| 89 | -// 留言内容 | ||
| 90 | -const message = ref('') | ||
| 91 | -// 上传状态 | ||
| 92 | -const uploading = ref(false) | ||
| 93 | -// 上传loading | ||
| 94 | -const loading = ref(false) | ||
| 95 | - | ||
| 96 | -// 是否可以提交 | ||
| 97 | -const canSubmit = computed(() => { | ||
| 98 | - return fileList.value.length > 0 && message.value.trim() !== '' | ||
| 99 | -}) | ||
| 100 | - | ||
| 101 | -// 文件校验 | ||
| 102 | -const beforeRead = (file) => { | ||
| 103 | - let flag = true | ||
| 104 | - | ||
| 105 | - if (Array.isArray(file)) { | ||
| 106 | - // 多个文件 | ||
| 107 | - const invalidTypes = file.filter(item => { | ||
| 108 | - const fileType = item.type.toLowerCase(); | ||
| 109 | - return !fileType.startsWith('video/'); | ||
| 110 | - }) | ||
| 111 | - if (invalidTypes.length) { | ||
| 112 | - flag = false | ||
| 113 | - showToast('请上传视频文件') | ||
| 114 | - } | ||
| 115 | - if (fileList.value.length + file.length > max_count.value) { | ||
| 116 | - flag = false | ||
| 117 | - showToast(`最大上传数量为${max_count.value}个`) | ||
| 118 | - } | ||
| 119 | - } else { | ||
| 120 | - const fileType = file.type.toLowerCase(); | ||
| 121 | - if (!fileType.startsWith('video/')) { | ||
| 122 | - showToast('请上传视频文件') | ||
| 123 | - flag = false | ||
| 124 | - } | ||
| 125 | - if (fileList.value.length + 1 > max_count.value) { | ||
| 126 | - flag = false | ||
| 127 | - showToast(`最大上传数量为${max_count.value}个`) | ||
| 128 | - } | ||
| 129 | - if ((file.size / 1024 / 1024).toFixed(2) > 20) { | ||
| 130 | - flag = false | ||
| 131 | - showToast('最大文件体积为20MB') | ||
| 132 | - } | ||
| 133 | - } | ||
| 134 | - return flag | ||
| 135 | -} | ||
| 136 | - | ||
| 137 | -/** | ||
| 138 | - * 获取文件哈希(与七牛云ETag一致) | ||
| 139 | - * @param {File} file 文件对象 | ||
| 140 | - * @returns {Promise<string>} 哈希字符串 | ||
| 141 | - * 注释:使用 qiniuFileHash 进行计算,替代浏览器MD5方案。 | ||
| 142 | - */ | ||
| 143 | -const getFileMD5 = async (file) => { | ||
| 144 | - return await qiniuFileHash(file) | ||
| 145 | -} | ||
| 146 | - | ||
| 147 | -// 上传到七牛云 | ||
| 148 | -const uploadToQiniu = async (file, token, fileName) => { | ||
| 149 | - const formData = new FormData() | ||
| 150 | - formData.append('file', file) | ||
| 151 | - formData.append('token', token) | ||
| 152 | - formData.append('key', fileName) | ||
| 153 | - | ||
| 154 | - const config = { | ||
| 155 | - headers: { 'Content-Type': 'multipart/form-data' } | ||
| 156 | - } | ||
| 157 | - | ||
| 158 | - // 根据协议选择上传地址 | ||
| 159 | - const qiniuUploadUrl = window.location.protocol === 'https:' | ||
| 160 | - ? 'https://up.qbox.me' | ||
| 161 | - : 'http://upload.qiniu.com' | ||
| 162 | - | ||
| 163 | - return await qiniuUploadAPI(qiniuUploadUrl, formData, config) | ||
| 164 | -} | ||
| 165 | - | ||
| 166 | -// 处理单个文件上传 | ||
| 167 | -const handleUpload = async (file) => { | ||
| 168 | - loading.value = true | ||
| 169 | - try { | ||
| 170 | - // 获取MD5值 | ||
| 171 | - const md5 = await getFileMD5(file.file) | ||
| 172 | - | ||
| 173 | - // 获取七牛token | ||
| 174 | - const tokenResult = await qiniuTokenAPI({ | ||
| 175 | - name: file.file.name, | ||
| 176 | - hash: md5 | ||
| 177 | - }) | ||
| 178 | - | ||
| 179 | - // 文件已存在,直接返回 | ||
| 180 | - if (tokenResult.data) { | ||
| 181 | - return tokenResult.data | ||
| 182 | - } | ||
| 183 | - | ||
| 184 | - // 新文件上传 | ||
| 185 | - if (tokenResult.token) { | ||
| 186 | - const suffix = /.[^.]+$/.exec(file.file.name) || '' | ||
| 187 | - const fileName = `mlaj/upload/checkin/${currentUser.value.mobile}/file/${md5}${suffix}` | ||
| 188 | - | ||
| 189 | - const { filekey } = await uploadToQiniu( | ||
| 190 | - file.file, | ||
| 191 | - tokenResult.token, | ||
| 192 | - fileName | ||
| 193 | - ) | ||
| 194 | - | ||
| 195 | - if (filekey) { | ||
| 196 | - // 保存文件信息 | ||
| 197 | - const { data } = await saveFileAPI({ | ||
| 198 | - name: file.file.name, | ||
| 199 | - filekey, | ||
| 200 | - hash: md5 | ||
| 201 | - }) | ||
| 202 | - return data | ||
| 203 | - } | ||
| 204 | - } | ||
| 205 | - return null | ||
| 206 | - } catch (error) { | ||
| 207 | - console.error('Upload error:', error) | ||
| 208 | - return null | ||
| 209 | - } finally { | ||
| 210 | - loading.value = false | ||
| 211 | - } | ||
| 212 | -} | ||
| 213 | - | ||
| 214 | -// 文件读取后的处理 | ||
| 215 | -const afterRead = async (file) => { | ||
| 216 | - if (Array.isArray(file)) { | ||
| 217 | - // 多文件上传 | ||
| 218 | - for (const item of file) { | ||
| 219 | - item.status = 'uploading' | ||
| 220 | - item.message = '上传中...' | ||
| 221 | - const result = await handleUpload(item) | ||
| 222 | - if (result) { | ||
| 223 | - item.status = 'done' | ||
| 224 | - item.message = '上传成功' | ||
| 225 | - item.url = result.url | ||
| 226 | - item.meta_id = result.meta_id | ||
| 227 | - item.name = result.name | ||
| 228 | - } else { | ||
| 229 | - item.status = 'failed' | ||
| 230 | - item.message = '上传失败' | ||
| 231 | - showToast('上传失败,请重试') | ||
| 232 | - } | ||
| 233 | - } | ||
| 234 | - } else { | ||
| 235 | - // 单文件上传 | ||
| 236 | - file.status = 'uploading' | ||
| 237 | - file.message = '上传中...' | ||
| 238 | - const result = await handleUpload(file) | ||
| 239 | - if (result) { | ||
| 240 | - file.status = 'done' | ||
| 241 | - file.message = '上传成功' | ||
| 242 | - file.url = result.url | ||
| 243 | - file.meta_id = result.meta_id | ||
| 244 | - file.name = result.name | ||
| 245 | - } else { | ||
| 246 | - file.status = 'failed' | ||
| 247 | - file.message = '上传失败' | ||
| 248 | - showToast('上传失败,请重试') | ||
| 249 | - } | ||
| 250 | - } | ||
| 251 | -} | ||
| 252 | - | ||
| 253 | -// 删除文件 | ||
| 254 | -const onDelete = (file) => { | ||
| 255 | - const index = fileList.value.indexOf(file) | ||
| 256 | - if (index !== -1) { | ||
| 257 | - fileList.value.splice(index, 1) | ||
| 258 | - } | ||
| 259 | -} | ||
| 260 | - | ||
| 261 | -// 提交表单 | ||
| 262 | -const onSubmit = async () => { | ||
| 263 | - if (uploading.value) return | ||
| 264 | - | ||
| 265 | - // 检查是否所有文件都上传完成 | ||
| 266 | - const hasUploadingFiles = fileList.value.some(file => file.status === 'uploading') | ||
| 267 | - if (hasUploadingFiles) { | ||
| 268 | - showToast('请等待所有文件上传完成') | ||
| 269 | - return | ||
| 270 | - } | ||
| 271 | - | ||
| 272 | - uploading.value = true | ||
| 273 | - const toast = showLoadingToast({ | ||
| 274 | - message: '提交中...', | ||
| 275 | - forbidClick: true, | ||
| 276 | - }) | ||
| 277 | - | ||
| 278 | - try { | ||
| 279 | - if (route.query.status === 'edit') { | ||
| 280 | - // 编辑打卡接口 | ||
| 281 | - const { code, data } = await editUploadTaskInfoAPI({ | ||
| 282 | - i: route.query.post_id, | ||
| 283 | - note: message.value, | ||
| 284 | - meta_id: fileList.value.map(item => item.meta_id), | ||
| 285 | - file_type: route.query.type, | ||
| 286 | - }); | ||
| 287 | - if (code === 1) { | ||
| 288 | - showToast('提交成功') | ||
| 289 | - router.back() | ||
| 290 | - } | ||
| 291 | - } else { | ||
| 292 | - // 新增打卡接口 | ||
| 293 | - const { code, data } = await addUploadTaskAPI({ | ||
| 294 | - task_id: route.query.id, | ||
| 295 | - note: message.value, | ||
| 296 | - meta_id: fileList.value.map(item => item.meta_id), | ||
| 297 | - file_type: route.query.type, | ||
| 298 | - }); | ||
| 299 | - if (code === 1) { | ||
| 300 | - showToast('提交成功') | ||
| 301 | - router.back() | ||
| 302 | - } | ||
| 303 | - } | ||
| 304 | - } catch (error) { | ||
| 305 | - showToast('提交失败,请重试') | ||
| 306 | - } finally { | ||
| 307 | - toast.close() | ||
| 308 | - uploading.value = false | ||
| 309 | - } | ||
| 310 | -} | ||
| 311 | - | ||
| 312 | -onMounted(async () => { | ||
| 313 | - if (route.query.status === 'edit') { | ||
| 314 | - const { code, data } = await getUploadTaskInfoAPI({ i: route.query.post_id }); | ||
| 315 | - if (code === 1) { | ||
| 316 | - fileList.value = data.files.map(item => ({ | ||
| 317 | - url: item.value, | ||
| 318 | - status: 'done', | ||
| 319 | - message: '上传成功', | ||
| 320 | - meta_id: item.meta_id, | ||
| 321 | - name: item.name, | ||
| 322 | - })) | ||
| 323 | - message.value = data.note | ||
| 324 | - } | ||
| 325 | - } | ||
| 326 | -}); | ||
| 327 | - | ||
| 328 | -const delItem = (item) => { | ||
| 329 | - const index = fileList.value.indexOf(item) | ||
| 330 | - if (index!== -1) { | ||
| 331 | - fileList.value.splice(index, 1) | ||
| 332 | - } | ||
| 333 | -} | ||
| 334 | -</script> | ||
| 335 | - | ||
| 336 | -<style lang="less" scoped> | ||
| 337 | -.checkin-upload-file { | ||
| 338 | - min-height: 100vh; | ||
| 339 | - padding-bottom: 80px; | ||
| 340 | -} | ||
| 341 | - | ||
| 342 | -.wrapper { | ||
| 343 | - display: flex; | ||
| 344 | - align-items: center; | ||
| 345 | - justify-content: center; | ||
| 346 | - height: 100%; | ||
| 347 | -} | ||
| 348 | -</style> |
| 1 | <!-- | 1 | <!-- |
| 2 | * @Date: 2025-10-22 10:45:51 | 2 | * @Date: 2025-10-22 10:45:51 |
| 3 | * @LastEditors: hookehuyr hookehuyr@gmail.com | 3 | * @LastEditors: hookehuyr hookehuyr@gmail.com |
| 4 | - * @LastEditTime: 2025-10-22 10:54:10 | 4 | + * @LastEditTime: 2026-01-24 14:00:37 |
| 5 | * @FilePath: /mlaj/src/views/study/PdfPreviewPage.vue | 5 | * @FilePath: /mlaj/src/views/study/PdfPreviewPage.vue |
| 6 | - * @Description: 文件描述 | 6 | + * @Description: PDF预览页 |
| 7 | --> | 7 | --> |
| 8 | <template> | 8 | <template> |
| 9 | <div class="pdf-preview-page"> | 9 | <div class="pdf-preview-page"> |
| ... | @@ -12,9 +12,10 @@ | ... | @@ -12,9 +12,10 @@ |
| 12 | </template> | 12 | </template> |
| 13 | 13 | ||
| 14 | <script setup> | 14 | <script setup> |
| 15 | -import { computed, onMounted, onBeforeUnmount } from 'vue' | 15 | +import { computed, defineAsyncComponent, onMounted, onBeforeUnmount } from 'vue' |
| 16 | import { useRoute, useRouter } from 'vue-router' | 16 | import { useRoute, useRouter } from 'vue-router' |
| 17 | -import PdfViewer from '@/components/media/PdfViewer.vue' | 17 | + |
| 18 | +const PdfViewer = defineAsyncComponent(() => import('@/components/media/PdfViewer.vue')) | ||
| 18 | 19 | ||
| 19 | const route = useRoute() | 20 | const route = useRoute() |
| 20 | const router = useRouter() | 21 | const router = useRouter() |
| ... | @@ -37,7 +38,7 @@ const pdfTitle = computed(() => { | ... | @@ -37,7 +38,7 @@ const pdfTitle = computed(() => { |
| 37 | const handleClose = () => { | 38 | const handleClose = () => { |
| 38 | // 清除刷新标记 | 39 | // 清除刷新标记 |
| 39 | sessionStorage.removeItem('pdf-preview-refreshed') | 40 | sessionStorage.removeItem('pdf-preview-refreshed') |
| 40 | - | 41 | + |
| 41 | const returnId = route.query.returnId | 42 | const returnId = route.query.returnId |
| 42 | const openMaterials = route.query.openMaterials | 43 | const openMaterials = route.query.openMaterials |
| 43 | if (returnId) { | 44 | if (returnId) { | ... | ... |
| ... | @@ -702,29 +702,33 @@ const formatData = (data) => { | ... | @@ -702,29 +702,33 @@ const formatData = (data) => { |
| 702 | let images = []; | 702 | let images = []; |
| 703 | let audio = []; | 703 | let audio = []; |
| 704 | let videoList = []; | 704 | let videoList = []; |
| 705 | - if (item.file_type === 'image') { | 705 | + |
| 706 | - images = item.files.map(file => { | 706 | + // 支持多类型混合显示:遍历 files 数组根据 file_type 分类 |
| 707 | - return file.value; | 707 | + if (item.files && Array.isArray(item.files)) { |
| 708 | - }); | 708 | + item.files.forEach(file => { |
| 709 | - } else if (item.file_type === 'video') { | 709 | + // 优先使用文件自身的 file_type,如果没有则回退到 item.file_type |
| 710 | - videoList = item.files.map(file => { | 710 | + const type = file.file_type || item.file_type; |
| 711 | - return { | 711 | + |
| 712 | - id: file.meta_id, | 712 | + if (type === 'image') { |
| 713 | - video: file.value, | 713 | + images.push(file.value); |
| 714 | - videoCover: file.cover, | 714 | + } else if (type === 'video') { |
| 715 | - isPlaying: false, | 715 | + videoList.push({ |
| 716 | - } | 716 | + id: file.meta_id, |
| 717 | - }) | 717 | + video: file.value, |
| 718 | - } else if (item.file_type === 'audio') { | 718 | + videoCover: file.cover, |
| 719 | - audio = item.files.map(file => { | 719 | + isPlaying: false, |
| 720 | - return { | 720 | + }); |
| 721 | - title: file.name ? file.name : '打卡音频', | 721 | + } else if (type === 'audio') { |
| 722 | - artist: file.artist ? file.artist : '', | 722 | + audio.push({ |
| 723 | - url: file.value, | 723 | + title: file.name ? file.name : '打卡音频', |
| 724 | - cover: file.cover ? file.cover : '', | 724 | + artist: file.artist ? file.artist : '', |
| 725 | + url: file.value, | ||
| 726 | + cover: file.cover ? file.cover : '', | ||
| 727 | + }); | ||
| 725 | } | 728 | } |
| 726 | - }) | 729 | + }); |
| 727 | } | 730 | } |
| 731 | + | ||
| 728 | return { | 732 | return { |
| 729 | id: item.id, | 733 | id: item.id, |
| 730 | task_id: item.task_id, | 734 | task_id: item.task_id, | ... | ... |
| ... | @@ -166,8 +166,7 @@ | ... | @@ -166,8 +166,7 @@ |
| 166 | 166 | ||
| 167 | <script setup> | 167 | <script setup> |
| 168 | import { ref, computed, onMounted, onUnmounted } from 'vue' | 168 | import { ref, computed, onMounted, onUnmounted } from 'vue' |
| 169 | -import { useRouter } from 'vue-router' | 169 | +import { useRouter, useRoute } from 'vue-router' |
| 170 | -import AppLayout from '@/layouts/AppLayout.vue' | ||
| 171 | import { useTitle } from '@vueuse/core'; | 170 | import { useTitle } from '@vueuse/core'; |
| 172 | import { useAuth } from '@/contexts/auth' | 171 | import { useAuth } from '@/contexts/auth' |
| 173 | import CourseGroupCascader from '@/components/courses/CourseGroupCascader.vue' | 172 | import CourseGroupCascader from '@/components/courses/CourseGroupCascader.vue' | ... | ... |
| ... | @@ -841,29 +841,33 @@ const formatData = (data) => { | ... | @@ -841,29 +841,33 @@ const formatData = (data) => { |
| 841 | let images = []; | 841 | let images = []; |
| 842 | let audio = []; | 842 | let audio = []; |
| 843 | let videoList = []; | 843 | let videoList = []; |
| 844 | - if (item.file_type === 'image') { | 844 | + |
| 845 | - images = item.files.map(file => { | 845 | + // 支持多类型混合显示:遍历 files 数组根据 file_type 分类 |
| 846 | - return file.value; | 846 | + if (item.files && Array.isArray(item.files)) { |
| 847 | - }); | 847 | + item.files.forEach(file => { |
| 848 | - } else if (item.file_type === 'video') { | 848 | + // 优先使用文件自身的 file_type,如果没有则回退到 item.file_type |
| 849 | - videoList = item.files.map(file => { | 849 | + const type = file.file_type || item.file_type; |
| 850 | - return { | 850 | + |
| 851 | - id: file.meta_id, | 851 | + if (type === 'image') { |
| 852 | - video: file.value, | 852 | + images.push(file.value); |
| 853 | - videoCover: file.cover, | 853 | + } else if (type === 'video') { |
| 854 | - isPlaying: false, | 854 | + videoList.push({ |
| 855 | - } | 855 | + id: file.meta_id, |
| 856 | - }) | 856 | + video: file.value, |
| 857 | - } else if (item.file_type === 'audio') { | 857 | + videoCover: file.cover, |
| 858 | - audio = item.files.map(file => { | 858 | + isPlaying: false, |
| 859 | - return { | 859 | + }); |
| 860 | - title: file.name ? file.name : '打卡音频', | 860 | + } else if (type === 'audio') { |
| 861 | - artist: file.artist ? file.artist : '', | 861 | + audio.push({ |
| 862 | - url: file.value, | 862 | + title: file.name ? file.name : '打卡音频', |
| 863 | - cover: file.cover ? file.cover : '', | 863 | + artist: file.artist ? file.artist : '', |
| 864 | + url: file.value, | ||
| 865 | + cover: file.cover ? file.cover : '', | ||
| 866 | + }); | ||
| 864 | } | 867 | } |
| 865 | - }) | 868 | + }); |
| 866 | } | 869 | } |
| 870 | + | ||
| 867 | return { | 871 | return { |
| 868 | id: item.id, | 872 | id: item.id, |
| 869 | task_id: item.task_id, | 873 | task_id: item.task_id, | ... | ... |
| 1 | /* | 1 | /* |
| 2 | * @Date: 2025-03-20 19:53:12 | 2 | * @Date: 2025-03-20 19:53:12 |
| 3 | * @LastEditors: hookehuyr hookehuyr@gmail.com | 3 | * @LastEditors: hookehuyr hookehuyr@gmail.com |
| 4 | - * @LastEditTime: 2026-01-18 23:17:57 | 4 | + * @LastEditTime: 2026-01-24 13:56:05 |
| 5 | * @FilePath: /mlaj/vite.config.js | 5 | * @FilePath: /mlaj/vite.config.js |
| 6 | * @Description: 文件描述 | 6 | * @Description: 文件描述 |
| 7 | */ | 7 | */ |
| ... | @@ -111,6 +111,11 @@ export default ({ mode }) => { | ... | @@ -111,6 +111,11 @@ export default ({ mode }) => { |
| 111 | chunkFileNames: 'static/js/[name]-[hash].js', | 111 | chunkFileNames: 'static/js/[name]-[hash].js', |
| 112 | entryFileNames: 'static/js/[name]-[hash].js', | 112 | entryFileNames: 'static/js/[name]-[hash].js', |
| 113 | assetFileNames: 'static/[ext]/[name]-[hash].[ext]', | 113 | assetFileNames: 'static/[ext]/[name]-[hash].[ext]', |
| 114 | + manualChunks: (id) => { | ||
| 115 | + if (!id.includes('node_modules')) return; | ||
| 116 | + if (id.includes('@vue-office/docx') || id.includes('@vue-office/excel') || id.includes('@vue-office/pptx')) return 'vue-office'; | ||
| 117 | + if (id.includes('html2canvas') || id.includes('html-to-image')) return 'image-tools'; | ||
| 118 | + }, | ||
| 114 | }, | 119 | }, |
| 115 | input: { // 多页面应用模式, 打包时配置,运行配置要处理root | 120 | input: { // 多页面应用模式, 打包时配置,运行配置要处理root |
| 116 | main: path.resolve(__dirname, 'index.html'), | 121 | main: path.resolve(__dirname, 'index.html'), | ... | ... |
-
Please register or login to post a comment